Skip to content

Instantly share code, notes, and snippets.

@othree
Last active September 14, 2026 02:25
Show Gist options
  • Select an option

  • Save othree/3cc6cfa27575ee96b0edb4ffc08ace3e to your computer and use it in GitHub Desktop.

Select an option

Save othree/3cc6cfa27575ee96b0edb4ffc08ace3e to your computer and use it in GitHub Desktop.
IKEA BILRESA - Live Scroll Light Controller
blueprint:
name: "IKEA BILRESA - Live Scroll Controller (v2.5.2)"
description: >
**v2.5.2** - Live, per-detent control of lights, fans and climate devices from
the IKEA BILRESA scroll wheel (Matter over Thread).
Point a channel at a light and the wheel dims it (or shifts colour
temperature / hue); point it at a fan and the wheel sets fan speed; point it
at a climate entity and the wheel moves the target temperature. The entity
type decides which - no mode switch needed.
**Requires a patched Home Assistant.** Stock HA drops the `multi_press_ongoing`
events this blueprint relies on, and caps multi-press event types at 8 while
BILRESA reports up to 18. Apply core PR #177101 (or a build that includes it)
before using this blueprint:
https://github.com/home-assistant/core/pull/177101
Unlike blueprints built on `MultiPressComplete`, this one reacts while the wheel
is still turning instead of waiting for the ~500 ms multi-press window to close.
A gesture is handled as one run: the first detent reads the light once, while
it is idle and its report can be trusted, and every later event from the remote
- which carries a running detent count - is placed at an absolute position off
that anchor. That makes the result exact however many intermediate events the
firmware sends (this remote batches them), and it needs no helper entities.
Direction comes from which endpoint fired, not from the event payload:
`newPosition` and `previousPosition` are always 1 on this remote, so clockwise
and counter-clockwise are separate entities.
Entities are selected explicitly rather than discovered by index. A patched
install exposes two event entities per endpoint (the new one plus the deprecated
one), so index-based discovery silently mismaps. Pick the entities whose
`event_types` attribute contains `multi_press_ongoing`.
domain: automation
input:
# ══════════════════════════════════════════════════════════════════
# CHANNEL 1
# ══════════════════════════════════════════════════════════════════
debug:
name: "Report unhandled events"
description: >
Raises a notification whenever an event reaches this automation but
matches no branch, listing the values that decided it. Useful while
mapping entities to channels; leave it off in normal use. If turning
the wheel raises nothing at all, the clockwise and counter-clockwise
entities are pointing at endpoints the wheel does not drive.
default: false
selector:
boolean:
header_1:
name: "Channel 1"
description: >
The remote exposes three independent channels; the bottom button cycles
between them. Channel 1 is required, channels 2 and 3 are optional.
default: ""
selector:
constant:
value: ""
group_entities_1:
name: "Remote entities"
icon: "mdi:identifier"
collapsed: false
input:
e_cw_1:
name: "Clockwise event entity"
description: >
The event entity that fires when the wheel turns clockwise on this
channel. Its `event_types` attribute must contain `multi_press_ongoing`.
default: ""
selector: { entity: { domain: event } }
e_ccw_1:
name: "Counter-clockwise event entity"
default: ""
selector: { entity: { domain: event } }
e_click_1:
name: "Button event entity"
default: ""
selector: { entity: { domain: event } }
group_scroll_1:
name: "Scroll wheel"
icon: "mdi:rotate-right"
collapsed: true
input:
light_1:
name: "Light / fan / climate (or group)"
description: >
What this channel controls. A light is dimmed (or colour-shifted,
per the scroll function below); a fan has its speed set; a climate
entity has its target temperature moved. Mixed selections use the
first entity as the reference and are driven as that kind.
default: {}
selector:
target:
entity:
- domain: light
- domain: fan
- domain: climate
mode_1:
name: "Scroll function (lights only)"
description: >
Which light property the wheel changes. Ignored for fans (always
speed) and climate entities (always target temperature).
default: "brightness"
selector:
select:
mode: dropdown
options:
- { label: "Brightness", value: "brightness" }
- { label: "Colour temperature", value: "temp_kelvin" }
- { label: "Colour", value: "hs_color" }
step_1:
name: "Step per detent"
description: >
How much a single detent changes the value: % of brightness for a
light, % of speed for a fan, x20 K for colour temperature, degrees of
hue for colour. Climate entities use the separate temperature step
below. The remote reports how many detents a gesture has covered, and
that count times this step is where the target lands - at 8% about
twelve detents sweep the whole range.
default: 8
selector:
number: { min: 1, max: 50, mode: slider }
temp_step_1:
name: "Temperature step per detent (climate only)"
description: >
Degrees the target temperature moves per detent when this channel
controls a climate entity. Ignored for lights and fans.
default: 0.5
selector:
number: { min: 0.1, max: 5, step: 0.1, unit_of_measurement: "°", mode: slider }
wheel_source_1:
name: "Wheel event source"
description: >
How the wheel drives the light.
"Per detent, immediate" opens a gesture on the first detent, reads the
light once while it is still idle, and then follows the remote's own
running detent count to an absolute position off that anchor - so
every event lands exactly where the wheel is, however many of the
intermediate events the firmware chose to send. The gesture closes on
the remote's completion event or after 1.5 s of silence. No helper
entity is involved; the anchor lives inside the running automation.
"On release, one jump" ignores the immediate events and waits for the
multi-press window to close, roughly 500 ms after you stop, then
applies the whole gesture at once. Less responsive, but it survives
firmware that reports the immediate events unreliably.
Climate entities always use "On release" regardless of this setting:
one temperature command per gesture, so the unit does not beep once
per detent.
default: "gesture"
selector:
select:
mode: dropdown
options:
- { label: "Per detent, immediate (recommended)", value: "gesture" }
- { label: "On release, one jump (multi_press_end)", value: "multi_press_end" }
transition_1:
name: "Transition"
description: >
Zero tracks the wheel most closely - each event lands at once. A
small value smooths the steps but every millisecond of it is felt
as lag behind the hand, and above roughly 300 ms a fade is still
running when the next event lands. Leave it at zero unless the
steps look harsh on your light.
default: 0
selector:
number: { min: 0, max: 500, unit_of_measurement: "ms", mode: slider }
off_at_min_1:
name: "Turning down past the bottom switches off"
description: >
On, a gesture that would take the light below its lowest level
switches it off instead of parking it at 1, so the wheel reaches
off the way a physical dimmer does. Off, the light holds its
dimmest setting and only the button can switch it off.
default: true
selector:
boolean:
off_behaviour_1:
name: "When the light is off"
default: "from_zero"
selector:
select:
mode: dropdown
options:
- { label: "Turning up fades in from minimum", value: "from_zero" }
- { label: "Ignore the wheel entirely", value: "ignore" }
group_button_1:
name: "Button actions"
icon: "mdi:gesture-tap"
collapsed: true
input:
click_mode_1:
name: "Single click"
description: >
What a single click does when no custom action is filled in below.
A filled-in "Single click action" always takes precedence over this.
default: "toggle"
selector:
select:
mode: dropdown
options:
- { label: "Toggle the light / fan / climate", value: "toggle" }
- { label: "Run the custom action below", value: "action" }
- { label: "Cycle through scenes", value: "scene_cycle" }
scenes_1:
name: "Scenes to cycle"
default: []
selector: { entity: { domain: scene, multiple: true } }
act_single_1:
name: "Single click action"
description: >
Runs on a single click. When anything is set here it is what a click
does, whatever the mode above says - so a toggle with a fixed
brightness, or any other action, just goes here.
default: []
selector: { action: {} }
act_double_1:
name: "Double click action"
default: []
selector: { action: {} }
act_triple_1:
name: "Triple click action"
default: []
selector: { action: {} }
act_hold_1:
name: "Hold action"
default: []
selector: { action: {} }
act_release_1:
name: "Release action"
description: "Fires when the button is released after a hold."
default: []
selector: { action: {} }
# ══════════════════════════════════════════════════════════════════
# CHANNEL 2
# ══════════════════════════════════════════════════════════════════
header_2:
name: "Channel 2"
default: ""
selector:
constant:
value: ""
group_entities_2:
name: "Remote entities"
icon: "mdi:identifier"
collapsed: true
input:
e_cw_2:
name: "Clockwise event entity"
default: ""
selector: { entity: { domain: event } }
e_ccw_2:
name: "Counter-clockwise event entity"
default: ""
selector: { entity: { domain: event } }
e_click_2:
name: "Button event entity"
default: ""
selector: { entity: { domain: event } }
group_scroll_2:
name: "Scroll wheel"
icon: "mdi:rotate-right"
collapsed: true
input:
light_2:
name: "Light / fan / climate (or group)"
description: >
What this channel controls. A light is dimmed (or colour-shifted,
per the scroll function below); a fan has its speed set; a climate
entity has its target temperature moved. Mixed selections use the
first entity as the reference and are driven as that kind.
default: {}
selector:
target:
entity:
- domain: light
- domain: fan
- domain: climate
mode_2:
name: "Scroll function (lights only)"
default: "brightness"
selector:
select:
mode: dropdown
options:
- { label: "Brightness", value: "brightness" }
- { label: "Colour temperature", value: "temp_kelvin" }
- { label: "Colour", value: "hs_color" }
step_2:
name: "Step per detent"
description: >
How much a single detent changes the value. Units follow the scroll
function: % for brightness, x20 K for colour temperature, degrees of
hue for colour. The remote reports how many detents a gesture has
covered, and that count times this step is where the light goes -
at 8% about twelve detents sweep the whole brightness range.
default: 8
selector:
number: { min: 1, max: 50, mode: slider }
temp_step_2:
name: "Temperature step per detent (climate only)"
description: >
Degrees the target temperature moves per detent when this channel
controls a climate entity. Ignored for lights and fans.
default: 0.5
selector:
number: { min: 0.1, max: 5, step: 0.1, unit_of_measurement: "°", mode: slider }
wheel_source_2:
name: "Wheel event source"
description: >
How the wheel drives the light.
"Per detent, immediate" opens a gesture on the first detent, reads the
light once while it is still idle, and then follows the remote's own
running detent count to an absolute position off that anchor - so
every event lands exactly where the wheel is, however many of the
intermediate events the firmware chose to send. The gesture closes on
the remote's completion event or after 1.5 s of silence. No helper
entity is involved; the anchor lives inside the running automation.
"On release, one jump" ignores the immediate events and waits for the
multi-press window to close, roughly 500 ms after you stop, then
applies the whole gesture at once. Less responsive, but it survives
firmware that reports the immediate events unreliably.
Climate entities always use "On release" regardless of this setting:
one temperature command per gesture, so the unit does not beep once
per detent.
default: "gesture"
selector:
select:
mode: dropdown
options:
- { label: "Per detent, immediate (recommended)", value: "gesture" }
- { label: "On release, one jump (multi_press_end)", value: "multi_press_end" }
transition_2:
name: "Transition"
default: 0
selector:
number: { min: 0, max: 500, unit_of_measurement: "ms", mode: slider }
off_at_min_2:
name: "Turning down past the bottom switches off"
description: >
On, a gesture that would take the light below its lowest level
switches it off instead of parking it at 1, so the wheel reaches
off the way a physical dimmer does. Off, the light holds its
dimmest setting and only the button can switch it off.
default: true
selector:
boolean:
off_behaviour_2:
name: "When the light is off"
default: "from_zero"
selector:
select:
mode: dropdown
options:
- { label: "Turning up fades in from minimum", value: "from_zero" }
- { label: "Ignore the wheel entirely", value: "ignore" }
group_button_2:
name: "Button actions"
icon: "mdi:gesture-tap"
collapsed: true
input:
click_mode_2:
name: "Single click"
description: >
What a single click does when no custom action is filled in below.
A filled-in "Single click action" always takes precedence over this.
default: "toggle"
selector:
select:
mode: dropdown
options:
- { label: "Toggle the light / fan / climate", value: "toggle" }
- { label: "Run the custom action below", value: "action" }
- { label: "Cycle through scenes", value: "scene_cycle" }
scenes_2:
name: "Scenes to cycle"
default: []
selector: { entity: { domain: scene, multiple: true } }
act_single_2:
name: "Single click action"
description: >
Runs on a single click. When anything is set here it is what a click
does, whatever the mode above says - so a toggle with a fixed
brightness, or any other action, just goes here.
default: []
selector: { action: {} }
act_double_2:
name: "Double click action"
default: []
selector: { action: {} }
act_triple_2:
name: "Triple click action"
default: []
selector: { action: {} }
act_hold_2:
name: "Hold action"
default: []
selector: { action: {} }
act_release_2:
name: "Release action"
default: []
selector: { action: {} }
# ══════════════════════════════════════════════════════════════════
# CHANNEL 3
# ══════════════════════════════════════════════════════════════════
header_3:
name: "Channel 3"
default: ""
selector:
constant:
value: ""
group_entities_3:
name: "Remote entities"
icon: "mdi:identifier"
collapsed: true
input:
e_cw_3:
name: "Clockwise event entity"
default: ""
selector: { entity: { domain: event } }
e_ccw_3:
name: "Counter-clockwise event entity"
default: ""
selector: { entity: { domain: event } }
e_click_3:
name: "Button event entity"
default: ""
selector: { entity: { domain: event } }
group_scroll_3:
name: "Scroll wheel"
icon: "mdi:rotate-right"
collapsed: true
input:
light_3:
name: "Light / fan / climate (or group)"
description: >
What this channel controls. A light is dimmed (or colour-shifted,
per the scroll function below); a fan has its speed set; a climate
entity has its target temperature moved. Mixed selections use the
first entity as the reference and are driven as that kind.
default: {}
selector:
target:
entity:
- domain: light
- domain: fan
- domain: climate
mode_3:
name: "Scroll function (lights only)"
default: "brightness"
selector:
select:
mode: dropdown
options:
- { label: "Brightness", value: "brightness" }
- { label: "Colour temperature", value: "temp_kelvin" }
- { label: "Colour", value: "hs_color" }
step_3:
name: "Step per detent"
description: >
How much a single detent changes the value. Units follow the scroll
function: % for brightness, x20 K for colour temperature, degrees of
hue for colour. The remote reports how many detents a gesture has
covered, and that count times this step is where the light goes -
at 8% about twelve detents sweep the whole brightness range.
default: 8
selector:
number: { min: 1, max: 50, mode: slider }
temp_step_3:
name: "Temperature step per detent (climate only)"
description: >
Degrees the target temperature moves per detent when this channel
controls a climate entity. Ignored for lights and fans.
default: 0.5
selector:
number: { min: 0.1, max: 5, step: 0.1, unit_of_measurement: "°", mode: slider }
wheel_source_3:
name: "Wheel event source"
description: >
How the wheel drives the light.
"Per detent, immediate" opens a gesture on the first detent, reads the
light once while it is still idle, and then follows the remote's own
running detent count to an absolute position off that anchor - so
every event lands exactly where the wheel is, however many of the
intermediate events the firmware chose to send. The gesture closes on
the remote's completion event or after 1.5 s of silence. No helper
entity is involved; the anchor lives inside the running automation.
"On release, one jump" ignores the immediate events and waits for the
multi-press window to close, roughly 500 ms after you stop, then
applies the whole gesture at once. Less responsive, but it survives
firmware that reports the immediate events unreliably.
Climate entities always use "On release" regardless of this setting:
one temperature command per gesture, so the unit does not beep once
per detent.
default: "gesture"
selector:
select:
mode: dropdown
options:
- { label: "Per detent, immediate (recommended)", value: "gesture" }
- { label: "On release, one jump (multi_press_end)", value: "multi_press_end" }
transition_3:
name: "Transition"
default: 0
selector:
number: { min: 0, max: 500, unit_of_measurement: "ms", mode: slider }
off_at_min_3:
name: "Turning down past the bottom switches off"
description: >
On, a gesture that would take the light below its lowest level
switches it off instead of parking it at 1, so the wheel reaches
off the way a physical dimmer does. Off, the light holds its
dimmest setting and only the button can switch it off.
default: true
selector:
boolean:
off_behaviour_3:
name: "When the light is off"
default: "from_zero"
selector:
select:
mode: dropdown
options:
- { label: "Turning up fades in from minimum", value: "from_zero" }
- { label: "Ignore the wheel entirely", value: "ignore" }
group_button_3:
name: "Button actions"
icon: "mdi:gesture-tap"
collapsed: true
input:
click_mode_3:
name: "Single click"
description: >
What a single click does when no custom action is filled in below.
A filled-in "Single click action" always takes precedence over this.
default: "toggle"
selector:
select:
mode: dropdown
options:
- { label: "Toggle the light / fan / climate", value: "toggle" }
- { label: "Run the custom action below", value: "action" }
- { label: "Cycle through scenes", value: "scene_cycle" }
scenes_3:
name: "Scenes to cycle"
default: []
selector: { entity: { domain: scene, multiple: true } }
act_single_3:
name: "Single click action"
description: >
Runs on a single click. When anything is set here it is what a click
does, whatever the mode above says - so a toggle with a fixed
brightness, or any other action, just goes here.
default: []
selector: { action: {} }
act_double_3:
name: "Double click action"
default: []
selector: { action: {} }
act_triple_3:
name: "Triple click action"
default: []
selector: { action: {} }
act_hold_3:
name: "Hold action"
default: []
selector: { action: {} }
act_release_3:
name: "Release action"
default: []
selector: { action: {} }
# ════════════════════════════════════════════════════════════════════════
# Every detent emits press_start, multi_press_ongoing and press_end within
# about 34 ms, and all three reach this automation because a state_changed
# trigger cannot filter on an attribute. Only the middle one drives a light,
# but under `restart` the trailing press_end would cancel that service call
# before the light answered. `parallel` lets the two no-op runs finish
# harmlessly alongside it; genuine wheel events are ~500 ms apart, so they
# never actually overlap.
# ════════════════════════════════════════════════════════════════════════
mode: parallel
max: 10
max_exceeded: silent
trigger_variables:
t_cw_1: !input e_cw_1
t_ccw_1: !input e_ccw_1
t_click_1: !input e_click_1
t_cw_2: !input e_cw_2
t_ccw_2: !input e_ccw_2
t_click_2: !input e_click_2
t_cw_3: !input e_cw_3
t_ccw_3: !input e_ccw_3
t_click_3: !input e_click_3
trigger:
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_cw_1 }}" }, id: "c1_cw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_ccw_1 }}" }, id: "c1_ccw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_click_1 }}" }, id: "c1_click" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_cw_2 }}" }, id: "c2_cw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_ccw_2 }}" }, id: "c2_ccw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_click_2 }}" }, id: "c2_click" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_cw_3 }}" }, id: "c3_cw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_ccw_3 }}" }, id: "c3_ccw" }
- { trigger: event, event_type: state_changed, event_data: { entity_id: "{{ t_click_3 }}" }, id: "c3_click" }
action:
- variables:
# The trigger ids are prefixed 'c1'/'c2'/'c3' rather than bare digits on
# purpose: a template rendering to "1" comes back as the integer 1, and
# every `ch == '1'` below would then be false, silently routing all three
# channels to channel 3's unset inputs.
dbg: !input debug
ch: "{{ trigger.id.split('_')[0] }}"
kind: "{{ trigger.id.split('_')[1] }}"
# An event entity only ever writes state from inside _trigger_event, so
# every state_changed it emits is a real event and none need filtering.
# Comparing timestamps instead would drop events silently: the state is a
# millisecond-precision stamp, and this remote can emit two inside one.
# All this rejects is the unknown placeholder an entity holds before its
# first event. Tested per branch rather than at automation level, so the
# debug arm below still reports what it rejected.
fresh: >-
{{ trigger.event.data.new_state is not none
and trigger.event.data.new_state.state not in ['unknown', 'unavailable'] }}
# ReadOnlyDict does not survive a template round-trip as a mapping;
# to_json/from_json normalises it so .get() still works below.
attrs: "{{ trigger.event.data.new_state.attributes | to_json | from_json }}"
etype: "{{ attrs.get('event_type', 'none') }}"
# Only the release event needs this: it stands in for a whole gesture and
# carries its detent total. Zero means the gesture overran MultiPressMax
# (18 detents, one full turn), so read that as a full sweep, and the
# immediate events, which carry no count at all, as a single detent.
count: "{{ (attrs.get('multi_press_count') | default(1, true) | int(1)) or 18 }}"
m_1: !input mode_1
m_2: !input mode_2
m_3: !input mode_3
st_1: !input step_1
st_2: !input step_2
st_3: !input step_3
tr_1: !input transition_1
tr_2: !input transition_2
tr_3: !input transition_3
ts_1: !input temp_step_1
ts_2: !input temp_step_2
ts_3: !input temp_step_3
ws_1: !input wheel_source_1
ws_2: !input wheel_source_2
ws_3: !input wheel_source_3
om_1: !input off_at_min_1
om_2: !input off_at_min_2
om_3: !input off_at_min_3
ob_1: !input off_behaviour_1
ob_2: !input off_behaviour_2
ob_3: !input off_behaviour_3
lt_1: !input light_1
lt_2: !input light_2
lt_3: !input light_3
curr_mode: "{% if ch == 'c1' %}{{ m_1 }}{% elif ch == 'c2' %}{{ m_2 }}{% else %}{{ m_3 }}{% endif %}"
step_size: "{% if ch == 'c1' %}{{ st_1 }}{% elif ch == 'c2' %}{{ st_2 }}{% else %}{{ st_3 }}{% endif %}"
temp_step: "{% if ch == 'c1' %}{{ ts_1 }}{% elif ch == 'c2' %}{{ ts_2 }}{% else %}{{ ts_3 }}{% endif %}"
transition_s: "{% if ch == 'c1' %}{{ tr_1 / 1000 }}{% elif ch == 'c2' %}{{ tr_2 / 1000 }}{% else %}{{ tr_3 / 1000 }}{% endif %}"
off_mode: "{% if ch == 'c1' %}{{ ob_1 }}{% elif ch == 'c2' %}{{ ob_2 }}{% else %}{{ ob_3 }}{% endif %}"
off_at_min: "{% if ch == 'c1' %}{{ om_1 }}{% elif ch == 'c2' %}{{ om_2 }}{% else %}{{ om_3 }}{% endif %}"
wheel_src_cfg: "{% if ch == 'c1' %}{{ ws_1 }}{% elif ch == 'c2' %}{{ ws_2 }}{% else %}{{ ws_3 }}{% endif %}"
target_obj: "{{ (lt_1 if ch == 'c1' else (lt_2 if ch == 'c2' else lt_3)) | to_json | from_json }}"
# A target selector can hold entities, devices, areas or labels. Flatten
# them all to a plain entity list (lights, fans, climate) so one reference
# entity can be read for the absolute modes and to decide the kind.
target_list: >
{% set t = target_obj %}
{% set ns = namespace(e=[]) %}
{% set ids = t.entity_id if t.entity_id is defined else [] %}
{% set ns.e = [ids] if ids is string else ids %}
{% set ds = t.device_id if t.device_id is defined else [] %}
{% for d in ([ds] if ds is string else ds) %}
{% set ns.e = ns.e + (device_entities(d) | select('match', '(light|fan|climate)\.') | list) %}
{% endfor %}
{% set as_ = t.area_id if t.area_id is defined else [] %}
{% for a in ([as_] if as_ is string else as_) %}
{% set ns.e = ns.e + (area_entities(a) | select('match', '(light|fan|climate)\.') | list) %}
{% endfor %}
{% set ls = t.label_id if t.label_id is defined else [] %}
{% for l in ([ls] if ls is string else ls) %}
{% set ns.e = ns.e + (label_entities(l) | select('match', '(light|fan|climate)\.') | list) %}
{% endfor %}
{{ ns.e | unique | reject('none') | list }}
ref: "{{ target_list | first | default('none') }}"
domain: "{{ ref.split('.')[0] }}"
# The entity kind picks the behaviour; the scroll-function dropdown only
# refines what a light does.
eff_mode: >-
{{ 'fan_pct' if domain == 'fan' else ('climate_temp' if domain == 'climate' else curr_mode) }}
# 'off' is the off state for lights, fans and climate alike; a light or
# fan is 'on', a climate entity reports its HVAC mode.
is_off: "{{ states(ref) == 'off' }}"
# A climate setpoint is not something to chase detent by detent: every
# command reaches the unit (often with a beep), so a climate channel
# always waits for the release event and sends one command per gesture,
# whatever the wheel-source dropdown says.
wheel_src: "{{ 'multi_press_end' if eff_mode == 'climate_temp' else wheel_src_cfg }}"
sign: "{{ 1 if kind == 'cw' else -1 }}"
# One immediate event per detent, so a fixed step per event already tracks
# how far the wheel turned. The release event is the exception: it stands
# in for the whole gesture, so it is worth its detent count.
magnitude: >
{% if wheel_src == 'multi_press_end' %}
{{ [(count | int) * (step_size | int), 100] | min }}
{% else %}
{{ step_size | int }}
{% endif %}
- choose:
# ──────────────────────────────────────────────────────────────
# Wheel
#
# The remote does not report every detent. A gesture arrives as
# InitialPress, then a single MultiPressOngoing carrying the running
# count, then MultiPressComplete with the total - so anything additive
# undercounts. Instead the gesture is one long run: the light is read
# once on the first detent, while it is idle and its report can be
# trusted, and every later event is placed at an absolute position off
# that anchor. The anchor is a run-local variable; nothing persists.
# ──────────────────────────────────────────────────────────────
- alias: "Wheel gesture"
conditions:
- condition: template
value_template: >-
{{ fresh and kind in ['cw', 'ccw'] and target_list | count > 0
and ((wheel_src == 'gesture' and etype == 'press_start')
or (wheel_src == 'multi_press_end' and etype == 'multi_press_end')) }}
sequence:
# A dark light or stopped fan has no sensible "lower" reading, and
# 'ignore' opts out of waking it, so those gestures end here. A
# climate setpoint is meaningful whether the unit is running or not,
# so it is exempt.
- condition: template
value_template: >-
{{ eff_mode == 'climate_temp'
or not (is_off and (off_mode == 'ignore' or (sign | int) == -1)) }}
- variables:
ent: "{{ trigger.event.data.entity_id }}"
base_br: "{{ 0 if is_off else state_attr(ref, 'brightness') | int(128) }}"
base_pct: "{{ 0 if is_off else state_attr(ref, 'percentage') | int(0) }}"
base_temp: "{{ state_attr(ref, 'temperature') | float(24) }}"
t_min: "{{ state_attr(ref, 'min_temp') | float(16) }}"
t_max: "{{ state_attr(ref, 'max_temp') | float(30) }}"
base_k: "{{ state_attr(ref, 'color_temp_kelvin') | int(3000) }}"
k_min: "{{ state_attr(ref, 'min_color_temp_kelvin') | int(2000) }}"
k_max: "{{ state_attr(ref, 'max_color_temp_kelvin') | int(6535) }}"
hs_now: "{{ state_attr(ref, 'hs_color') | default([0, 100], true) }}"
cmode: "{{ state_attr(ref, 'color_mode') }}"
base_hue: "{{ hs_now[0] | float(0) }}"
sat: "{{ hs_now[1] | float(100) if cmode in ['hs', 'xy', 'rgb', 'rgbw', 'rgbww'] else 100 }}"
# InitialPress carries no count and is the first detent; the
# release event carries the gesture total.
n: "{{ count | int }}"
# when this gesture's opening event landed - anything newer on
# the entity happened after we started
t0: "{{ as_timestamp(states[ent].last_changed, 0) }}"
first: true
apply_now: true
done: "{{ wheel_src != 'gesture' }}"
seen: "{{ [etype] }}"
applied: 1
- repeat:
until: "{{ done }}"
sequence:
# Every pass but the first blocks on the next event from the
# same entity - but then reads the entity's *current* state
# rather than trusting the wait. A wait only sees events that
# arrive while it is armed, and the light.turn_on just above
# blocks for a Matter round trip, so a fast spin's batched
# ongoing event often lands in that gap. The entity itself
# always holds the newest event, so reading it catches up.
# The remote's completion event or a fresh InitialPress (a new
# gesture, which spawns its own run) ends this one; so does
# silence, so a lost event cannot pin it.
- if:
- condition: template
value_template: "{{ not first }}"
then:
- wait_for_trigger:
- trigger: event
event_type: state_changed
event_data:
entity_id: "{{ ent }}"
timeout: "00:00:01.5"
continue_on_timeout: true
- variables:
cur_ev: "{{ state_attr(ent, 'event_type') }}"
cur_t: "{{ as_timestamp(states[ent].last_changed, 0) }}"
# zero means the count overran MultiPressMax - a full turn
cur_n: "{{ (state_attr(ent, 'multi_press_count') | default(1, true) | int(1)) or 18 }}"
newer: "{{ (cur_t | float) > (t0 | float) }}"
got: "{{ newer and cur_ev in ['multi_press_ongoing', 'multi_press_end'] and (cur_n | int) != (n | int) }}"
n: "{{ cur_n if got else n }}"
apply_now: "{{ got }}"
applied: "{{ (applied | int) + (1 if got else 0) }}"
seen: "{{ seen + [cur_ev ~ '(' ~ cur_n ~ ')' if newer else ('timeout' if wait.trigger is none else 'stale')] }}"
done: >-
{{ (newer and cur_ev in ['multi_press_end', 'press_start'])
or wait.trigger is none }}
- variables:
first: false
- if:
- condition: template
value_template: "{{ apply_now }}"
then:
- choose:
- alias: "Brightness"
conditions:
- condition: template
value_template: "{{ eff_mode == 'brightness' }}"
sequence:
- variables:
# step is a percentage; brightness is 0-254
raw: >-
{{ (base_br | int)
+ ((sign | int) * (n | int) * (step_size | int) * 2.55) | round(0) | int }}
- choose:
- alias: "Wound past the bottom"
conditions:
- condition: template
value_template: "{{ off_at_min and (raw | int) < 1 }}"
sequence:
- action: light.turn_off
target: { entity_id: "{{ target_list }}" }
data:
transition: "{{ transition_s | float }}"
default:
- action: light.turn_on
target: { entity_id: "{{ target_list }}" }
data:
brightness: "{{ [[ raw | int, 1] | max, 254] | min }}"
transition: "{{ transition_s | float }}"
- alias: "Colour temperature"
conditions:
- condition: template
value_template: "{{ eff_mode == 'temp_kelvin' }}"
sequence:
- action: light.turn_on
target: { entity_id: "{{ target_list }}" }
data:
color_temp_kelvin: >-
{{ [[ (base_k | int) + (sign | int) * (n | int) * (step_size | int) * 20,
k_min | int] | max, k_max | int] | min }}
transition: "{{ transition_s | float }}"
- alias: "Colour"
conditions:
- condition: template
value_template: "{{ eff_mode == 'hs_color' }}"
sequence:
- action: light.turn_on
target: { entity_id: "{{ target_list }}" }
data:
hs_color: >-
{{ [ (((base_hue | float) + (sign | int) * (n | int) * (step_size | int)) % 360) | round(2),
sat | float ] }}
transition: "{{ transition_s | float }}"
- alias: "Fan speed"
conditions:
- condition: template
value_template: "{{ eff_mode == 'fan_pct' }}"
sequence:
- variables:
raw_pct: "{{ (base_pct | int) + (sign | int) * (n | int) * (step_size | int) }}"
- choose:
- alias: "Wound past the bottom"
conditions:
- condition: template
value_template: "{{ off_at_min and (raw_pct | int) < 1 }}"
sequence:
- action: fan.turn_off
target: { entity_id: "{{ target_list }}" }
default:
# set_percentage switches the fan on as needed;
# integrations with fixed speeds snap to the
# nearest one.
- action: fan.set_percentage
target: { entity_id: "{{ target_list }}" }
data:
percentage: "{{ [[ raw_pct | int, 1] | max, 100] | min }}"
- alias: "Climate target temperature"
conditions:
- condition: template
value_template: "{{ eff_mode == 'climate_temp' }}"
sequence:
- action: climate.set_temperature
target: { entity_id: "{{ target_list }}" }
data:
# snap to the nearest 0.5 so the setpoint stays
# on the grid most thermostats accept
temperature: >-
{{ ([[ ((base_temp | float) + (sign | int) * (n | int) * (temp_step | float)) * 2
| round(0) / 2, t_min | float] | max, t_max | float] | min) }}
- if:
- condition: template
value_template: "{{ dbg }}"
then:
- action: persistent_notification.create
data:
title: "BILRESA - gesture {{ kind }} {{ ch }}"
message: >
events = {{ seen | join(' → ') }}
final count = {{ n }}
applied {{ applied }} time(s)
kind = {{ eff_mode }} on {{ ref }}
base = {{ base_temp if eff_mode == 'climate_temp' else (base_pct if eff_mode == 'fan_pct' else base_br) }}
# Every other wheel event belongs to a gesture run that is already
# waiting for it (or to a mode that ignores it). Absorb it here so the
# debug arm below only ever reports something genuinely unexpected.
- alias: "Wheel event consumed by a running gesture"
conditions:
- condition: template
value_template: "{{ kind in ['cw', 'ccw'] }}"
sequence:
- variables:
absorbed: true
# ──────────────────────────────────────────────────────────────
# Button
# ──────────────────────────────────────────────────────────────
- alias: "Button pressed"
conditions:
- condition: template
value_template: "{{ fresh and kind == 'click' }}"
sequence:
- variables:
cm_1: !input click_mode_1
cm_2: !input click_mode_2
cm_3: !input click_mode_3
sc_1: !input scenes_1
sc_2: !input scenes_2
sc_3: !input scenes_3
as_1: !input act_single_1
as_2: !input act_single_2
as_3: !input act_single_3
click_mode: "{% if ch == 'c1' %}{{ cm_1 }}{% elif ch == 'c2' %}{{ cm_2 }}{% else %}{{ cm_3 }}{% endif %}"
scenes: "{{ (sc_1 if ch == 'c1' else (sc_2 if ch == 'c2' else sc_3)) | to_json | from_json }}"
# A filled-in custom action is an explicit instruction and wins
# outright; the mode dropdown only chooses among the built-ins.
# Otherwise a user who fills the field but leaves the dropdown on
# its default gets the plain toggle and no hint why.
has_action: "{{ ((as_1 if ch == 'c1' else (as_2 if ch == 'c2' else as_3)) | count) > 0 }}"
- choose:
# `press_end` is deliberately unused: BILRESA does not advertise the
# release feature but sends ShortRelease anyway, so InitialPress and
# ShortRelease both surface as press_end about 34 ms apart and every
# toggle fires twice. multi_press_end is emitted exactly once.
- alias: "Single click"
conditions:
- condition: template
value_template: "{{ etype == 'multi_press_end' and (count | int) == 1 }}"
sequence:
- choose:
- alias: "Custom action"
conditions:
- condition: template
value_template: "{{ has_action }}"
sequence:
- choose:
- conditions: "{{ ch == 'c1' }}"
sequence: !input act_single_1
- conditions: "{{ ch == 'c2' }}"
sequence: !input act_single_2
- conditions: "{{ ch == 'c3' }}"
sequence: !input act_single_3
- alias: "Cycle scenes"
conditions:
- condition: template
value_template: "{{ click_mode == 'scene_cycle' and scenes | count > 0 }}"
sequence:
- variables:
# A scene's state is when it was last applied, so
# the newest timestamp is the scene currently showing.
# Nothing applied yet lands on the first scene.
idx: >
{% set ns = namespace(best = -1, ts = 0) %}
{% for s in scenes %}
{% set t = as_timestamp(states(s), 0) | float(0) %}
{% if t > ns.ts %}
{% set ns.ts = t %}
{% set ns.best = loop.index0 %}
{% endif %}
{% endfor %}
{{ (ns.best + 1) % (scenes | count) }}
- action: scene.turn_on
target: { entity_id: "{{ scenes[idx | int] }}" }
default:
# Plain toggle - also the fallback for a mode of "custom
# action" with nothing filled in, and for scene cycling
# with no scenes, so a click is never a silent no-op.
- condition: template
value_template: "{{ target_list | count > 0 }}"
# homeassistant.toggle dispatches to light/fan/climate
- action: homeassistant.toggle
target: { entity_id: "{{ target_list }}" }
- alias: "Double click"
conditions:
- condition: template
value_template: "{{ etype == 'multi_press_end' and (count | int) == 2 }}"
sequence:
- choose:
- conditions: "{{ ch == 'c1' }}"
sequence: !input act_double_1
- conditions: "{{ ch == 'c2' }}"
sequence: !input act_double_2
- conditions: "{{ ch == 'c3' }}"
sequence: !input act_double_3
- alias: "Triple click"
conditions:
- condition: template
value_template: "{{ etype == 'multi_press_end' and (count | int) == 3 }}"
sequence:
- choose:
- conditions: "{{ ch == 'c1' }}"
sequence: !input act_triple_1
- conditions: "{{ ch == 'c2' }}"
sequence: !input act_triple_2
- conditions: "{{ ch == 'c3' }}"
sequence: !input act_triple_3
- alias: "Hold"
conditions:
- condition: template
value_template: "{{ etype == 'long_press_start' }}"
sequence:
- choose:
- conditions: "{{ ch == 'c1' }}"
sequence: !input act_hold_1
- conditions: "{{ ch == 'c2' }}"
sequence: !input act_hold_2
- conditions: "{{ ch == 'c3' }}"
sequence: !input act_hold_3
- alias: "Release"
conditions:
- condition: template
value_template: "{{ etype == 'long_press_end' }}"
sequence:
- choose:
- conditions: "{{ ch == 'c1' }}"
sequence: !input act_release_1
- conditions: "{{ ch == 'c2' }}"
sequence: !input act_release_2
- conditions: "{{ ch == 'c3' }}"
sequence: !input act_release_3
default:
- condition: template
value_template: "{{ dbg }}"
- action: persistent_notification.create
data:
title: "BILRESA - unhandled event"
message: >
ch = {{ ch }}
kind = {{ kind }}
etype = {{ etype }}
count = {{ count }}
wheel_src = {{ wheel_src }}
accepted = {{ wheel_src.split('+') }}
match = {{ etype in wheel_src.split('+') }}
fresh = {{ fresh }}
state = {{ trigger.event.data.old_state.state if trigger.event.data.old_state is not none else 'none' }} -> {{ trigger.event.data.new_state.state if trigger.event.data.new_state is not none else 'none' }}
target_list = {{ target_list }}
curr_mode = {{ curr_mode }}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment