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:
- Find the dial's room — derive the dial's Home Assistant area from its device registry entry.
- Find the speakers in that room — all
media_player entities in the area belonging to alexa_devices, sonos, or smartthings.
- Decide which are actually playing — with integration-specific quirk handling (see below).
- 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.
- 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:
- Its state is
playing or on, and
- It has real content — at least one of
media_title, media_artist, source is non-empty, and
- It is not an idle TV passthrough (
source: digital with both title and artist present), and
- 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.