Last active
September 14, 2026 02:25
-
-
Save othree/3cc6cfa27575ee96b0edb4ffc08ace3e to your computer and use it in GitHub Desktop.
IKEA BILRESA - Live Scroll Light Controller
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 - 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