Skip to content

Instantly share code, notes, and snippets.

@StephanMeijer
Last active April 16, 2026 11:20
Show Gist options
  • Select an option

  • Save StephanMeijer/75a26615f9b692cf7ce7cf8d6716ade4 to your computer and use it in GitHub Desktop.

Select an option

Save StephanMeijer/75a26615f9b692cf7ce7cf8d6716ade4 to your computer and use it in GitHub Desktop.
IKEA BILRESA scroll wheel dimmer for Home Assistant (Matter/Thread) — patch + automation

IKEA BILRESA Scroll Wheel Dimmer for Home Assistant

Use the IKEA BILRESA scroll wheel (Matter/Thread) as a smooth dimmer for any HA light. Slow rotation = fine adjustment. Fast spin = rapid sweep. Direction-locked, no bounce.

Background

The BILRESA is a Matter Switch cluster device (Thread SED, 9 endpoints across 3 modes). Mode 2 exposes EP4 (CW) and EP5 (CCW) as scroll endpoints with FeatureMap=22 (MS + MSR + MSM), MultiPressMax=18, ActionSwitch NOT set.

Each scroll detent fires a full Matter event sequence:

T+0ms    InitialPress (newPosition=1)
T+30ms   MultiPressOngoing (currentNumberOfPressesCounted=N)
T+34ms   ShortRelease
T+500ms  Next detent's InitialPress

Events arrive individually every ~500ms (the Matter MultiPress window timeout). Transport delay is 30-150ms. There is NO multi-second SED batching. DIRIGERA operates at the same 500ms cadence.

The Problem

Home Assistant's Matter integration silently drops initial_press and multi_press_ongoing events from scroll wheel devices when kMomentarySwitchMultiPress is set. The root cause is an elif chain in event.py that checks kMomentarySwitchMultiPress before kMomentarySwitch, so initial_press, short_release, and multi_press_ongoing are never registered for MSM devices.

The Fix

Upstream status: PR #159045 is open and implements the same approach. Until it merges, apply this patch manually.

1. Patch event.py (required until upstream PR is merged)

# Find event.py inside the HA container
docker exec homeassistant find /usr/src/homeassistant -path "*/matter/event.py" -type f

# Backup original
docker exec homeassistant cp <path>/event.py /config/event.py.original

# Copy patch into container and apply
docker cp event.py.patch homeassistant:/tmp/event.py.patch
docker exec homeassistant bash -c "cd / && patch -p1 < /tmp/event.py.patch"

# Full HA restart required
docker restart homeassistant

Note: The patch lives inside the Docker container and is overwritten on HA updates. Re-apply after each update until an upstream PR is merged.

2. Add helpers to configuration.yaml

Copy helpers.yaml contents into your configuration.yaml and restart HA.

3. Add automations

Copy automations.yaml into your automations file. Edit these entity IDs:

Placeholder Replace with
event.bilresa_scroll_wheel_button_4 Your CW scroll button entity
event.bilresa_scroll_wheel_button_5 Your CCW scroll button entity
light.kajplats_e14_ws_globe_806lm Your target light entity

4. Full HA restart

Required after patching and adding helpers.

How It Works

Patch (event.py.patch)

Aligns with PR #159045: replaces the elif chain with independent if checks per feature flag. Registers initial_press, short_release, multi_press_ongoing, and multi_press_complete as flat event types (no more multi_press_1..N enumeration). Adds MULTI_PRESS_COUNT_TO_NAME so multi_press_complete events carry an event_type_extra field ("single", "double", "triple") for easy button automations. Removes the artificial min(..., 8) cap on MultiPressMax.

Main Automation (bilresa_mode2_dimmer)

  • Triggers: initial_press (first detent) and multi_press_ongoing (subsequent detents) for both CW and CCW endpoints, using attribute: event_type filters so only relevant events fire the automation.
  • Step calculation: Quadratic acceleration via currentNumberOfPressesCounted: step = clamp(count² ÷ 6, 2, 50). Slow click → step=2. Fast spin (count=18) → step=50.
  • Direction lock: Prevents bounce when CW/CCW events fire within 400ms of each other (common during direction transitions). Unlocks after 400ms idle.
  • Own-state brightness: Reads input_number.bilresa_brightness instead of the light entity, avoiding stale Matter state during rapid scrolling.
  • Transition: 300ms smooth dimming on every step.
  • Mode: restart — each new event cancels the previous run.

Sync Automation (bilresa_brightness_sync)

Syncs input_number.bilresa_brightness when the light changes from other sources. Blocked for 2s after any scroll event to prevent corrupting own-state brightness.

Helpers

Helper Purpose
input_number.bilresa_direction Current scroll direction (1=CW, -1=CCW, 0=unlocked)
input_number.bilresa_brightness Own-state brightness tracker (1-254)
input_datetime.bilresa_last_scroll Timestamp of last processed scroll event

Customization

Parameter Location Default Description
Max step step:50] | min 50 Max brightness change per event (fast spin)
Min step step:2] | max 2 Min brightness change (slow click)
Acceleration step: — divisor 6 6 Lower = steeper curve (try 4 or 8)
Direction lock idle_time | float > 0.4 0.4s Time before allowing direction reversal
Sync grace > 2.0 in sync condition 2.0s Idle time before external sync fires
Transition transition: 0.3 0.3s Dimming transition (keep < 0.5s)

Debugging

Use debug_logging.py to instrument the matter-server and measure event timing:

# Apply debug patch (wiped on addon restart — safe to leave)
docker cp debug_logging.py addon_core_matter_server:/tmp/debug_logging.py
docker exec addon_core_matter_server python3 /tmp/debug_logging.py
docker exec addon_core_matter_server s6-svc -r /run/service/matter-server

# Watch live events
docker logs -f addon_core_matter_server 2>&1 | grep SWITCH_EVENT

Output format:

SWITCH_EVENT received_at=2026-04-16T09:42:24.775 ep=5 event_id=1 event_number=68800 dev_ts=33423740 ts_type=... data=Switch.Events.InitialPress(newPosition=1)
SWITCH_EVENT received_at=2026-04-16T09:42:24.927 ep=5 event_id=5 event_number=68801 dev_ts=33423781 data=Switch.Events.MultiPressOngoing(newPosition=1, currentNumberOfPressesCounted=3)

Device Info

Tested with:

  • IKEA BILRESA scroll wheel (Matter/Thread SED, Mode 2: EP4=CW, EP5=CCW)
  • IKEA KAJPLATS E14 WS Globe 806lm
  • Home Assistant OS, SkyConnect USB dongle (Thread/Matter)

Known Limitations

  • Patch is local: Re-apply after HA updates.
  • 500ms cadence is firmware: The MultiPress window is hardcoded in BILRESA firmware.
  • input_number max=254: HA caps input_number at 254. The automation uses 254 as the brightness ceiling. Setting 255 causes a silent automation crash.
  • Direction bounce: CW/CCW endpoints can fire within 1ms during direction transitions. The 400ms direction lock handles this.

Changelog

v1: Basic initial_press trigger, interval-based velocity (time between events for step size).

v2 (2026-04-16): Replaced interval-based velocity with count-based acceleration using currentNumberOfPressesCounted. Empirical testing confirmed events arrive every ~500ms (Matter MultiPress window), not multi-second SED batches. Added 300ms transition. Fixed direction lock for unlocked state. Sync automation has 2s idle guard.

v3 (2026-04-16): Replaced linear step clamp(count, 3, 20) with quadratic clamp(count²÷6, 2, 50). Slow scroll=step 2 (precise), fast spin=step 50 (~2.5s full range). Fixed: new_brightness upper bound corrected to 254 to match input_number max (255 caused silent automation crash).

v4 (2026-04-16): Updated event.py patch to align with upstream PR #159045. Replaced multi_press_1..N enumeration with flat multi_press_ongoing + multi_press_complete event types. Added MULTI_PRESS_COUNT_TO_NAME dict and event_type_extra field on multi_press_complete events ("single", "double", "triple"). Simplified _on_matter_node_event handler. No automation changes needed — scroll wheel automation already used multi_press_ongoing.

- id: bilresa_mode2_dimmer
alias: "BILRESA Mode 2 Dimmer"
description: "Count-based acceleration. Direction-locked. Own-state brightness."
mode: restart
triggers:
- trigger: state
entity_id: event.bilresa_scroll_wheel_button_4
attribute: event_type
to: initial_press
id: cw_press
- trigger: state
entity_id: event.bilresa_scroll_wheel_button_4
attribute: event_type
to: multi_press_ongoing
id: cw_count
- trigger: state
entity_id: event.bilresa_scroll_wheel_button_5
attribute: event_type
to: initial_press
id: ccw_press
- trigger: state
entity_id: event.bilresa_scroll_wheel_button_5
attribute: event_type
to: multi_press_ongoing
id: ccw_count
actions:
- variables:
direction: "{{ 1 if trigger.id in ['cw_press', 'cw_count'] else -1 }}"
count: "{{ trigger.to_state.attributes.currentNumberOfPressesCounted | default(1) | int }}"
step: "{{ [[(count * count) // 6, 2] | max, 50] | min }}"
last_dir: "{{ states('input_number.bilresa_direction') | int(0) }}"
idle_time: >-
{% set last_scroll_str = states('input_datetime.bilresa_last_scroll') %}
{% if last_scroll_str in ['unknown', 'unavailable', ''] %}
999
{% else %}
{{ (now() - as_local(as_datetime(last_scroll_str))).total_seconds() }}
{% endif %}
current_brightness: "{{ states('input_number.bilresa_brightness') | int(128) }}"
new_brightness: "{{ [[current_brightness + step * direction, 1] | max, 254] | min }}"
- condition: template
value_template: "{{ direction == last_dir or last_dir == 0 or idle_time | float > 0.4 }}"
- action: input_number.set_value
target:
entity_id: input_number.bilresa_direction
data:
value: "{{ direction }}"
- action: input_datetime.set_datetime
target:
entity_id: input_datetime.bilresa_last_scroll
data:
datetime: "{{ now().isoformat() }}"
- action: input_number.set_value
target:
entity_id: input_number.bilresa_brightness
data:
value: "{{ new_brightness }}"
- action: light.turn_on
target:
entity_id: light.kajplats_e14_ws_globe_806lm
data:
brightness: "{{ new_brightness | int }}"
transition: 0.3
- id: bilresa_brightness_sync
alias: "BILRESA Brightness Sync"
description: "Sync helper when light changes from other sources. Blocked during active scrolling."
mode: single
triggers:
- trigger: state
entity_id: light.kajplats_e14_ws_globe_806lm
attribute: brightness
conditions:
- condition: template
value_template: >-
{{ trigger.to_state.state == 'on' and
trigger.to_state.attributes.brightness is defined and
trigger.to_state.attributes.brightness is not none and
(trigger.to_state.attributes.brightness | int) != (states('input_number.bilresa_brightness') | int) }}
- condition: template
value_template: >-
{% set last_ts = states('input_datetime.bilresa_last_scroll') %}
{% if last_ts in ['unknown', 'unavailable', ''] %}
{{ true }}
{% else %}
{{ (now() - as_local(as_datetime(last_ts))).total_seconds() > 2.0 }}
{% endif %}
actions:
- action: input_number.set_value
target:
entity_id: input_number.bilresa_brightness
data:
value: "{{ trigger.to_state.attributes.brightness | int }}"
"""
Patch matter-server device_controller.py to log Switch cluster (cluster 59)
events at INFO level with precise timestamps.
Run inside the addon_core_matter_server container:
python3 /tmp/debug_logging.py
Then restart the matter-server process:
s6-svc -r /run/service/matter-server
Watch output:
docker logs -f addon_core_matter_server 2>&1 | grep SWITCH_EVENT
The patch is wiped when the addon container restarts. Safe to leave in place.
"""
import sys
PATH = "/usr/local/lib/python3.12/site-packages/matter_server/server/device_controller.py"
with open(PATH) as f:
content = f.read()
OLD = """ def event_callback(
data: Attribute.EventReadResult,
transaction: Attribute.SubscriptionTransaction,
) -> None:
node_logger.log(
VERBOSE_LOG_LEVEL,
"Received node event: %s - transaction: %s",
data,
transaction,
)
node_event = MatterNodeEvent("""
NEW = """ def event_callback(
data: Attribute.EventReadResult,
transaction: Attribute.SubscriptionTransaction,
) -> None:
node_logger.log(
VERBOSE_LOG_LEVEL,
"Received node event: %s - transaction: %s",
data,
transaction,
)
if data.Header.ClusterId == 59: # Switch cluster
node_logger.info(
"SWITCH_EVENT received_at=%s ep=%d event_id=%d "
"event_number=%d dev_ts=%s ts_type=%s data=%s",
datetime.now().isoformat(timespec="milliseconds"),
data.Header.EndpointId,
data.Header.EventId,
data.Header.EventNumber,
data.Header.Timestamp,
data.Header.TimestampType,
data.Data,
)
node_event = MatterNodeEvent("""
if OLD in content:
content = content.replace(OLD, NEW)
with open(PATH, "w") as f:
f.write(content)
print("PATCH APPLIED SUCCESSFULLY")
else:
print("ERROR: target string not found — matter-server version may differ", file=sys.stderr)
sys.exit(1)
--- a/homeassistant/components/matter/event.py
+++ b/homeassistant/components/matter/event.py
@@ -36,7 +36,13 @@
6: "multi_press_complete", # clusters.Switch.Events.MultiPressComplete
}
+MULTI_PRESS_COUNT_TO_NAME = {
+ 1: "single",
+ 2: "double",
+ 3: "triple",
+}
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -63,29 +69,26 @@
feature_map = int(
self.get_matter_attribute_value(clusters.Switch.Attributes.FeatureMap)
)
+ # Either momentary xor latching is required
if feature_map & SwitchFeature.kLatchingSwitch:
- # a latching switch only supports switch_latched event
event_types.append("switch_latched")
- elif feature_map & SwitchFeature.kMomentarySwitchMultiPress:
- # Momentary switch with multi press support
- # NOTE: We ignore 'multi press ongoing' as it doesn't make a lot
- # of sense and many devices do not support it.
- # Instead we report on the 'multi press complete' event with the number
- # of presses.
- max_presses_supported = self.get_matter_attribute_value(
- clusters.Switch.Attributes.MultiPressMax
- )
- max_presses_supported = min(max_presses_supported or 2, 8)
- for i in range(max_presses_supported):
- event_types.append(f"multi_press_{i + 1}") # noqa: PERF401
- elif feature_map & SwitchFeature.kMomentarySwitch:
- # momentary switch without multi press support
+
+ if feature_map & SwitchFeature.kMomentarySwitch:
event_types.append("initial_press")
- if feature_map & SwitchFeature.kMomentarySwitchRelease:
- # momentary switch without multi press support can optionally support release
- event_types.append("short_release")
- # a momentary switch can optionally support long press
+ # The specs are more strict about what features a device must support
+ # We just trust the device. No harm in expecting more events.
+
+ # release is optional, but requires momentary support
+ if feature_map & SwitchFeature.kMomentarySwitchRelease:
+ event_types.append("short_release")
+
+ # multi press is optional, but requires release support
+ if feature_map & SwitchFeature.kMomentarySwitchMultiPress:
+ event_types.append("multi_press_ongoing")
+ event_types.append("multi_press_complete")
+
+ # long press is optional, but requires release support
if feature_map & SwitchFeature.kMomentarySwitchLongPress:
event_types.append("long_press")
event_types.append("long_release")
@@ -117,12 +120,12 @@
"""Call on NodeEvent."""
if data.endpoint_id != self._endpoint.endpoint_id:
return
- if data.event_id == clusters.Switch.Events.MultiPressComplete.event_id:
- # multi press event
- presses = (data.data or {}).get("totalNumberOfPressesCounted", 1)
- event_type = f"multi_press_{presses}"
- else:
- event_type = EVENT_TYPES_MAP[data.event_id]
+ event_type = EVENT_TYPES_MAP[data.event_id]
+
+ if event_type == "multi_press_complete" and data.data:
+ presses = data.data.get("totalNumberOfPressesCounted", 1)
+ if presses in MULTI_PRESS_COUNT_TO_NAME:
+ data.data["event_type_extra"] = MULTI_PRESS_COUNT_TO_NAME[presses]
if event_type not in self.event_types:
# this should not happen, but guard for bad things
# Add these to your configuration.yaml
#
# These helpers track dimmer state locally to avoid issues with
# stale brightness readings from Thread Sleepy End Devices.
input_number:
bilresa_direction:
name: "BILRESA Direction"
min: -1
max: 1
step: 1
mode: box
bilresa_brightness:
name: "BILRESA Brightness"
min: 1
max: 254
step: 1
mode: box
initial: 128
input_datetime:
bilresa_last_scroll:
name: "BILRESA Last Scroll"
has_date: true
has_time: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment