YAML Light Fader for light entities that don't support "transition"

I happen to use Lutron Caseta switches, which work great, but the Home Assistant native integration does not support the “transition” feature of the light.turn_on service with these switches. I like automations that gradually transition light levels, so I solved this problem with a Python script that I ran as a service. It accepted starting brightness, ending brightness, and transition time as parameters, and then ran a loop to create the gradual transition. With no looping feature in the HASS script syntax there wasn’t a way to do the necessary repeat actions with YAML … at least not smoothly.

Until release 0.113.0

With 113.0 we now have a repeat action, a choose action, and automations can be triggered multiple times and run in parallel with the mode option. This release also brought some performance enhancements that allow for sub-second performance on these repeat actions. All the ingredients are now available to do this in YAML.

This script is a single, reusable automation script that is “callable” with a triggering event that supplies the necessary event data. I made up a piece of event data named “branch”, which the conditions in the choose action utilize to select the branch that should run. The flow starts with a triggering event that runs the first branch, which then re-triggers the same automation (thanks to “parallel” mode) to run the second branch.

An event action in this format will trigger the automation:

  - event: light_fader_parameters
    event_data:
      # The fader automation will choose the branch based on this value
      branch: calculate
      # The light to be faded
      entity_id: light.your_fading_light       
      # Desired ending light level percentage
      end_level_pct: 60
      # Desired transition time in hh:mm:ss
      transition: 00:15:00

And below is the automation that will consume this event and do the light transition. It is pretty verbose with comments, so hopefully it is easy enough to follow. Been testing this for a few days and it seems to work pretty well. My primary use case is for slow transitions, so I haven’t optimized it for faster transitions of less than 15 seconds (I’ll leave that to you!)

Questions, comments, feedback always welcomed.

# This automation consumes an event that includes the data needed 
# to drive a gradual light transition.  This scripting utilizes
# the new "run mode", "looping", & chooser features introduced in HASS 113.0
id: light_fader
alias: Light Fader Automation
initial_state: 'on'
trigger:
  - platform: event
    event_type: light_fader_parameters
mode: parallel

action:
# SUMMARY: (1) The first branch is seleced when the triggering event data includes "trigger.event.data.branch == 'calculate'.  
#              The additional data that is included with the triggering event is used to calculate all the parameters needed 
#              to execute a repeat loop that transitions the light brightness from one level, to another, in a given amount of time.
#          (2) The first branch is essentially just one "event" action which supplies all of the parameters needed to execute the 
#              light transition (fade in or fade out). Some of the event data is a pass through from the original triggering event,
#              but three values - "start_level_pct", "delay_milli_sec", and "step_pct" - are calculated with templates.
#              By including trigger.event.data.branch == 'execute_fade' in the event data, the second branch is selected in this automation.
#          (3) The repeat loop in the second branch executes until the desired "end_level_pct" is reached.

  - choose:
      #FIRST BRANCH "calculate" ... derive values from the data in original trigger and pass them to second branch, "execute_fade"
      - conditions:
          - condition: template
            value_template: "{{trigger.event.data.branch == 'calculate' }}"
        sequence:
          - event: light_fader_parameters
            event_data_template:
              branch: execute_fade
              entity_id: "{{ trigger.event.data.entity_id }}"
              start_level_pct: >-
                {% if state_attr(trigger.event.data.entity_id, 'brightness') == none -%}
                  0
                {%- else -%}
                 {{((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round }}
                {% endif %}
              end_level_pct: "{{ trigger.event.data.end_level_pct|int }}"
              
              # ======= "delay_milli_sec" calculation notes ==============
              # The basic calculation for delay_milli_sec could result in either a positive
              # or negative value, but the delay must always be a positve value - no matter 
              # which direction the fade is going - so last step uses "abs" for absolute value.
              delay_milli_sec: >- 
                {# ============================================================  #}
                {# start_level_pct MUST BE "0" IF BRIGHTNESS ATTRIBUTE IS "none" #}
                {# ============================================================  #}
                {##}
                {% if state_attr(trigger.event.data.entity_id, 'brightness') == none -%}
                  {% set start_level_pct = 0 %}
                {%- else -%}
                  {% set start_level_pct = 
                   ((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round %}                  
                {%- endif %}      
                {# ============================================================ #}
                {# END_level_pct IS PROVIDED BY EVENT DATA  #}
                {# ============================================================ #}
                {##}
                {% set end_level_pct = trigger.event.data.end_level_pct|int %} 
                {##}
                {# ============================================================ #}
                {# DERIVE transition_secs FROM EVENT DATA STRING hh:mm:ss #}
                {# ============================================================ #}  
                {##}
                {% set transition_secs = (trigger.event.data.transition[:2]|int * 3600) + 
                  (trigger.event.data.transition[3:5]|int * 60) + 
                  (trigger.event.data.transition[-2:]|int) %}    
                {##}
                {# ============================================================ #}
                {# CALCULATE & RETURN delay_milli_sec  ... min set to 100ms     #}
                {# ============================================================ #}                    
                {% set delay_milli_sec = (((transition_secs/(end_level_pct - start_level_pct))
                   |abs)|round(precision=3)*1000)|int %}                   
                {% if delay_milli_sec <= 99 -%}
                  100
                {%- else -%}
                 {{ delay_milli_sec }}
                {%- endif %}                                      
                
              # ======= "step_pct" calculation notes ==============  
              # Compare start level to end level to see if we increasing or decreasing the brightness
              # by 1% with each loop.  Must repeat the same "if" templating as in delay_milli_sec for start_level_pct
              step_pct: >
                {% if state_attr(trigger.event.data.entity_id, 'brightness') == none -%}
                  {% set start_level_pct = 0 %}
                {%- else -%}
                  {% set start_level_pct = 
                   ((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round %}                  
                {%- endif %}
                
                {% if start_level_pct < (trigger.event.data.end_level_pct|int) -%}
                  1
                {%- else -%}
                  -1                 
                {%- endif %}  
                 
                
       #SECOND BRANCH ... accepts values derived in the first branch, as well as "pass throughs" from 
       #                  the original trigger, and then actually does the light transition.
      - conditions:
          - condition: template
            value_template: "{{trigger.event.data.branch == 'execute_fade' }}"
        sequence:
          - repeat:
              while:
              
                  # Terminates at set # of iterations ... just in case  
                - condition: template
                  value_template: "{{ repeat.index <= 102 }}" 
                  
                  # Check to confirm that current brightness has not yet reached end_level_pct                   
                - condition: template
                  value_template: >-
                    {% if (state_attr(trigger.event.data.entity_id, 'brightness') == none) and
                         (trigger.event.data.step_pct|int == -1) -%}
                      False
                    {% elif (state_attr(trigger.event.data.entity_id, 'brightness') == none) and
                         (trigger.event.data.step_pct|int == 1) -%}   
                      True                                             
                    {%- elif (trigger.event.data.step_pct|int == -1) and
                       ((((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
                       > ((trigger.event.data.end_level_pct)|int)) -%} 
                      True                        
                    {%- elif (((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
                       < ((trigger.event.data.end_level_pct)|int) -%} 
                      True                             
                    {%- else -%}  
                      False                   
                    {%- endif %}
                
                  # Check to confirm that current brightness is at the expected level for this iteration.
                  # If during a long fade (e.g. 10 minutes) someone manually adjusted the brightness,
                  # the current brightness will not match what the loop expected, and it will stop, 
                  # respecting the manual intervention of a person who is setting their preference.                  
                - condition: template
                  value_template: >-
                    {% if repeat.first -%}
                      True   
                    {%- elif ((((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
                        == (trigger.event.data.start_level_pct|int + ((repeat.index - 1)*(trigger.event.data.step_pct|int))))  -%} 
                      True                        
                    {%- else -%}
                      False
                    {%- endif %}  
             
              sequence:              
                - service: light.turn_on
                  data_template:
                    entity_id: "{{ trigger.event.data.entity_id }} "
                    # brightness_step_pct: "{{trigger.event.data.step_pct}}"
                    brightness_pct: >-
                      {% if  ((trigger.event.data.start_level_pct|int) + 
                        ((repeat.index|int)*(trigger.event.data.step_pct|int))) <= 0 %}
                         0
                      {% elif  ((trigger.event.data.start_level_pct|int) + 
                        ((repeat.index|int)*(trigger.event.data.step_pct|int))) >= 100 %}
                         100
                      {% else %}
                         {{(trigger.event.data.start_level_pct|int) + ((repeat.index|int)*(trigger.event.data.step_pct|int))}}
                      {% endif %}
                - delay: 
                    milliseconds: "{{ trigger.event.data.delay_milli_sec|int }}"
3 Likes

I’m new to HA, and decided to try this script out.

One issue that I ran into is that if the user turned the light off, the script would continue running and therefore turn the light back on. I fixed this by changing the conditions:

Original:

                  value_template: >-
                    {% if (state_attr(trigger.event.data.entity_id, 'brightness') == none) -%}
                      True
                    {% elif repeat.first -%}
                      True   

Updated:

                  value_template: >-
                    {% if repeat.first -%}
                      True
                    {% elif (state_attr(trigger.event.data.entity_id, 'brightness') == none) -%}
                      False

This allows the script to stop from the 2nd repeat onwards if the brightness value doesn’t exist (when the light has been turned off by the user).

Thank you for taking a look, and catching the error in my logic. :grinning:

Your fix will work for the condition where someone turns the light off while the script is running, but my aim was that the script should stop for any user intervention. For example, if the user is not aware a fade is happening (could be fade in or fade out) and just manually sets the brightness to 70% (or any other value that does not match the expected brightness at that iteration), I would want the script to stop.

Try this, as I think this will accomplish what you want, but also catch any other manual adjustments:

                - condition: template
                  value_template: >-
                    {% if repeat.first -%}
                      True   
                    {%- elif ((((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
                        == (trigger.event.data.start_level_pct|int + ((repeat.index - 1)*(trigger.event.data.step_pct|int))))  -%} 
                      True                        
                    {%- else -%}
                      False
                    {%- endif %}

i’m super new to HA and just starting to get into the more advance stuff.
I’m not sure how to go about setting this script up, any advice/pointers?

If you find yourself writing a template that explicitly returns true or false then you’re not taking full advantage of a template’s abilities.

This:

- condition: template
  value_template: >-
    {% if repeat.first -%}
      True   
    {%- elif ((((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
        == (trigger.event.data.start_level_pct|int + ((repeat.index - 1)*(trigger.event.data.step_pct|int))))  -%} 
      True                        
    {%- else -%}
      False
    {%- endif %}

can be reduced to this:

- condition: template
  value_template: >-
    {{ repeat.first or 
       ((((state_attr(trigger.event.data.entity_id, 'brightness')/255)*100)|round)
        == (trigger.event.data.start_level_pct|int + ((repeat.index - 1)*(trigger.event.data.step_pct|int)))) }}

repeat.first is either true or false so if we do this:

{{ repeat.first }}

the template will evaluate to either true or false.

Similarly, the calculation checks if the left hand side of the equation is equal to the right hand side. If they’re equal the result is true, if not then false.

Thank you for taking the time to reply. Excellent point about the template condition, and true and false. Only the “true” need be defined, since the “false” is implicit in the failure to meet the condition.

Tried to use this, took the automation as it was written and then fired the event with my data, but nothing happened? Outside of updating the event trigger, is there anything that needs to be done to use it?

Welcome to HA. Hope you have fun automating your life!

Using this will require learning a bit of the advanced stuff. For example, I am writing these automations in YAML directly in a text editor, so my post is assuming you will know how to do that. (I don’t know how you would do this by creating automations in the front end UI.)

For some context, what I am describing is actually two automations. Neither one is a “script”, per se. One automation contains an event action, and that action is a trigger for a second automation. So the first snippet of code is just one action withing the action: section of some automation that is running … maybe you trigger that first automation on a certain schedule, or by a voice command, or whatever … and then as the automation is running, it fires off an event which is a trigger for the “Light Fader Automation”. The event contains all the parameters needed to run the fader automation (as noted in the “Raise and Consume Custom Events” documentation).

I tried to hyperlink the relevant portions of HA documentation. One thing to mention as you read the script syntax is that the document is describing actions that can be used in standalone scripts (in the sequence: section), or in the automatons component (in the actions: section of an automation), which is what I am doing in this usage.

So if you follow all of this, you can create my example automation (in automation.yaml, or as I do, in a separate file for each automation, following the documentation for Splitting up the configuration), and then trigger that automation with your custom event action.

A lot to digest, maybe, so just reply back if you get stuck.

The only thing you should need to change is the entity_id value in the custom event that you create, replacing “light.your_fading_light” with your own actual light entity.

If you just fire an event in developer tools (see screenshot) with your actual entity_id replacing the example shown, the Light Fader Automation should fire. Check Developer Tools-State to see the that the automation is there, and whether it has been triggered. Might get a clue by checking logs as well.

event

Yep, I have the event and the automation. It says the automation was triggered but nothing occurs. I tried firing it from Developers tab and from Node Red. I am trying to use these with Zooz/GE zwave switches, not sure if that’s important. I took the automation directly from the above post and put it into my automation dir. Formatting seems to be fine, as I got no error messages when I reloaded the automations via the GUI. All I updated was I replaced the entity id per your comments.

Does the light respond if you call the light.turn_on service with the brightness_pct: for that entity in Developer Tools - Services? If not, there’s your answer.

Also test with brightness: , and if that works you could reconfigure the script to operate on that basis, instead of my version that is configured to use brightness_pct:

Confirmed working with brightness_pct. Tried a few different lights (including the one I tested with light_fader) and they all worked

Quick question: Why is this implemented as an automation with an Event Trigger?

The reason why I ask is because it seems like a good fit for being a script that accepts variables. Then you can call the script like this:

  - service: script.light_fader
    data:
      branch: calculate
      entity_id: light.your_fading_light       
      end_level_pct: 60
      transition: 00:15:00

The script can refer to the variables by their name, like {{entity_id}}, as opposed to the more verbose {{trigger.event.data.entity_id}}.

Perhaps there’s an important reason that it must exist as an automation and I just haven’t understood it.

Should work in that case. Since the automation is being triggered, either it does not get to the action that calls the light.turn_on service, or if it does get to that step, perhaps the values aren’t formatting correctly and the step fails. A way I have used to check (might not be the best way) is to temporarily add a step that writes to the system log with system_log.write service. Just add a few of those actions at various points in the flow as a way to see what’s happening in the automation, such as if it reached a certain step, or just to write out values.

No important reason. I went with the custom event approach because it allowed me to have one automation, then use the new choose feature to direct the flow to one set of steps or the other. Scripts could work too, if you had the first script calculate all the parameters for the loop, you could then call a second script that does the looping and makes the transition happen. Just personal preference, I guess, but I’d be interested to hear more about an alternate approach, if you wanted to share.

I liked the idea of one (automation) script, rather than two, and thought it was kind of cool that we can now use custom event data to effectively “call” one “function/sub” or another, so I just ran with that to see how it would work.

Why two scripts?

One script can call itself precisely the way your automation calls itself (allowed by virtue of mode: parallel).

An automation is effectively a script with a trigger. The action section of an automation uses the same syntax as in a standalone script.

I somehow missed the fact of the parallel mode being available to both automations and scripts. I now agree that the same effect could be achieved with one script, and that the syntax is more concise, and that a script seems to be a more logical fit. I appreciate the insights and well thought out feedback. Most appreciated. Thank you.

And scripts? I guess that’s a matter of perspective.

The mode feature is new to scripts (as of 0.113) and is described in the Script integration under the heading Script Modes. The action section of an automation is effectively a script so the mode feature is available to scripts and automations. :wink:

Again, the only reason for my question was to learn if there was some special need to construct this as an automation. It’s unusual to see an automation that triggers itself. It’s clear to me now that this application doesn’t require a trigger and can exist exclusively as a single script.

Would it be much work to convert this to a script?

If you’re interested, I have converted it to a script.