An open door is only a problem once it is forgotten: the back door left after taking the bins out, a window opened to air a room and never shut. This recipe watches every door and window sensor you pick and, when one stays open longer than the grace period you set, pushes a notification to the Home Assistant app on your phone, naming what is open rather than sending a vague alert you have to go and chase down.
Ten minutes is a sensible default: long enough to carry something in and out without nagging, short enough to catch a door that never closed. Lower it for a tighter watch, raise it for a house where doors are held open a lot. It runs on your own sensors with no cloud in the loop.
Only when the house is empty
A door standing open while you are in the kitchen is not worth a notification. A door standing open after everyone has left is. Give the recipe a way to tell the difference and it holds the alert back until the house is empty.
There are two signals and you can use either, or both. Presence sensors cover the house room by room. Tracked phones cover the people: the Home Assistant app reports whether each phone is home, and no extra hardware is needed for it. The alert waits until every signal you picked reads clear.
The moment you leave counts as a trigger in its own right, not just the moment a door opens. Forgetting the back door usually means it was already open for a while before anyone walked out, so an alert that only fired on the door would check whether the house was empty while you were still standing in it, find that it was not, and never fire again. Leaving with something open tells you then and there.
A phone has to actually report itself as away for this to count. One that is switched off or has lost its location is treated as unknown rather than gone, and the recipe stays quiet instead of claiming an empty house it cannot vouch for.
Pick neither signal and the recipe behaves as it always has, alerting whenever an opening is left open past the grace period, whoever is in.
Flashing the lights and announcing out loud
A push notification is easy to miss if your phone is on silent or in another room. Pick one or more lights as the optional role and the recipe flashes them when a door is left open, a hard signal to miss when you are in the room. Pick one or more speakers and the warning is announced out loud over text to speech, which works without a paid Home Assistant Cloud subscription.
Leave both selections empty and the recipe stays notification-only. Add window sensors as the optional contact role and they are watched exactly like the doors.
Files Selora AI's pipeline reads to install this recipe. The manifest declares roles, inputs, and integrations; the package files are Jinja-templated YAML applied to Home Assistant.
# Door or Window Left Open Alert — notify when a door or window is left open
# past a grace period while nobody is home, with optional light-flash and spoken
# announcement.
#
# One required door role (multi-select) plus an optional window contact role,
# combined and watched the same way. Two optional occupancy signals gate the
# alert: room presence sensors and tracked phones. Either, both, or neither may
# be picked; with neither the alert fires whenever an opening is left open, as
# it did before the gate existed.
#
# The automation triggers both on an opening passing the grace period and on the
# last person leaving, because the case this recipe is really for is walking out
# and forgetting the back door. Alerting only on the opening edge would miss it
# whenever the door was already open before anyone left.
slug: door-window-left-open-alert
version: 1.0.0
title: Door or Window Left Open Alert
tagline: When a door or window is left open too long, get a notification that names it, and optionally flash the lights or hear it out loud.
description: >-
Watches the door and window sensors you pick and fires when one is left open
longer than the grace period while nobody is home. Sends a notification to
your phone naming what is open, and optionally flashes lights and announces
the warning over TTS.
author: Selora AI
released: "2026-08-28"
min_integration_version: "0.12.0"
tags: [security, doors, windows, alerts]
roles:
# Door sensors — multi-select. binary_sensor + device_class door. min_count 1.
- id: door_sensors
title: Door sensors
kind: binary_sensor
device_class: door
min_count: 1
selection: required
description: >-
The door sensors to watch. Any one open past the grace period fires the
alert.
# Optional window contact sensors watched the same way. device_class window.
- id: window_sensors
title: Window sensors
kind: binary_sensor
device_class: window
min_count: 0
selection: required
description: >-
Optional. Window contact sensors watched the same way as the doors. Leave
empty to watch doors only.
# Optional room presence sensors. binary_sensor + occupancy. min_count 0.
# occupancy is the right class here, unlike unlock-on-arrival: this role only
# SUPPRESSES an alert, so a room sensor false-positive costs a missed
# notification rather than an unlocked door.
- id: presence_sensors
title: Presence sensors
kind: binary_sensor
device_class: occupancy
min_count: 0
selection: required
description: >-
Optional. Room presence sensors. The alert is held back while any of them
sees someone, so you are not told about a door you just walked through.
# Optional tracked phones from the companion app. kind device_tracker.
# min_count 0. Pairs with presence_sensors: pick either, both, or neither.
- id: device_trackers
title: Tracked phones
kind: device_tracker
min_count: 0
selection: required
description: >-
Optional. Phones tracked by the Home Assistant app. The alert is held back
while any of them is home. With no presence sensors or tracked phones
picked, the alert fires whenever an opening is left open.
# Optional announcement speakers over TTS. media_player. min_count 0.
- id: announce_speakers
title: Announcement speakers
kind: media_player
min_count: 0
selection: required
description: >-
Optional. Speakers that announce the open door out loud over TTS in
addition to the push notification. Leave empty to be notified on your
phone only.
# Optional lights to flash on the alert. kind light. min_count 0.
- id: alert_lights
title: Lights to flash
kind: light
min_count: 0
selection: required
description: >-
Optional. Lights that flash when a door is left open, so the alert is
visible in the room. Leave empty to be notified on your phone only.
inputs:
# Grace period, in minutes, a door may stay open before the alert fires.
- id: open_minutes
type: number
label: Grace period (minutes)
description: >-
How long a door may stay open before the alert fires.
default: 10
min: 0
max: 240
# TTS engine entity for the optional spoken announcement. Auto-resolved by the
# tts_engine resolver from the home's configured tts.* entities, so the wizard
# never shows this field. tts.speak works without a paid Home Assistant Cloud
# subscription. The resolver returns an empty string when the home has no TTS
# engine, and the template then skips the announcement. The default below is
# only used by the offline recipe-validation gate.
- id: tts_engine
type: string
label: Text-to-Speech engine
description: >-
Speech engine entity used for the spoken announcement. Resolved
automatically from the Text-to-Speech engines configured in Home
Assistant.
resolver: tts_engine
default: "tts.piper"
package_files:
- package/automations/left_open.yaml.j2
{# Door or Window Left Open Alert: fire when a door or window has been open past
the grace period and nobody is home.
Two trigger edges, because either can be the moment the situation becomes
true. An opening passing the grace period is one. The last person leaving is
the other, and it is the one that matters most: the case this recipe is for
is walking out and forgetting the back door, and by then the opening has
usually been open for a while already. Triggering on the opening alone would
have evaluated the occupancy gate while someone was still home, blocked, and
never fired again.
The condition is therefore written to be true of a state rather than of an
edge: at least one opening open past the grace period, and every occupancy
signal clear. That reads the same whichever trigger fired.
Occupancy is two optional roles, presence sensors and tracked phones. Each
contributes a state condition over its whole list only when it was picked, so
picking either, both, or neither all render correctly. With neither the alert
keeps its original behaviour and fires whenever an opening is left open.
A tracked phone counts as away on any known state other than home, which
includes named zones like Work. An unknown or unavailable tracker, a phone
that is off or has lost GPS, does not count as away, so the recipe stays
quiet rather than announcing an empty house it cannot vouch for.
source is computed from the openings that are currently open rather than from
trigger.to_state, which would name a phone on the departure edge.
mode: queued so a second opening crossing the line close behind the first is
handled rather than dropped. #}
{% set openings = roles.door_sensors + roles.window_sensors %}
{% set gated = roles.presence_sensors or roles.device_trackers %}
automation:
- id: selora_recipe_{{ slug | replace('-', '_') }}_left_open
alias: "Door or Window Left Open Alert"
description: >-
When a door or window is left open past the grace period and nobody is
home, notify and, if configured, flash lights and announce it.
mode: queued
max: 10
trigger:
{% for opening in openings %}
- platform: state
entity_id: {{ opening }}
to: "on"
for:
minutes: {{ inputs.open_minutes }}
{% endfor %}
{% for sensor in roles.presence_sensors %}
- platform: state
entity_id: {{ sensor }}
to: "off"
{% endfor %}
{% for tracker in roles.device_trackers %}
# Leaving home, whatever they leave to. A phone that goes straight from
# home to a named zone never passes through not_home, so keying on that
# as the destination missed the departure edge entirely.
- platform: state
entity_id: {{ tracker }}
from: "home"
{% endfor %}
condition:
- condition: or
conditions:
{% for opening in openings %}
- condition: state
entity_id: {{ opening }}
state: "on"
for:
minutes: {{ inputs.open_minutes }}
{% endfor %}
{% if roles.presence_sensors %}
- condition: state
state: "off"
entity_id:
{% for sensor in roles.presence_sensors %}
- {{ sensor }}
{% endfor %}
{% endif %}
{% if roles.device_trackers %}
# Away is "not home", not the literal not_home state: a phone in a named
# zone reads as that zone, so requiring not_home blocked every alert while
# somebody sat at Work. unknown and unavailable are still rejected, so a
# phone that is off or has lost GPS is never taken as evidence of an empty
# house.
- condition: template
value_template: "{% raw %}{{ expand({% endraw %}{% for tracker in roles.device_trackers %}'{{ tracker }}'{{ ', ' if not loop.last else '' }}{% endfor %}{% raw %}) | selectattr('state', 'in', ['home', 'unknown', 'unavailable']) | list | count == 0 }}{% endraw %}"
{% endif %}
action:
- variables:
# Only the openings actually past the grace period. Naming every
# opening that happens to be on would report a window opened moments
# ago, on purpose, as left open alongside the back door that genuinely
# was. The cutoff mirrors the condition that let this run through.
source: "{% raw %}{% set cutoff = now() - timedelta(minutes={% endraw %}{{ inputs.open_minutes }}{% raw %}) %}{{ expand({% endraw %}{% for opening in openings %}'{{ opening }}'{{ ', ' if not loop.last else '' }}{% endfor %}{% raw %}) | selectattr('state', 'eq', 'on') | selectattr('last_changed', 'lt', cutoff) | map(attribute='name') | list | join(', ') }}{% endraw %}"
- service: notify.notify
data:
{% if gated %}
title: "{% raw %}Left open while you are out: {{ source }}{% endraw %}"
message: "{% raw %}{{ source }} was left open and nobody is home. Check it before you get too far.{% endraw %}"
{% else %}
title: "{% raw %}{{ source }} left open{% endraw %}"
message: "{% raw %}{{ source }} has been open for a while. Check it is shut.{% endraw %}"
{% endif %}
{% if roles.alert_lights %}
- service: light.turn_on
target:
entity_id:
{% for light in roles.alert_lights %}
- {{ light }}
{% endfor %}
data:
flash: long
{% endif %}
{% if roles.announce_speakers and inputs.tts_engine %}
- service: tts.speak
target:
entity_id: {{ inputs.tts_engine }}
data:
media_player_entity_id:
{% for player in roles.announce_speakers %}
- {{ player }}
{% endfor %}
message: "{% raw %}{{ source }} has been left open. Please check it.{% endraw %}"
{% endif %}