Philips Tap Dial Switch with double tap and 4 dial actions

Hi all,

maybe someone have a hint for me.
The blueprint works fine and as expectet.
Only thing what doesn’t work are the rotation steps.
Broker sees the rotation but dimming p.e. isn’t possible.
Which Action do you recomment to increase and decrease a light / light group?
I’ve testet it with Action = Device or Action = Light.

Both works for me if i use it to turn it on or off.

I’ve read the whole thread but didn’t found a solution for me.
Is there something where i do it wrong?

In addition here a bit of my yaml

button_3_rotate_clockwise:
      - action: light.turn_on
        target:
          entity_id: light.lampe_buro
        data:
          transition: 0
          brightness_step: 10
    button_3_rotate_counter_clockwise:
      - action: light.turn_on
        target:
          entity_id: light.lampe_buro
        data:
          brightness_step: -10 

I also have the problem that z2m sends 2 commands at a time. The counter jumps to 2 when you click once and to 4 when you double click. This must be related to some z2m update.

I deleted the dial switch, the counter, etc. Can someone tell me exactly where I need to change the blueprint or maybe make a small update.

thank you

So I am trying to figure this out.

What i wanted to do was utilize the step_size to make the increase the values faster or slower depending on amount of steps. E.g 0-15, 10% 16-30, 20% etc etc.
But then i walked into this.

The device seems to send two different payloads (dial_rotate* and brightness_step*) at the approximately same time (within 0.1s).
Both contain unique values. E.g action_direction and action_step_size.

This automation/blueprint seems to work with the first type of payload, rendering it impossible (as is) to get hold of the step_size.

I also notices that sometimes, instead of only 2 events. I get 4. i.e two of each kind, when spinning the dial fast, creating one a “slow” and a “fast” event for a single turn of the dial.

This made me start looking into de-bounce in z2m.
But doing that it will overwrite the action, and depending on what action triggered first i guess it will retain common attributes.

I do not know, is this intended behavior or not and is there other mitigation to be made and not only denounce?

I guess one could add a choice to act differently if the action is a _fast/_slow?
E.g slow = 10%, fast 20%.

Thoughts and suggestions?

  payload_json:
    action: dial_rotate_right_fast
    action_direction: right
    action_step_size: null
    action_time: 90
    action_type: step
    battery: 100
    brightness: null
    last_seen: '2025-01-08T19:21:36+01:00'
    linkquality: 98
    update:
      installed_version: 33569561
      latest_version: 33569561
      state: idle
  payload_json:
    action: brightness_step_up
    action_direction: null
    action_step_size: 38
    action_time: null
    action_transition_time: 0.04
    action_type: null
    battery: 100
    brightness: null
    last_seen: '2025-01-08T19:21:36+01:00'
    linkquality: 94
    update:
      installed_version: 33569561
      latest_version: 33569561
      state: idle

Debounce set to 0.1

payload_json:
    action: brightness_step_up
    action_direction: right
    action_step_size: 26
    action_time: 60
    action_transition_time: 0.04
    action_type: rotate
    battery: 100
    brightness: null
    last_seen: '2025-01-08T19:30:58+01:00'
    linkquality: 58
    update:
      installed_version: 33569561
      latest_version: 33569561
      state: idle

Ended up doing a choose block, with template conditions that check for :

dial_rotate_*_fast
dial_rotate_*_slow

And a default action for the step.
Also added a condition in the beginning of the blueprint so that it ignores the brightness_step* actions as early as possible to slightly speed up the execution time.

Experimenting with if using queued execution also feels more reliable than parallel as it seems like sometimes the turning of the wheel will send a fast and slow action and the light in/de-crease looks a bit jerky when it’s parallel. Could be imaginary though.

Love the blueprint and your work
Is there an easy way to modify the Zigbee2mqtt to set the dial to not turn on a light if it is off and I’m increasing brightness? I’m also looking to set different values (say eg. brightness change) for a fast and a slow rotation

I received a request from someone having my issue with the tap dial recording twice with each button press. Hope this is ok - sharing my automation below because I couldn’t fit the full script in a PM and figured others might have a use for this too.

alias: Basement Tap Dial Switch Automation YAML
description: >-
  Zigbee2MQTT - Philips Hue Tap Dial Switch by SmartHomeGeeks.io


  4-9-2024: JWD Modified single and double presses to account for
  double-registers on Z2M with every press…
triggers:
  - topic: zigbee2mqtt/Basement Tap Dial
    trigger: mqtt
conditions:
  - condition: template
    value_template: "{{ trigger.payload_json.action != '' }}"
actions:
  - variables:
      input_text_var: input_text.basement_dial_switch_last_pressed
      counter_var: counter.basement_dial_switch_button_counter
    alias: Variables for input and counter helpers
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ trigger.payload_json.action is match('^button_[1-4]_press$') }}"
        sequence:
          - if:
              - condition: template
                value_template: >-
                  {{ trigger.payload_json.action | regex_replace(find='[^1-4]',
                  replace='') != states(input_text_var) }}
            then:
              - target:
                  entity_id: "{{ counter_var }}"
                action: counter.reset
            alias: Prevent different buttons to be registered as double press
          - data:
              value: >-
                {{ trigger.payload_json.action | regex_replace(find='[^1-4]',
                replace='') }}
            target:
              entity_id: "{{ input_text_var }}"
            alias: Update the input text helper with the last pressed button number
            action: input_text.set_value
          - target:
              entity_id: "{{ counter_var }}"
            alias: Increase the counter helper with 1
            action: counter.increment
          - delay:
              seconds: 1
          - target:
              entity_id: "{{ counter_var }}"
            alias: Reset the counter helper
            action: counter.reset
        alias: Update Last Pressed
      - conditions:
          - condition: template
            value_template: >-
              {{ trigger.payload_json.action is
              match('button_[1-4]_press_release') and states(counter_var) > '2'
              }}
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: >-
                      {{ trigger.payload_json.action == 'button_1_press_release'
                      }}
                sequence:
                  - action: automation.trigger
                    metadata: {}
                    data:
                      skip_condition: true
                    target:
                      entity_id: automation.basement_fan_power
                alias: Double press button 1
              - conditions:
                  - condition: template
                    value_template: >-
                      {{ trigger.payload_json.action == 'button_2_press_release'
                      }}
                sequence:
                  - action: automation.trigger
                    metadata: {}
                    data:
                      skip_condition: true
                    target:
                      entity_id: automation.basement_fan_speed_up
                alias: Double press button 2
              - conditions:
                  - condition: template
                    value_template: >-
                      {{ trigger.payload_json.action == 'button_3_press_release'
                      }}
                sequence:
                  - action: automation.trigger
                    metadata: {}
                    data:
                      skip_condition: true
                    target:
                      entity_id: automation.basement_fan_reverse_direction
                alias: Double press button 3
              - conditions:
                  - condition: template
                    value_template: >-
                      {{ trigger.payload_json.action == 'button_4_press_release'
                      }}
                sequence:
                  - action: automation.trigger
                    metadata: {}
                    data:
                      skip_condition: true
                    target:
                      entity_id: automation.basement_fan_speed_down
                alias: Double press button 4
              - conditions: []
                sequence: []
        alias: Double xxx2 event
    default:
      - if:
          - condition: template
            value_template: >-
              {{ trigger.payload_json.action is
              match('button_[1-4]_press_release') }}
        then:
          - delay:
              milliseconds: 250
          - if:
              - condition: template
                value_template: "{{ states(counter_var) > '2' }}"
            then:
              - stop: Double tap detected
        alias: Single xxx2 event
      - choose:
          - conditions:
              - condition: template
                value_template: >-
                  {{ states(input_text_var) == '1' and
                  trigger.payload_json.action is match('^dial_rotate_') }}
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_left_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                          - condition: device
                            type: is_off
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              brightness_step_pct: -10
                              transition: 1
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                                - light.basement_hue_overhead
                            action: light.turn_on
                        enabled: true
                      - if: []
                        then: []
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_right_') }}
                    sequence:
                      - metadata: {}
                        data:
                          brightness_step_pct: 10
                          transition: 1
                        target:
                          entity_id:
                            - light.basement_aqara_light_white
                            - light.basement_hue_overhead
                        action: light.turn_on
            alias: Dial rotate after button 1 pressed
          - conditions:
              - condition: template
                value_template: >-
                  {{ states(input_text_var) == '2' and
                  trigger.payload_json.action is match('^dial_rotate_') }}
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_left_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_right_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
            alias: Dial rotate after button 2 pressed
          - conditions:
              - condition: template
                value_template: >-
                  {{ states(input_text_var) == '3' and
                  trigger.payload_json.action is match('^dial_rotate_') }}
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_left_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_right_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
            alias: Dial rotate after button 3 pressed
          - conditions:
              - condition: template
                value_template: >-
                  {{ states(input_text_var) == '4' and
                  trigger.payload_json.action is match('^dial_rotate_') }}
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_left_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: -10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
                  - conditions:
                      - condition: template
                        value_template: >-
                          {{ trigger.payload_json.action is
                          match('^dial_rotate_right_') }}
                    sequence:
                      - if:
                          - condition: device
                            type: is_on
                            device_id: abe459c54de778cc9a02b28b708828cc
                            entity_id: 8dc9951481ea5f4da632be0db234b838
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_hue_overhead
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: c9ab062b90f984dc278d1ef7974833aa
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_white
                            action: light.turn_on
                      - if:
                          - condition: device
                            type: is_on
                            device_id: 4943284b3347df987a71cee5aa6a9473
                            entity_id: 454c09d2a21fb1f3edae8c7378507f1b
                            domain: light
                        then:
                          - metadata: {}
                            data:
                              transition: 1
                              brightness_step_pct: 10
                            target:
                              entity_id:
                                - light.basement_aqara_light_rgb
                            action: light.turn_on
            alias: Dial rotate after button 4 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_1_press_release' }}"
            sequence:
              - target:
                  entity_id:
                    - light.basement_theater_lights_ringonly
                data:
                  transition: 1
                  kelvin: 3999
                  brightness_pct: 55
                action: light.toggle
            alias: Button 1 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_1_hold' }}"
            sequence:
              - metadata: {}
                data:
                  transition: 3
                target:
                  entity_id:
                    - light.basement_theater_lights_ringonly
                    - light.basement_aqara_light_rgb
                action: light.turn_off
            alias: Button 1 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_2_press_release' }}"
            sequence:
              - metadata: {}
                data:
                  transition: 1
                  brightness_pct: 100
                  kelvin: 4002
                target:
                  entity_id: light.basement_aqara_light_rgb
                action: light.toggle
            alias: Button 2 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_2_hold' }}"
            sequence: []
            alias: Button 2 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_3_press_release' }}"
            sequence:
              - metadata: {}
                data:
                  transition: 1
                  kelvin: 4000
                  brightness: 55
                target:
                  entity_id: light.basement_hue_overhead
                action: light.toggle
            alias: Button 3 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_3_hold' }}"
            sequence: []
            alias: Button 3 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_4_press_release' }}"
            sequence:
              - metadata: {}
                data:
                  transition: 1
                  kelvin: 3997
                  brightness_pct: 55
                target:
                  entity_id: light.basement_aqara_light_white
                action: light.toggle
            alias: Button 4 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_4_hold' }}"
            sequence:
              - action: light.turn_off
                metadata: {}
                data:
                  transition: 5
                target:
                  entity_id:
                    - light.basement_aqara_light_white
                    - light.basement_aqara_light_rgb
              - action: light.turn_on
                metadata: {}
                data:
                  transition: 5
                  brightness_pct: 15
                  kelvin: 2596
                target:
                  device_id:
                    - 8675afb6e6400d442fd8ca525a676ca8
                    - 3d6a14ab94c9f45709d53fb47d92699f
              - action: light.turn_on
                metadata: {}
                data:
                  transition: 5
                  kelvin: 2596
                  brightness_pct: 50
                target:
                  device_id:
                    - c1bd36e0699a2014388915c474f1d01a
                    - 2b5a52ebc18eb61a9a1e579387e7faff
            alias: Button 4 hold
mode: parallel
max: 10
max_exceeded: silent

Thank you for sharing this Blueprint. It works great with my Hue Dial Switch and Z2M integration.

I was wondering if anyone had a tip to share to accelerate/decelerate the brightness change when rotating the dial?

What I mean is that I can set an action to increase or decrease brightness through the Blueprint but that step is the same whether the dial rotates half a rotation or one click.

Not sure if this can be done through the blueprint or I need to move away from it.

I started to use a bunch of these controllers with zigbee2mqtt and got frustrated with the multiple button presses and other superfluous mqtt messages that make it a bit difficult to use.

So I created an integration that hopefully resolves this:

It supports short & long presses, dial rotations and combined button holds + dial rotations.

For easy dial control, it exposes a parameter brightness_delta to the automation.

To create an automation that changes a value on rotation you can use the exposed “Device” as a trigger and select brightness_step_up and brightness_step_down as triggers. Then you can access the delta and scale it down like so:
{{ trigger.event.data.brightness_delta | float ) * 0.1) | round(0) }}
it is positive for right turns and negative for left turns, so you can just add it to the current value of the device.

To make it work you need to enable simulated brightness in zigbee2mqtt. A delta of 1 and an interval of 50ms seem to work well.

I would appreciate your feedback if you decide to try it out! :slight_smile:

@xuio Thank you for your work and for sharing your approach. I’m curious if your combined controls could co-exist with multiple button presses, so basically if the original smarthomegeeks control possibilities could be extended by yours? If this was possible we’d have the most versatile tap dial switch possible I think. I could imagine to use them e.g. like this (just a real life example in my personal environment):

  • one button press: on/identify and select light (blink)
  • press button twice: off
  • rotate: change brightness of selected light
  • button hold+dial: change light color (cold-warm/RGB)
  • button hold: change e.g. from cold to warm immediately (CCT), change from CCT to RGB mode or - for my star projectors - switch through different modes (stars on/off, rotation on/off, nebula on/off)

I have 10 hue tap dial switches in my apartment which I use to control about 30 different light sources (each button on the tap dial switch for one light) and currently I use them with very basic functionality but one of my next projects will be to really make use all their potential to provide intuitive light control in all rooms. Your contribution and the contributions of the other blueprint creators is really valuable here and will make achieving this final goal much much easier!

I just set up my Philips Hue Tap Dial controller as a scene controller with ZHA. It works quite well so I thought I’d share my experience with folks here.

First, I used Scenery to define some favorite colors and light profiles for my lights (and light groups).

Then, I created an automation blueprint to associate a Philips Hue Tap Dial controller with a light (or a light group). It works like this:

  • Single/double/triple/quadruple/quintuple click button 1, 2, or 3 to turn on the light and apply a light profile.

  • Click button 4 to turn off the light.

  • Hold button 1, 2, 3, or 4 to run a script.

  • Turn the dial to change the brightness of the light.

For my purposes, I configured button 1 to turn the light on bright white, button 2 for warm white, button 3 for a variety of colors that are easy on the eyes for bedtime, button 4 turns the light off. Multi-clicking each button produces variations on this theme. I configured the scripts to perform global actions for lights in the same area when I hold a button. In particular, holding button 4 turns off all of the lights. It feels pretty intuitive overall.

I’ve uploaded the blueprint in the Scenery repository because it works best when you have already defined a bunch of light profiles (and that’s what Scenery is good for). Hope you find it useful!

Link to the blueprint:

@jeffbrown Very interesting, thank you!

I just found time to test your integration and wanted to let you know it’s really well done, thank you. One thing that doesn’t work for me is Hold+Rotate actions. The sensor and the logbook don’t show any actions.

Hi @fleeman, thanks for the ZHA blueprint! I do have one issue though, I’m not able to select the device in the blueprint in the field ‘Philips Hue Tap Dial Switch’. There are no devices available. I did add the switch in the Zigbee environment, I’m also able to see the actions (buttons pressed) in the logfiles.

I did create a counter and a text helper and for those i’m able to select the correct entities.

Am I missing something?

Hi there,
I am pretty new to yaml automations. I have downloaded your most recent automation. When i paste it into my yaml code in studio code server in Hpme assistant it doesn’t get recognised as an automation. All my automations in HA start with a “-alias” but your code doesnt have “-” in front of “alias”. Am I missing something here. IU keep all my automations in a automations folder and they all work fine. How can I get the yaml file recognised as an automation.
Thank you for your help.
here is the code I have pasted in my yaml file.

alias: Z2M Tap Dial Switch Automation
description: Zigbee2MQTT - Philips Hue Tap Dial Switch by SmartHomeGeeks.io
trigger:
  - platform: mqtt
    topic: zigbee2mqtt/Tap Dial Switch Man Cave # Change to correct value
condition:
  - condition: template
    value_template: "{{ trigger.payload_json.action != '' }}"
action:
  - variables:
      input_text_var: input_text.philips_hue_dial_switch_last_pressed # Change to correct value
      counter_var: counter.philips_hue_dial_switch_button_counter # Change to correct value
    alias: Variables for input and counter helpers
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ trigger.payload_json.action is match('^button_[1-4]_press$') }}"
        sequence:
          - if:
              - condition: template
                value_template: "{{ trigger.payload_json.action | regex_replace(find='[^1-4]', replace='') != states(input_text_var) }}"
            then:
              - service: counter.reset
                target:
                  entity_id: "{{ counter_var }}"
            alias: Prevent different buttons to be registered as double press
          - service: input_text.set_value
            data:
              value: "{{ trigger.payload_json.action | regex_replace(find='[^1-4]', replace='') }}"
            target:
              entity_id: "{{ input_text_var }}"
            alias: Update the input text helper with the last pressed button number
          - service: counter.increment
            target:
              entity_id: "{{ counter_var }}"
            alias: Increase the counter helper with 1
          - delay:
              seconds: 1
          - service: counter.reset
            target:
              entity_id: "{{ counter_var }}"
            alias: Reset the counter helper
        alias: Update Last Pressed
      - conditions:
          - condition: template
            value_template: "{{ trigger.payload_json.action is match('button_[1-4]_press_release') and states(counter_var) > '1' }}"
        sequence:
          - choose:
              - conditions:
                  - condition: template
                    value_template: "{{ trigger.payload_json.action == 'button_1_press_release' }}"
                sequence: []
                alias: Double press button 1
              - conditions:
                  - condition: template
                    value_template: "{{ trigger.payload_json.action == 'button_2_press_release' }}"
                sequence: []
                alias: Double press button 2
              - conditions:
                  - condition: template
                    value_template: "{{ trigger.payload_json.action == 'button_3_press_release' }}"
                sequence: []
                alias: Double press button 3
              - conditions:
                  - condition: template
                    value_template: "{{ trigger.payload_json.action == 'button_4_press_release' }}"
                sequence: []
                alias: Double press button 4
        alias: Double xxx2 event
    default:
      - if:
          - condition: template
            value_template: "{{ trigger.payload_json.action is match('button_[1-4]_press_release') }}"
        then:
          - delay:
              milliseconds: 250
          - if:
              - condition: template
                value_template: "{{ states(counter_var) > '1' }}"
            then:
              - stop: Double tap detected
        alias: Single xxx2 event
      - choose:
          - conditions:
              - condition: template
                value_template: "{{ states(input_text_var) == '1' and trigger.payload_json.action is match('^dial_rotate_') }}"
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_left_') }}"
                    sequence: []
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_right_') }}"
                    sequence: []
            alias: Dial rotate after button 1 pressed
          - conditions:
              - condition: template
                value_template: "{{ states(input_text_var) == '2' and trigger.payload_json.action is match('^dial_rotate_') }}"
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_left_') }}"
                    sequence: []
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_right_') }}"
                    sequence: []
            alias: Dial rotate after button 2 pressed
          - conditions:
              - condition: template
                value_template: "{{ states(input_text_var) == '3' and trigger.payload_json.action is match('^dial_rotate_') }}"
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_left_') }}"
                    sequence: []
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_right_') }}"
                    sequence: []
            alias: Dial rotate after button 3 pressed
          - conditions:
              - condition: template
                value_template: "{{ states(input_text_var) == '4' and trigger.payload_json.action is match('^dial_rotate_') }}"
            sequence:
              - choose:
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_left_') }}"
                    sequence: []
                  - conditions:
                      - condition: template
                        value_template: "{{ trigger.payload_json.action is match('^dial_rotate_right_') }}"
                    sequence: []
            alias: Dial rotate after button 4 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_1_press_release' }}"
            sequence: []
            alias: Button 1 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_1_hold' }}"
            sequence: []
            alias: Button 1 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_2_press_release' }}"
            sequence: []
            alias: Button 2 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_2_hold' }}"
            sequence: []
            alias: Button 2 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_3_press_release' }}"
            sequence: []
            alias: Button 3 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_3_hold' }}"
            sequence: []
            alias: Button 3 hold
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_4_press_release' }}"
            sequence: []
            alias: Button 4 pressed
          - conditions:
              - condition: template
                value_template: "{{ trigger.payload_json.action == 'button_4_hold' }}"
            sequence: []
            alias: Button 4 hold
mode: parallel
max: 10
max_exceeded: silent

Hi,

based on a comprehensive analysis of the log output of my Hue Dial Taps in Z2M in detail (what exactly happens for single press, hold, step/slow/fast rotation) I created - with a lot of ai help - a universal architecture to use the Hue Dial Taps in a way, that the rotary knob can be used for different devices.

I have 10 Hue Dial Taps in my apartment and they are central to controlling my different light devices (a mix of CCT, CCT/RGB, special devices like star projectors, infrared controlled candle lights, led fire place, etc.). I also will add a touch kiosk device later, but I'm a fan of still providing haptic controls in my apartment.

The control logic of the basic architecture provided here is as follows:

The Hue Dial Tap has 4 buttons and a rotary dial. Each button controls a different device. Press a button (1-4) to select the device you want to control. Now you have five seconds to send additional commands to the device by using the dial or other button actions (single press, double press, hold) all control that device. After 5 seconds the selection resets to idle.

In idle state the dial does not affect any of the four devices. Instead it can be used for something else entirely, like e.g.:

  • Controlling the volume of a speaker in the room
  • Adjusting the brightness of a whole light group
  • Any other action you want to assign

This is the example final hue dial switch configuration at my place:

Below is the complete project summary with the opportunity to just copy&paste the yamls into the needed automations and scripts + the necessary information to create the required helpers. To easily monitor the triggered button and rotate actions I highly recommend creating a dashboard in HA which shows at least these two helpers shown in the screenshot (and part of the setup instructions below). Like this I was able to do a detailed test whether the correct actions where triggered by the various possible button/dial rotate sequences:

Part 1 — Philips Hue Dial Tap Switch (RDM002): Scalable Architecture for Home Assistant via Zigbee2MQTT

This post describes a clean, scalable architecture for controlling multiple devices with one or more Philips Hue Dial Tap Switches (RDM002) in Home Assistant via Zigbee2MQTT. The design separates routing logic from device logic, so you can add new devices or change behaviour without touching the core automation.

This is the foundation. Part 2 adds working device-type automations (CCT and CCT+RGB lights). Part 3 adds idle-mode volume control for speakers.


How it works

Each dial tap has four buttons and a rotary dial. The architecture implements the following state machine:

  • Idle state — no button selected. Dial rotation is handled by a shared idle script (Part 3 uses this for speaker volume control).
  • Button 1–4 single press — selects that button's device. The dial and any further actions on that button now control that device only.
  • Button 1–4 double press — dispatches a double_press action and returns immediately to idle.
  • Button 1–4 hold — selects the device and dispatches a hold action.
  • Button 1–4 hold release — dispatches a hold_release action.
  • 5-second timeout — if no button or dial interaction happens for 5 seconds, the selection returns to idle.

All routing logic lives in one automation (works for any number of dial taps — just add more MQTT triggers). All device-specific logic lives in scripts that you fill in (Part 2).

One-switch-at-a-time lock

When several dial taps share the same room or zone, they also share selection state. To prevent two people operating two dials at once from corrupting that shared state, the router enforces a simple rule: once a switch leaves idle, it locks out every other switch until it returns to idle (via double press or the 5-second timeout). Idle dial rotation is exempt — it never sets the lock, so volume control from any idle dial always works.


What you need

  • Home Assistant with Zigbee2MQTT and MQTT integration
  • One or more Philips Hue Dial Tap Switches (RDM002) paired in Z2M
  • The friendly names of your devices from the Z2M frontend

Step 1 — Create helpers

Create these helpers via Settings → Devices & Services → Helpers, or add them to your configuration.yaml.

Input select — selected device

  • Name: Hue Dial – selected device
  • Entity ID: input_select.hue_dial_selected_device
  • Options: idle, device_1, device_2, device_3, device_4
  • Initial: idle

Input text — last button pressed (double-press detection)

  • Name: Hue Dial – last button pressed
  • Entity ID: input_text.hue_dial_last_button_pressed
  • Max length: 10
  • Initial value: 0

Input text — active switch (one-at-a-time lock)

  • Name: Hue Dial – active switch
  • Entity ID: input_text.hue_dial_active_switch
  • Max length: 50
  • Initial value: (leave empty)

Input text — status monitor

  • Name: Hue Dial – status
  • Entity ID: input_text.hue_dial_status
  • Max length: 255

Timer — idle timeout

  • Name: Hue Dial – idle timeout
  • Entity ID: timer.hue_dial_idle_timeout
  • Duration: 00:00:05

Counter — tap counter (double-press detection)

  • Name: Hue Dial – tap counter
  • Entity ID: counter.hue_dial_tap_counter
  • Minimum: 0, Step: 1, Initial: 0

Step 2 — Create the stub scripts

Create these two scripts via Settings → Automations & Scenes → Scripts → Add Script → Edit as YAML. They are stubs that fire persistent notifications so you can confirm routing works. Part 2 replaces them with real logic.

Script 1: hue_dial_idle_action

Called when the dial is rotated and no device is selected. Part 3 replaces this with speaker volume control; for now it just reports what it received.

alias: "Hue Dial – idle action"
description: >
  Dial rotated while no device is selected.
  Replaced in Part 3 with speaker volume control.
fields:
  dial_source:
    description: "Z2M friendly name of the dial"
    example: "Light Control Living Room"
  dial_action:
    description: "'brightness_step_up' or 'brightness_step_down'"
    example: "brightness_step_up"
  dial_step_size:
    description: "Raw action_step_size from Z2M (0-255)"
    example: 26
sequence:
  - action: persistent_notification.create
    data:
      title: "Hue Dial – idle"
      message: >
        [{{ dial_source }}] {{ dial_action }}
        (step_size={{ dial_step_size }})

Script 2: hue_dial_device_action

Called for all actions on a selected device. device_num tells you which button/device (1–4). Part 2 replaces this with a full assignment table.

alias: "Hue Dial – device action"
description: >
  Handles all actions for any selected device.
  device_num identifies which device (1-4) is being controlled.
fields:
  dial_source:
    description: "Z2M friendly name of the dial tap"
    example: "Light Control Living Room"
  device_num:
    description: "Which device is selected (1, 2, 3 or 4)"
    example: 1
  action_type:
    description: "'single_press' | 'double_press' | 'hold' | 'hold_release' | 'dial'"
    example: "dial"
  dial_action:
    description: "'brightness_step_up' or 'brightness_step_down' (dial events only)"
    example: "brightness_step_up"
  dial_step_size:
    description: "Raw action_step_size from Z2M (dial events only; 0 for button events)"
    example: 26
sequence:
  - variables:
      dial_action:    "{{ dial_action | default('brightness_step_up') }}"
      dial_step_size: "{{ dial_step_size | default(0) | int(0) }}"
  - action: persistent_notification.create
    data:
      title: "Hue Dial – device {{ device_num }}"
      message: >
        [{{ dial_source }}] {{ action_type }}
        {{ dial_action if action_type == 'dial' else '' }}
        (step_size={{ dial_step_size }})

Step 3 — Create the router automation

Replace the topic list with your own Z2M friendly names. Add or remove trigger blocks as needed.

alias: "Hue Dial Tap – all devices router"
description: >
  v22: Routes Zigbee2MQTT events from all Hue Dial Tap Switches to shared scripts.
  Only one switch can be active at a time — when a switch exits idle, it locks out
  all other switches until it returns to idle. The active switch is tracked in
  input_text.hue_dial_active_switch.
mode: parallel
max: 50
max_exceeded: silent

triggers:
  - trigger: mqtt
    topic: zigbee2mqtt/YOUR DIAL TAP NAME HERE
  # Add more devices:
  # - trigger: mqtt
  #   topic: zigbee2mqtt/Light Control Bedroom

conditions:
  - condition: template
    value_template: >
      {{ trigger.payload_json.action not in [none, ''] }}

actions:
  - variables:
      z2m_action:     "{{ trigger.payload_json.action }}"
      dial_step_size: "{{ trigger.payload_json.action_step_size | int(0) }}"
      dial_source:    "{{ trigger.topic | replace('zigbee2mqtt/', '') }}"
      selected:       "{{ states('input_select.hue_dial_selected_device') }}"
      device_num: >
        {{ selected | regex_replace(find='device_', replace='') if selected != 'idle' else '0' }}
      active_switch:  "{{ states('input_text.hue_dial_active_switch') }}"

  # ── IGNORE dial_rotate_* (duplicate of brightness_step_*) ─────────────────
  - if:
      - condition: template
        value_template: "{{ z2m_action is match('^dial_rotate_') }}"
    then:
      - stop: "dial_rotate ignored"

  # ── ONE-AT-A-TIME LOCK: drop events from other switches while one is active
  - if:
      - condition: template
        value_template: "{{ active_switch not in ['', 'unknown', dial_source] }}"
    then:
      - stop: "Another switch is active"

  # ── DIAL EVENT ─────────────────────────────────────────────────────────────
  # dial_step_size > 0 filters out the spurious brightness_step_* that fires
  # during a button hold (which has action_step_size: null → 0).
  - if:
      - condition: template
        value_template: >
          {{ z2m_action in ['brightness_step_up', 'brightness_step_down'] and dial_step_size > 0 }}
    then:
      - action: timer.start
        target:
          entity_id: timer.hue_dial_idle_timeout
      - if:
          - condition: state
            entity_id: input_select.hue_dial_selected_device
            state: idle
        then:
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_status
            data:
              value: "[{{ dial_source }}] idle | {{ z2m_action }} (step_size={{ dial_step_size }})"
          - action: script.hue_dial_idle_action
            data:
              dial_source:    "{{ dial_source }}"
              dial_action:    "{{ z2m_action }}"
              dial_step_size: "{{ dial_step_size }}"
        else:
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_status
            data:
              value: "[{{ dial_source }}] device_{{ device_num }} | {{ z2m_action }} (step_size={{ dial_step_size }})"
          - action: script.hue_dial_device_action
            data:
              dial_source:    "{{ dial_source }}"
              device_num:     "{{ device_num }}"
              action_type:    dial
              dial_action:    "{{ z2m_action }}"
              dial_step_size: "{{ dial_step_size }}"
      - stop: "Dial event dispatched"

  # ── BUTTON PRESS: increment counter, reset only on button change ───────────
  - if:
      - condition: template
        value_template: "{{ z2m_action is match('^button_[1-4]_press$') }}"
    then:
      - variables:
          btn_num: "{{ z2m_action[7] }}"
      - if:
          - condition: template
            value_template: >
              {{ btn_num | int(0) != states('input_text.hue_dial_last_button_pressed') | int(0) }}
        then:
          - action: counter.reset
            target:
              entity_id: counter.hue_dial_tap_counter
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_last_button_pressed
            data:
              value: "{{ btn_num }}"
      - action: counter.increment
        target:
          entity_id: counter.hue_dial_tap_counter
      - stop: "Press recorded"

  # ── BUTTON PRESS RELEASE: single or double press resolution ───────────────
  - if:
      - condition: template
        value_template: "{{ z2m_action is match('^button_[1-4]_press_release$') }}"
    then:
      - variables:
          btn_num: "{{ z2m_action[7] }}"
      - delay:
          milliseconds: 400
      - if:
          - condition: template
            value_template: >
              {{ states('input_text.hue_dial_last_button_pressed') == '0' }}
        then:
          - stop: "Consumed by double press instance"

      # ── DOUBLE PRESS: dispatch, then return immediately to idle ──────────
      - if:
          - condition: template
            value_template: >
              {{ states('counter.hue_dial_tap_counter') | int(0) > 1 }}
        then:
          - action: counter.reset
            target:
              entity_id: counter.hue_dial_tap_counter
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_last_button_pressed
            data:
              value: "0"
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_status
            data:
              value: "[{{ dial_source }}] button_{{ btn_num }} | double_press"
          - action: input_select.select_option
            target:
              entity_id: input_select.hue_dial_selected_device
            data:
              option: idle
          - action: timer.cancel
            target:
              entity_id: timer.hue_dial_idle_timeout
          - action: input_text.set_value
            target:
              entity_id: input_text.hue_dial_active_switch
            data:
              value: ""
          - action: script.hue_dial_device_action
            data:
              dial_source: "{{ dial_source }}"
              device_num:  "{{ btn_num }}"
              action_type: double_press
          - stop: "Double press dispatched"

      # ── SINGLE PRESS: select device, lock this switch ────────────────────
      - action: counter.reset
        target:
          entity_id: counter.hue_dial_tap_counter
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_last_button_pressed
        data:
          value: "0"
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_status
        data:
          value: "[{{ dial_source }}] button_{{ btn_num }} | single_press → device_{{ btn_num }} selected"
      - action: input_select.select_option
        target:
          entity_id: input_select.hue_dial_selected_device
        data:
          option: "device_{{ btn_num }}"
      - action: timer.start
        target:
          entity_id: timer.hue_dial_idle_timeout
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_active_switch
        data:
          value: "{{ dial_source }}"
      - action: script.hue_dial_device_action
        data:
          dial_source: "{{ dial_source }}"
          device_num:  "{{ btn_num }}"
          action_type: single_press
      - stop: "Device {{ btn_num }} selected"

  # ── BUTTON HOLD ────────────────────────────────────────────────────────────
  - if:
      - condition: template
        value_template: "{{ z2m_action is match('^button_[1-4]_hold$') }}"
    then:
      - variables:
          btn_num: "{{ z2m_action[7] }}"
      - if:
          - condition: template
            value_template: >
              {{ states('input_select.hue_dial_selected_device') != 'device_' ~ btn_num }}
        then:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.hue_dial_cct_rgb_switch_next
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.hue_dial_cct_rgb_in_switch
      - action: input_select.select_option
        target:
          entity_id: input_select.hue_dial_selected_device
        data:
          option: "device_{{ btn_num }}"
      - action: counter.reset
        target:
          entity_id: counter.hue_dial_tap_counter
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_last_button_pressed
        data:
          value: "0"
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_status
        data:
          value: "[{{ dial_source }}] button_{{ btn_num }} | hold"
      - action: timer.start
        target:
          entity_id: timer.hue_dial_idle_timeout
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_active_switch
        data:
          value: "{{ dial_source }}"
      - action: script.hue_dial_device_action
        data:
          dial_source: "{{ dial_source }}"
          device_num:  "{{ btn_num }}"
          action_type: hold
      - stop: "Hold dispatched"

  # ── BUTTON HOLD RELEASE ────────────────────────────────────────────────────
  - if:
      - condition: template
        value_template: "{{ z2m_action is match('^button_[1-4]_hold_release$') }}"
    then:
      - variables:
          btn_num: "{{ z2m_action[7] }}"
      - action: input_text.set_value
        target:
          entity_id: input_text.hue_dial_status
        data:
          value: "[{{ dial_source }}] button_{{ btn_num }} | hold_release"
      - action: timer.start
        target:
          entity_id: timer.hue_dial_idle_timeout
      - action: script.hue_dial_device_action
        data:
          dial_source: "{{ dial_source }}"
          device_num:  "{{ btn_num }}"
          action_type: hold_release
      - stop: "Hold release dispatched"

trace:
  stored_traces: 20

Note on the hold handler: it references input_boolean.hue_dial_cct_rgb_switch_next and input_boolean.hue_dial_cct_rgb_in_switch. Those helpers are introduced in Part 2. If you are running Part 1 standalone, either create them as simple toggle helpers or remove the two input_boolean.turn_off actions from the hold branch.


Step 4 — Create the idle timeout automation

alias: "Hue Dial Tap – idle timeout (shared)"
description: "Returns dial to idle state after 5s of inactivity. Shared across all dial tap devices."
mode: single

triggers:
  - trigger: event
    event_type: timer.finished
    event_data:
      entity_id: timer.hue_dial_idle_timeout

actions:
  - action: input_select.select_option
    target:
      entity_id: input_select.hue_dial_selected_device
    data:
      option: idle
  - action: input_text.set_value
    target:
      entity_id: input_text.hue_dial_active_switch
    data:
      value: ""

trace:
  stored_traces: 20

Part 2 extends this automation to also reset the CCT+RGB hold-state helpers on idle.


Step 5 — Monitor and test

Add an Entities card to your dashboard with:

  • input_select.hue_dial_selected_device — current selection
  • input_text.hue_dial_active_switch — which dial currently holds the lock
  • input_text.hue_dial_status — last action with source dial

With the stub scripts in place, every action creates a persistent notification showing exactly what was detected. Once you confirm routing works, move on to Part 2 to replace the stubs with real device logic.


How to extend

Adding a dial tap

Add one trigger block to the router automation:

  - trigger: mqtt
    topic: zigbee2mqtt/YOUR NEW DIAL NAME

Dial step size

The dial_step_size field carries the raw Zigbee brightness unit (0–255). Observed values:

  • One detent: ~8
  • Slow continuous turn: ~20–40 per message
  • Fast spin: ~50–255 per message

A single physical turn often emits a short burst of several brightness_step_* messages with different step sizes. Part 3 debounces these for smooth volume control.

Notes

  • All dial taps share the same selection state — only one device can be selected at a time across all dials, and the active-switch lock enforces one operator at a time. This is intentional for a single room/zone setup.
  • Double-press detection uses a 400 ms window. Two taps within 400 ms = double press, which dispatches and returns to idle immediately.
  • A hold interaction never fires press_release, so hold and single/double press are cleanly separated.
  • The brightness_step_* event that fires during a button hold (with action_step_size: null) is filtered out by the dial_step_size > 0 guard.

Part 2:

After creating the basic architecture I created automations for the first two light types in my apartment: CCT and CCT/RGB. Later I will add more for the other light types (RGB, ir controlled candles, led fireplace, laser/led star projector, etc.). The central part for controlling these two light types is "press hold" which triggers cycling through color temperature / colors as long as you hold press the button on the Hue Dial Switch.

One (more or less) important remark: My approach is to reuse automations/helpers for the same light types (CCT, CCT/RGB, RGB, etc.) to not work with too many automations/helpers in my setup with 10 Hue Dial Taps and about 35 different light devices that I control with them. This comes with trade-off that I accepted for the benefit of less files: If two or more buttons on different Hue Dial Switches were pressed/held at the same time (e.g. by two people in different rooms) then this can lead to unexpected behaviour of the selected lights. Since this is a very unlikely or such rare possible scenario in my environment, I'm fine with this.

The chart below is not perfect, but it's hopefully a bit useful to get a better high-level picture of the project logic:

Device Type Automations: CCT and CCT+RGB Lights

This is a follow-up to Part 1, which describes the core routing architecture for the Philips Hue Dial Tap Switch (RDM002). Part 1 sets up the infrastructure and stub scripts. This part adds two working device-type automations and replaces the stub device-action script with a real assignment table.


What this part adds

  • A clean assignment table in the device action script — one line per button, no helpers needed for configuration
  • CCT light automation — white spectrum lights (brightness + colour-temperature cycling)
  • CCT+RGB light automation — colour + white spectrum lights with mode toggling
  • An updated idle timeout automation that resets CCT+RGB hold state on idle

Step 1 — Additional helpers

Create these helpers in addition to those from Part 1.

For CCT lights:

Toggle (input_boolean)

  • Name: Hue Dial – CCT warming
  • Entity ID: input_boolean.hue_dial_cct_warming
  • Initial: on

Number (input_number)

  • Name: Hue Dial – CCT position
  • Entity ID: input_number.hue_dial_cct_position
  • Min: 150 — Max: 500 — Step: 1 — Initial: 350
  • Note: the wide range covers devices from 158 mired (Gledopto) to 500 mired. IKEA bulbs stay within their own limits automatically.

For CCT+RGB lights (4 more helpers):

Dropdown (input_select)

  • Name: Hue Dial – CCT+RGB mode
  • Entity ID: input_select.hue_dial_cct_rgb_mode
  • Options: cct, rgb — Initial: cct

Number (input_number)

  • Name: Hue Dial – RGB hue position
  • Entity ID: input_number.hue_dial_rgb_hue_position
  • Min: 0 — Max: 360 — Step: 1 — Initial: 0

Toggle (input_boolean)

  • Name: Hue Dial – CCT+RGB switch next
  • Entity ID: input_boolean.hue_dial_cct_rgb_switch_next
  • Initial: off

Toggle (input_boolean)

  • Name: Hue Dial – CCT+RGB in switch
  • Entity ID: input_boolean.hue_dial_cct_rgb_in_switch
  • Initial: off

Step 2 — Replace the device action script

Replace the stub hue_dial_device_action script from Part 1 with this version. It has two sections:

  • Assignment table — the only place you ever edit. One line per button: device_type|entity_id.
  • Dispatch — routes each device type to its automation via a custom event. Only touch this when adding a new device type.

A note on formatting

The assignment table uses a literal block scalar (|) with Jinja whitespace-control markers ({%- ... -%}). This keeps every assignment on its own line in the editor and survives the Home Assistant YAML editor's save/reformat cycle. (A folded scalar > gets collapsed to one line on save; a literal | does not.) The whitespace markers strip the structural newlines so each branch renders to exactly its value, e.g. cct_light|light.sofa_light, with no stray whitespace for .split('|') to trip over.

alias: "Hue Dial – device action"
description: >
  Single source of truth for all button assignments.
  Edit via: Settings → Scripts → this script → Edit as YAML (top right).
  ASSIGNMENT TABLE: one line per assigned button, format: device_type|entity_id
  DISPATCH: one choose branch per device type — only edit when adding a new type.
fields:
  dial_source:
    description: "Z2M friendly name of the dial tap"
    example: "Light Control Sofa 3"
  device_num:
    description: "Button / device number (1-4)"
    example: 4
  action_type:
    description: "'single_press' | 'double_press' | 'hold' | 'hold_release' | 'dial'"
    example: "dial"
  dial_action:
    description: "'brightness_step_up' or 'brightness_step_down' (dial only)"
    example: "brightness_step_up"
  dial_step_size:
    description: "Raw Z2M action_step_size (dial only)"
    example: 26
sequence:
  - variables:
      dial_action:    "{{ dial_action    | default('brightness_step_up') }}"
      dial_step_size: "{{ dial_step_size | default(0) | int(0) }}"
      brightness_pct: >
        {{ [1, [(dial_step_size | int(0) / 2.55) | round(0) | int, 100] | min] | max }}
      d: "{{ dial_source }}"
      n: "{{ device_num | int }}"
      # ════════════════════════════════════════════════════════════════════════
      # ASSIGNMENT TABLE  ← edit this section to assign devices to buttons
      # Format per line:  device_type|entity_id     (unassigned: none)
      # Device types:
      #   cct_light      — white spectrum light (brightness + colour temp)
      #   cct_rgb_light  — colour + white spectrum light (CCT and RGB modes)
      # ════════════════════════════════════════════════════════════════════════
      assignment: |
        {%- if   d == 'Light Control Sofa 3'     and n == 1 -%}none
        {%- elif d == 'Light Control Sofa 3'     and n == 2 -%}none
        {%- elif d == 'Light Control Sofa 3'     and n == 3 -%}none
        {%- elif d == 'Light Control Sofa 3'     and n == 4 -%}cct_light|light.sofa_light
        {%- elif d == 'Light Control Bedroom 1'  and n == 1 -%}cct_rgb_light|light.ceiling_light_bedroom
        {%- elif d == 'Light Control Bedroom 1'  and n == 2 -%}cct_light|light.bed_reading_light
        {%- elif d == 'Light Control Bedroom 1'  and n == 3 -%}none
        {%- elif d == 'Light Control Bedroom 1'  and n == 4 -%}cct_rgb_light|light.window_light_bedroom
        {%- elif d == 'Light Control Bathroom'   and n == 1 -%}none
        {%- elif d == 'Light Control Bathroom'   and n == 2 -%}none
        {%- elif d == 'Light Control Bathroom'   and n == 3 -%}cct_rgb_light|light.bathroom_ambient_light_mirror_and_ceiling
        {%- elif d == 'Light Control Bathroom'   and n == 4 -%}cct_light|light.mirror_light_bathroom
        {%- elif d == 'Light Control Living Room' and n == 1 -%}cct_light|light.arc_light
        {%- elif d == 'Light Control Living Room' and n == 2 -%}none
        {%- elif d == 'Light Control Living Room' and n == 3 -%}cct_rgb_light|light.window_light_living_room
        {%- elif d == 'Light Control Living Room' and n == 4 -%}none
        {%- elif d == 'Light Control Kitchen'    and n == 1 -%}cct_light|light.bench_light
        {%- elif d == 'Light Control Kitchen'    and n == 2 -%}none
        {%- elif d == 'Light Control Kitchen'    and n == 3 -%}cct_rgb_light|light.wall_light_kitchen
        {%- elif d == 'Light Control Kitchen'    and n == 4 -%}cct_rgb_light|light.kitchen_ambient_light_cabinets_and_ceiling
        {# Add all your dial taps and buttons here following the same pattern #}
        {%- else -%}none
        {%- endif -%}
      assigned_type:   "{{ assignment.split('|')[0] | trim }}"
      assigned_entity: "{{ assignment.split('|')[1] | trim if '|' in assignment else '' }}"
  # ════════════════════════════════════════════════════════════════════════════
  # DISPATCH  ← only edit when adding a new device type
  # ════════════════════════════════════════════════════════════════════════════
  - choose:
      - conditions: "{{ assigned_type == 'cct_light' }}"
        sequence:
          - event: hue_dial_cct_light_action
            event_data:
              entity_id:      "{{ assigned_entity }}"
              action_type:    "{{ action_type }}"
              dial_action:    "{{ dial_action }}"
              dial_step_size: "{{ dial_step_size }}"
              brightness_pct: "{{ brightness_pct }}"
      - conditions: "{{ assigned_type == 'cct_rgb_light' }}"
        sequence:
          - event: hue_dial_cct_rgb_light_action
            event_data:
              entity_id:      "{{ assigned_entity }}"
              action_type:    "{{ action_type }}"
              dial_action:    "{{ dial_action }}"
              dial_step_size: "{{ dial_step_size }}"
              brightness_pct: "{{ brightness_pct }}"

To assign a button, find its line and change none to device_type|light.your_entity. List all your dials and buttons in the one if/elif chain (the example above shows a representative subset — extend it with every button you use).


Step 3 — Update the idle timeout automation

The CCT+RGB automation uses two hold-state helpers that must be reset when the dial returns to idle. Replace the Part 1 idle timeout automation with this version (it keeps the active-switch reset from Part 1 and adds the two boolean resets).

alias: "Hue Dial Tap – idle timeout (shared)"
description: "Returns dial to idle state after 5s of inactivity. Shared across all dial tap devices."
mode: single

triggers:
  - trigger: event
    event_type: timer.finished
    event_data:
      entity_id: timer.hue_dial_idle_timeout

actions:
  - action: input_select.select_option
    target:
      entity_id: input_select.hue_dial_selected_device
    data:
      option: idle
  - action: input_text.set_value
    target:
      entity_id: input_text.hue_dial_active_switch
    data:
      value: ""
  - action: input_boolean.turn_off
    target:
      entity_id: input_boolean.hue_dial_cct_rgb_switch_next
  - action: input_boolean.turn_off
    target:
      entity_id: input_boolean.hue_dial_cct_rgb_in_switch

trace:
  stored_traces: 20

Step 4 — CCT light automation

Handles any white spectrum light. The entity to control is passed in the event, so one automation serves all your CCT lights.

Actions:

  • Single press → brightness flash (selection feedback). On a powered-off light it simply turns the light on.
  • Double press → turn off
  • Hold → cycle colour temperature cold→warm→cold continuously
  • Dial → adjust brightness

Implementation notes:

  • Uses color_temp_kelvin (not color_temp) as required by modern HA
  • Colour-temp boundaries are read from state_attr(entity_id, 'min_mireds') / max_mireds, so the correct range is used automatically per device
  • The hold step is derived from the device's actual mired range ((max − min) / 8.75) so the full range is always covered, with a cycle time of roughly 7–8 seconds regardless of device
  • Position is tracked in input_number.hue_dial_cct_position rather than read back from the light, avoiding stale state between rapid hold events
alias: "Hue Dial – CCT light control"
description: >
  Handles all Hue Dial Tap actions for CCT (white spectrum) lights.
  Triggered by hue_dial_cct_light_action event from script.hue_dial_device_action.
  Actions:
    single_press  → brightness flash (selection feedback)
    double_press  → turn off
    hold          → cycle colour temperature cold→warm→cold
    hold_release  → cycling stops (hold events simply stop arriving)
    dial right    → increase brightness
    dial left     → decrease brightness
  Requires helpers:
    input_boolean.hue_dial_cct_warming
    input_number.hue_dial_cct_position
mode: parallel
max: 20
max_exceeded: silent

triggers:
  - trigger: event
    event_type: hue_dial_cct_light_action

actions:
  - variables:
      entity_id:      "{{ trigger.event.data.entity_id }}"
      action_type:    "{{ trigger.event.data.action_type }}"
      dial_action:    "{{ trigger.event.data.dial_action | default('brightness_step_up') }}"
      dial_step_size: "{{ trigger.event.data.dial_step_size | int(0) }}"
      brightness_pct: >
        {{ [1, [(trigger.event.data.dial_step_size | int(0) / 2.55) | round(0) | int, 100] | min] | max }}

  - choose:

      # ── Single press ──────────────────────────────────────────────────────
      - conditions: "{{ action_type == 'single_press' }}"
        sequence:
          - if:
              - condition: template
                value_template: "{{ states(entity_id) == 'on' }}"
            then:
              - action: input_number.set_value
                target:
                  entity_id: input_number.hue_dial_cct_position
                data:
                  value: "{{ state_attr(entity_id, 'color_temp') | int(350) }}"
              - variables:
                  current_pct: "{{ ((state_attr(entity_id, 'brightness') | int(0)) / 2.55) | round(0) | int }}"
                  flash_pct:   "{{ [current_pct + 40, 100] | min if current_pct <= 60 else [current_pct - 40, 1] | max }}"
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"
                data:
                  brightness_pct: "{{ flash_pct }}"
                  transition: 0.3
              - delay:
                  milliseconds: 2000
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"
                data:
                  brightness_pct: "{{ current_pct }}"
                  transition: 0.3
            else:
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"

      # ── Double press: turn off ─────────────────────────────────────────────
      - conditions: "{{ action_type == 'double_press' }}"
        sequence:
          - action: light.turn_off
            target:
              entity_id: "{{ entity_id }}"

      # ── Hold: cycle colour temperature ────────────────────────────────────
      - conditions: "{{ action_type == 'hold' }}"
        sequence:
          - variables:
              min_temp:     "{{ state_attr(entity_id, 'min_mireds') | int(150) }}"
              max_temp:     "{{ state_attr(entity_id, 'max_mireds') | int(500) }}"
              step:         "{{ ((max_temp - min_temp) / 8.75) | round(0) | int }}"
              current_temp: "{{ states('input_number.hue_dial_cct_position') | int(350) }}"
              warming:      "{{ states('input_boolean.hue_dial_cct_warming') == 'on' }}"
              new_temp:     "{{ [current_temp + step, max_temp] | min if warming else [current_temp - step, min_temp] | max }}"
          - if:
              - condition: template
                value_template: "{{ new_temp >= max_temp }}"
            then:
              - action: input_boolean.turn_off
                target:
                  entity_id: input_boolean.hue_dial_cct_warming
          - if:
              - condition: template
                value_template: "{{ new_temp <= min_temp }}"
            then:
              - action: input_boolean.turn_on
                target:
                  entity_id: input_boolean.hue_dial_cct_warming
          - action: input_number.set_value
            target:
              entity_id: input_number.hue_dial_cct_position
            data:
              value: "{{ new_temp }}"
          - action: light.turn_on
            target:
              entity_id: "{{ entity_id }}"
            data:
              color_temp_kelvin: "{{ (1000000 / new_temp) | int }}"
              transition: 0.5

      # ── Hold release: no action needed ────────────────────────────────────
      - conditions: "{{ action_type == 'hold_release' }}"
        sequence: []

      # ── Dial right: increase brightness ───────────────────────────────────
      - conditions: "{{ action_type == 'dial' and dial_action == 'brightness_step_up' }}"
        sequence:
          - action: light.turn_on
            target:
              entity_id: "{{ entity_id }}"
            data:
              brightness_step_pct: "{{ brightness_pct }}"

      # ── Dial left: decrease brightness ────────────────────────────────────
      - conditions: "{{ action_type == 'dial' and dial_action == 'brightness_step_down' }}"
        sequence:
          - action: light.turn_on
            target:
              entity_id: "{{ entity_id }}"
            data:
              brightness_step_pct: "{{ brightness_pct * -1 }}"

trace:
  stored_traces: 20

Step 5 — CCT+RGB light automation

Handles colour + white spectrum lights with two modes (CCT and RGB) switchable during use.

Actions:

  • Single press → brightness flash, detect current mode, sync position helpers. On a powered-off light it simply turns the light on.
  • Double press → turn off
  • Hold 1 → cycle in current mode (CCT or RGB)
  • Hold 2+ → toggle mode (CCT↔RGB) then immediately cycle in the new mode. Repeats on every hold until the 5-second idle timeout resets the selection.
  • Dial → adjust brightness

RGB colour algorithm: HSV hue cycling (0→360°) at full saturation, mathematically derived — no fixed colour list. Cycling the hue produces a smooth rainbow (red → orange → yellow → green → cyan → blue → violet → magenta → red) visiting every vivid spectral colour. A full loop takes roughly 15 seconds (20° per hold event at ~800 ms intervals).

Hold state machine:

Interaction switch_next in_switch Action
Single press OFF OFF Reset flags
Hold 1 events OFF OFF Cycle
Hold 1 release ON OFF Arms toggle
Hold 2 event 1 ON OFF → ON Toggle mode + cycle
Hold 2 events 2+ ON ON Cycle in new mode
Hold 2 release ON OFF Resets in_switch
Hold 3+ ON OFF Toggle again each time

Mode detection: on every hold event, stored_mode reads the light's live color_mode attribute first, so the correct mode is used even when switching directly from another button without a single press. The input_select helper is only consulted as a fallback when the light is off and color_mode is unavailable.

alias: "Hue Dial – CCT+RGB light control"
description: >
  Handles all Hue Dial Tap actions for CCT+RGB (colour + white spectrum) lights.
  Triggered by hue_dial_cct_rgb_light_action event.
  Hold 1   → cycle in current mode (CCT or RGB)
  Hold 2+  → toggle CCT↔RGB then cycle in new mode (repeats until idle)
  Requires helpers:
    input_select.hue_dial_cct_rgb_mode
    input_number.hue_dial_rgb_hue_position
    input_boolean.hue_dial_cct_rgb_switch_next
    input_boolean.hue_dial_cct_rgb_in_switch
    input_number.hue_dial_cct_position    (shared with CCT automation)
    input_boolean.hue_dial_cct_warming    (shared with CCT automation)
mode: parallel
max: 20
max_exceeded: silent

triggers:
  - trigger: event
    event_type: hue_dial_cct_rgb_light_action

actions:
  - variables:
      entity_id:      "{{ trigger.event.data.entity_id }}"
      action_type:    "{{ trigger.event.data.action_type }}"
      dial_action:    "{{ trigger.event.data.dial_action | default('brightness_step_up') }}"
      brightness_pct: >
        {{ [1, [(trigger.event.data.dial_step_size | int(0) / 2.55) | round(0) | int, 100] | min] | max }}

  - choose:

      # ── Single press ──────────────────────────────────────────────────────
      - conditions: "{{ action_type == 'single_press' }}"
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.hue_dial_cct_rgb_switch_next
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.hue_dial_cct_rgb_in_switch
          - choose:
              - conditions: "{{ state_attr(entity_id, 'color_mode') == 'color_temp' }}"
                sequence:
                  - action: input_select.select_option
                    target:
                      entity_id: input_select.hue_dial_cct_rgb_mode
                    data:
                      option: cct
                  - action: input_number.set_value
                    target:
                      entity_id: input_number.hue_dial_cct_position
                    data:
                      value: "{{ state_attr(entity_id, 'color_temp') | int(350) }}"
              - conditions: "{{ state_attr(entity_id, 'color_mode') in ['hs', 'rgb', 'xy'] }}"
                sequence:
                  - action: input_select.select_option
                    target:
                      entity_id: input_select.hue_dial_cct_rgb_mode
                    data:
                      option: rgb
                  - action: input_number.set_value
                    target:
                      entity_id: input_number.hue_dial_rgb_hue_position
                    data:
                      value: >
                        {{ state_attr(entity_id, 'hs_color')[0] | int(0)
                           if state_attr(entity_id, 'hs_color') else 0 }}
          - if:
              - condition: template
                value_template: "{{ states(entity_id) == 'on' }}"
            then:
              - variables:
                  current_pct: "{{ ((state_attr(entity_id, 'brightness') | int(0)) / 2.55) | round(0) | int }}"
                  flash_pct:   "{{ [current_pct + 40, 100] | min if current_pct <= 60 else [current_pct - 40, 1] | max }}"
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"
                data:
                  brightness_pct: "{{ flash_pct }}"
                  transition: 0.3
              - delay:
                  milliseconds: 2000
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"
                data:
                  brightness_pct: "{{ current_pct }}"
                  transition: 0.3
            else:
              - action: light.turn_on
                target:
                  entity_id: "{{ entity_id }}"

      # ── Double press: turn off ─────────────────────────────────────────────
      - conditions: "{{ action_type == 'double_press' }}"
        sequence:
          - action: light.turn_off
            target:
              entity_id: "{{ entity_id }}"

      # ── Hold: toggle (if armed) then always cycle ──────────────────────────
      - conditions: "{{ action_type == 'hold' }}"
        sequence:
          - variables:
              will_toggle: >
                {{ states('input_boolean.hue_dial_cct_rgb_switch_next') == 'on'
                   and states('input_boolean.hue_dial_cct_rgb_in_switch') != 'on' }}
              stored_mode: >
                {% set live = state_attr(entity_id, 'color_mode') %}
                {% if live in ['hs', 'rgb', 'xy'] %}
                  rgb
                {% elif live == 'color_temp' %}
                  cct
                {% elif states('input_select.hue_dial_cct_rgb_mode') == 'rgb' %}
                  rgb
                {% else %}
                  cct
                {% endif %}
              effective_mode: >
                {{ ('rgb' if stored_mode == 'cct' else 'cct') if will_toggle else stored_mode }}
          - if:
              - condition: template
                value_template: "{{ will_toggle }}"
            then:
              - action: input_boolean.turn_on
                target:
                  entity_id: input_boolean.hue_dial_cct_rgb_in_switch
              - action: input_select.select_option
                target:
                  entity_id: input_select.hue_dial_cct_rgb_mode
                data:
                  option: "{{ effective_mode }}"
          - choose:
              - conditions: "{{ effective_mode == 'cct' }}"
                sequence:
                  - variables:
                      min_temp:     "{{ state_attr(entity_id, 'min_mireds') | int(150) }}"
                      max_temp:     "{{ state_attr(entity_id, 'max_mireds') | int(500) }}"
                      step:         "{{ ((max_temp - min_temp) / 8.75) | round(0) | int }}"
                      current_temp: "{{ states('input_number.hue_dial_cct_position') | int(350) }}"
                      warming:      "{{ states('input_boolean.hue_dial_cct_warming') == 'on' }}"
                      new_temp:     "{{ [current_temp + step, max_temp] | min if warming else [current_temp - step, min_temp] | max }}"
                  - if:
                      - condition: template
                        value_template: "{{ new_temp >= max_temp }}"
                    then:
                      - action: input_boolean.turn_off
                        target:
                          entity_id: input_boolean.hue_dial_cct_warming
                  - if:
                      - condition: template
                        value_template: "{{ new_temp <= min_temp }}"
                    then:
                      - action: input_boolean.turn_on
                        target:
                          entity_id: input_boolean.hue_dial_cct_warming
                  - action: input_number.set_value
                    target:
                      entity_id: input_number.hue_dial_cct_position
                    data:
                      value: "{{ new_temp }}"
                  - action: light.turn_on
                    target:
                      entity_id: "{{ entity_id }}"
                    data:
                      color_temp_kelvin: "{{ (1000000 / new_temp) | int }}"
                      transition: 0.5
              - conditions: "{{ effective_mode == 'rgb' }}"
                sequence:
                  - variables:
                      current_hue: "{{ states('input_number.hue_dial_rgb_hue_position') | int(0) }}"
                      new_hue:     "{{ (current_hue + 20) % 360 }}"
                  - action: input_number.set_value
                    target:
                      entity_id: input_number.hue_dial_rgb_hue_position
                    data:
                      value: "{{ new_hue }}"
                  - action: light.turn_on
                    target:
                      entity_id: "{{ entity_id }}"
                    data:
                      hs_color: "{{ [new_hue, 100] }}"
                      transition: 0.7

      # ── Hold release: update toggle state ─────────────────────────────────
      - conditions: "{{ action_type == 'hold_release' }}"
        sequence:
          - choose:
              - conditions: "{{ states('input_boolean.hue_dial_cct_rgb_in_switch') == 'on' }}"
                sequence:
                  - action: input_boolean.turn_off
                    target:
                      entity_id: input_boolean.hue_dial_cct_rgb_in_switch
              - conditions: "{{ states('input_boolean.hue_dial_cct_rgb_switch_next') != 'on' }}"
                sequence:
                  - action: input_boolean.turn_on
                    target:
                      entity_id: input_boolean.hue_dial_cct_rgb_switch_next

      # ── Dial right: increase brightness ───────────────────────────────────
      - conditions: "{{ action_type == 'dial' and dial_action == 'brightness_step_up' }}"
        sequence:
          - action: light.turn_on
            target:
              entity_id: "{{ entity_id }}"
            data:
              brightness_step_pct: "{{ brightness_pct }}"

      # ── Dial left: decrease brightness ────────────────────────────────────
      - conditions: "{{ action_type == 'dial' and dial_action == 'brightness_step_down' }}"
        sequence:
          - action: light.turn_on
            target:
              entity_id: "{{ entity_id }}"
            data:
              brightness_step_pct: "{{ brightness_pct * -1 }}"

trace:
  stored_traces: 20

How to add more device types

  1. Assignment table — add a new device_type string, e.g. switch|switch.garden_lights
  2. Dispatch — add one choose branch in the script firing a new custom event, e.g. hue_dial_switch_action
  3. New automation — create an automation triggered by that event and handle single_press, double_press, hold, hold_release, and dial as needed

The existing CCT and CCT+RGB automations never need to be touched when adding new types.


Notes

  • color_temp_kelvin is used instead of color_temp — the latter was removed from light.turn_on in recent HA versions. Conversion: kelvin = (1000000 / mireds) | int.
  • Colour-temperature boundaries are read dynamically from min_mireds / max_mireds, so devices with different ranges (IKEA 250–454, Gledopto 158–500) are handled by the same automation. The hold step is derived from the device's actual range ((max − min) / 8.75), so the full range is always covered with a cycle time of ~7–8 s regardless of device.
  • The position helper (input_number.hue_dial_cct_position) tracks the cycling position independently rather than reading it back from the light, avoiding stale state when hold events fire faster than the Zigbee state update arrives.
  • Single press always syncs the position helper from the light's actual state before cycling, so switching between two CCT lights on different buttons always starts from the correct position.
  • On a direct hold without a preceding single press, stored_mode reads the light's live color_mode to determine the correct mode; the input_select helper is only a fallback when the light is off.
  • Single-press selection feedback uses a brightness flash rather than identify, so it works universally. The light flashes up 40% if at or below 60% brightness, or down 40% (minimum 1%) otherwise, then returns to its original brightness after 2 seconds. On a powered-off light, single press simply turns it on.

I think your AI is hallucinating a bit there, buddy :face_with_raised_eyebrow:

@ShadowFist well observed, but it's not a halluzination :grinning_face: it's a reference to a previously proposed button <-> device assignment solution which would have required separate helpers for each assignment, that would have eventually led to 40 additional helpers. I didn't want to continue with this logic, so I accepted the assignments in the script itself, although it requires a little bit of yaml editing and can't be done via ha ui editor. A purely ui based solution would have required adding a blueprint component, but I didn't want to add one more component besides automations, scripts and helpers.

I removed this misleading information. Thank you. I also just updated Part 2 after optimizing the automations further. There had been some inconsistencies in certain button hold scenarios when I changed from one light to another before the 5s idle time of the Hue Dial Switch was reached. In my setup all lights act completely independent from the state of other lights, despite the fact that they make shared use of the same helpers.

btw: everything was created in collab with Claude Sonnet 4.6, with a total investment of about 30 € (because I wanted to finish the project uninterruptedly). Without ai support this would have taken me weeks at least to develop and debug.

Here comes the final Part 3.

Short summary of Part 1 and 2 before:

Part 1 provides a mechanism / control logic to use the Hue dial tap to control 4 different light devices and use the rotary wheel for all of them to e.g. control brightness. (I have 10 Hue dial swithches in my apartment, in every room at least one).

Part 2 provides examples for controlling CCT and combined CCT/RGB lights.

  • Single button press either turns the light on or let's the already turned on light blink for identification.
  • Press hold after single press / press hold when the light is off: starts the CCT temperature or RGB color selection cycle until button is released.
  • Press hold again within 5 seconds (before Hue dial switch goes back into idle mode): Switch from CCT to RGB or vice versa and continuing with temperature / RGB color cycle.
  • Double press turns the light off (often not needed if you have e.g. presence based detection to turn off light automatically (e.g. in bathroom or kitchen).
  • Rotary wheel action (within 5 seconds after single press or press hold): change brightness of currently selected light.

Now Part 3 implements volume control for currently playing speakers in the same room of the Hue dial switch. I have several Alexa speakers, one Samsung Soundbar (SmartThings) and Sonos speakers. The Alexa and Samsung speakers were a bit tricky to implement because they have some API glitches, while the Sonos speakers are much better local smart home companions. So quite some debugging and workarounds went into the Alexa and Samsung speakers. Because I play from Spotify on my Alexa and Samung speakers (often in "Everywhere" mode on all my 9 Alexa speakers in different rooms) I also used checks against the HA Spotify integration to further stabilise the playing speaker detection and be less dependent on Alexa API and Samsung API glitches. Finally I can now comfortably and quickly adjust the volume individually for speakers in every room, especially useful also when all speakers are playing in "Everywhere" mode.

When the Hue dial taps are in idle mode (no button was pressed to control lights in the past 5 seconds) then rotary wheel action leads to a check if any speakers in the same room (area) are playing. If yes, these speakers receive a volume up or down command depending on the rotary wheel direction received.
Because one rotary wheel action on the Hue dial tap, which is more than just one step rotation, generates a burst of payloads (sometimes up to six if I do a longer rotation) I implemented a mechanism which evaluates all payloads received within 1.5 seconds and takes from these payloads the max action_step_size value. Like this I achieved I quite good consistency of my actual rotary wheel actions with the then calculated volume change in the automation.

I'm really happy with the usage experience now, for both the light control and volume control. And also for other people being at my place I'm confident that the control logic will be easy to understand, without requiring extensive instructions. With the knowledge of double press and hold options one can probably even intuitively find out the rest oneself (except the volume change functionality with the rotary wheel, that certainly needs to be mentioned, but many speakers in my apartment are also in reach so that the volume can also be changed on most speakers themselves, and a smart home kiosk will also be available in the future to allow central control off all devices in the apartment).

So here's Part 3:

Part 3 — Idle-Mode Volume Control for Speakers

This builds on Parts 1 and 2. In Part 1 the dial, when idle (no button selected), called a stub hue_dial_idle_action. This part replaces that stub with a complete speaker-volume-control feature: rotate any dial while idle and it adjusts the volume of whatever is playing in that dial's room.

It works across Sonos, Amazon Echo (alexa_devices), and SmartThings speakers, automatically discovers which speakers belong to the dial's area, figures out which are actually playing, debounces the dial's noisy event bursts, and confirms each volume change before accepting the next.


What this part does

When a dial is rotated in idle mode:

  1. Find the dial's room — derive the dial's Home Assistant area from its device registry entry.
  2. Find the speakers in that room — all media_player entities in the area belonging to alexa_devices, sonos, or smartthings.
  3. Decide which are actually playing — with integration-specific quirk handling (see below).
  4. Debounce the dial — one physical turn emits a burst of events; collapse each burst into a single volume change using the burst's largest step size.
  5. Apply the volume change — with a response curve tuned per integration, then wait for each speaker to confirm before the next change is accepted.

If nothing is playing in the room, idle rotation does nothing.


Prerequisites

  • Parts 1 and 2 installed and working
  • Your dial tap devices must be assigned to an area in Home Assistant (Settings → Devices → each "Light Control …" device → set Area). The feature derives the room from this.
  • The dial's friendly name in Z2M must match its HA device name (it does by default).
  • Speakers must also be in the correct areas.
  • Optional: the Spotify integration (media_player.spotify) — used to work around unreliable Echo state reporting.

Step 1 — Create helpers

Two new helpers for debouncing the dial's event bursts.

Number (input_number)

  • Name: Hue Dial – volume burst max step
  • Entity ID: input_number.hue_dial_volume_burst_max_step
  • Min: 0 — Max: 255 — Step: 1 — Initial: 0

Input text (input_text)

  • Name: Hue Dial – volume burst direction
  • Entity ID: input_text.hue_dial_volume_burst_direction
  • Max length: 20

How playing detection works (and why it's not trivial)

Detecting whether a speaker is "playing" sounds simple but each integration misbehaves differently:

  • Sonos / SmartThings report a usable state (playing, paused, idle), but some also report on instead of playing, and a soundbar on a TV input (source: digital) with track metadata present is really just idle passthrough, not music.
  • Echo / alexa_devices is the worst offender: it frequently reports playing for hours after playback actually stopped. To work around this we cross-check against Spotify.

So a speaker counts as playing when:

  1. Its state is playing or on, and
  2. It has real content — at least one of media_title, media_artist, source is non-empty, and
  3. It is not an idle TV passthrough (source: digital with both title and artist present), and
  4. If it is an Echo speaker, Spotify must also be playing and Spotify's source must point at this speaker (by device name, or a group like Everywhere / All Speakers, or a named multi-room group).

There's also a Spotify fallback: if Spotify is playing and its source matches a speaker in the room, that speaker is included even if its own reported state is unreliable.

The group-name matching (e.g. Echo Bathroom All → the two bathroom Echos) is specific to one setup. Adjust those names to match your own Spotify/Echo group names, or remove them.


Step 2 — Replace hue_dial_idle_action

This no longer does the work itself — it just records the burst's running maximum step size and direction, then calls the debounce script. Every event in a burst updates the max; the debounce collapses them.

alias: "Hue Dial – idle action"
description: >
  Dial rotated while in idle mode. Accumulates the highest step size within a
  burst, then delegates to hue_dial_volume_debounce which (after the burst
  settles) resolves area speakers and applies volume control.
fields:
  dial_source:
    description: "Z2M friendly name of the dial"
    example: "Light Control Kitchen"
  dial_action:
    description: "brightness_step_up or brightness_step_down"
    example: "brightness_step_up"
  dial_step_size:
    description: "Raw action_step_size from Z2M (0-255)"
    example: 26
sequence:
  - variables:
      prev_max: "{{ states('input_number.hue_dial_volume_burst_max_step') | int(0) }}"
      new_max:  "{{ [prev_max, dial_step_size | int(0)] | max }}"
  - action: input_number.set_value
    target:
      entity_id: input_number.hue_dial_volume_burst_max_step
    data:
      value: "{{ new_max }}"
  - action: input_text.set_value
    target:
      entity_id: input_text.hue_dial_volume_burst_direction
    data:
      value: "{{ dial_action }}"
  - action: script.hue_dial_volume_debounce
    data:
      dial_source: "{{ dial_source }}"
mode: parallel
max: 10
max_exceeded: silent

Step 3 — Create hue_dial_volume_debounce

The debounce is the heart of the burst handling. It runs with mode: restart, so each new event in the burst cancels and restarts its 1.5 s wait. Only after 1.5 s of quiet does it proceed — at which point it reads the accumulated max step, resets the accumulator, resolves the room's playing speakers, and calls the volume control script once.

This is also where all the speaker-detection logic lives.

alias: "Hue Dial – Volume Debounce"
description: >
  Debounces bursts of rotary wheel events. mode: restart means each new event
  within the burst cancels the pending wait and restarts it. After 1.5s of
  quiet, resolves area speakers and applies volume control using the highest
  accumulated step size. If no speakers are playing, idle rotation does nothing.
  Resets the accumulator afterwards.
fields:
  dial_source:
    description: "Z2M friendly name of the dial"
    example: "Light Control Kitchen"
sequence:
  - delay:
      seconds: 1.5
  - variables:
      burst_max_step:  "{{ states('input_number.hue_dial_volume_burst_max_step') | int(0) }}"
      burst_direction: "{{ states('input_text.hue_dial_volume_burst_direction') }}"
  - action: input_number.set_value
    target:
      entity_id: input_number.hue_dial_volume_burst_max_step
    data:
      value: 0
  - variables:
      dev_id: "{{ device_id(dial_source) }}"
      area:   "{{ device_attr(dev_id, 'area_id') }}"
      area_ents: "{{ area_entities(area) | list }}"
      alexa_ents: "{{ integration_entities('alexa_devices') }}"
      integration_ents: >
        {{ alexa_ents
         + integration_entities('sonos')
         + integration_entities('smartthings') }}
      speakers: >
        {{ area_ents
           | select('match', 'media_player\.')
           | select('in', integration_ents)
           | list }}
      spotify_playing: "{{ is_state('media_player.spotify', 'playing') }}"
      spotify_source:  "{{ state_attr('media_player.spotify', 'source') }}"
      playing_speakers: >
        {% set ns = namespace(result=[]) %}
        {% for s in speakers %}
          {% if states(s) in ['playing', 'on'] %}
            {% set title  = state_attr(s, 'media_title') %}
            {% set artist = state_attr(s, 'media_artist') %}
            {% set source = state_attr(s, 'source') %}
            {% set has_content = title not in [none, ''] or artist not in [none, ''] or source not in [none, ''] %}
            {% set digital_idle = source == 'digital' and title not in [none, ''] and artist not in [none, ''] %}
            {% if has_content and not digital_idle %}
              {% if s in alexa_ents %}
                {% if spotify_playing %}
                  {% set speaker_name = device_attr(device_id(s), 'name') %}
                  {% set source_match = spotify_source == speaker_name %}
                  {% set everywhere = spotify_source in ['Everywhere', 'All Speakers'] %}
                  {% set bathroom_group = spotify_source == 'Echo Bathroom All'
                     and speaker_name in ['Echo Bathroom Speaker Pair', 'Echo Pop Bathroom Door'] %}
                  {% if source_match or everywhere or bathroom_group %}
                    {% set ns.result = ns.result + [s] %}
                  {% endif %}
                {% endif %}
              {% else %}
                {% set ns.result = ns.result + [s] %}
              {% endif %}
            {% endif %}
          {% endif %}
        {% endfor %}
        {{ ns.result }}
      spotify_area_speakers: >
        {% set ns = namespace(result=[]) %}
        {% if spotify_playing %}
          {% for s in speakers %}
            {% if s not in playing_speakers %}
              {% set speaker_name = device_attr(device_id(s), 'name') %}
              {% set source_match = spotify_source == speaker_name %}
              {% set everywhere = spotify_source in ['Everywhere', 'All Speakers'] %}
              {% set bathroom_group = spotify_source == 'Echo Bathroom All'
                 and speaker_name in ['Echo Bathroom Speaker Pair', 'Echo Pop Bathroom Door'] %}
              {% if source_match or everywhere or bathroom_group %}
                {% set ns.result = ns.result + [s] %}
              {% endif %}
            {% endif %}
          {% endfor %}
        {% endif %}
        {{ ns.result }}
      combined_speakers: "{{ (playing_speakers + spotify_area_speakers) | unique | list }}"
  - if:
      - condition: template
        value_template: "{{ combined_speakers | length > 0 }}"
    then:
      - action: script.hue_dial_idle_volume_control
        data:
          playing_speakers: "{{ combined_speakers }}"
          dial_action:      "{{ burst_direction }}"
          dial_step_size:   "{{ burst_max_step }}"
mode: restart
max_exceeded: silent

Step 4 — Create hue_dial_idle_volume_control

This computes and applies the new volume for every playing speaker, then waits for confirmation. Three things make it robust:

Response curve

The dial's step size (0–255) is mapped to a volume change differently per integration:

  • Echo speakers use a four-tier curve quantised to Amazon's coarse 1/30 volume grid (each grid step = 3.3%): step ≤12 → 3.3%, 13–25 → 6.7%, 26–40 → 13.3%, 41–55 → 20%, then scaling up to ~66.7% at full speed. Echo only accepts ~30 discrete levels, so snapping to that grid avoids the "set 31% but it reports 33%" jitter.
  • Sonos / SmartThings have continuous volume, so they get a smooth linear mapping (every step size → a unique percentage, up to ~66.7% at max) for finer control.

Speaker-pair confirmation

Amazon Echo stereo pairs (e.g. media_player.bathroom_echo_bathroom_speaker_pair) have a bug (alexa_devices integration #174422): the pair entity never reflects volume changes back to HA, even though the change is applied. The individual member entities do update correctly. So for pairs we:

  • read the current volume as the average of the two members,
  • send the volume command to the pair entity,
  • confirm by checking that both members report the new value.

Map your own pairs in the pm dictionary at the top of target_volumes.

Confirmation before next change

mode: single means a new dial burst arriving while a volume change is still confirming is silently dropped. After sending, the script waits (up to 5 s) until every speaker reports the new level within a small tolerance, so each change builds cleanly on the last.

alias: "Hue Dial – Idle Volume Control"
description: >
  Sets volume on all playing speakers simultaneously, then waits for all to
  confirm the new value before accepting the next command. mode: single ensures
  commands arriving during confirmation are silently dropped. Echo speakers snap
  to the 1/30 volume grid; Echo stereo pairs use both member entities for
  confirmation since pair entities don't reliably report back volume changes.
fields:
  playing_speakers:
    description: "List of playing media_player entity IDs"
    example: "['media_player.bedroom_speaker']"
  dial_action:
    description: "brightness_step_up or brightness_step_down"
    example: "brightness_step_up"
  dial_step_size:
    description: "Raw action_step_size from Z2M (0-255)"
    example: 26
sequence:
  - variables:
      alexa_ents: "{{ integration_entities('alexa_devices') }}"
      volume_direction: "{{ 1 if dial_action == 'brightness_step_up' else -1 }}"
      # Four-tier grid-step curve for Echo (1 grid step = 1/30 = 3.3%).
      # Low end: 3.3 / 6.7 / 13.3 / 20%, then scaling to ~66.7% at step 255.
      grid_steps: >
        {% set step = dial_step_size | int(0) %}
        {% if step <= 12 %}1
        {% elif step <= 25 %}2
        {% elif step <= 40 %}4
        {% elif step <= 55 %}6
        {% else %}{{ [6 + (((step - 55) / (255 - 55)) * (20 - 6)) | round(0) | int, 20] | min }}
        {% endif %}
      volume_delta_pct: "{{ ((grid_steps | int) / 30 * 100 * volume_direction) | round(1) }}"
      target_volumes: >
        {% set gs = grid_steps | int %}
        {% set pm = {
          'media_player.bathroom_echo_bathroom_speaker_pair': [
            'media_player.bathroom_echo_pop_bathroom_mirror_left',
            'media_player.bathroom_echo_pop_bathroom_mirror_right'
          ],
          'media_player.kitchen_echo_kitchen_speaker_pair': [
            'media_player.kitchen_echo_dot_kitchen',
            'media_player.kitchen_echo_dot_kitchen_fridge'
          ],
          'media_player.living_room_echo_living_room_speaker_pair': [
            'media_player.living_room_echo_studio_living_room_black',
            'media_player.living_room_echo_studio_living_room_white'
          ]
        } %}
        {% set ns = namespace(result=[]) %}
        {% for s in playing_speakers %}
          {% set current = state_attr(s, 'volume_level') | float(0) %}
          {% set members = pm[s] if s in pm else [s] %}
          {% if members | length > 1 %}
            {% set current = (members | map('state_attr', 'volume_level') | map('float', 0) | list | sum) / (members | length) %}
          {% endif %}
          {% if s in alexa_ents %}
            {% set current_step = (current * 30) | round(0) | int %}
            {% set target_step = current_step + (gs * (volume_direction | int)) %}
            {% set target = [[target_step, 0] | max, 30] | min / 30 | round(4) %}
          {% else %}
            {% set delta = (dial_step_size | int(0) / 255 * 0.667) * (volume_direction | int) %}
            {% set target = [[current + delta, 0] | max, 1] | min | round(4) %}
          {% endif %}
          {% set ns.result = ns.result + [{'entity': s, 'current': current, 'target': target, 'confirm_via': members}] %}
        {% endfor %}
        {{ ns.result }}
  - repeat:
      for_each: "{{ target_volumes }}"
      sequence:
        - action: media_player.volume_set
          target:
            entity_id: "{{ repeat.item.entity }}"
          data:
            volume_level: "{{ repeat.item.target }}"
  - wait_template: >
      {% set ns = namespace(all_confirmed=true) %}
      {% for item in target_volumes %}
        {% set target = item.target | float %}
        {% for member in item.confirm_via %}
          {% set reported = state_attr(member, 'volume_level') | float(0) %}
          {% if (reported - target) | abs > 0.005 %}
            {% set ns.all_confirmed = false %}
          {% endif %}
        {% endfor %}
      {% endfor %}
      {{ ns.all_confirmed }}
    timeout:
      seconds: 5
    continue_on_timeout: true
  # ── Optional debug notification (remove once happy) ──────────────────────
  - variables:
      debug_lines: >
        {% set ns = namespace(lines=[]) %}
        {% for item in target_volumes %}
          {% if item.confirm_via | length > 1 %}
            {% set member_parts = namespace(parts=[]) %}
            {% for member in item.confirm_via %}
              {% set reported = state_attr(member, 'volume_level') | float(0) %}
              {% set part = member ~ ': ' ~ (reported * 100) | round(1) ~ '%' %}
              {% set member_parts.parts = member_parts.parts + [part] %}
            {% endfor %}
            {% set line = item.entity ~ ': ' ~ (item.current * 100) | round(1) ~ '% → ' ~ (item.target * 100) | round(1) ~ '% (confirmed via: ' ~ member_parts.parts | join(', ') ~ ')' %}
          {% else %}
            {% set reported = state_attr(item.confirm_via[0], 'volume_level') | float(0) %}
            {% set line = item.entity ~ ': ' ~ (item.current * 100) | round(1) ~ '% → ' ~ (item.target * 100) | round(1) ~ '% (confirmed: ' ~ (reported * 100) | round(1) ~ '%)' %}
          {% endif %}
          {% set ns.lines = ns.lines + [line] %}
        {% endfor %}
        {{ ns.lines | join('\n') }}
  - action: persistent_notification.create
    data:
      title: "Volume Control (step={{ dial_step_size }}, delta={{ volume_delta_pct }}%)"
      message: "{{ debug_lines }}"
      notification_id: "hue_dial_volume_debug_{{ now().strftime('%H%M%S') }}"
mode: single
max_exceeded: silent
trace:
  stored_traces: 20

The final variables + persistent_notification.create block is just for tuning — it prints each speaker's before → target → confirmed volume. Each notification gets a unique notification_id (timestamp), so a sequence of turns stacks up in the notification list instead of overwriting. Remove both steps once you're happy with the behaviour.


How the pieces fit together

Dial rotated in idle
   → router (Part 1) calls hue_dial_idle_action per event
        → records max step size + direction, calls hue_dial_volume_debounce
             → mode: restart → waits for the 1.5s burst to settle
                  → resolves area speakers (+ Spotify cross-check)
                  → hue_dial_idle_volume_control
                       → computes per-integration target volume
                       → sends volume_set to all speakers
                       → waits for confirmation (mode: single locks out new turns)

Tuning notes

  • Burst window — delay: 1.5 in the debounce. If single turns occasionally register as two changes, increase it slightly; if it feels laggy, reduce it.
  • Maximum change — currently ~66.7% at full-speed spin (20 of Echo's 30 grid steps). Change the 20 in the grid_steps formula and the 0.667 in the non-Echo delta to retune both consistently.
  • Low-end tiers — the four {% if step <= … %} thresholds set the fine-control feel. The defaults give four distinct small increments (3.3 / 6.7 / 13.3 / 20%).
  • Confirmation tolerance — 0.005 (0.5%). Echo confirms within ~0.3% of the grid target, so this is comfortably tight; loosen toward 0.02 if a slow speaker keeps timing out.
  • Speaker pairs — extend the pm dictionary with your own pair → members mappings.
  • Spotify group names — adjust or remove the Everywhere / All Speakers / Echo Bathroom All matches to fit your own setup.

Notes

  • The whole feature touches no per-light helpers and only the two new burst helpers, both written before they are read within a single debounce pass, so simultaneous idle rotation on two dials in different rooms is safe.
  • Idle rotation deliberately does not set the Part 1 one-at-a-time lock — volume control from any idle dial always works regardless of what another dial is doing.
  • If a room has no playing speakers, idle rotation simply does nothing.