Skip to content

Instantly share code, notes, and snippets.

@spotshare-nick
Last active June 12, 2026 06:34
Show Gist options
  • Select an option

  • Save spotshare-nick/9fa11f28dc428f480ff5dc6fd5d49155 to your computer and use it in GitHub Desktop.

Select an option

Save spotshare-nick/9fa11f28dc428f480ff5dc6fd5d49155 to your computer and use it in GitHub Desktop.
Home Assistant blueprint: Inovelli VZM31-SN real-time hold-to-dim (step-loop, speed derived from switch dimming-speed param; optional transition method)
blueprint:
name: VZM31-SN Hold-to-Dim
description: >
Real-time hold-to-dim for an Inovelli VZM31-SN paddle in Smart Bulb Mode.
Hold Up to brighten / Down to dim the target light, with the ramp speed
derived from the switch's OWN dimming-speed param so the bulb tracks the
LED bar's native ramp. Two methods:
* step — many small absolute brightness steps (use for bulbs that
IGNORE `transition`: Matter, eWeLink/Zigbee, most Govee).
* transition — one `light.turn_on(transition=…)` call (only for bulbs
that actually honour transitions).
Requires a per-switch input_boolean "guard" (gates the hold loop on/off).
On release the LED bar is reconciled to the bulb's final level.
Hardware assumes Invert Switch OFF (our standard): Up = button_2,
Down = button_1.
domain: automation
input:
switch_device:
name: Inovelli switch
description: The VZM31-SN whose paddle fires the dim.
selector:
device:
integration: zha
manufacturer: Inovelli
model: VZM31-SN
target_light:
name: Target light
description: The bulb(s) to dim.
selector:
entity:
filter:
domain: light
switch_led:
name: Switch LED bar (internal level)
description: >
The switch's own light entity (light.inovelli_vzm31_sn*). On release the
bar is set to the bulb's final brightness so the two stay in sync.
selector:
entity:
filter:
domain: light
guard_boolean:
name: Dimming guard (input_boolean)
description: >
Per-switch boolean that gates the hold loop. Hold turns it ON and loops
while ON; release turns it OFF. One unique boolean per switch.
selector:
entity:
filter:
domain: input_boolean
dim_method:
name: Dim method
description: >
step = absolute brightness steps (bulbs that ignore transition).
transition = single transition call (bulbs that honour transitions).
default: step
selector:
select:
options:
- {label: "Step loop (ignores transition)", value: step}
- {label: "Transition (honours transition)", value: transition}
dim_step_pct:
name: Step size (%)
description: >
Brightness change per step (step method only). Smaller = smoother but
more service calls. 10 is a good default; 5 for smoother/faster bulbs.
Ignored if a Step-size source helper is set below.
default: 10
selector:
number:
min: 0.5
max: 50
step: 0.5
unit_of_measurement: "%"
mode: slider
dim_step_source:
name: Step-size source helper (optional)
description: >
Optional input_number to read the step size from LIVE instead of the
baked value above — point several switches of the same bulb type at one
helper to tune them together. Leave blank to use the baked value.
default: ""
selector:
entity:
filter:
domain: input_number
speed_up_entity:
name: Up dimming-speed param
description: >
The switch's UP dimming-speed number (e.g. local_dimming_up_speed). Sets
the brighten ramp duration. A value of 127 means "sync to Parameter 1" —
the root param below is used instead.
selector:
entity:
filter:
domain: number
speed_down_entity:
name: Down dimming-speed param
description: >
The switch's DOWN dimming-speed number (e.g. local_dimming_down_speed).
Sets the dim ramp duration. 127 = sync to Parameter 1 (root below).
selector:
entity:
filter:
domain: number
speed_root_entity:
name: Root dimming-speed param (Parameter 1)
description: >
The switch's remote_dimming_up_speed (Parameter 1) — the global root that
127-valued params sync to. Used whenever Up/Down above read 127.
selector:
entity:
filter:
domain: number
min_pct:
name: Minimum brightness (%)
default: 1
selector:
number: {min: 1, max: 100, step: 1, unit_of_measurement: "%", mode: box}
max_pct:
name: Maximum brightness (%)
default: 100
selector:
number: {min: 1, max: 100, step: 1, unit_of_measurement: "%", mode: box}
mode: parallel
max: 10
max_exceeded: silent
variables:
v_target: !input target_light
v_led: !input switch_led
v_guard: !input guard_boolean
v_method: !input dim_method
v_step_static: !input dim_step_pct
v_step_src: !input dim_step_source
v_sp_up: !input speed_up_entity
v_sp_down: !input speed_down_entity
v_sp_root: !input speed_root_entity
v_min: !input min_pct
v_max: !input max_pct
# Resolved step size: live helper if set, else the baked value.
step_pct: >
{% set s = v_step_src %}
{% if s and states(s) not in ['unknown','unavailable','','None', none] %}
{{ [ states(s)|float(10), 1 ]|max }}
{% else %}
{{ [ v_step_static|float(10), 1 ]|max }}
{% endif %}
steps: "{{ [ (100 / (step_pct|float(10)))|round(0,'ceil')|int, 1 ]|max }}"
trigger:
- platform: event
event_type: zha_event
event_data:
device_id: !input switch_device
command: button_2_hold
id: up_hold
- platform: event
event_type: zha_event
event_data:
device_id: !input switch_device
command: button_1_hold
id: down_hold
- platform: event
event_type: zha_event
event_data:
device_id: !input switch_device
command: button_2_release
id: up_release
- platform: event
event_type: zha_event
event_data:
device_id: !input switch_device
command: button_1_release
id: down_release
action:
- choose:
# ---------------------------------------------------------------- UP hold
- conditions: "{{ trigger.id == 'up_hold' }}"
sequence:
- variables:
raw_speed: >
{% set v = states(v_sp_up)|int(127) %}
{{ states(v_sp_root)|int(25) if v == 127 else v }}
# Anchor to the LED bar (switch reports instantly/reliably), NOT
# the bulb — laggy bulbs report stale brightness and cause a
# momentary wrong-direction jump on the first step.
start_pct: "{{ ((state_attr(v_led,'brightness')|float(0))/2.55)|round(0) }}"
start_ts: "{{ as_timestamp(now()) }}"
- variables:
sweep_sec: "{{ [ (raw_speed|float)/10, 0.4 ]|max }}"
step_ms: "{{ [ ((raw_speed|int)*100 / (steps|int))|int, 40 ]|max }}"
- choose:
# transition method: single call, ramp to max
- conditions: "{{ v_method == 'transition' }}"
sequence:
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input target_light}
data:
brightness_pct: "{{ v_max|int }}"
transition: "{{ sweep_sec|float }}"
# step method (default): TIME-based absolute steps (tracks the LED's
# wall-clock ramp, so slow Zigbee/Matter commands can't make it lag)
default:
- action: input_boolean.turn_on
target: {entity_id: !input guard_boolean}
- repeat:
while:
- condition: state
entity_id: !input guard_boolean
state: "on"
- condition: template
value_template: "{{ (as_timestamp(now()) - (start_ts|float)) < ((sweep_sec|float) + 1) }}"
sequence:
# lockstep: one time-based target, written to the LED bar
# FIRST (fast switch write) then the bulb, so both ride one
# clock and the bar can't lag behind the bulb.
- variables:
tgt: >
{% set frac = [ (as_timestamp(now()) - (start_ts|float)) / (sweep_sec|float), 1 ]|min %}
{{ [ [ (start_pct|float) + frac*100, v_max|float ]|min, 1 ]|max | round(0) | int }}
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input switch_led}
data: {brightness_pct: "{{ tgt }}"}
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input target_light}
data: {brightness_pct: "{{ tgt }}"}
- delay: {milliseconds: "{{ step_ms|int }}"}
- action: input_boolean.turn_off
target: {entity_id: !input guard_boolean}
# -------------------------------------------------------------- DOWN hold
- conditions: "{{ trigger.id == 'down_hold' }}"
sequence:
- variables:
raw_speed: >
{% set v = states(v_sp_down)|int(127) %}
{{ states(v_sp_root)|int(25) if v == 127 else v }}
# Anchor to the LED bar (switch reports instantly/reliably), NOT
# the bulb — laggy bulbs report stale brightness and cause a
# momentary wrong-direction jump on the first step.
start_pct: "{{ ((state_attr(v_led,'brightness')|float(0))/2.55)|round(0) }}"
start_ts: "{{ as_timestamp(now()) }}"
- variables:
sweep_sec: "{{ [ (raw_speed|float)/10, 0.4 ]|max }}"
step_ms: "{{ [ ((raw_speed|int)*100 / (steps|int))|int, 40 ]|max }}"
- choose:
- conditions: "{{ v_method == 'transition' }}"
sequence:
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input target_light}
data:
brightness_pct: "{{ v_min|int }}"
transition: "{{ sweep_sec|float }}"
default:
- action: input_boolean.turn_on
target: {entity_id: !input guard_boolean}
- repeat:
while:
- condition: state
entity_id: !input guard_boolean
state: "on"
- condition: template
value_template: "{{ (as_timestamp(now()) - (start_ts|float)) < ((sweep_sec|float) + 1) }}"
sequence:
# lockstep: one time-based target, LED bar FIRST then bulb.
- variables:
tgt: >
{% set frac = [ (as_timestamp(now()) - (start_ts|float)) / (sweep_sec|float), 1 ]|min %}
{{ [ [ (start_pct|float) - frac*100, v_min|float ]|max, 1 ]|max | round(0) | int }}
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input switch_led}
data: {brightness_pct: "{{ tgt }}"}
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input target_light}
data: {brightness_pct: "{{ tgt }}"}
- delay: {milliseconds: "{{ step_ms|int }}"}
- action: input_boolean.turn_off
target: {entity_id: !input guard_boolean}
# ------------------------------------------------------- release (either)
- conditions: "{{ trigger.id in ['up_release','down_release'] }}"
sequence:
- action: input_boolean.turn_off
target: {entity_id: !input guard_boolean}
# transition method: freeze the ramp where the paddle was released
- choose:
- conditions: "{{ v_method == 'transition' }}"
sequence:
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input target_light}
data:
brightness: "{{ state_attr(v_target,'brightness')|int(0) }}"
transition: 0
- delay: {milliseconds: 250}
# final reconcile (mostly a no-op now lockstep keeps the bar in sync;
# kept as a safety net + to settle the transition method's freeze)
- action: light.turn_on
continue_on_error: true
target: {entity_id: !input switch_led}
data:
brightness: "{{ state_attr(v_target,'brightness')|int(0) }}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment