Skip to content

Instantly share code, notes, and snippets.

@mcinnes01
Last active February 23, 2026 08:01
Show Gist options
  • Select an option

  • Save mcinnes01/399132520034291b89a82659653b2893 to your computer and use it in GitHub Desktop.

Select an option

Save mcinnes01/399132520034291b89a82659653b2893 to your computer and use it in GitHub Desktop.
IKEA E2490 BILRESA Scroll Wheel
blueprint:
name: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA)
description: |
Unified controller blueprint for IKEA E2490 BILRESA scroll wheel working with Zigbee2MQTT and ZHA.
- Buttons: on, off, on_double, off_double
- Scroll: brightness_move_to_level with action_level from Zigbee2MQTT main topic, or ZHA move_to_level args
Supports light brightness, media_player volume, light color_temp, light hue.
Version: 2026-01-18
domain: automation
input:
controller_device:
name: Controller Device (Zigbee2MQTT or ZHA)
description: Select the E2490 device integrated via Zigbee2MQTT or ZHA.
selector:
device:
filter:
- integration: mqtt
- integration: zha
multiple: false
scroll_target:
name: Scroll wheel target entity (light)
selector:
entity:
filter:
- domain: light
scroll_mode:
name: Default scroll mode
description: "Default mode when automation starts or after auto-reset timeout"
default: brightness
selector:
select:
mode: dropdown
options:
- label: Brightness (light)
value: brightness
- label: Volume (media_player)
value: volume
- label: Color temperature (light)
value: color_temp
- label: Hue (light)
value: hue
volume_target:
name: Volume target (media_player)
default: null
selector:
entity:
filter:
- domain: media_player
color_temp_target:
name: Color temperature target (light)
default: null
selector:
entity:
filter:
- domain: light
hue_target:
name: Hue target (light)
default: null
selector:
entity:
filter:
- domain: light
scroll_mode_helper:
name: "(Optional) Scroll mode helper (input_select)"
description: "Provide an input_select to allow cycling scroll modes at runtime with double ON. Must have options: brightness, volume, color_temp, hue (in that order)"
default: null
selector:
entity:
filter:
- domain: input_select
last_activity_helper:
name: "(Optional) Last activity helper (input_datetime)"
description: "Provide an input_datetime to enable auto-reset after inactivity. Works with scroll mode helper and reset timeout."
default: null
selector:
entity:
filter:
- domain: input_datetime
reset_timeout:
name: "(Optional) Auto-reset timeout (seconds)"
description: "Time in seconds of inactivity before resetting to default scroll mode. Set to 0 to disable auto-reset. Requires both scroll mode helper and last activity helper."
default: 60
selector:
number:
min: 0
max: 3600
step: 1
unit_of_measurement: seconds
mode: box
volume_max:
name: "(Optional) Max volume"
description: "Upper limit for volume mode (0.0 - 1.0). Values from the wheel will be clamped to this maximum."
default: 1.0
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider
button_on_short:
name: Button ON - short press (optional)
default: []
selector:
action: {}
button_off_short:
name: Button OFF - short press (optional)
default: []
selector:
action: {}
button_on_double:
name: Button ON - double press (optional)
default: []
selector:
action: {}
button_off_double:
name: Button OFF - double press (optional)
default: []
selector:
action: {}
alias: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA)
variables:
controller_device: !input controller_device
friendly_name: "{{ device_attr(controller_device, 'name') | default('') }}"
z2m_topic: "zigbee2mqtt/{{ friendly_name }}"
scroll_mode: !input scroll_mode
scroll_mode_helper: !input scroll_mode_helper
last_activity_helper: !input last_activity_helper
reset_timeout: !input reset_timeout
effective_mode: "{{ states(scroll_mode_helper) if scroll_mode_helper is not none and states(scroll_mode_helper) != '' else scroll_mode }}"
brightness_target: !input scroll_target
volume_target: !input volume_target
color_temp_target: !input color_temp_target
hue_target: !input hue_target
volume_max: !input volume_max
# Build list of available modes based on configured targets
available_modes: >
{% set modes = [] %}
{% if brightness_target is not none %}{% set modes = modes + ['brightness'] %}{% endif %}
{% if volume_target is not none %}{% set modes = modes + ['volume'] %}{% endif %}
{% if color_temp_target is not none %}{% set modes = modes + ['color_temp'] %}{% endif %}
{% if hue_target is not none %}{% set modes = modes + ['hue'] %}{% endif %}
{{ modes }}
button_on_short_seq: !input button_on_short
button_off_short_seq: !input button_off_short
button_on_double_seq: !input button_on_double
button_off_double_seq: !input button_off_double
trigger:
# Zigbee2MQTT device triggers for discrete actions
- platform: device
id: z2m-on
domain: mqtt
device_id: !input controller_device
type: action
subtype: 'on'
- platform: device
id: z2m-off
domain: mqtt
device_id: !input controller_device
type: action
subtype: 'off'
- platform: device
id: z2m-on-double
domain: mqtt
device_id: !input controller_device
type: action
subtype: on_double
- platform: device
id: z2m-off-double
domain: mqtt
device_id: !input controller_device
type: action
subtype: off_double
# Zigbee2MQTT main topic for payload_json with action_level (subscribe wide, filter in actions)
- platform: mqtt
id: z2m-payload
topic: zigbee2mqtt/#
# ZHA events
- platform: event
id: zha-on
event_type: zha_event
event_data:
device_id: !input controller_device
command: 'on'
- platform: event
id: zha-off
event_type: zha_event
event_data:
device_id: !input controller_device
command: 'off'
- platform: event
id: zha-level
event_type: zha_event
event_data:
device_id: !input controller_device
command: move_to_level
condition: []
action:
# Check and reset mode if inactive for configured timeout
- if:
- condition: template
value_template: >
{{ scroll_mode_helper is not none
and last_activity_helper is not none
and reset_timeout > 0
and states(last_activity_helper) not in ['unknown', 'unavailable', '']
and (now() - (states(last_activity_helper) | as_datetime)).total_seconds() > reset_timeout }}
then:
- service: input_select.select_option
target:
entity_id: "{{ scroll_mode_helper }}"
data:
option: "{{ scroll_mode }}"
- choose:
# Handle Zigbee2MQTT payload with level
- conditions:
- condition: template
value_template: >
{{ trigger.id == 'z2m-payload'
and trigger.topic == z2m_topic
and trigger.payload_json is defined
and trigger.payload_json.action == 'brightness_move_to_level' }}
sequence:
- variables:
lvl: "{{ trigger.payload_json.action_level | default(none) }}"
trans: "{{ trigger.payload_json.action_transition_time | default(0) }}"
- choose:
# Brightness mode
- conditions:
- condition: template
value_template: "{{ effective_mode == 'brightness' and brightness_target is not none }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ brightness_target }}"
data:
brightness: >
{% if lvl is none %}255{% else %}{{ ((lvl|float/241.0)*255.0)|round(0)|int }}{% endif %}
transition: "{{ trans }}"
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
# Volume mode
- conditions:
- condition: template
value_template: "{{ effective_mode == 'volume' and volume_target is not none }}"
sequence:
- service: media_player.volume_set
target:
entity_id: "{{ volume_target }}"
data:
volume_level: >
{% if lvl is none %}
{{ volume_max }}
{% else %}
{{ ((lvl|float/241.0) * volume_max)|round(3) }}
{% endif %}
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
# Color temperature mode
- conditions:
- condition: template
value_template: "{{ effective_mode == 'color_temp' and color_temp_target is not none }}"
sequence:
- variables:
min_mireds: "{{ state_attr(color_temp_target, 'min_mireds') | default(153) }}"
max_mireds: "{{ state_attr(color_temp_target, 'max_mireds') | default(500) }}"
- service: light.turn_on
target:
entity_id: "{{ color_temp_target }}"
data:
color_temp: >
{% set minm = min_mireds|int %}
{% set maxm = max_mireds|int %}
{% if lvl is none %}
{{ maxm }}
{% else %}
{% set ratio = ((lvl|float - 1.0)/240.0) %}
{{ (minm + ratio*(maxm-minm))|round(0)|int }}
{% endif %}
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
# Hue mode
- conditions:
- condition: template
value_template: "{{ effective_mode == 'hue' and hue_target is not none }}"
sequence:
- variables:
current_hs: "{{ state_attr(hue_target, 'hs_color') | default([0, 100]) }}"
current_sat: "{{ current_hs[1] if current_hs is sequence and current_hs|length > 1 else 100 }}"
hue_val: >
{% if lvl is none %}360{% else %}{{ ((lvl|float/241.0)*360.0)|round(0)|int }}{% endif %}
- service: light.turn_on
target:
entity_id: "{{ hue_target }}"
data:
hs_color: "[ {{ hue_val }}, {{ current_sat }} ]"
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
# ZHA move_to_level (args contain level)
- conditions:
- condition: template
value_template: "{{ trigger.id == 'zha-level' }}"
sequence:
- variables:
lvl: >
{% set a = trigger.event.data.args | default([]) %}
{% if a is sequence and a|length > 0 %}{{ a[0] }}{% else %}{{ none }}{% endif %}
trans: >
{% set a = trigger.event.data.args | default([]) %}
{% if a is sequence and a|length > 1 %}{{ a[1] }}{% else %}0{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ effective_mode == 'brightness' and brightness_target is not none }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ brightness_target }}"
data:
brightness: >
{% if lvl is none %}255{% else %}{{ (lvl|int)|min(255)|max(1) }}{% endif %}
transition: "{{ trans }}"
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
# ON
- conditions:
- condition: template
value_template: "{{ trigger.id in ['z2m-on','zha-on'] }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: "{{ button_on_short_seq | length > 0 }}"
sequence: !input button_on_short
default:
- choose:
- conditions:
- condition: template
value_template: "{{ effective_mode == 'volume' and volume_target is not none }}"
sequence:
- service: media_player.turn_on
target:
entity_id: "{{ volume_target }}"
- conditions: []
sequence:
- service: light.turn_on
target:
entity_id: !input scroll_target
# OFF
- conditions:
- condition: template
value_template: "{{ trigger.id in ['z2m-off','zha-off'] }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: "{{ button_off_short_seq | length > 0 }}"
sequence: !input button_off_short
default:
- choose:
- conditions:
- condition: template
value_template: "{{ effective_mode == 'volume' and volume_target is not none }}"
sequence:
- service: media_player.turn_off
target:
entity_id: "{{ volume_target }}"
- conditions: []
sequence:
- service: light.turn_off
target:
entity_id: !input scroll_target
# ON DOUBLE
- conditions:
- condition: template
value_template: "{{ trigger.id == 'z2m-on-double' }}"
sequence:
- choose:
# If a mode helper is provided, cycle the mode on double ON
- conditions:
- condition: template
value_template: "{{ scroll_mode_helper is not none }}"
sequence:
# Cycle through only configured modes
- variables:
current_mode: "{{ states(scroll_mode_helper) }}"
modes_list: "{{ available_modes }}"
current_index: "{{ modes_list.index(current_mode) if current_mode in modes_list else 0 }}"
next_index: "{{ (current_index + 1) % (modes_list | length) }}"
next_mode: "{{ modes_list[next_index] if modes_list | length > 0 else 'brightness' }}"
- service: input_select.select_option
target:
entity_id: "{{ scroll_mode_helper }}"
data:
option: "{{ next_mode }}"
# Update last activity timestamp
- if:
- condition: template
value_template: "{{ last_activity_helper is not none }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: "{{ last_activity_helper }}"
data:
timestamp: "{{ now().timestamp() }}"
- choose:
- conditions:
- condition: template
value_template: "{{ button_on_double_seq | length > 0 }}"
sequence: !input button_on_double
# Fallback: no helper → preserve original default behavior
- conditions: []
sequence:
- choose:
- conditions:
- condition: template
value_template: "{{ button_on_double_seq | length > 0 }}"
sequence: !input button_on_double
- conditions: []
sequence:
- service: light.turn_on
target:
entity_id: !input scroll_target
data:
brightness: 255
# OFF DOUBLE
- conditions:
- condition: template
value_template: "{{ trigger.id == 'z2m-off-double' }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: "{{ button_off_double_seq | length > 0 }}"
sequence: !input button_off_double
default:
- service: light.turn_off
target:
entity_id: !input scroll_target
mode: restart
max_exceeded: silent
@Yann-Cloarec
Copy link

Hey! Thanks for the blueprint. I've added visual LED feedback when changing modes (double-click).
It publishes {"identify": "identify"} to the Z2M topic after mode change
to make the BILRESA's LEDs blink as confirmation.

Fork with modifications: https://gist.github.com/Yann-Cloarec/4cceef2015b210e26de42885a2562ca6

Code change (added after input_select.select_option):

# Visual LED feedback
- service: mqtt.publish
  data:
    topic: "{{ z2m_topic }}/set"
    payload: '{"identify": "identify"}'

This gives immediate feedback that the mode switch was registered,
which is especially useful when cycling through multiple modes.

What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment