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 sunriseif the sun is less than 3.2 degrees above the horizon. (I just picked this value because that was what thesun.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 Switching to using the sun’s elevation rather than times eliminates my concerns about an off-by-1-hour issue.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.
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.