Skip to content

Instantly share code, notes, and snippets.

@funkfinger
Last active April 25, 2026 23:44
Show Gist options
  • Select an option

  • Save funkfinger/25a1cc5be52e17f9b81ecd1baeaddaf1 to your computer and use it in GitHub Desktop.

Select an option

Save funkfinger/25a1cc5be52e17f9b81ecd1baeaddaf1 to your computer and use it in GitHub Desktop.
IKEA BILRESA scroll wheel → Sonos / media_player blueprint for Home Assistant
blueprint:
name: "IKEA BILRESA - Sonos / Media Player Controller (v1.1)"
description: >
Control a Sonos (or any media_player) with the IKEA BILRESA scroll wheel (Matter).
Three independent channels. Per channel:
- Scroll wheel: volume, track seek (next/prev), or favorite cycle.
- Single click: play/pause toggle, cycle favorites, or custom action.
- Safe-volume-on-play: when set, click play/pause from a paused/idle/off
state will first set the player to this volume, so resumes never blast.
- Double click: defaults to next track (override with custom action).
- Triple click: defaults to previous track (override with custom action).
- Hold / release: optional custom actions.
Favorites are matched against the media_player's `source_list` (Sonos favorites
appear here) and selected with `media_player.select_source`.
Note on responsiveness: the BILRESA fires one Matter event per scroll burst,
not one per tick — `totalNumberOfPressesCounted` carries the tick count.
Each gesture = one volume change scaled by tick count × step %.
domain: automation
source_url: https://github.com/funkfinger/my-brain/blob/master/home-assistant/blueprints/bilresa-sonos.yaml
input:
remote_device:
name: "Remote Control"
description: "The IKEA BILRESA scroll wheel (Matter) used as the trigger."
selector:
device:
filter:
- manufacturer: "IKEA of Sweden"
model: "BILRESA scroll wheel"
# --- CHANNEL 1 ---
header_1:
name: "Channel 1"
default: ""
selector:
constant:
value: ""
group_scroll_1:
name: "Scroll Wheel Settings"
icon: "mdi:rotate-right"
collapsed: true
input:
input_player_1:
name: "Media Player"
description: "The media_player entity controlled by this channel (e.g. a Sonos zone)."
default:
selector:
entity:
domain: media_player
input_mode_1:
name: "Scroll Function"
description: "What the wheel adjusts."
default: "volume"
selector:
select:
mode: dropdown
options:
- { label: "Volume", value: "volume" }
- { label: "Track seek (next / previous)", value: "seek" }
- { label: "Favorite cycle", value: "favorite" }
input_step_1:
name: "Volume Step (%)"
description: "Percent volume change per scroll tick (only used in Volume mode)."
default: 8
selector:
number:
min: 1
max: 25
unit_of_measurement: "%"
mode: slider
group_single_click_1:
name: "Single Click"
icon: "mdi:gesture-tap"
collapsed: true
input:
input_click_mode_1:
name: "Click Mode"
description: "What a single click does."
default: "play_pause"
selector:
select:
mode: dropdown
options:
- { label: "Play / Pause toggle", value: "play_pause" }
- { label: "Cycle favorites", value: "favorite_cycle" }
- { label: "Custom action", value: "action" }
input_safe_volume_1:
name: "Safe Volume on Play (%)"
description: >
When clicking to resume a paused/idle/off player, force the volume
to this level FIRST so it never blasts. Set to 0 to disable.
default: 20
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
input_favorites_1:
name: "Favorites / Sources"
description: >
Used by 'Cycle favorites' (single click) and 'Favorite cycle' (scroll) modes.
Enter the names exactly as they appear in the media_player's `source_list`
attribute. For Sonos, your saved favorites usually appear there.
default: []
selector:
text:
multiple: true
input_click_1:
name: "Custom Action"
description: "Action used only when Click Mode is 'Custom action'."
default: []
selector:
action: {}
group_multi_click_1:
name: "Additional Button Functions"
icon: "mdi:plus-circle-outline"
collapsed: true
input:
input_double_1:
name: "Double Click"
description: "Override action on double press. Leave empty for default: next track."
default: []
selector: { action: {} }
input_triple_1:
name: "Triple Click"
description: "Override action on triple press. Leave empty for default: previous track."
default: []
selector: { action: {} }
input_long_1:
name: "Hold"
description: "Action on long press. Leave empty for no-op."
default: []
selector: { action: {} }
input_release_1:
name: "Release"
description: "Action when releasing after a long press. Leave empty for no-op."
default: []
selector: { action: {} }
# --- CHANNEL 2 ---
header_2:
name: "Channel 2"
default: ""
selector:
constant:
value: ""
group_scroll_2:
name: "Scroll Wheel Settings"
icon: "mdi:rotate-right"
collapsed: true
input:
input_player_2:
name: "Media Player"
default:
selector:
entity:
domain: media_player
input_mode_2:
name: "Scroll Function"
default: "volume"
selector:
select:
mode: dropdown
options:
- { label: "Volume", value: "volume" }
- { label: "Track seek (next / previous)", value: "seek" }
- { label: "Favorite cycle", value: "favorite" }
input_step_2:
name: "Volume Step (%)"
default: 8
selector:
number:
min: 1
max: 25
unit_of_measurement: "%"
mode: slider
group_single_click_2:
name: "Single Click"
icon: "mdi:gesture-tap"
collapsed: true
input:
input_click_mode_2:
name: "Click Mode"
default: "play_pause"
selector:
select:
mode: dropdown
options:
- { label: "Play / Pause toggle", value: "play_pause" }
- { label: "Cycle favorites", value: "favorite_cycle" }
- { label: "Custom action", value: "action" }
input_safe_volume_2:
name: "Safe Volume on Play (%)"
default: 20
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
input_favorites_2:
name: "Favorites / Sources"
default: []
selector:
text:
multiple: true
input_click_2:
name: "Custom Action"
default: []
selector: { action: {} }
group_multi_click_2:
name: "Additional Button Functions"
icon: "mdi:plus-circle-outline"
collapsed: true
input:
input_double_2:
name: "Double Click"
default: []
selector: { action: {} }
input_triple_2:
name: "Triple Click"
default: []
selector: { action: {} }
input_long_2:
name: "Hold"
default: []
selector: { action: {} }
input_release_2:
name: "Release"
default: []
selector: { action: {} }
# --- CHANNEL 3 ---
header_3:
name: "Channel 3"
default: ""
selector:
constant:
value: ""
group_scroll_3:
name: "Scroll Wheel Settings"
icon: "mdi:rotate-right"
collapsed: true
input:
input_player_3:
name: "Media Player"
default:
selector:
entity:
domain: media_player
input_mode_3:
name: "Scroll Function"
default: "volume"
selector:
select:
mode: dropdown
options:
- { label: "Volume", value: "volume" }
- { label: "Track seek (next / previous)", value: "seek" }
- { label: "Favorite cycle", value: "favorite" }
input_step_3:
name: "Volume Step (%)"
default: 8
selector:
number:
min: 1
max: 25
unit_of_measurement: "%"
mode: slider
group_single_click_3:
name: "Single Click"
icon: "mdi:gesture-tap"
collapsed: true
input:
input_click_mode_3:
name: "Click Mode"
default: "play_pause"
selector:
select:
mode: dropdown
options:
- { label: "Play / Pause toggle", value: "play_pause" }
- { label: "Cycle favorites", value: "favorite_cycle" }
- { label: "Custom action", value: "action" }
input_safe_volume_3:
name: "Safe Volume on Play (%)"
default: 20
selector:
number:
min: 0
max: 100
unit_of_measurement: "%"
mode: slider
input_favorites_3:
name: "Favorites / Sources"
default: []
selector:
text:
multiple: true
input_click_3:
name: "Custom Action"
default: []
selector: { action: {} }
group_multi_click_3:
name: "Additional Button Functions"
icon: "mdi:plus-circle-outline"
collapsed: true
input:
input_double_3:
name: "Double Click"
default: []
selector: { action: {} }
input_triple_3:
name: "Triple Click"
default: []
selector: { action: {} }
input_long_3:
name: "Hold"
default: []
selector: { action: {} }
input_release_3:
name: "Release"
default: []
selector: { action: {} }
mode: single
max_exceeded: silent
trigger_variables:
var_remote: !input remote_device
var_events: >
{{ device_entities(var_remote) | select('match', 'event\.') | sort | list }}
temp_kanal1_rechts: "{{ var_events[0] if var_events | length > 0 else 'none' }}"
temp_kanal1_links: "{{ var_events[1] if var_events | length > 1 else 'none' }}"
temp_kanal1_klick: "{{ var_events[2] if var_events | length > 2 else 'none' }}"
temp_kanal2_rechts: "{{ var_events[3] if var_events | length > 3 else 'none' }}"
temp_kanal2_links: "{{ var_events[4] if var_events | length > 4 else 'none' }}"
temp_kanal2_klick: "{{ var_events[5] if var_events | length > 5 else 'none' }}"
temp_kanal3_rechts: "{{ var_events[6] if var_events | length > 6 else 'none' }}"
temp_kanal3_links: "{{ var_events[7] if var_events | length > 7 else 'none' }}"
temp_kanal3_klick: "{{ var_events[8] if var_events | length > 8 else 'none' }}"
trigger:
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal1_links }}" }, id: trigger_kanal1_links }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal1_rechts }}" }, id: trigger_kanal1_rechts }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal1_klick }}" }, id: trigger_kanal1_klick }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal2_links }}" }, id: trigger_kanal2_links }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal2_rechts }}" }, id: trigger_kanal2_rechts }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal2_klick }}" }, id: trigger_kanal2_klick }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal3_links }}" }, id: trigger_kanal3_links }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal3_rechts }}" }, id: trigger_kanal3_rechts }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ temp_kanal3_klick }}" }, id: trigger_kanal3_klick }
action:
# Skip if the entity went unavailable (no usable event payload)
- condition: template
value_template: "{{ trigger.event.data.new_state is not none }}"
- variables:
# Per-channel inputs
m_1: !input input_mode_1
m_2: !input input_mode_2
m_3: !input input_mode_3
s_1: !input input_step_1
s_2: !input input_step_2
s_3: !input input_step_3
p_1: !input input_player_1
p_2: !input input_player_2
p_3: !input input_player_3
f_1: !input input_favorites_1
f_2: !input input_favorites_2
f_3: !input input_favorites_3
sv_1: !input input_safe_volume_1
sv_2: !input input_safe_volume_2
sv_3: !input input_safe_volume_3
# Channel id from trigger (kanal1 / kanal2 / kanal3)
action_kanal_id: "{{ trigger.id.split('_')[1] }}"
event_attr: "{{ trigger.event.data.new_state.attributes }}"
ticks: "{{ event_attr.totalNumberOfPressesCounted | default(1) | int }}"
action_event_type: "{{ event_attr.event_type | default('none') }}"
curr_mode: "{% if action_kanal_id == 'kanal1' %}{{ m_1 }}{% elif action_kanal_id == 'kanal2' %}{{ m_2 }}{% else %}{{ m_3 }}{% endif %}"
curr_step: "{% if action_kanal_id == 'kanal1' %}{{ s_1 }}{% elif action_kanal_id == 'kanal2' %}{{ s_2 }}{% else %}{{ s_3 }}{% endif %}"
curr_player: "{% if action_kanal_id == 'kanal1' %}{{ p_1 }}{% elif action_kanal_id == 'kanal2' %}{{ p_2 }}{% else %}{{ p_3 }}{% endif %}"
curr_favs: "{% if action_kanal_id == 'kanal1' %}{{ f_1 }}{% elif action_kanal_id == 'kanal2' %}{{ f_2 }}{% else %}{{ f_3 }}{% endif %}"
curr_safe_vol: "{% if action_kanal_id == 'kanal1' %}{{ sv_1 }}{% elif action_kanal_id == 'kanal2' %}{{ sv_2 }}{% else %}{{ sv_3 }}{% endif %}"
# Direction: +1 for right (clockwise), -1 for left
direction: "{{ 1 if trigger.id.endswith('_rechts') else -1 }}"
step_val: "{{ (curr_step | int) * (ticks | int) * direction }}"
- choose:
# ----- SCROLL EVENTS -----
- conditions: "{{ trigger.id.endswith('_rechts') or trigger.id.endswith('_links') }}"
sequence:
- condition: template
value_template: "{{ curr_player not in [none, '', 'none'] }}"
- choose:
# Volume
- conditions: "{{ curr_mode == 'volume' }}"
sequence:
- variables:
current_vol: "{{ state_attr(curr_player, 'volume_level') | float(0.3) }}"
target_vol: "{{ [[current_vol + (step_val | float / 100.0), 0.0] | max, 1.0] | min }}"
- service: media_player.volume_set
target:
entity_id: "{{ curr_player }}"
data:
volume_level: "{{ target_vol }}"
# Seek (next / previous track)
- conditions: "{{ curr_mode == 'seek' }}"
sequence:
- service: "{{ 'media_player.media_next_track' if direction | int > 0 else 'media_player.media_previous_track' }}"
target:
entity_id: "{{ curr_player }}"
# Favorite cycle
- conditions: "{{ curr_mode == 'favorite' and (curr_favs | count) > 0 }}"
sequence:
- variables:
curr_source: "{{ state_attr(curr_player, 'source') }}"
idx: "{{ curr_favs.index(curr_source) if curr_source in curr_favs else 0 }}"
next_idx: "{{ ((idx | int) + (direction | int)) % (curr_favs | count) }}"
- service: media_player.select_source
target:
entity_id: "{{ curr_player }}"
data:
source: "{{ curr_favs[next_idx | int] }}"
# ----- BUTTON EVENTS -----
- conditions: "{{ trigger.id.endswith('_klick') }}"
sequence:
- variables:
cm_1: !input input_click_mode_1
cm_2: !input input_click_mode_2
cm_3: !input input_click_mode_3
click_mode: "{% if action_kanal_id == 'kanal1' %}{{ cm_1 }}{% elif action_kanal_id == 'kanal2' %}{{ cm_2 }}{% else %}{{ cm_3 }}{% endif %}"
double_action_1: !input input_double_1
double_action_2: !input input_double_2
double_action_3: !input input_double_3
triple_action_1: !input input_triple_1
triple_action_2: !input input_triple_2
triple_action_3: !input input_triple_3
- choose:
# Single press
- conditions: "{{ action_event_type == 'multi_press_1' }}"
sequence:
- choose:
- conditions: "{{ click_mode == 'play_pause' }}"
sequence:
# If resuming from paused/idle/off and a safe volume is configured,
# set the volume FIRST so play doesn't blast at the previous level.
- choose:
- conditions:
- "{{ (curr_safe_vol | int(0)) > 0 }}"
- "{{ states(curr_player) in ['paused', 'idle', 'off', 'standby'] }}"
sequence:
- service: media_player.volume_set
target:
entity_id: "{{ curr_player }}"
data:
volume_level: "{{ (curr_safe_vol | float) / 100.0 }}"
- service: media_player.media_play_pause
target:
entity_id: "{{ curr_player }}"
- conditions: "{{ click_mode == 'favorite_cycle' and (curr_favs | count) > 0 }}"
sequence:
- variables:
curr_source: "{{ state_attr(curr_player, 'source') }}"
idx: "{{ curr_favs.index(curr_source) if curr_source in curr_favs else -1 }}"
next_idx: "{{ ((idx | int) + 1) % (curr_favs | count) }}"
- service: media_player.select_source
target:
entity_id: "{{ curr_player }}"
data:
source: "{{ curr_favs[next_idx | int] }}"
- conditions: "{{ click_mode == 'action' }}"
sequence:
- choose:
- { conditions: "{{ action_kanal_id == 'kanal1' }}", sequence: !input input_click_1 }
- { conditions: "{{ action_kanal_id == 'kanal2' }}", sequence: !input input_click_2 }
- { conditions: "{{ action_kanal_id == 'kanal3' }}", sequence: !input input_click_3 }
# Double press → custom override, else next track
- conditions: "{{ action_event_type == 'multi_press_2' }}"
sequence:
- choose:
- conditions: "{{ action_kanal_id == 'kanal1' and (double_action_1 | count) > 0 }}"
sequence: !input input_double_1
- conditions: "{{ action_kanal_id == 'kanal2' and (double_action_2 | count) > 0 }}"
sequence: !input input_double_2
- conditions: "{{ action_kanal_id == 'kanal3' and (double_action_3 | count) > 0 }}"
sequence: !input input_double_3
default:
- service: media_player.media_next_track
target:
entity_id: "{{ curr_player }}"
# Triple press → custom override, else previous track
- conditions: "{{ action_event_type == 'multi_press_3' }}"
sequence:
- choose:
- conditions: "{{ action_kanal_id == 'kanal1' and (triple_action_1 | count) > 0 }}"
sequence: !input input_triple_1
- conditions: "{{ action_kanal_id == 'kanal2' and (triple_action_2 | count) > 0 }}"
sequence: !input input_triple_2
- conditions: "{{ action_kanal_id == 'kanal3' and (triple_action_3 | count) > 0 }}"
sequence: !input input_triple_3
default:
- service: media_player.media_previous_track
target:
entity_id: "{{ curr_player }}"
# Hold
- conditions: "{{ action_event_type == 'long_press' }}"
sequence:
- choose:
- { conditions: "{{ action_kanal_id == 'kanal1' }}", sequence: !input input_long_1 }
- { conditions: "{{ action_kanal_id == 'kanal2' }}", sequence: !input input_long_2 }
- { conditions: "{{ action_kanal_id == 'kanal3' }}", sequence: !input input_long_3 }
# Release after hold
- conditions: "{{ action_event_type == 'long_release' }}"
sequence:
- choose:
- { conditions: "{{ action_kanal_id == 'kanal1' }}", sequence: !input input_release_1 }
- { conditions: "{{ action_kanal_id == 'kanal2' }}", sequence: !input input_release_2 }
- { conditions: "{{ action_kanal_id == 'kanal3' }}", sequence: !input input_release_3 }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment