Last active
April 25, 2026 23:44
-
-
Save funkfinger/25a1cc5be52e17f9b81ecd1baeaddaf1 to your computer and use it in GitHub Desktop.
IKEA BILRESA scroll wheel → Sonos / media_player blueprint for Home Assistant
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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