title:Automating Security Camera Notifications With Home Assistant and Ntfy
posted:2023-11-25
updated:2024-01-15
tags:["all", "api", "automation", "homeassistant"]

A couple of months ago, I wrote about how I was using a self-hosted instance of ntfy to help streamline notification pushes from a variety of sources. I closed that post with a quick look at how I had integrated ntfy into my Home Assistant setup for some basic notifications.

I've now used that immense power to enhance the notifications I get from the Reolink security cameras scattered around my house. I selected Reolink cameras specifically because I knew it was supported by Home Assistant, and for the on-device animal/person/vehicle detection which allowed for a bit of extra control over which types of motion events would trigger a notification or other action. I've been very happy with this choice, but I have found that the Reolink app itself can be a bit clunky:

  • The app lets you send notifications on a schedule (I only want notifications from the indoor cameras during work hours when no one is home), but doesn't make it easy to override that schedule (like when it's a holiday and we're all at home anyway).
  • Push notifications don't include an image capture so when I receive a notification about a person in my backyard I have to open the app, go select the correct camera, select the Playback option, and scrub back and forth until I see whatever my camera saw.

I figured I could combine the excellent Reolink integration for Home Assistant with Home Assistant's powerful Automation platform and ntfy to get more informative notifications and more flexible alert schedules. Here's the route I took.

Alert on motion detection

Ntfy Integration

Since manually configuring ntfy in Home Assistant via the RESTful Notifications integration, I found that a ntfy-specific integration was available through the Home Assistant Community Store addon. That setup is a bit more flexible so I've switched my setup to use it instead:

# configuration.yaml
notify:
- name: ntfy
- platform: rest { ... }
- method: POST_JSON
- headers:
- Authorization: !secret ntfy_token
- data:
- topic: home_assistant
- title_param_name: title
- message_param_name: message
- resource: ! secret ntfy_url
+ platform: ntfy
+ url: !secret ntfy_url
+ token: !secret ntfy_token
+ topic: home_assistant

The Reolink integration exposes a number of entities for each camera. For triggering a notification on motion detection, I'll be interested in the binary sensor entities named like binary_sensor.$location_$type (like binary_sensor.backyard_person and binary_sensor.driveway_vehicle), the state of which will transition from off to on when the selected motion type is detected.

So I'll begin by crafting a simple automation which will push out a notification whenever any of the listed cameras detect a person or vehicle1:

1# exterior_motion.yaml
2alias: Exterior Motion Alerts
3description: ""
4trigger:
5 - platform: state
6 entity_id:
7 - binary_sensor.backyard_person
8 - binary_sensor.driveway_person
9 - binary_sensor.driveway_vehicle
10 - binary_sensor.east_side_front_person
11 - binary_sensor.east_side_rear_person
12 - binary_sensor.west_side_person
13 from: "off"
14 to: "on"
15condition: []
16action:
17 - service: notify.ntfy
18 data:
19 title: Motion detected!
20 message: "{{ trigger.to_state.attributes.friendly_name }}"

Templating

That last line is taking advantage of Jinja templating and trigger variables so that the resulting notification displays the friendly name of whichever binary_sensor triggered the automation run. This way, I'll see something like "Backyard Person" instead of the entity ID listed earlier.

I'll step outside and see if it works... backyard person

Capture a snapshot

Each Reolink camera also exposes a camera.$location_sub entity which represents the video stream from the connected camera. I can add another action to the notification so that it will grab a snapshot, but I'll also need a way to match the camera entity to the correct binary_sensor entity. I can do that by adding a variable set to the bottom of the automation:

1# exterior_motion.yaml
2alias: Exterior Motion Alerts
3description: ""
4trigger: { ... }
5 - platform: state
6 entity_id:
7 - binary_sensor.backyard_person
8 - binary_sensor.driveway_person
9 - binary_sensor.driveway_vehicle
10 - binary_sensor.east_side_front_person
11 - binary_sensor.east_side_rear_person
12 - binary_sensor.west_side_person
13 from: "off"
14 to: "on"
15condition: []
16action:
+ - service: camera.snapshot
+ target:
+ entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
+ data:
+ filename: /media/snaps/motion.jpg
22 - service: notify.ntfy
23 data:
24 title: Motion detected!
25 message: "{{ trigger.to_state.attributes.friendly_name }}"
+variables:
+ cameras:
+ binary_sensor.backyard_person: camera.backyard_sub
+ binary_sensor.driveway_person: camera.driveway_sub
+ binary_sensor.driveway_vehicle: camera.driveway_sub
+ binary_sensor.east_side_front_person: camera.east_side_front_sub
+ binary_sensor.east_side_rear_person: camera.east_side_rear_sub
+ binary_sensor.west_side_person: camera.west_side_sub

That "{{ cameras[trigger.to_state.entity_id] }}" template will look up the ID of the triggering binary_sensor and return the appropriate camera entity, and that will use the camera.snapshot service to save a snapshot to the desginated location (/media/snaps/motion.jpg).

Before this will actually work, though, I need to reconfigure Home Assistant to allow access to the storage location, and I should also go ahead and pre-create the folder so there aren't any access issues.

# configuration.yaml
homeassistant:
allowlist_external_dirs:
- "/media/snaps/"

I'm using the Home Assistant Operating System virtual appliance, so /media is already symlinked to /root/media inside the Home Assistant installation directory. So I'll just log into that shell and create the snaps subdirectory:

mkdir -p /media/snaps

Rather than walking outside each time I want to test this, I'll just use the Home Assistant Developer Tools to manually toggle the state of the binary_sensor.backyard_person entity to on, and I should then be able to see the snapshot in the Media interface: backyard snap

Woo, look at me making progress!

Attach the snapshot

Now that I've captured the snap, I need to figure out how to attach it to the notification. Ntfy supports inline image attachments, which is handy, but it expects those to be delivered via HTTP PUT action. Neither my original HTTP POST approach or the Ntfy integration support this currently, so I had to use the shell_command integration to make the call directly.

I can't use the handy !secret expansion inside of the shell command, though, so I'll need a workaround to avoid sticking sensitive details directly in my configuration.yaml. I can use a dummy sensor to hold the value, and then use the {{ states('sensor.$sensor_name') }} template to retrieve it.

So here we go:

# configuration.yaml
 
# dummy sensor to make ntfy secrets available to template engine
template:
- sensor:
- name: ntfy_token
state: !secret ntfy_token
- name: ntfy_url
state: !secret ntfy_url
 
notify:
- name: ntfy
platform: ntfy
url: !secret ntfy_url
token: !secret ntfy_token
topic: home_assistant
 
shell_command:
ntfy_put: >
curl
--header 'Title: {{ title }}'
--header 'Priority: {{ priority }}'
--header 'Filename: {{ filename }}'
--header 'Authorization: Bearer {{ states('sensor.ntfy_token') }}'
--upload-file '{{ file }}'
--header 'Message: {{ message }}'
--url '{{ states('sensor.ntfy_url') }}/home_assistant'

Now I just need to replace the service call in the automation with the new shell_command.ntfy_put one:

1# exterior_motion.yaml #
2alias: Exterior Motion Alerts
3description: ""
4trigger: { ... }
5 - platform: state
6 entity_id:
7 - binary_sensor.backyard_person
8 - binary_sensor.driveway_person
9 - binary_sensor.driveway_vehicle
10 - binary_sensor.east_side_front_person
11 - binary_sensor.east_side_rear_person
12 - binary_sensor.west_side_person
13 from: "off"
14 to: "on"
15condition: []
16action:
17 - service: camera.snapshot
18 target:
19 entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
20 data:
21 filename: /media/snaps/motion.jpg
- - service: notify.ntfy
- data:
- title: Motion detected!
- message: "{{ trigger.to_state.attributes.friendly_name }}"
+ - service: shell_command.ntfy_put
+ data:
+ title: Motion detected!
+ message: "{{ trigger.to_state.attributes.friendly_name }}"
+ file: /media/snaps/motion.jpg
27variables: { ... }
28 cameras:
29 binary_sensor.backyard_person: camera.backyard_sub
30 binary_sensor.driveway_person: camera.driveway_sub
31 binary_sensor.driveway_vehicle: camera.driveway_sub
32 binary_sensor.east_side_front_person: camera.east_side_front_sub
33 binary_sensor.east_side_rear_person: camera.east_side_rear_sub
34 binary_sensor.west_side_person: camera.west_side_sub

Now when I wander outside... backyard_person_attached Well that guy seems sus - but hey, it worked!

Backoff rate limit

Of course, I'll also continue to get notified about that creeper in the backyard about every 15-20 seconds or so. That's not quite what I want. The easy way to prevent an automation from firing constantly would be to insert a delay action, but that would be a global delay rather than per-camera. I don't necessarily need to know every time the weirdo in the backyard moves, but I would like to know if he moves around to the side yard or driveway. So I needed something more flexible than an automation-wide delay.

Instead, I'll create a 5-minute timer for each camera by simply adding this to my configuration.yaml:

# configuration.yaml
timer:
backyard_person:
duration: "00:05:00"
driveway_person:
duration: "00:05:00"
driveway_vehicle:
duration: "00:05:00"
east_front_person:
duration: "00:05:00"
east_rear_person:
duration: "00:05:00"
west_person:
duration: "00:05:00"

Back in the automation, I'll add a new timers variable set which will help to map the binary_sensor to the corresponding timer object. I can then append an action to start the timer, and a condition so that the automation will only fire if the timer for a given camera is not currently running. I'll also set the automation's mode to single (so that it will only run once at a time), and set the max_exceeded value to silent (so that multiple triggers won't raise any errors).

1# exterior_motion.yaml #
2alias: Exterior Motion Alerts
3description: ""
4trigger: { ... }
5 - platform: state
6 entity_id:
7 - binary_sensor.backyard_person
8 - binary_sensor.driveway_person
9 - binary_sensor.driveway_vehicle
10 - binary_sensor.east_side_front_person
11 - binary_sensor.east_side_rear_person
12 - binary_sensor.west_side_person
13 from: "off"
14 to: "on"
-condition: []
+condition:
+ - condition: template
+ value_template: "{{ is_state(timers[trigger.to_state.entity_id], 'idle') }}"
18action:
19 - service: camera.snapshot
20 target:
21 entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
22 data:
23 filename: /media/snaps/motion.jpg
24 - service: notify.ntfy
25 data:
26 title: Motion detected!
27 message: "{{ trigger.to_state.attributes.friendly_name }}"
28 - service: shell_command.ntfy_put
29 data:
30 title: Motion detected!
31 message: "{{ trigger.to_state.attributes.friendly_name }}"
32 file: /media/snaps/motion.jpg
+ - service: timer.start
+ target:
+ entity_id: "{{ timers[trigger.to_state.entity_id] }}"
+mode: single
+max_exceeded: silent
38variables:
39 cameras: { ... }
40 binary_sensor.backyard_person: camera.backyard_sub
41 binary_sensor.driveway_person: camera.driveway_sub
42 binary_sensor.driveway_vehicle: camera.driveway_sub
43 binary_sensor.east_side_front_person: camera.east_side_front_sub
44 binary_sensor.east_side_rear_person: camera.east_side_rear_sub
45 binary_sensor.west_side_person: camera.west_side_sub
46 timers:
47 binary_sensor.backyard_person: timer.backyard_person
48 binary_sensor.driveway_person: timer.driveway_person
49 binary_sensor.driveway_vehicle: timer.driveway_vehicle
50 binary_sensor.east_side_front_person: timer.east_front_person
51 binary_sensor.east_side_rear_person: timer.east_rear_person
52 binary_sensor.west_side_person: timer.west_person# [tl! ++:end focus:end]

That pretty much takes care of my needs for exterior motion alerts, and should keep me informed if someone is poking around my house (or, more frequently, making a delivery).

Managing interior alerts

I've got a few interior cameras which I'd like to monitor too, so I'll start by just copying the exterior automation and updating the entity IDs:

1# interior_motion.yaml
2alias: Interior Motion Alerts
3description: ""
4trigger:
5 - platform: state
6 entity_id:
7 - binary_sensor.kitchen_back_door_person
8 - binary_sensor.garage_person
9 - binary_sensor.garage_vehicle
10 - binary_sensor.study_entryway_person
11 from: "off"
12 to: "on"
13condition:
14 - condition: template
15 value_template: "{{ is_state(timers[trigger.to_state.entity_id], 'idle') }}"
16action:
17 - service: camera.snapshot
18 target:
19 entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
20 data:
21 filename: /media/snaps/motion.jpg
22 - service: shell_command.ntfy_put
23 data:
24 title: Motion detected!
25 message: "{{ trigger.to_state.attributes.friendly_name }}"
26 file: /media/snaps/motion.jpg
27 - service: timer.start
28 target:
29 entity_id: "{{ timers[trigger.to_state.entity_id] }}"
30max_exceeded: silent
31mode: single
32variables:
33 cameras:
34 binary_sensor.kitchen_back_door_person: camera.kitchen_back_door_sub
35 binary_sensor.study_entryway_person: camera.study_entryway_sub
36 binary_sensor.garage_person: camera.garage_sub
37 binary_sensor.garage_vehicle: camera.garage_sub
38 timers:
39 binary_sensor.kitchen_back_door_person: timer.kitchen_person
40 binary_sensor.study_entryway_person: timer.study_person
41 binary_sensor.garage_person: timer.garage_person
42 binary_sensor.garage_vehicle: timer.garage_vehicle

But I don't typically want to get alerted by these cameras if my wife or I are home and awake. So I'll use the local calendar integration to create a schedule for when the interior cameras should be active. Once that integration is enabled and the entity calendar.interior_camera_schedule created, I can navigate to the Calendar section of my Home Assistant interface to create the recurring calendar events (with the summary "On"). I'll basically be enabling notifications while we're sleeping and while we're at work, but disabling notifications while we're expected to be at home.

calendar

So then I'll just add another condition so that the automation will only fire during those calendar events:

1# interior_motion.yaml
2alias: Interior Motion Alerts
3description: ""
4trigger: { ... }
5 - platform: state
6 entity_id:
7 - binary_sensor.kitchen_back_door_person
8 - binary_sensor.garage_person
9 - binary_sensor.garage_vehicle
10 - binary_sensor.study_entryway_person
11 from: "off"
12 to: "on"
13condition:
14 - condition: template
15 value_template: "{{ is_state(timers[trigger.to_state.entity_id], 'idle') }}"
+ - condition: state
+ entity_id: calendar.interior_camera_schedule
+ state: "on"
19action: { ... }
20 - service: camera.snapshot
21 target:
22 entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
23 data:
24 filename: /media/snaps/motion.jpg
25 - service: shell_command.ntfy_put
26 data:
27 title: Motion detected!
28 message: "{{ trigger.to_state.attributes.friendly_name }}"
29 file: /media/snaps/motion.jpg
30 - service: timer.start
31 target:
32 entity_id: "{{ timers[trigger.to_state.entity_id] }}"
33max_exceeded: silent
34mode: single
35variables: { ... }
36 cameras:
37 binary_sensor.kitchen_back_door_person: camera.kitchen_back_door_sub
38 binary_sensor.study_entryway_person: camera.study_entryway_sub
39 binary_sensor.garage_person: camera.garage_sub
40 binary_sensor.garage_vehicle: camera.garage_sub
41 timers:
42 binary_sensor.kitchen_back_door_person: timer.kitchen_person
43 binary_sensor.study_entryway_person: timer.study_person
44 binary_sensor.garage_person: timer.garage_person
45 binary_sensor.garage_vehicle: timer.garage_vehicle

I'd also like to ensure that the interior motion alerts are also activated whenever our Abode security system is armed, regardless of what time that may be. That will make the condition a little bit trickier: alerts should be pushed if the timer isn't running AND the schedule is active OR the security system is armed (in either "Home" or "Away" mode). So here's what that will look like:

1# interior_motion.yaml
2alias: Interior Motion Alerts
3description: ""
4trigger: { ... }
5 - platform: state
6 entity_id:
7 - binary_sensor.kitchen_back_door_person
8 - binary_sensor.garage_person
9 - binary_sensor.garage_vehicle
10 - binary_sensor.study_entryway_person
11 from: "off"
12 to: "on"
13condition:
+ - condition: and
+ conditions: { ... }
- - condition: template
- value_template: "{{ is_state(timers[trigger.to_state.entity_id], 'idle') }}"
- - condition: state
- entity_id: calendar.interior_camera_schedule
- state: "on"
+ - condition: template
+ value_template: "{{ is_state(timers[trigger.to_state.entity_id], 'idle') }}"
+ - condition: or
+ conditions:
+ - condition: state
+ entity_id: calendar.interior_camera_schedule
+ state: "on"
+ - condition: state
+ state: armed_away
+ entity_id: alarm_control_panel.abode_alarm
+ - condition: state
+ state: armed_home
+ entity_id: alarm_control_panel.abode_alarm
29action: { ... }
30 - service: camera.snapshot
31 target:
32 entity_id: "{{ cameras[trigger.to_state.entity_id] }}"
33 data:
34 filename: /media/snaps/motion.jpg
35 - service: shell_command.ntfy_put
36 data:
37 title: Motion detected!
38 message: "{{ trigger.to_state.attributes.friendly_name }}"
39 file: /media/snaps/motion.jpg
40 - service: timer.start
41 target:
42 entity_id: "{{ timers[trigger.to_state.entity_id] }}"
43max_exceeded: silent
44mode: single
45variables: { ... }
46 cameras:
47 binary_sensor.kitchen_back_door_person: camera.kitchen_back_door_sub
48 binary_sensor.study_entryway_person: camera.study_entryway_sub
49 binary_sensor.garage_person: camera.garage_sub
50 binary_sensor.garage_vehicle: camera.garage_sub
51 timers:
52 binary_sensor.kitchen_back_door_person: timer.kitchen_person
53 binary_sensor.study_entryway_person: timer.study_person
54 binary_sensor.garage_person: timer.garage_person
55 binary_sensor.garage_vehicle: timer.garage_vehicle

Snooze or disable alerts

We've got a lawn service that comes pretty regularly to take care of things, and I don't want to get constant alerts while they're doing things in the yard. Or maybe we stay up a little late one night and don't want to get pinged with interior alerts during that time. So I created a script to snooze all motion alerts for 30 minutes, simply by temporarily disabling the automations I just created:

1# snooze_motion_alerts.yaml
2alias: Snooze Motion Alerts
3sequence:
4 - service: automation.turn_off
5 data:
6 stop_actions: true
7 target:
8 entity_id:
9 - automation.exterior_motion_alerts
10 - automation.interior_motion_alerts
11 - service: notify.ntfy
12 data:
13 title: Motion Snooze
14 message: Camera motion alerts are disabled for 30 minutes.
15 - delay:
16 hours: 0
17 minutes: 30
18 seconds: 0
19 milliseconds: 0
20 - service: automation.turn_on
21 data: {}
22 target:
23 entity_id:
24 - automation.interior_motion_alerts
25 - automation.exterior_motion_alerts
26 - service: notify.ntfy
27 data:
28 title: Motion Resume
29 message: Camera motion alerts are resumed.
30mode: single
31icon: mdi:alarm-snooze

I can then add that script to the camera dashboard in Home Assistant or pin it to the home controls on my Android phone for easy access.

I'll also create another script for manually toggling interior alerts for when we're home at an odd time:

1# toggle_interior_alerts.yaml
2alias: Toggle Indoor Camera Alerts
3sequence:
4 - service: automation.toggle
5 data: {}
6 target:
7 entity_id: automation.interior_motion_alerts
8 - service: notify.ntfy
9 data:
10 title: "Interior Camera Alerts "
11 message: "Alerts are {{ states('automation.interior_motion_alerts') }} "
12mode: single
13icon: mdi:cctv

That's a wrap

This was a fun little project which had me digging a bit deeper into Home Assistant than I had previously ventured, and I'm really happy with how things turned out. I definitely learned a ton in the process. I might explore adding action buttons to the notifications to directly snooze alerts that way, but that will have to wait a bit as I'm out of tinkering time for now.


  1. Hopefully I only need to worry about vehicles in the driveway. Please don't drive through my backyard, thanks. ↩︎


Celebrate this post: 

runtimeterror  


 jbowdre