Instant notification on alarm + machine learning object detection

If you've made a patch to quick fix a bug or to add a new feature not yet in the main tree then post it here so others can try it out.
montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Instant notification on alarm + machine learning object detection

Post by montagdude » Wed Nov 22, 2017 5:46 pm

UPDATE 1/31/19: I have ditched the Perl script below and switched to Python with the ZoneMinder API. I have also implemented object detection to eliminate false alerts. See post 12 for more information and a link to the code.

It's easy to set up filters to send SMS or email notifications. The problem is, if you want to get prompt notifications, you have to set all of your filters to run very frequently, causing unnecessary load on your server, and even then there will be a delay between when motion was detected and when the notification is sent.

I wrote a perl script to send real-time (or very close to it) notifications based on the FAQ entry:

http://zoneminder.readthedocs.io/en/lat ... s-an-alarm

and the script in utils/zm-alarm.pl. However, I had some problems running those examples, as some of the functions called seem to be not actually exported from the ZoneMinder perl module. This link took care of that:

viewtopic.php?t=21781.

This has been done before, but this version adds the code for sending an email or SMS and has some other helpful features. It loops over each monitor and checks whether it is in an alarm/alert/tape state. If so, it sends a message immediately, unless the monitor was also in alarm/alert/tape state during the previous iteration. This is checked every 3 seconds. Because that timeout can cause some alarms to go unnoticed while they are occurring, a message will also be sent if there is a new event available since the previous iteration (and the monitor was not in alarm/alert/tape previously). It also performs some checks to make sure ZoneMinder is running and that monitors are sending a signal. Timeouts and other things are configurable, but these settings have been working well for me to receive very prompt alerts when motion is detected. Please fill in toaddr and eventurl in the notify functions and the From and MailFrom fields in the sendMessage function as appropriate.

Code: Select all

#!/usr/bin/perl -w

# This script has been adapted from utils/zm-alarm.pl to send a notification
# when a monitor has been in an alarm/alert/tape state within the last 3
# seconds. For reference, see:
# scripts/ZoneMinder/lib/ZoneMinder/Memory.pm
#     and
# http://zoneminder.readthedocs.io/en/latest/faq.html#how-can-i-use-zoneminder-to-trigger-something-else-when-there-is-an-alarm
#     and
# https://forums.zoneminder.com/viewtopic.php?t=21781

use strict;
use warnings;
use ZoneMinder;
use DBI;
require MIME::Entity;
use List::Util qw[min max];

$| = 1;

# Interval between monitor checking cycles
my $timeout = 3;

# Length of pause after sending a message (to reduce spam)
our $message_timeout = 1;

# Only send a new event message for a given monitor if it was last detected
# in alarm state more than this many cycles previously. Goal is to prevent
# duplicate messages about the same event. Should be >= 2.
my $alarm_overload_count = 2;   

# Email parameters
our @toaddrs = ("");	# Enter to address(es) here
our $fromaddr = "";	# Enter from address here
our $eventurl = "";	# Enter URL to events page here

my $driver = "mysql";
my $database = "zm";
my $user = "zmuser"; 
my $password = "zmpass";

my @monitors;
my $rebuild_monitors = 1;
while (1) {
        sleep $timeout;

        # Have to exit when ZoneMinder stops running to make sure mapped memory is freed
        my $status = runCommand( "zmdc.pl check" );
        if ( $status ne "running" ) {
                Info( "zm_event_alert.pl is exiting" );
                last;
        }

        # Initialize or re-initialize array of monitors
        if ( $rebuild_monitors ) {
                my $dbh = DBI->connect(
                "DBI:$driver:$database",
                $user, $password,
                ) or die $DBI::errstr;
                
                my $sql = "select M.*, max(E.Id) as LastEventId from Monitors as M left join Events as E on M.Id = E.MonitorId where M.Function != 'None' group by (M.Id)";
                my $sth = $dbh->prepare_cached( $sql ) or die( "Can't prepare '$sql': ".$dbh->errstr() );
                my $res = $sth->execute() or die( "Can't execute '$sql': ".$sth->errstr() );

                @monitors = ();
                while ( my $monitor = $sth->fetchrow_hashref() ) {
                    $monitor->{LastAlarm} = $alarm_overload_count + 1;
                    $monitor->{LastEventId} = zmGetLastEvent( $monitor );
                    $monitor->{Signal} = 1;
                    push( @monitors, $monitor );
                }
        }
        $rebuild_monitors = 0;

        # Loop over monitors
        foreach my $monitor ( @monitors ) {
                if ( !zmMemVerify( $monitor ) ) {
                        $rebuild_monitors = 1;
                        next;
                } 

                # Skip any monitor that is inactive
                if ( !getMonitorActive( $monitor ) ) {
                        next;
                } 

                # Skip any monitor that is not receiving a signal
                my $signal = getMonitorSignal( $monitor );
                if ( !$signal ) {
                        $monitor->{Signal} = $signal;
                        Info("$monitor->{Name} is not sending a signal.");
                        next;
                }

                # Send a message in alarm state if not during previous cycle
                if ( myInAlarm( $monitor ) ) {
                        if ( $monitor->{LastAlarm} > 1 ) {
                                notifyInAlarm( $monitor->{Name} );
                                Info("Sent alarm message for $monitor->{Name}");
                        } 
                        $monitor->{LastAlarm} = 1;
                } else {
                        $monitor->{LastAlarm} = min( $monitor->{LastAlarm} + 1,
                                                     $alarm_overload_count + 1);
                }

                # Send a message if a new event is available, because alarms may
                # not be caught while they are occurring. Try to filter out any
                # events caused by signal loss. Don't send a message if monitor
                # was in alarm state within last alarm_overload_count cycles.
                my $last_event_id = zmGetLastEvent( $monitor );
                my $time = localtime();
                if ( $last_event_id != $monitor->{LastEventId} ) {
                        if ( $monitor->{Signal} and 
                           ( $monitor->{LastAlarm} > $alarm_overload_count ) ) {
                                notifyNewEvent( $monitor->{Name},
                                                $last_event_id );
                                Info("Sent new event $last_event_id alert ".
                                     "for $monitor->{Name}");
                        } elsif ( $monitor->{Signal} ) {
                                Info("$monitor->{Name} event overload count ".
                                     "$monitor->{LastAlarm}");
                        }
                        $monitor->{LastEventId} = $last_event_id;
                }        
                $monitor->{Signal} = $signal;
        }
}

sub getMonitorActive {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:active' ) );
}

sub getMonitorSignal {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:signal' ) );
}

# Same as zmInAlarm from Memory.pm, but also counts STATE_TAPE as alarm state
sub myInAlarm {
        my $monitor = shift;

        my $state = zmGetMonitorState( $monitor );
        return( $state == 2 || $state == 3 || $state == 4 );
}

sub notifyInAlarm {
        my $monitor_name = shift;

        my $subject = "ZoneMinder alarm";
        my $message = "Monitor $monitor_name is in alarm state!\n".
                      "URL: $eventurl";
        foreach my $toaddr ( @toaddrs ) {
                sendMessage($toaddr, $subject, $message);
        }
}

sub notifyNewEvent {
        my $monitor_name = shift;
        my $last_event_id = shift;

        my $subject = "ZoneMinder alarm";
        my $message = "New event $last_event_id for monitor $monitor_name.\n".
                      "URL: $eventurl";
        foreach my $toaddr ( @toaddrs ) {
                sendMessage($toaddr, $subject, $message);
        }
}

sub sendMessage {
        my $toaddr = shift;
        my $subject = shift;
        my $message = shift;
 
        my $mail = MIME::Entity->build(
                From => $fromaddr,
                To => $toaddr,
                Subject => $subject,
                Type => (($message=~/<html>/)?'text/html':'text/plain'),
                Data => $message
        );
        $mail->smtpsend( Host => "localhost",
                         MailFrom => $fromaddr,
        ); 
        sleep $message_timeout;
}
Please excuse any poor coding practices on my part (but let me know!) All the perl I know was learned yesterday while trying to create this script. :wink:

EDIT 12/2/2017: Please see posts below for improvements and additional tips.
Last edited by montagdude on Fri Feb 01, 2019 4:57 am, edited 8 times in total.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Thu Nov 23, 2017 4:26 am

I made a couple changes (edited the code in the first post).

1. I found out that if the run state changes (either stopped -> running or in any other way that causes a different set of monitors to be enabled), the list of monitors needs to be rebuilt. Therefore, I put the code to create the list of monitors in the loop, and it is executed after restarting ZM or if zmMemVerify fails on a monitor. It is now working across run state changes.

2. I replaced the custom logwrite function with calls to the Info() function, which puts the messages from this script in the regular ZoneMinder log.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Thu Nov 23, 2017 6:45 am

A couple more tweaks:

1. Allow sending notifications to more than one address. Just enter multiple addresses in the @toaddrs list.
2. I was previously getting at least two alerts for each event, one for being in alarm/alert/tape state, and the other for a new event. I added a variable alarm_overload_count. Notifications for a new event will not be sent unless the monitor was last in alarm/alert/tape state more than this many cycles ago. I set this to 2, and now I'm just getting one alert for each event.

I think that will just about do it for this script.

kennbr34
Posts: 36
Joined: Sat Jan 14, 2017 6:43 pm

Re: Instant notification on alarm

Post by kennbr34 » Thu Nov 23, 2017 8:46 am

Cool! I only just realized that the alerts weren't really instantaneous. I also like that this enables you to send it to multiple email addresses. Is there any way you think you could modify this to send a notification to one email address or another based on which zone was triggered?

So for example, I filter with "Notes" matching "Motion: BedroomDoor" to find events that match an alert from inside my house on my bed room door, and similar with "Motion: Porch/Walk" because I only have 1 monitor that's viewing a 4 channel analog DVR--so filtering by monitor isn't possible for me. I can't clone the monitor either as I can only have one open instance of /dev/video0

So given that, could this script send any matches of "Motion: BedroomDoor" to a different email than matches for "Motion: Porch/Walk"? I want to send emails of events outside to my gmail, but get SMS text alerts instantaneously for any events that occur inside (tripped by BedroomDoor zone).

Does this make sense? (Aside from being too cheap to buy multiple monitors)

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Thu Nov 23, 2017 7:42 pm

kennbr34 wrote:
Thu Nov 23, 2017 8:46 am
Cool! I only just realized that the alerts weren't really instantaneous. I also like that this enables you to send it to multiple email addresses. Is there any way you think you could modify this to send a notification to one email address or another based on which zone was triggered?

So for example, I filter with "Notes" matching "Motion: BedroomDoor" to find events that match an alert from inside my house on my bed room door, and similar with "Motion: Porch/Walk" because I only have 1 monitor that's viewing a 4 channel analog DVR--so filtering by monitor isn't possible for me. I can't clone the monitor either as I can only have one open instance of /dev/video0

So given that, could this script send any matches of "Motion: BedroomDoor" to a different email than matches for "Motion: Porch/Walk"? I want to send emails of events outside to my gmail, but get SMS text alerts instantaneously for any events that occur inside (tripped by BedroomDoor zone).

Does this make sense? (Aside from being too cheap to buy multiple monitors)
I'm not sure. I don't see anything in the Memory.pm module that would identify which zones are triggered. I think you would need to query the event from the zmGetLastEvent function, but I believe the event data only becomes available after the recording is done (so not "real-time", but it could still be done quickly if running it as a script instead of a filter). However, it wasn't immediately apparent to me how to do this after browsing through the code. zmGetLastEvent only gives the ID of the event, not an actual reference to the event itself, but I'm sure there must be a way to pick the event out of the database if you have the event ID. That's above my pay grade, though. :D

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Fri Nov 24, 2017 2:10 am

Okay, so actually I'm not done yet. :D In the process of testing this script, I realized that a new event is assigned as soon as a monitor goes into alarm state. Therefore, it's not necessary to check whether the monitor is in alarm state, only whether a new event is available. This has the benefit that the notification can also tell you the event ID and, as I've done here, send a link to the frame with the highest score. It also simplifies the script a bit. I've also added in some additional checks to avoid notifications from to signal loss events. I will leave the original post intact, in case someone prefers that version for some reason.

In this version, the URL is supposed to be the URL to the main ZoneMinder page (e.g., http://server_ip_address/zm).

Code: Select all

#!/usr/bin/perl -w

# This script has been adapted from utils/zm-alarm.pl to send a notification
# within 3 seconds of a new event being generated for a monitor, which occurs
# as soon as motion is detected. The notification includes a link to the frame
# with the highest score, and filters out events caused by signal loss. For
# reference, see:
# scripts/ZoneMinder/lib/ZoneMinder/Memory.pm
#     and
# http://zoneminder.readthedocs.io/en/latest/faq.html#how-can-i-use-zoneminder-to-trigger-something-else-when-there-is-an-alarm
#     and
# https://forums.zoneminder.com/viewtopic.php?t=21781

use strict;
use warnings;
use ZoneMinder;
use DBI;
require MIME::Entity;
use List::Util qw[min max];

$| = 1;

# Interval between monitor checking cycles
my $timeout = 3;

# Pause after sending a message (to reduce spam)
our $message_timeout = 1;

# Min number of cycles after signal loss to send event message. Goal is to
# filter out any events caused by signal loss.
my $signal_overload_count = 5;

# Email parameters. Fill these in! Note that "@" has to be escaped like "\@"
our @toaddrs = ("");
our $fromaddr = "";
our $url = "";

my $driver = "mysql";
my $database = "zm";
my $user = "zmuser"; 
my $password = "zmpass";

my @monitors;
my $rebuild_monitors = 1;
while (1) {
        sleep $timeout;

        # Have to exit when ZoneMinder stops running to make sure mapped memory is freed
        my $status = runCommand( "zmdc.pl check" );
        if ( $status ne "running" ) {
                Info( "zm_event_alert.pl is exiting" );
                last;
        }

        # Initialize or re-initialize array of monitors
        if ( $rebuild_monitors ) {
                my $dbh = DBI->connect("DBI:$driver:$database", $user,
                                       $password) or die $DBI::errstr;
                
                my $sql = "select M.*, max(E.Id) as LastEventId from Monitors as M left join Events as E on M.Id = E.MonitorId where M.Function != 'None' group by (M.Id)";
                my $sth = $dbh->prepare_cached( $sql ) or die( "Can't prepare '$sql': ".$dbh->errstr() );
                my $res = $sth->execute() or die( "Can't execute '$sql': ".$sth->errstr() );

                @monitors = ();
                while ( my $monitor = $sth->fetchrow_hashref() ) {
                    $monitor->{LastEventId} = zmGetLastEvent( $monitor );
                    $monitor->{LastSignal} = $signal_overload_count + 1;
                    push( @monitors, $monitor );
                }
        }
        $rebuild_monitors = 0;

        # Loop over monitors
        foreach my $monitor ( @monitors ) {
                if ( !zmMemVerify( $monitor ) ) {
                        $rebuild_monitors = 1;
                        next;
                } 

                # Skip any monitor that is inactive
                if ( !getMonitorActive( $monitor ) ) {
                        next;
                } 

                # Skip any monitor that is not receiving a signal
                my $signal = getMonitorSignal( $monitor );
                if ( !$signal ) {
                        $monitor->{LastSignal} = 1;
                        Info( "$monitor->{Name} is not sending a signal." );
                        next;
                } else {
                        $monitor->{LastSignal} = min( $monitor->{LastSignal} + 1,
                                                      $signal_overload_count + 1 );
                }

                # Send a message if a new event is available. Try to filter out
                # any events caused by signal loss.
                my $last_event_id = zmGetLastEvent( $monitor );
                if ( $last_event_id != $monitor->{LastEventId} ) {
                        if ( $monitor->{LastSignal} <= $signal_overload_count ) {
                                Info( "$monitor->{Name} signal overload count ".
                                      "$monitor->{LastSignal}" );
                        } else {
                                notifyNewEvent( $monitor->{Name},
                                                $last_event_id );
                                Info( "Sent new event $last_event_id alert ".
                                      "for $monitor->{Name}" );
                        }
                        $monitor->{LastEventId} = $last_event_id;
                }        
        }
}

sub getMonitorActive {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:active' ) );
}

sub getMonitorSignal {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:signal' ) );
}

sub notifyNewEvent {
        my $monitor_name = shift;
        my $last_event_id = shift;

        my $subject = "ZoneMinder alarm";
        my $maxscore_url = $url . "/index.php?view=frame&eid=".
                           $last_event_id . "&fid=0";
        my $message = "New event $last_event_id for monitor $monitor_name.\n".
                      "URL: $maxscore_url";
        foreach my $toaddr ( @toaddrs ) {
                sendMessage($toaddr, $subject, $message);
        }
}

sub sendMessage {
        my $toaddr = shift;
        my $subject = shift;
        my $message = shift;
 
        my $mail = MIME::Entity->build(
                From => $fromaddr,
                To => $toaddr,
                Subject => $subject,
                Type => (($message=~/<html>/)?'text/html':'text/plain'),
                Data => $message
        );
        $mail->smtpsend( Host => "localhost",
                         MailFrom => $fromaddr,
        );
        sleep $message_timeout; 
}
Last edited by montagdude on Sat Dec 02, 2017 6:40 am, edited 1 time in total.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Sat Dec 02, 2017 6:37 am

So I discovered a problem with this script. When ZoneMinder stops, the running script prevents the memory map in /dev/shm from being deleted, and when ZoneMinder starts up again, a new one is created. This leads to increasing memory usage every time ZoneMinder is started. Unfortunately, calling zmMemTidy() from within the script didn't help. The only thing that worked is stopping the script when ZoneMinder stops. Therefore, I edited the scripts above to do that and added a simple "watcher" bash script that starts it back up again when ZoneMinder comes back up. It's not elegant, but it works.

Here is the script. It assumes the event notification script is at /usr/local/bin/zm_event_alert.pl.

Code: Select all

#!/bin/bash
#
# If ZoneMinder is running, checks if zm_event_alert.pl is running and starts it
# if not.
#

get_pid()
{
  echo $(pgrep -f \/usr\/local\/bin\/zm_event_alert\.pl)
}

INTERVAL=30
while [ 1 ]; do
  STATUS=$(zmpkg.pl status)
  if [ "$STATUS" == "running" ]; then
    ZM_ALERT_PID=$(get_pid)
    if [ -z "$ZM_ALERT_PID" ]; then
      /usr/local/bin/zm_event_alert.pl &
    fi
  fi
  sleep $INTERVAL
done

Alec
Posts: 40
Joined: Thu Aug 17, 2017 3:55 am

Re: Instant notification on alarm

Post by Alec » Fri Feb 02, 2018 1:07 am

Thank you very, very much. I had really wanted to do something like this for a long time.

Please do post any updates or better solutions that you find.

If anyone is interested in some variants I am trying:

Use zmDbConnect to simplify access to the database.

Code: Select all

my $dbh = zmDbConnect();
zmDbConnect is included in the ZoneMinder module.

Create an image file to attach to the message.

Code: Select all

# Directory for images to be attached:
my $imageDir = '/dev/shm/' ;
# Where to write the current image from the monitor determined to be in alarm:
my $imagePath = "$imageDir".'Monitor'."$monitor->{Id}".'.jpg' ;
# Make sure any old image from previous messages is deleted:
system ("rm $imagePath &> /dev/null");
# Call the zmu utility to write the current image from the monitor determined to be in alarm:
system ("cd $imageDir && exec zmu -m $monitor->{Id} -i -U ***** -P *********");
# Check to make sure that the image is written before sending the message:
my $imageExists = undef ;
while (!$imageExists) {
$imageExists = ( -s $imagePath );
 }
This gets the current image from the camera and saves it in the /dev/shm directory without having to wait for buffered images to be written to the disk. I have my wait time equal to the inverse of my frames per second, and am not worried (at this time) about going back and getting events I missed.

The only way that I found to get the current camera image is to use the zmu utility -- is there a more elegant way to do this?

I had some trouble with earlier test scripts sending the message before the file was written, so added the while loop to make sure the file has a non-zero size before composing the message. This may not be a problem using /dev/shm instead of a directory on the disk.
DFU
v 1.33.x on Ubuntu 16.04 server using LAMP
v 1.31.x on Ubuntu 16.04 server VM using LAMP

Alec
Posts: 40
Joined: Thu Aug 17, 2017 3:55 am

Re: Instant notification on alarm

Post by Alec » Sun Feb 04, 2018 7:24 pm

viewtopic.php?f=32&t=23826&start=15

The topic above really covers most of what is required in this sort of script. Hearty thank you's to the contributors there.

Please be patient with me as I still don't really understand the details of how the ZoneMinder::MappedMem module methods work.

Please refer to the following code. I seem to be able run this script continuously to access my monitors' shared memory, and check their state (and then grab an image if I want to, but I left that code out for simplicity). It recovers when ZoneMinder stops and starts, and allows zm.mmap files to be deleted from /dev/shm.

I added the "MemExists" criterion so I would not call zmMemInvalidate without actually having a handle to invalidate.

However, every time it calls zmMemVerify when zmc is not running (e.g. ZoneMinder is stopped), I get an error message in the log. Should I be using a different method or calling this method differently so as not to return a log entry?

Thank you,

Alec

Code: Select all

foreach my $monitor (@monitors) {               
# use zmMemVerify method to get memory handle for monitor or test that my handle is good
                my $monitorMemverify = zmMemVerify($monitor);
# if I successfully got a memory handle, flag the monitor as having a good memory handle and execute my code                
                if ($monitorMemverify) {
                	$monitor->{MemExists} = 1 ;
 ++++++++++++++++++++++++++++++++++++++++++++++++
              . . . Do my stuff here . . .
 ++++++++++++++++++++++++++++++++++++++++++++++++              
# if I have a bad memory handle, release the handle & unset flag so we do not to try to release a non-existant handle
                 elsif ($monitor->{MemExists}) {
	                 zmMemInvalidate($monitor) ;
        	         $monitor->{MemExists} = undef ;
                }
       }
DFU
v 1.33.x on Ubuntu 16.04 server using LAMP
v 1.31.x on Ubuntu 16.04 server VM using LAMP

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Mon Feb 05, 2018 8:36 pm

Alec wrote:
Sun Feb 04, 2018 7:24 pm
However, every time it calls zmMemVerify when zmc is not running (e.g. ZoneMinder is stopped), I get an error message in the log. Should I be using a different method or calling this method differently so as not to return a log entry?
Personally, I have my script set up to check whether ZoneMinder is running during each cycle. If it is not, it exits and starts a separate "watcher" script whose purpose is to restart the event notifications script when ZoneMinder turns back on. It is not the most elegant, but it works well and avoids such problems. I will post both scripts when I get a chance later.

EDIT: Woops, looks like I already posted the watcher script earlier, but I didn't show how I edited the main event alert script to start it. I will post that later.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Wed Feb 14, 2018 6:06 am

Here is my latest version of the script. The differences compared to the previous one are (going from memory, because I'm too lazy to do a diff):

1. Exit and start the watcher script when ZoneMinder is not running or when zmMemVerify fails for a monitor.
2. Use an smtp client to send the email instead of sendmail. This uses a system call instead of bothering with the Perl email stuff, since it seems to be simpler to set up this way, at least on my system. As usual, fill in settings near the top (toaddrs, url, ssmtp_location) appropriately.

Code: Select all

#!/usr/bin/perl -w

# This script has been adapted from utils/zm-alarm.pl to send a notification
# within 3 seconds of a new event being generated for a monitor, which occurs
# as soon as motion is detected. The notification includes a link to the frame
# with the highest score, and filters out events caused by signal loss. For
# reference, see:
# scripts/ZoneMinder/lib/ZoneMinder/Memory.pm
#     and
# http://zoneminder.readthedocs.io/en/latest/faq.html#how-can-i-use-zoneminder-to-trigger-something-else-when-there-is-an-alarm
#     and
# https://forums.zoneminder.com/viewtopic.php?t=21781

use strict;
use warnings;
use ZoneMinder;
# require MIME::Entity; # Only used for old email method
use DBI;
use List::Util qw[min max];

$| = 1;

Info( "zm_event_alert.pl has started" );

# Interval between monitor checking cycles
my $timeout = 3;

# Pause after sending a message (to reduce spam)
our $message_timeout = 1;

# Min number of cycles after signal loss to send event message. Goal is to
# filter out any events caused by signal loss.
my $signal_overload_count = 5;

# Email parameters
our @toaddrs = ("");
our $url = "";
our $ssmtp_location = "";
our $tmp_message_file = "/tmp/zm_event_email.txt";

my $driver = "mysql";
my $database = "zm";
my $user = "zmuser";
my $password = "zmpass";

my $rebuild_monitors = 1;
my $time_to_exit = 0;
my @monitors;
while (1) {
        sleep $timeout;
        if ( $time_to_exit ) {
                system( "/usr/local/bin/zm_event_alert_watcher.sh &" );
                last;
        }

        # Initialize array of monitors
        if ( $rebuild_monitors ) {
                Info( "Rebuilding monitors list" );
                my $dbh = DBI->connect("DBI:$driver:$database", $user,
                                       $password) or die $DBI::errstr;

                my $sql = "select M.*, max(E.Id) as LastEventId from Monitors as M left join Events as E on M.Id = E.MonitorId where M.Function != 'None' group by (M.Id)";
                my $sth = $dbh->prepare_cached( $sql ) or die( "Can't prepare '$sql': ".$dbh->errstr() );
                my $res = $sth->execute() or die( "Can't execute '$sql': ".$sth->errstr() );

                @monitors = ();
                while ( my $monitor = $sth->fetchrow_hashref() ) {
                    $monitor->{LastEventId} = zmGetLastEvent( $monitor );
                    $monitor->{LastSignal} = $signal_overload_count + 1;
                    push( @monitors, $monitor );
                }
        }
        $rebuild_monitors = 0;

        # Loop over monitors
        foreach my $monitor ( @monitors ) {
                if ( !zmMemVerify( $monitor ) ) {
                        # Exit to release shared memory -> watcher daemon will start it up again if needed
                        Info( "zm_event_alert.pl is exiting due to invalid monitor" );
                        $time_to_exit = 1;
                        last;
                }

                # Skip any monitor that is inactive
                if ( !getMonitorActive( $monitor ) ) {
                        next;
                }

                # Skip any monitor that is not receiving a signal
                my $signal = getMonitorSignal( $monitor );
                if ( !$signal ) {
                        $monitor->{LastSignal} = 1;
                        Info( "$monitor->{Name} is not sending a signal" );
                        next;
                } else {
                        $monitor->{LastSignal} = min( $monitor->{LastSignal} + 1,
                                                      $signal_overload_count + 1 );
                }
                
                # Send a message if a new event is available. Try to filter out
                # any events caused by signal loss.
                my $last_event_id = zmGetLastEvent( $monitor );
                if ( $last_event_id != $monitor->{LastEventId} ) {
                        if ( $monitor->{LastSignal} <= $signal_overload_count ) {
                                Info( "$monitor->{Name} signal overload count ".
                                      "$monitor->{LastSignal}" );
                        } else {
                                notifyNewEvent( $monitor->{Name},
                                                $last_event_id );
                                Info( "Sent new event $last_event_id alert ".
                                      "for $monitor->{Name}" );
                        }
                        $monitor->{LastEventId} = $last_event_id;
                }
        }
}

sub getMonitorActive {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:active' ) );
}

sub getMonitorSignal {
        my $monitor = shift;

        return( zmMemRead( $monitor, 'shared_data:signal' ) );
}

sub notifyNewEvent {
        my $monitor_name = shift;
        my $last_event_id = shift;

        my $subject = "ZoneMinder alarm";
        my $maxscore_url = $url . "/index.php?view=frame&eid=".
                           $last_event_id . "&fid=0";
        my $message = "New event $last_event_id for monitor $monitor_name.\n".
                      "URL: $maxscore_url";

        writeMessage( $subject, $message );
        foreach my $toaddr ( @toaddrs ) {
                sendMessage( $toaddr );
        }
}

               sub writeMessage {
        my $subject = shift;
        my $message = shift;

        open( my $fh, '>', $tmp_message_file );
        print $fh "Subject: $subject\n\n";
        print $fh "$message\n";
        close( $fh );
}

sub sendMessage {
        my $toaddr = shift;

        system( "$ssmtp_location -a default $toaddr < $tmp_message_file" );
}
By the way, I have been using this for awhile to send the alerts to multiple SMS gateways. Just enter multiple addresses in toaddrs, separated by commas.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Fri Feb 01, 2019 4:55 am

I have ditched my perl script and switched to a Python script that uses the API. Inspired by asker's zmeventserver, I have also added object detection with OpenCV + YOLO v3 to eliminate false notifications, which is working really well. This method also has the benefit of not reading the shared memory directly, so I no longer need to kill it when ZoneMinder stops to free the shared memory. The code is here - feel free to give it a try if so inclined:

https://github.com/montagdude/zoneminder-notifier

alabamatoy
Posts: 151
Joined: Sun Jun 05, 2016 2:53 pm

Re: Instant notification on alarm

Post by alabamatoy » Fri Feb 22, 2019 3:40 pm

montagdude wrote:
Fri Feb 01, 2019 4:55 am
The code is here - feel free to give it a try if so inclined:

https://github.com/montagdude/zoneminder-notifier
I am looking at your zoneminder-notifier. I would like very much to implement it on my ZM install. I have a some questions....

1 - The readme says "ZoneMinder with API enabled (tested with 1.32.3)" is required. Does your code use any of the API that is not available in older versions such as 1.30.x ? I am fearful of upgrade to 1.32 since there seem to be so many problems experienced post upgrade (reported in these forums). My 1.30.0 seems pretty rock solid and has been running for about 2 years now. I fear the upgrade.

2 - Your readme says "Mutt" is required. ZM is already able to send messages using default email such as SSMTP. In looking through your code, it looks like the commo with Mutt is isolated to just 2 calls within zm_notifier. Do you know of any reason why I couldn't easily change those lines to use SSMTP? Its a simple bash 'cat "textfile" | mail -s "Subject" recipient@somewhere.com' command format. This would alleviate the requirement to install Mutt. Is there anywhere else that Mutt is called that Im overlooking?

3 - Timing...my setup of ZM does not rely on ZM motion detection any longer, it was simply too unreliable for me (I could not get it tuned right, mea culpa). I have external PIR motion detectors which are polled by a script outside of ZM and if motion is detected, CURL is used to force an alarm in ZM. But I force these alarms to be long, minimum of 30 seconds. Will this long of an alarm cause problems with your notifier?

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm

Post by montagdude » Wed Feb 27, 2019 4:20 pm

alabamatoy wrote:
Fri Feb 22, 2019 3:40 pm
I am looking at your zoneminder-notifier. I would like very much to implement it on my ZM install. I have a some questions....

1 - The readme says "ZoneMinder with API enabled (tested with 1.32.3)" is required. Does your code use any of the API that is not available in older versions such as 1.30.x ? I am fearful of upgrade to 1.32 since there seem to be so many problems experienced post upgrade (reported in these forums). My 1.30.0 seems pretty rock solid and has been running for about 2 years now. I fear the upgrade.
I don't know for sure because I haven't tested it, but I'm guessing it would work fine on 1.30. It depends on whether the parts of the API it uses have changed between 1.30 and 1.32. In any case, it doesn't hurt to try. If there are errors, post them and I will try to help figure out what needs to be changed.
alabamatoy wrote:
Fri Feb 22, 2019 3:40 pm
2 - Your readme says "Mutt" is required. ZM is already able to send messages using default email such as SSMTP. In looking through your code, it looks like the commo with Mutt is isolated to just 2 calls within zm_notifier. Do you know of any reason why I couldn't easily change those lines to use SSMTP? Its a simple bash 'cat "textfile" | mail -s "Subject" recipient@somewhere.com' command format. This would alleviate the requirement to install Mutt. Is there anywhere else that Mutt is called that Im overlooking?
You can use ssmtp like that as long as you choose No for attach_image. I chose to use mutt because it handles attachments effortlessly. I originally hoped to use a plain smtp client, but unfortunately it is not a simple matter to attach an image to an email that way. The best solution would be to use smtplib in Python rather than a separate executable, but for the time being I just wanted to take the path of least resistance.
alabamatoy wrote:
Fri Feb 22, 2019 3:40 pm
3 - Timing...my setup of ZM does not rely on ZM motion detection any longer, it was simply too unreliable for me (I could not get it tuned right, mea culpa). I have external PIR motion detectors which are polled by a script outside of ZM and if motion is detected, CURL is used to force an alarm in ZM. But I force these alarms to be long, minimum of 30 seconds. Will this long of an alarm cause problems with your notifier?
It should work, but for the time being you will need to wait for the alarm to end before a notification is sent. The reason I have done it this way is because the maxscore frame from the event is not available until after the event has ended (needed if you want to attach it to the notification or do object detection). In the next few days I am hoping to implement a mode that will send the notification as soon as an alarm starts, provided that is possible with the API. It will disable attaching the image or doing object detection, of course.

montagdude
Posts: 58
Joined: Fri Nov 10, 2017 6:05 pm

Re: Instant notification on alarm + machine learning object detection

Post by montagdude » Thu Feb 28, 2019 3:44 am

I added an option called notify_incomplete_events. If set to yes, it will send notifications for events that have not completed yet, so that you can be alerted as soon as possible. This is beneficial if you tend to have long-running events, or just don't want to waste those few seconds while an event is occurring. The default is No, though, because it also disables image attachments and object detection (the maxscore frame is not available yet while the event is still occurring).

Edit: actually, I think I was wrong about that. It appears that there is a maxscore frame available as soon as the event starts, but the frame identified as having the max score gets updated as the event is occurring. Still, I think having an alarm frame available immediately is valuable for notifications and object detection, even if it doesn't end up being the one with the max score by the time the event is over. I need to do some more testing, but I will probably remove the notify_incomplete_events setting and just make it always notify as soon as the event starts, rather than waiting for it to complete, while still allowing image attachments and object detection.

Post Reply

Who is online

Users browsing this forum: No registered users and 1 guest