[ Home Assistant: Simple DIY Alarm System ]
[ Overview |
Step 1 — Panel |
Step 2 — Detection |
Step 3 — Response |
Step 4 — RGB feedback |
Step 5 — PTZ tracking |
Step 6 — Privacy mode |
Step 7 — Door locks |
Optional |
Caveats ]
[ Overview ]
A simple, self-contained DIY alarm system built on top of Home Assistant.
Uses whatever contact + motion sensors you already have, an
alarm_control_panel entity for arm/disarm state, and TTS + flashing
lights as the response when something trips.
Three modes, distinct trigger sets:
armed_home perimeter only (you're inside, motion is fine)
armed_night perimeter only, interior doors excluded
so you can pee at 3am without setting it off
armed_away everything — perimeter, interior, and motion
The example below assumes a small set of sensors (front + rear contact,
one bedroom contact, one kitchen motion) but the pattern scales to any
number of sensors — just append entity_ids to the right trigger lists.
Example sensor map
Sensor Entity Type Location
─────────────────────────────────────────────────────────────────────────────────────
Front Door binary_sensor.front_door_door contact Living Room
Rear/Sliding binary_sensor.rear_door_door contact Living Room
Bedroom Door binary_sensor.bedroom_door_door contact Bedroom (interior)
Kitchen Motion binary_sensor.camera_e1_5295_motion_sensor motion Kitchen
The bedroom door is interior, so it's excluded from armed_night — otherwise a
2am bathroom trip would trigger the siren on yourself.
[ Step 1 — The Alarm Panel ]
Drop this in configuration.yaml. It creates the alarm_control_panel.home_alarm entity that everything else hangs off of. After saving, restart Home Assistant.
# configuration.yaml
alarm_control_panel:
- platform: manual
name: Home Alarm
code: !secret alarm_code # set this in secrets.yaml, e.g. alarm_code: "1234"
code_arm_required: false # don't require code to arm, only to disarm
arming_time: 30 # 30s grace period to leave after arming
delay_time: 20 # 20s entry delay before triggering
trigger_time: 600 # alarm sirens for 10 minutes
disarmed:
trigger_time: 0
armed_home:
arming_time: 0 # arming home = instant, you're already there
delay_time: 0 # no entry delay in home mode (instant alarm)
armed_night:
arming_time: 0
delay_time: 20
armed_away:
arming_time: 30
delay_time: 20
After restart you'll have alarm_control_panel.home_alarm as a state machine, plus a Lovelace card you can drop on a dashboard for arm/disarm buttons. The arm code is enforced on disarm only — arming with a single tap, disarm requires the PIN. Adjust to taste.
[ Step 2 — Detection automations ]
Three automations watch the sensors. Each one fires alarm_control_panel.alarm_trigger when its conditions are met — the panel itself handles the entry-delay countdown and state machine. Place these in automations.yaml (or wherever you keep automations).
# automations.yaml
# Perimeter trigger — fires in ALL armed modes
- alias: "Alarm: Perimeter breach"
description: "Front or rear door opened while alarm is armed"
trigger:
- platform: state
entity_id:
- binary_sensor.front_door_door
- binary_sensor.rear_door_door
to: "on"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state:
- armed_home
- armed_away
- armed_night
action:
- action: alarm_control_panel.alarm_trigger
target:
entity_id: alarm_control_panel.home_alarm
# Interior trigger — only in away mode (home/night = you're inside)
- alias: "Alarm: Interior motion"
description: "Kitchen motion while armed away"
trigger:
- platform: state
entity_id: binary_sensor.camera_e1_5295_motion_sensor
to: "on"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: armed_away
action:
- action: alarm_control_panel.alarm_trigger
target:
entity_id: alarm_control_panel.home_alarm
# Bedroom door — only in away mode (otherwise the bathroom trip rule)
- alias: "Alarm: Bedroom door (away only)"
trigger:
- platform: state
entity_id: binary_sensor.bedroom_door_door
to: "on"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: armed_away
action:
- action: alarm_control_panel.alarm_trigger
target:
entity_id: alarm_control_panel.home_alarm
[ Step 3 — Response automations ]
When the panel transitions states, these fire the actual response — entry warning, full alarm, and a disarm confirmation. The pattern is to watch state changes on the panel itself, not on the sensors, so you don't have to duplicate "is it armed?" logic everywhere.
# When the alarm enters "pending" (entry delay countdown), warn first
- alias: "Alarm: Entry warning"
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to: pending
action:
- action: media_player.play_media
target:
entity_id: media_player.nest_display
data:
media_content_id: >-
media-source://tts/tts.piper?message=Alarm+will+trigger+in+twenty+seconds.+Disarm+now.&language=en_US
media_content_type: audio/mp3
# When the alarm actually triggers — full response
- alias: "Alarm: Triggered response"
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to: triggered
action:
# 1. Push notification with high priority
- action: notify.mobile_app_magikh0e_phone
data:
title: "🚨 ALARM TRIGGERED"
message: "Security system has been triggered at home."
data:
priority: high
ttl: 0
# 2. Loud TTS on every available speaker
- action: media_player.volume_set
target:
entity_id:
- media_player.nest_display
- media_player.m5stack_atom_echo_a17a48
data:
volume_level: 1.0
- action: media_player.play_media
target:
entity_id:
- media_player.nest_display
- media_player.m5stack_atom_echo_a17a48
data:
media_content_id: >-
media-source://tts/tts.piper?message=Intruder+Alert!&language=en_US
media_content_type: audio/mp3
# 3. After TTS finishes, cast a YouTube video to the Nest display
# (Google Cast handles video/youtube content type natively).
- delay: "00:00:03"
- action: media_player.play_media
target:
entity_id: media_player.nest_display
data:
media_content_id: https://www.youtube.com/watch?v=04F4xlWSFh0
media_content_type: video/youtube
# 4. Flash all lights red as a deterrent + visibility
- repeat:
count: 30
sequence:
- action: light.turn_on
target:
entity_id:
- light.living_room_lights
- light.bedroom_lights
data:
brightness: 255
rgb_color: [255, 0, 0]
- delay: "00:00:01"
- action: light.turn_off
target:
entity_id:
- light.living_room_lights
- light.bedroom_lights
- delay: "00:00:01"
# When disarmed, send confirmation
- alias: "Alarm: Disarmed"
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to: disarmed
action:
- action: notify.mobile_app_magikh0e_phone
data:
message: "Alarm disarmed at {{ now().strftime('%I:%M %p') }}"
[ Step 4 — RGB light feedback ]
Visual feedback for state changes — bulbs flash blue when you arm, green when you disarm, breathe amber during the entry-delay countdown, and the existing red strobe handles intruder alerts. Makes the alarm state obvious from across the room without staring at a dashboard. First, group your RGB bulbs into a single light.alarm_lights entity so the automations target one thing instead of repeating an entity_id list everywhere. Add this to configuration.yaml alongside the alarm_control_panel block from Step 1.
# configuration.yaml
light:
- platform: group
name: Alarm Lights
entities:
- light.living_room_lights
- light.bedroom_lights
- light.kitchen_lights
After restart you'll have light.alarm_lights covering every bulb. Now the feedback automations — drop these in automations.yaml.
# automations.yaml — RGB light feedback
# Armed → quick double blue flash to confirm the arm action.
# Fires for any of the three armed states so you get the same visual
# regardless of how you armed (manual / auto-arm / presence-based).
- alias: "Alarm: Light feedback — armed"
mode: restart
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to:
- armed_home
- armed_night
- armed_away
action:
# Snapshot current light state so we can restore it after the flash
- action: scene.create
data:
scene_id: alarm_lights_before
snapshot_entities: light.alarm_lights
- repeat:
count: 2
sequence:
- action: light.turn_on
target:
entity_id: light.alarm_lights
data:
brightness: 200
rgb_color: [0, 100, 255] # blue
transition: 0
- delay: "00:00:00.4"
- action: light.turn_off
target:
entity_id: light.alarm_lights
data:
transition: 0
- delay: "00:00:00.4"
# Restore whatever the lights were doing before
- action: scene.turn_on
target:
entity_id: scene.alarm_lights_before
# Disarmed → single green pulse confirming "all clear"
- alias: "Alarm: Light feedback — disarmed"
mode: restart
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to: disarmed
action:
- action: scene.create
data:
scene_id: alarm_lights_before
snapshot_entities: light.alarm_lights
- action: light.turn_on
target:
entity_id: light.alarm_lights
data:
brightness: 200
rgb_color: [50, 255, 80] # green
transition: 0
- delay: "00:00:01.5"
- action: scene.turn_on
target:
entity_id: scene.alarm_lights_before
# Pending (entry-delay countdown) → amber breathing pulse
# Stops automatically when the panel leaves "pending" — either you
# disarmed in time, or the alarm tripped and Step 3's red flash takes
# over. mode: restart so a fresh entry resets cleanly.
- alias: "Alarm: Light feedback — entry delay"
mode: restart
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to: pending
action:
- action: scene.create
data:
scene_id: alarm_lights_before
snapshot_entities: light.alarm_lights
- repeat:
while:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: pending
sequence:
- action: light.turn_on
target:
entity_id: light.alarm_lights
data:
brightness: 255
rgb_color: [255, 140, 0] # amber
transition: 0
- delay: "00:00:00.6"
- action: light.turn_off
target:
entity_id: light.alarm_lights
data:
transition: 0
- delay: "00:00:00.6"
# Restore prior state if we exited because of disarm
# (if we exited because of trigger, Step 3 takes over and
# this scene.turn_on is harmlessly overwritten by the red flash)
- action: scene.turn_on
target:
entity_id: scene.alarm_lights_before
Color cheat sheet blue [0, 100, 255] armed (any mode) green [50, 255, 80] disarmed / all clear amber [255, 140, 0] pending (entry delay countdown) red [255, 0, 0] triggered (Step 3, already wired) The scene.create → flash → scene.turn_on sandwich is the canonical "pulse without disrupting" pattern in HA. Without it, every arm/disarm would turn off whatever lights you had on. With it, your reading lamp keeps its warm-2700K glow once the blue confirmation flash finishes. Refactoring tip The Step 3 red-flash response still uses light.living_room_lights and light.bedroom_lights directly. Now that light.alarm_lights exists, you can swap those entity_ids for the group and lose a few lines.
[ Step 5 — PTZ camera + presence-driven tracking ]
A pan/tilt/zoom camera turns "something tripped the motion sensor" into
"I have a face on file." When a presence or motion sensor fires, the
camera pivots to that zone's preset, settles, snaps a still, and sends
the image to your phone alongside the alert push.
This pairs naturally with the alarm: while armed_away, every
zone trip captures evidence; while disarmed, the camera stays
quiet so it isn't snapping you reading on the couch.
One-time setup — store presets on the camera
Use the camera's web UI (or a tool like ONVIF Device Manager) to drive
the gimbal to each zone in turn and save it as a numbered preset.
Cameras typically support 8–255 preset slots; small cameras
like a Reolink E1 hold 64. The token you'll reference in YAML is
just the preset slot number as a string.
Preset 1 → Living Room
Preset 2 → Kitchen
Preset 3 → Front Entry
Preset 4 → Hallway
Preset 0 → "home" / idle position (camera returns here
after a few minutes of quiet)
Motion vs. presence sensors
PIR motion (Aqara P1, Hue, Zigbee multi-sensors) —
cheap, battery-powered, fires on initial movement then has a
cooldown. Misses a stationary intruder. Best for hallways and
doorways where the target moves through.
mmWave presence (Aqara FP2, LD2410/LD2412 ESPHome boards,
Apollo MSR, Everything Presence One) — radar-based,
detects breathing/sub-cm motion. Holds "on" while
someone's in the room even if they sit still. Best for rooms
where dwell time matters. Mostly mains-powered.
Both expose binary_sensor.<name>_presence or
_occupancy — the YAML pattern below works the same
for either.
Example sensor-to-zone map
Sensor Zone Preset
─────────────────────────────────────────────────────────────────
binary_sensor.living_room_presence Living Room 1
binary_sensor.kitchen_motion Kitchen 2
binary_sensor.front_door_door Front Entry 3
binary_sensor.hallway_presence Hallway 4
The DRY version — one automation, a lookup table mapping each sensor to its preset + label, and a single snap/notify action chain that runs regardless of which sensor tripped. Adding a new zone is a two-line edit to the table, not a new automation.
# automations.yaml — PTZ tracking
- alias: "PTZ: Track motion to zone"
description: "When armed_away, pan to the tripped zone, snap, and notify"
mode: queued # queue triggers so rapid sequential motion doesn't lose snapshots
max: 5
trigger:
- platform: state
entity_id:
- binary_sensor.living_room_presence
- binary_sensor.kitchen_motion
- binary_sensor.front_door_door
- binary_sensor.hallway_presence
to: "on"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: armed_away
variables:
# Lookup table: sensor entity_id → { preset_token, human label }
zones:
binary_sensor.living_room_presence: { preset: "1", label: "Living Room" }
binary_sensor.kitchen_motion: { preset: "2", label: "Kitchen" }
binary_sensor.front_door_door: { preset: "3", label: "Front Entry" }
binary_sensor.hallway_presence: { preset: "4", label: "Hallway" }
zone: "{{ zones[trigger.entity_id] }}"
stamp: "{{ now().strftime('%Y%m%d_%H%M%S') }}"
file_path: "/config/www/snapshots/ptz_{{ stamp }}.jpg"
web_path: "/local/snapshots/ptz_{{ stamp }}.jpg"
action:
# 1. Move the gimbal to the zone preset
- action: onvif.ptz
target:
entity_id: camera.ptz_main
data:
preset_token: "{{ zone.preset }}"
# 2. Wait for the camera to settle and re-focus
- delay: "00:00:03"
# 3. Capture a still
- action: camera.snapshot
target:
entity_id: camera.ptz_main
data:
filename: "{{ file_path }}"
# 4. Push notification with snapshot attached
- action: notify.mobile_app_magikh0e_phone
data:
title: "🎥 Motion: {{ zone.label }}"
message: "{{ now().strftime('%I:%M %p') }} — snapshot captured"
data:
image: "{{ web_path }}"
ttl: 0
priority: high
Return-to-home After a flurry of zone-trips the camera ends up parked at whichever preset fired last. A separate automation parks it back at preset 0 (or wherever you like to leave it) once everything has been quiet for a while.
# automations.yaml — PTZ idle return
- alias: "PTZ: Return to home"
description: "Park the gimbal at the home preset after 5 min of no zone trips"
trigger:
- platform: state
entity_id:
- binary_sensor.living_room_presence
- binary_sensor.kitchen_motion
- binary_sensor.front_door_door
- binary_sensor.hallway_presence
to: "off"
for: "00:05:00"
condition:
# Only re-park if every zone is currently quiet
- condition: state
entity_id: binary_sensor.living_room_presence
state: "off"
- condition: state
entity_id: binary_sensor.kitchen_motion
state: "off"
- condition: state
entity_id: binary_sensor.front_door_door
state: "off"
- condition: state
entity_id: binary_sensor.hallway_presence
state: "off"
action:
- action: onvif.ptz
target:
entity_id: camera.ptz_main
data:
preset_token: "0"
Bonus — attach a snapshot to the main alarm trigger The triggered-response automation in Step 3 already pushes a notification when something fires the alarm. With PTZ wired up, you can chain a snapshot of whichever zone tripped first into that push so the alert that wakes you up has a face attached. Insert this between the existing "Push notification" step and the TTS step:
# 1b. Snapshot whichever zone tripped most recently
- action: camera.snapshot
target:
entity_id: camera.ptz_main
data:
filename: "/config/www/snapshots/alarm_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg"
- action: notify.mobile_app_magikh0e_phone
data:
title: "🚨 ALARM TRIGGERED"
message: "Snapshot from PTZ — {{ now().strftime('%I:%M %p') }}"
data:
image: "/local/snapshots/alarm_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg"
priority: high
ttl: 0
PTZ caveats
1. Mechanical wear.
The gimbal motors aren't rated for thousands of trips per day.
The queued / max: 5 mode on the tracking automation is a
soft limit; if a zone fires every minute, consider a
throttle condition (e.g., "not within 30s of last
run") to keep the motors alive.
2. Settling time.
The 3-second delay before the snapshot is the camera's pan
speed plus its autofocus settle time. Fast PTZs (Reolink RLC-823A)
can do ~1.5s; slower gimbals (Hikvision IP Speed Domes) need 4–5s.
Tune to your specific camera.
3. Snapshot path.
/config/www/ is HA's web-exposed directory — files
dropped there are reachable at /local/<path> from
the LAN, which is what the mobile app fetches the notification
image from. Different paths break the attached-image flow.
4. Storage.
One snapshot per zone-trip adds up. Pair this with a daily
automation that wipes /config/www/snapshots/ entries
older than N days, or pipe to S3 / a NAS share if you want
to keep evidence longer.
5. mmWave false positives.
Ceiling fans, HVAC vents, water vapor from showers, and curtain
movement can all trigger 24GHz radar. Place sensors thoughtfully
and use the per-sensor sensitivity sliders. PIR is more
selective by nature but pays for it with dwell-time blindness.
[ Step 6 — Privacy mode (camera off when home) ]
Step 5 wakes the camera up when the alarm is armed and someone trips a
zone. Step 6 is the inverse: as soon as anyone is home, the
camera goes into a privacy mode that keeps it from filming you making
coffee, walking to the bathroom, or reading on the couch.
Two signals decide whether someone's home:
Phone presence — the Home Assistant companion app
publishes person.<you> with state home /
not_home based on phone GPS + your defined Home zone. Combine
multiple device_tracker.* entities into a single
person.* for redundancy (phone + watch + laptop — if
any of them is home, you're home).
Indoor presence sensors — the same mmWave / PIR sensors
driving Step 5's tracking. Useful for guests, kids, and roommates
who don't have HA on their phone. Also catches the case where your
phone died but you're physically inside.
A template binary_sensor folds both into a single
binary_sensor.someone_home that the privacy automations watch.
The delay_off grace period prevents the sensor from flickering
off every time a presence sensor cools down between motion events.
What "privacy mode" means in YAML
Privacy mode is a stack — layer as many of these as your hardware
supports, because each one alone has a failure mode the others cover:
1. PTZ to a wall-facing preset (do this always).
Save a preset (e.g., slot 99) where the gimbal physically points
at a blank wall, the ceiling, or into a sock. For cameras
without a hardware shutter, this preset IS your privacy
mechanism — the lens still sees, but it sees nothing
useful. Pick a wall, not a ceiling, if your wall is closer than
the camera's minimum focus distance — out-of-focus blur
is bonus privacy.
2. Hardware shutter (if your camera has one).
Eufy indoor cams, Reolink E1 Outdoor, and a few others have a
motorized lens cover. Most integrations expose this as
switch.<cam>_privacy_mode. This is the only
privacy mechanism that's cryptographically certain — the
lens is physically blind regardless of firmware state.
3. Stop recording at the camera.
Vendor switches like switch.<cam>_record,
_motion_detection, _record_audio,
_ftp_upload, _email. Toggling these stops the
camera from persisting clips locally or shipping them to a
cloud you don't want them on.
4. Stop the stream into HA.
camera.turn_off tells the integration to stop pulling
RTSP from the camera. Kills any HA-side recording (Frigate,
LLAT, NVR add-on) without touching the camera itself.
5. Disable the Step 5 tracking automation.
Belt-and-suspenders: even if a frame somehow leaked through,
no automation will snap it and push it to your phone.
The example below stacks all five.
# configuration.yaml — composite "someone is home" sensor
template:
- binary_sensor:
- name: "Someone Home"
unique_id: someone_home
# ON when ANY tracker says home OR ANY presence sensor is active
state: >-
{{ is_state('person.magikh0e', 'home')
or is_state('person.partner', 'home')
or is_state('binary_sensor.living_room_presence', 'on')
or is_state('binary_sensor.bedroom_presence', 'on')
or is_state('binary_sensor.kitchen_motion', 'on')
or is_state('binary_sensor.hallway_presence', 'on') }}
# 5-minute grace so a brief presence dropout doesn't
# spin the camera up while you're still in the room.
delay_off: "00:05:00"
# automations.yaml — privacy mode
# Engage privacy mode the moment anyone is home
- alias: "Privacy: Engage when someone home"
description: "Wall-face the gimbal, kill recording + stream, disable tracking"
mode: single
trigger:
- platform: state
entity_id: binary_sensor.someone_home
to: "on"
action:
# 1. Pivot the lens to the wall-facing privacy preset.
# For cameras without a hardware shutter, this is the
# primary privacy mechanism — make sure the preset is
# actually saved and points somewhere blind.
- action: onvif.ptz
target:
entity_id: camera.ptz_main
data:
preset_token: "99"
# 2. Hardware shutter (if your camera has one — skipped
# silently otherwise via continue_on_error)
- action: switch.turn_on
target:
entity_id: switch.ptz_main_privacy_mode
continue_on_error: true
# 3. Stop recording at the camera. List every persistence
# switch your model exposes — recording, motion-triggered
# clips, audio capture, FTP upload, email alerts. Skip any
# switch your camera doesn't have via continue_on_error.
- action: switch.turn_off
target:
entity_id:
- switch.ptz_main_record
- switch.ptz_main_motion_detection
- switch.ptz_main_record_audio
- switch.ptz_main_ftp_upload
- switch.ptz_main_email
continue_on_error: true
# 4. Stop HA from pulling the RTSP stream. Kills any HA-side
# NVR / Frigate / LLAT recording. The camera itself is
# still up but no frames flow into HA.
- action: camera.turn_off
target:
entity_id: camera.ptz_main
# 5. Disable the Step 5 tracking automation so motion sensors
# can't trigger a snapshot pipeline (would fail anyway with
# the stream off, but no point spamming the log)
- action: automation.turn_off
target:
entity_id: automation.ptz_track_motion_to_zone
# 6. Confirm via push (one-time, no repeat) so you know it
# engaged. Comment out once you trust it.
- action: notify.mobile_app_magikh0e_phone
data:
title: "🔒 Privacy mode"
message: "Camera blinded — someone home"
# Disengage when the house has been empty for the grace window
- alias: "Privacy: Disengage when no one home"
description: "Reverse all five privacy layers once everyone has left"
mode: single
trigger:
- platform: state
entity_id: binary_sensor.someone_home
to: "off"
action:
# 1. Re-enable HA's stream pull first — the rest needs the
# camera entity to be available
- action: camera.turn_on
target:
entity_id: camera.ptz_main
# 2. Lift the hardware shutter
- action: switch.turn_off
target:
entity_id: switch.ptz_main_privacy_mode
continue_on_error: true
# 3. Re-enable camera-side recording + alerts
- action: switch.turn_on
target:
entity_id:
- switch.ptz_main_record
- switch.ptz_main_motion_detection
- switch.ptz_main_record_audio
- switch.ptz_main_ftp_upload
- switch.ptz_main_email
continue_on_error: true
# 4. Park the gimbal at the home / idle preset
- action: onvif.ptz
target:
entity_id: camera.ptz_main
data:
preset_token: "0"
# 5. Re-enable Step 5 tracking
- action: automation.turn_on
target:
entity_id: automation.ptz_track_motion_to_zone
- action: notify.mobile_app_magikh0e_phone
data:
title: "🎥 Privacy mode lifted"
message: "House is empty — surveillance back on"
Privacy caveats
1. Phone GPS is not instant.
Cellular GPS-based device_tracker updates can lag
30–90 seconds when you walk in the door, especially
in dense urban areas. The mmWave sensor backstop kicks
privacy mode in fast even when GPS is still catching up.
If you're paranoid, add a Wi-Fi-based zone trigger
(router DHCP-lease integration) for instant detection.
2. Hardware shutter beats software.
A camera that physically closes its lens (Eufy, Reolink E1
Outdoor) gives you cryptographic certainty the lens is
blind. Software-only privacy modes are still trusting
the firmware not to silently keep streaming.
3. Test the privacy preset.
Before trusting it, view the live stream while privacy mode
is engaged and confirm the camera is actually pointed
somewhere harmless. This matters most for no-shutter
cameras — the preset IS your only privacy mechanism,
and any drift past the wall edge means the lens is back on
the room. Step 5's gimbal drives accumulate step-counter
error over time. Re-save the preset every few months and
spot-check the live stream as a routine.
4. Guest mode.
If a guest is over and you want surveillance off but their
phone isn't tracked, the indoor sensors still flag
someone_home and engage privacy mode. That's the
right default. To toggle off manually for a long
vacation while a house-sitter is over, expose
input_boolean.privacy_override and OR it into the
template above.
5. Fail-safe orientation.
If HA crashes mid-engage, the camera might be left in
whatever state it was last commanded into. Decide whether
your default-on-failure is "privacy preserved" or
"surveillance running" and arrange the trigger
directions accordingly. The example above prefers
surveillance-on as the failure default (trigger on
off restores the camera) — flip if you'd
rather privacy-on be the default.
[ Step 7 — Smart door locks ]
Smart locks turn doors into a two-way alarm signal: they're an
output the alarm can drive (auto-lock when armed) and an
input that can drive the alarm (forced entry, PIN-based
disarm, lock-jammed states).
A door lock with HA support exposes at minimum a
lock.<name> entity (locked / unlocked / jammed states)
and usually a sensor.<name>_tampered or similar
diagnostic. Many also fire lock_state_changed events with
a code_slot attribute — that's how you wire
"disarm with my PIN" vs "disarm with the cleaning
service's PIN".
Supported platforms (most common)
Z-Wave Schlage Connect, Yale Assure, Kwikset 914/916.
Native HA support via the Z-Wave JS
integration. Best PIN-management story.
Zigbee Yale YRD220, Aqara U200, Sonoff via Z2M.
Z2M exposes lock.<name> plus
per-slot PIN configuration.
Matter Aqara U200, newer Yale models. Native HA
support; PIN management is still maturing.
Cloud-only August / Yale Access, Wyze Lock, Level Bolt.
Work but require a cloud integration —
the lock-via-internet failure mode is real.
Prefer local protocols when you can.
Example lock map
Lock Entity Type Location
────────────────────────────────────────────────────────────────────
Front Door lock.front_door Z-Wave Front entry
Rear Door lock.rear_door Z-Wave Sliding patio
Garage Side lock.garage_side Zigbee Side entry
Each one pairs with the binary_sensor.<name>_door contact
sensor from Step 2's perimeter list — the contact reports
whether the door is open, the lock reports whether the deadbolt is
thrown.
Auto-lock on arm. The cleanest behavior: when you arm the alarm in any mode, every exterior lock throws automatically. No more "wait, did I lock the back door?" on the way to bed.
# automations.yaml — auto-lock on arm
- alias: "Locks: Auto-lock on arm"
description: "Engage every exterior lock when the alarm goes armed"
mode: single
trigger:
- platform: state
entity_id: alarm_control_panel.home_alarm
to:
- armed_home
- armed_night
- armed_away
action:
- action: lock.lock
target:
entity_id:
- lock.front_door
- lock.rear_door
- lock.garage_side
# Verify all locks reported "locked" within 10s; if any didn't,
# the lock may be jammed (deadbolt hitting the strike plate
# misaligned, etc.) — notify so you can physically check.
- delay: "00:00:10"
- choose:
- conditions:
- condition: template
value_template: >-
{{ not is_state('lock.front_door', 'locked')
or not is_state('lock.rear_door', 'locked')
or not is_state('lock.garage_side', 'locked') }}
sequence:
- action: notify.mobile_app_magikh0e_phone
data:
title: "🔓 Lock did not engage"
message: >-
Front: {{ states('lock.front_door') }} ·
Rear: {{ states('lock.rear_door') }} ·
Garage: {{ states('lock.garage_side') }}
data:
priority: high
Disarm via PIN code. Most Z-Wave / Zigbee locks support per-user codes (slots 1–30 typically). Configure each slot in the lock integration with a name, then watch the lock_state_changed event and dispatch by slot. This lets you give the cleaning service a code that disarms-and-unlocks during their window only, give kids their own code so you know who came home, and rotate compromised codes without re-keying the lock.
# automations.yaml — disarm via authorized PIN
- alias: "Locks: Disarm on authorized PIN"
description: "Unlocking with a known code slot disarms the alarm"
mode: single
trigger:
# Z-Wave JS fires this event when a code slot unlocks the door
- platform: event
event_type: zwave_js_notification
event_data:
command_class_name: "Access Control"
event_label: "Keypad unlock operation"
condition:
# Only act on our exterior locks
- condition: template
value_template: >-
{{ trigger.event.data.entity_id in
['lock.front_door', 'lock.rear_door', 'lock.garage_side'] }}
# Only act on configured user slots (slot 0 = manufacturer/master)
- condition: template
value_template: "{{ trigger.event.data.parameters.userId | int > 0 }}"
variables:
# Lookup: user slot → human name (configure these in the lock
# integration to match)
slot_names:
"1": "magikh0e"
"2": "partner"
"3": "cleaning service"
"4": "house sitter"
user_name: "{{ slot_names[trigger.event.data.parameters.userId | string] | default('unknown') }}"
action:
# 1. Disarm the alarm
- action: alarm_control_panel.alarm_disarm
target:
entity_id: alarm_control_panel.home_alarm
data:
code: !secret alarm_code
# 2. Log who came in (audit trail beyond HA's history)
- action: logbook.log
data:
name: "Door entry"
message: "{{ user_name }} disarmed via {{ trigger.event.data.entity_id }}"
domain: lock
# 3. Optional welcome push so you know who arrived
- action: notify.mobile_app_magikh0e_phone
data:
title: "🔓 Door unlocked"
message: "{{ user_name }} entered through {{ trigger.event.data.entity_id }}"
Trigger alarm on forced entry. If a door contact reports "open" while its companion lock is still in the locked state, somebody pried the door open without unlocking. That's an unambiguous forced entry — trigger the alarm immediately, regardless of arm mode.
# automations.yaml — forced entry detection
- alias: "Locks: Forced entry detection"
description: "Contact open while lock still locked = pried door"
mode: single
trigger:
- platform: state
entity_id:
- binary_sensor.front_door_door
- binary_sensor.rear_door_door
to: "on"
condition:
# If the matching lock is still "locked", the door was forced.
# The lock entity name follows the contact name minus "_door".
- condition: template
value_template: >-
{% set lock_id = trigger.entity_id
| replace('binary_sensor.', 'lock.')
| replace('_door', '') %}
{{ is_state(lock_id, 'locked') }}
action:
# Trigger regardless of arm state — forced entry is always wrong
- action: alarm_control_panel.alarm_trigger
target:
entity_id: alarm_control_panel.home_alarm
- action: notify.mobile_app_magikh0e_phone
data:
title: "🚨 FORCED ENTRY"
message: >-
{{ trigger.to_state.attributes.friendly_name }} opened
while lock was still engaged.
data:
priority: high
ttl: 0
Notify on unexpected unlock while armed. Less alarming than forced entry but still suspicious: somebody unlocked the door (legitimately, with a key or PIN) while the alarm was armed_away. Could be a family member who forgot to use the disarm PIN, could be a problem — push a notification so you can ask.
# automations.yaml — unexpected unlock while armed
- alias: "Locks: Unexpected unlock while armed"
description: "Lock unlocked while armed_away with no preceding PIN slot"
mode: single
trigger:
- platform: state
entity_id:
- lock.front_door
- lock.rear_door
- lock.garage_side
to: "unlocked"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: armed_away
# 5-second gate: if the PIN-disarm automation just ran, the panel
# is already transitioning to "disarmed" — skip this notification
# so authorized entry doesn't double-fire.
- condition: template
value_template: >-
{{ as_timestamp(now()) -
as_timestamp(states.alarm_control_panel.home_alarm.last_changed)
> 5 }}
action:
- action: notify.mobile_app_magikh0e_phone
data:
title: "⚠ Unexpected unlock"
message: >-
{{ trigger.to_state.attributes.friendly_name }} unlocked
while armed_away. Check who's home.
data:
priority: high
Wrong-PIN lockout. A burglar trying random keypad combinations should both trigger the alarm and lock the keypad out for a few minutes. Most lock integrations fire a distinct event on wrong-code attempts.
# automations.yaml — wrong-PIN attempts
- alias: "Locks: Trigger on repeated wrong PIN"
description: "3 wrong PIN attempts within 2 min = trigger the alarm"
mode: single
trigger:
- platform: event
event_type: zwave_js_notification
event_data:
event_label: "Keypad lock/unlock attempt — invalid code"
action:
# Bump a counter (helper: input_number.wrong_pin_count, max 10, step 1)
- action: input_number.increment
target:
entity_id: input_number.wrong_pin_count
# 3+ wrong attempts = treat as attack
- condition: template
value_template: "{{ states('input_number.wrong_pin_count') | int >= 3 }}"
- action: alarm_control_panel.alarm_trigger
target:
entity_id: alarm_control_panel.home_alarm
- action: notify.mobile_app_magikh0e_phone
data:
title: "🚨 Lock under attack"
message: "3+ wrong PIN attempts on a smart lock — alarm triggered"
data:
priority: high
ttl: 0
# Reset the wrong-PIN counter after 2 minutes of no attempts
- alias: "Locks: Reset wrong-PIN counter"
trigger:
- platform: state
entity_id: input_number.wrong_pin_count
for: "00:02:00"
condition:
- condition: numeric_state
entity_id: input_number.wrong_pin_count
above: 0
action:
- action: input_number.set_value
target:
entity_id: input_number.wrong_pin_count
data:
value: 0
Door-lock caveats
1. Battery-powered locks die.
Most smart locks run on 4× AA. They report battery via
sensor.<name>_battery — wire a low-battery
notification at ~20% and a critical alert at ~10%. A dead
lock during a winter storm is its own emergency.
2. Jammed-deadbolt failure mode.
If a deadbolt is misaligned with the strike plate, the lock
motor stalls and reports jammed. The auto-lock
verification step above catches this, but the long-term fix
is mechanical — adjust the strike plate so the bolt
throws freely.
3. PIN codes are not unique to the burglar.
Most locks store codes in firmware that's been reverse-
engineered. Treat PIN-based disarm as a convenience layer
for known users, not a security boundary. The actual
security is the lock's mechanical bolt + the alarm
response automation.
4. Cloud-only locks have an extra failure mode.
August / Yale Access / Wyze require the vendor cloud to
unlock from HA — if their service is down, HA can't
drive the lock. The mechanical key still works, and the
keypad still works, but auto-lock and disarm-via-PIN both
break. Local-protocol locks (Z-Wave, Zigbee, Matter) don't
have this dependency.
5. Key override always works.
A smart lock with a keyway is only as secure as the keyway.
Bumping, picking, and snap-attacks all bypass the
electronics entirely. The forced-entry detection above
covers pried doors — it does not cover doors
that were keyed open. A separate contact-while-armed
trigger (Step 2) catches that case.
6. Lock event names vary by integration.
The YAML above uses zwave_js_notification event
types. Zigbee2MQTT exposes the same data via MQTT
action attributes; Matter via
matter_node_event. Pull the exact event name from
Developer Tools → Events → Listen while
you operate your specific lock model, then substitute it
into the triggers above.
[ Optional — Auto-arm ]
A scheduled auto-arm at midnight, in case you forget. Fires only if the panel is currently disarmed — won't disturb a manually-set state. Pair this with a presence-based armed_away automation for full hands-off coverage.
# Auto-arm at midnight if not already armed
- alias: "Alarm: Auto-arm at night"
trigger:
- platform: time
at: "00:00:00"
condition:
- condition: state
entity_id: alarm_control_panel.home_alarm
state: disarmed
action:
- action: alarm_control_panel.alarm_arm_night
target:
entity_id: alarm_control_panel.home_alarm
data:
code: !secret alarm_code
[ ⚠ Caveats ]
Things to know before you trust this with your sleep schedule.
1. This is not a monitored alarm.
No central station, no police dispatch — just push notifications to
your phone. For real security, layer this with a monitored service
(SimpliSafe, Ring, etc.) or route notifications through a service
you'll actually wake up for.
2. Single point of failure.
If HA is down, your network is dropped, or your phone is on
do-not-disturb, the alarm doesn't work. Real systems carry cellular
backup. This one carries hopes and dreams.
3. Wi-Fi cameras drop offline.
If your motion sensor is built into a Wi-Fi camera, it'll go
unavailable periodically. Worth layering on a "device unavailable"
alert so you know if a sensor stops reporting:
- alias: "Alarm: Sensor offline"
trigger:
- platform: state
entity_id: binary_sensor.camera_e1_5295_motion_sensor
to: "unavailable"
for: "00:05:00"
action:
- action: notify.mobile_app_magikh0e_phone
data:
message: "Kitchen motion sensor has been offline for 5+ min"
4. TTS provider — local Piper.
The examples use tts.piper, a local neural TTS that runs
in HA via the Wyoming protocol. Install it from Settings →
Add-ons → Piper, then configure the integration so the
tts.piper entity exists. Everything stays on-LAN — no
internet round-trip per announcement, no subscription. If you'd
rather use a hosted voice, swap to tts.home_assistant_cloud
(Nabu Casa) or tts.google_translate_en_com (free, but
phones home to Google for every utterance). The
media_content_id URL prefix is the only thing that
changes.
5. No real siren.
TTS through a Nest display + flashing lights is a deterrent — not a
real deterrent. A Zigbee siren ($25 — Heiman, Frient, etc.)
wired into the response automation is a serious upgrade. Add a
switch.alarm_siren to the armed_triggered action and the
burglar gets a 110 dB welcome instead of a friendly TTS voice.
6. Code in plaintext.
!secret alarm_code reads from secrets.yaml — that file
belongs in your local backup workflow. Don't commit it to git, don't
paste it into screenshots. The code only protects against casual
dashboard access; anyone with shell on your HA box can read it.
