Outside lights on at night

Thought I’d share my take on this classic automation of turning on the porch lights. This was my first ever automation, and my version of it has evolved over the past four years. Here are my latest set of requirements:

  • Be ‘on’ between 30 minutes before sunset and 30 minutes after sunrise if the sun is less than 3.2 degrees above the horizon. (I just picked this value because that was what the sun.sun entity said when I thought it was sufficiently dark.)
  • EXCEPT if it’s after 23:15h or before 07:00h (so ‘off’ in this case)
  • EXCEPT if (so, ‘on’ in these cases):
    • An exterior door is open
    • It is within 30 minutes of an exterior door being closed
    • it is within 30 minutes of somebody arriving home

So, first, I have a binary sensor that will be ‘on’ if these conditions are met, and ‘off’ otherwise. I found it frustrating that sun.sun always shows the next sunrise and not the last sunrise. I approximated by just forcing today’s date onto the setting and rising times; I’m not sure if I’ll run into a “1-hour-off” bug during daylight savings time changes. Switching to using the sun’s elevation rather than times eliminates my concerns about an off-by-1-hour issue.

template:
  - binary_sensor:
    - name: outside_lights_on
      state: >
        {#- The elevation angle of the sun, below which the lights should be on. -#}
        {%- set min_sun_elevation = 3.2 -%}

        {#- The time in the mornings before which the outside lights should be off. -#}
        {%- set morning_time = timedelta(hours=7) -%}

        {#- The time in the evenings after which the outside lights should be off. -#}
        {%- set evening_time = timedelta(hours=23, minutes=15) -%}

        {#- The binary sensors corresponding to the exterior doors of the house.
            If these doors are open, or have been closed for < 30 minutes, the
            outside lights should be on. -#}
        {%- set exterior_doors = [
              states['binary_sensor.frontdoor'],
              states['binary_sensor.backdoor'] ] -%}

        {%- set sun_elevation = state_attr('sun.sun', 'elevation') | float -%}
        {%- if (sun_elevation >= min_sun_elevation) -%}
          false
        {%- else-%}
          {%- set time = now() -%}  
          {%- set midnight = time.replace(hour=0, minute= 0, second=0, microsecond=0) -%}
          {%- set morning_limit = midnight + morning_time -%}
          {%- set evening_limit = midnight + evening_time -%}

          {%- if ((time > morning_limit) and (time < evening_limit)) -%}
            true
          {%- else -%}
            {%- set people_home_in_range = states.person
                | selectattr('state', 'eq', 'home')
                | selectattr('last_changed', 'gt', time - timedelta(minutes=30))
                | list -%}   
            {%- set doors_open = exterior_doors
                | selectattr('state', 'eq', 'on')
                | list -%}
            {%- set doors_closed_in_range = exterior_doors
                | selectattr('state', 'eq', 'off')
                | selectattr('last_changed', 'gt', time - timedelta(minutes=30))
                | list -%}

            {%- if (people_home_in_range | count > 0)
                or (doors_closed_in_range | count > 0)
                or (doors_open | count > 0) -%}
              true
            {%- else -%}
              false  
            {%- endif -%}
          {%- endif -%}
        {%- endif -%}

Next, I have an automation to set the state of the outside lights to whatever the sensor says they should be. Ignore the condition for now, I’ll get back to that.

automation:
  - alias: outside_lights_on_off
    mode: queued
    max: 10
    max_exceeded: silent
    initial_state: 'on'
    trigger:
      - platform: state
        entity_id: binary_sensor.outside_lights_on
    action:
      - condition: template
        value_template: "{{ not is_state('timer.porchlights', 'active') }}"
      - service: "light.turn_{{ states('binary_sensor.outside_lights_on') }}"
        data:
          entity_id:
            - light.front_porch_lights
            - light.garage_outside_light
            - light.deck_light

After trying this out for a night, I realized that there was a problem: if somebody manually changed the state of any of those lights, it would never come back in-line with the rest until the binary sensor changed state again. This is where that timer comes in. I introduced it along with two other automations to allow people to manually turn the lights on or off outside of their scheduled time ranges. When the lights are manually switched, start a 20 minute timer, after which another automation will set the lights back to the state the binary sensor says they should be:

timer:
  porchlights:
    duration: '00:20:00'

automation:
  - alias: outside_lights_manually_switched
    mode: restart
    trigger:
      - platform: state
        entity_id:
          - light.front_porch_lights
          - light.garage_outside_light
          - light.deck_light
    condition:
      condition: template
      value_template: >
        {#- Be careful not to trigger this automation when the lights are turned
            on or off via the actual schedule. -#}
        {%- set two_seconds_ago = (now() - timedelta(seconds=2)) -%}
        {%- set last_automation_run = state_attr('automation.outside_lights_on_off', 'last_triggered') | as_datetime -%}
        {{ last_automation_run < two_seconds_ago }}
    action:
      - service: timer.cancel
        target:
          entity_id: timer.porchlights
      - service: timer.start
        target:
          entity_id: timer.porchlights
        data:
          duration: "00:20:00"

  - alias: outside_lights_timer_finished
    trigger:
      - platform: event
        event_type: timer.finished
        event_data:
          entity_id: timer.porchlights
    action:
      - service: automation.trigger
        target:
          entity_id: automation.outside_lights_on_off

I’m pretty happy with this version; it covers all the cases I could think of.

Edit: Switched to using the sun’s elevation rather than setting and rising times. Cleaned up some of the logic in the sensor template. and added some comments for readability. Formatting.

Edit 2: Removed macros in favour of learning how to use selectattr in a better way.

Edit 3: Removed all uses of as_timestamp in favour of doing everything with datetime objects.

3 Likes

Unless there’s a particular reason the macro must use a for-loop, it’s possible to produce identical results without it.

For the filter_by_state macro:

{{ entities | selectattr('state', 'in', states_to_match) | list }}

Arguably, that’s short enough to use whatever needed without employing a macro but that’s your call.

Similarly, I have something shorter for the changed_in_last macro but I’m not sure it’s appropriate because I don’t know what your sensor.timestamp represents.

If it reports the current date and time as a datetime object, in other words it’s equivalent to now(), then the following should work. Otherwise it will need modifications to convert sensor.timestamp's value to a datetime object. In this example, you pass delta in minutes instead of seconds.

{{ entities | selectattr('last_changed', 'gt', now() - timedelta(minutes = delta)) | list }}

That’s cool. I’ll have to test that out.

In my sensor template, I have my list of exterior doors just defined locally in the template, it’s not a group that can use expand on or anything. Is there a way other than a for loop that I can take those textual entity_id values and get the states so that I can use selectattr()?

I just changed my array of exterior doors to be the states of those entities rather than just the names. Works great! See original post with the new version.

If you’re willing to reconsider the way you define these variables:

morning_time
evening_time
morning_limit
evening_limit

You can shrink them to just two variables, eliminate the need for sensor.date, and simplify the time arithmetic.

Have a look at this version and check if it works to your satisfaction:

template:
  - binary_sensor:
    - name: outside_lights_on
      state: >
        {#- The elevation angle of the sun, below which the lights should be on. -#}
        {%- set min_sun_elevation = 3.2 -%}

        {#- The time in the mornings before which the outside lights should be off. -#}
        {%- set morning_limit = now().replace(hour=7, minute=0, second=0, microsecond=0) -%}

        {#- The time in the evenings after which the outside lights should be off. -#}
        {%- set evening_limit = now().replace(hour=23, minute=15, second=0, microsecond=0) -%}

        {#- The binary sensors corresponding to the exterior doors of the house.
            If these doors are open, or have been closed for < 30 minutes, the
            outside lights should be on. -#}
        {%- set exterior_doors = [
              states['binary_sensor.frontdoor'], 
              states['binary_sensor.backdoor'] ] -%}

        {%- if (state_attr('sun.sun', 'elevation') >= min_sun_elevation) -%}
          false
        {%- else-%}
          {%- if morning_limit < now() < evening_limit -%}
            true
          {%- else -%}
            {{ states.person
                | selectattr('state', 'eq', 'home')
                | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
                | list | count > 0 or
               exterior_doors
                | selectattr('state', 'eq', 'on')
                | list | count > 0 or
               exterior_doors
                | selectattr('state', 'eq', 'off')
                | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
                | list | count > 0 }}
          {%- endif -%}
        {%- endif -%}

Summary of the changes

The morning_time and evening_time variables were eliminated and the morning_limit and evening_limit variables were promoted to the top of template and defined as datetime objects:

{%- set morning_limit = now().replace(hour=7, minute=0, second=0, microsecond=0) -%}
{%- set evening_limit = now().replace(hour=23, minute=15, second=0, microsecond=0) -%}

That change eliminates the need for these four lines:

{%- set date = states('sensor.date') -%}
{%- set time = now() | as_timestamp -%}
{%- set evening_limit = (date + "T" + evening_time) | as_timestamp -%}
{%- set morning_limit = (date + "T" + morning_time) | as_timestamp -%}

The following is a minor change; I replaced this:

{%- set sun_elevation = state_attr('sun.sun', 'elevation') | float -%}
{%- if (sun_elevation >= min_sun_elevation) -%}

with this (no need to use float because the attribute is already a float):

{%- if (state_attr('sun.sun', 'elevation') >= min_sun_elevation) -%}

The next one is just a reversal of logic to help streamline the template. I replaced this which checks if the current time is not between evening and morning limits:

{%- if (not ((time >= evening_limit) or (time <= morning_limit))) -%}

with this which does the equivalent by checking if the current time is between the morning and evening limits:

{%- if morning_limit < now() < evening_limit -%}

Finally, I reduced this:

{%- set people_home_in_range = states.person
    | selectattr('state', 'eq', 'home')
    | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
    | list -%}
{%- set doors_open = exterior_doors
    | selectattr('state', 'eq', 'on')
    | list -%}
{%- set doors_closed_in_range = exterior_doors
    | selectattr('state', 'eq', 'off')
    | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
    | list -%}

{%- if (people_home_in_range | count > 0)
    or (doors_closed_in_range | count > 0)
    or (doors_open | count > 0) -%}
  true
{%- else -%}
  false
{%- endif -%}

to this:

{{ states.person
    | selectattr('state', 'eq', 'home')
    | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
    | list | count > 0 or
   exterior_doors
    | selectattr('state', 'eq', 'on')
    | list | count > 0 or
   exterior_doors
    | selectattr('state', 'eq', 'off')
    | selectattr('last_changed', 'gt', now() - timedelta(minutes=30))
    | list | count > 0 }}

Yeah, those changes look reasonable. Some of those lines were so I could replace the values with static ones for testing and debugging. I took out all my debug printing statements before posting it :slight_smile:

I’ve actually changed my template to only use now() once so that I can substitute it when testing.

I did not know about datetime.replace(). I spent way too much time trying to instantiate a datetime from scratch with no success. That’s going to go in my toolbox.

There have been recent additions to the templating toolkit that make it much easier to perform datetime arithmetic than in the past.

as_datetime
as_local
astimezone

Using now().replace() to set the date and time we want is good but a bit verbose; it’s not as compact as how it’s done in python. Nevertheless, it’s the only game in town so I’m not knocking it. :slight_smile: