Last active
June 12, 2026 06:34
-
-
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)
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: 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