#!@PERL_EXECUTABLE@ -wT
#
# ==========================================================================
#
# ZoneMinder External Trigger Script
# Copyright (C) 2001-2008 Philip Coombes
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# ==========================================================================

use strict;
use bytes;

# ==========================================================================
#
# User config
#
# ==========================================================================

use constant MAX_CONNECT_DELAY => 10;
use constant MONITOR_RELOAD_INTERVAL => 300;
use constant SELECT_TIMEOUT => 0.25;

# ==========================================================================
#
# Channel/Connection Modules
#
# ==========================================================================

@EXTRA_PERL_LIB@
use ZoneMinder;
use ZoneMinder::Trigger::Channel::Inet;
use ZoneMinder::Trigger::Channel::Unix;
use ZoneMinder::Trigger::Channel::Serial;
use ZoneMinder::Trigger::Connection;

my @connections;
push( @connections,
    ZoneMinder::Trigger::Connection->new(
      name=>'Chan1 TCP on port 6802',
      channel=>ZoneMinder::Trigger::Channel::Inet->new( port=>6802 ),
      mode=>'rw'
      )
    );
push( @connections,
    ZoneMinder::Trigger::Connection->new(
      name=>'Chan2 Unix Socket at ' . $Config{ZM_PATH_SOCKS}.'/zmtrigger.sock',
      channel=>ZoneMinder::Trigger::Channel::Unix->new(
        path=>$Config{ZM_PATH_SOCKS}.'/zmtrigger.sock'
        ),
      mode=>'rw'
      )
    );
#push( @connections, ZoneMinder::Trigger::Connection->new( name=>'Chan3', channel=>ZoneMinder::Trigger::Channel::File->new( path=>'/tmp/zmtrigger.out' ), mode=>'w' ) );
#push( @connections, ZoneMinder::Trigger::Connection->new( name=>'Chan4', channel=>ZoneMinder::Trigger::Channel::Serial->new( path=>'/dev/ttyS0' ), mode=>'rw' ) );

# ==========================================================================
#
# Don't change anything from here on down
#
# ==========================================================================

use DBI;
#use Socket;
use autouse 'Data::Dumper'=>qw(Dumper);
use POSIX qw( EINTR );
use Time::HiRes qw( usleep );

$| = 1;

$ENV{PATH}  = '/bin:/usr/bin:/usr/local/bin';
$ENV{SHELL} = '/bin/sh' if exists $ENV{SHELL};
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};

logInit();
logSetSignal();
my $zm_terminate = 0;
sub TermHandler {
  Info('Received TERM, exiting');
  $zm_terminate = 1;
}
$SIG{TERM} = \&TermHandler;
$SIG{INT} = \&TermHandler;

Info('Trigger daemon starting');

my $dbh = zmDbConnect();

my $base_rin = '';
foreach my $connection ( @connections ) {
  Info("Opening connection '$connection->{name}'");
  $connection->open();
}

my @in_select_connections = grep { $_->input() && $_->selectable() } @connections;
my @in_poll_connections = grep { $_->input() && !$_->selectable() } @connections;
my @out_connections = grep { $_->output() } @connections;

foreach my $connection ( @in_select_connections ) {
  vec( $base_rin, $connection->fileno(), 1 ) = 1;
}

my %spawned_connections;
my %monitors;
my $monitor_reload_time = 0;
my @needsReload;
loadMonitors();

$! = undef;
my $rin = '';
my $win = $rin;
my $ein = $win;
my $timeout = SELECT_TIMEOUT;
my %actions;
while (!$zm_terminate) {
  $rin = $base_rin;
# Add the file descriptors of any spawned connections
  foreach my $fileno ( keys %spawned_connections ) {
    vec($rin, $fileno, 1) = 1;
  }

  my $nfound = select(my $rout = $rin, undef, my $eout = $ein, $timeout);
  if ( $nfound > 0 ) {
    Debug("Got input from $nfound connections");
    foreach my $connection ( @in_select_connections ) {
      if ( vec($rout, $connection->fileno(), 1) ) {
        Debug('Got input from connection '
            .$connection->name()
            .' ('
            .$connection->fileno()
            .')'
            );
        if ( $connection->spawns() ) {
          my $new_connection = $connection->accept();
          $spawned_connections{$new_connection->fileno()} = $new_connection;
          Debug('Added new spawned connection ('
              .$new_connection->fileno()
              .'), '
              .int(keys(%spawned_connections))
              .' spawned connections'
              );
        } else {
          my $messages = $connection->getMessages();
          if ( defined($messages) ) {
            foreach my $message ( @$messages ) {
              handleMessage( $connection, $message );
            }
          }
        } # end if connection->spawns
      } # end if vec
    } # end foreach connection

    foreach my $connection ( values(%spawned_connections) ) {
      if ( vec($rout, $connection->fileno(), 1) ) {
        Debug('Got input from spawned connection '
            .$connection->name()
            .' ('
            .$connection->fileno()
            .')'
            );
        my $messages = $connection->getMessages();
        if ( defined($messages) ) {
          foreach my $message ( @$messages ) {
            handleMessage($connection, $message);
          }
        } else {
          delete $spawned_connections{$connection->fileno()};
          Debug('Removed spawned connection ('
              .$connection->fileno()
              .'), '
              .int(keys(%spawned_connections))
              .' spawned connections'
              );
          $connection->close();
        }
      }
    } # end foreach spawned connection
  } elsif ( $nfound < 0 ) {
    if ( $! == EINTR ) {
# Do nothing
    } else {
      Fatal("Can't select: $!");
    }
  } # end if select returned activity

# Check polled connections
  foreach my $connection ( @in_poll_connections ) {
    my $messages = $connection->getMessages();
    if ( defined($messages) ) {
      foreach my $message ( @$messages ) {
        handleMessage($connection, $message);
      }
    }
  }

  # Check for alarms that might have happened
  my @out_messages;
  foreach my $monitor ( values %monitors ) {

    if ( ! zmMemVerify($monitor) ) {
      # Our attempt to verify the memory handle failed. We should reload the monitors.
      # Don't need to zmMemInvalidate because the monitor reload will do it.
      push @needsReload, $monitor;
      next;
    }

    my ( $state, $last_event ) = zmMemRead( $monitor,
        [
        'shared_data:state',
        'shared_data:last_event'
        ]
        );

#print( "$monitor->{Id}: S:$state, LE:$last_event" );
#print( "$monitor->{Id}: mS:$monitor->{LastState}, mLE:$monitor->{LastEvent}" );
    if ( $state == STATE_ALARM or $state == STATE_ALERT ) {
# In alarm state
      if ( !defined($monitor->{LastEvent})
          or ($last_event != $monitor->{LastEvent})
         ) {
# A new event
        push @out_messages, $monitor->{Id}.'|on|'.time().'|'.$last_event;
      } else {
 # The same one as last time, so ignore it
# Do nothing
      }
    } elsif ( 
        (($state == STATE_IDLE) and ($monitor->{LastState} != STATE_IDLE))
          or
        (($state == STATE_TAPE) and ($monitor->{LastState} != STATE_TAPE))
        ) {
# Out of alarm state
      push @out_messages, $monitor->{Id}.'|off|'.time().'|'.$last_event;
    } elsif (
        defined($monitor->{LastEvent})
        &&
        ($last_event != $monitor->{LastEvent})
        ) {
 # We've missed a whole event
      push @out_messages, $monitor->{Id}.'|on|'.time().'|'.$last_event;
      push @out_messages, $monitor->{Id}.'|off|'.time().'|'.$last_event;
    }
    $monitor->{LastState} = $state;
    $monitor->{LastEvent} = $last_event;
  } # end foreach monitor

  foreach my $connection ( @out_connections ) {
    if ( $connection->canWrite() ) {
      $connection->putMessages(\@out_messages);
    }
  }

  foreach my $connection ( values %spawned_connections ) {
    if ( $connection->canWrite() ) {
      $connection->putMessages(\@out_messages);
    }
  }

  if ( my @action_times = keys(%actions) ) {
    Debug('Checking for timed actions');
    my $now = time();
    foreach my $action_time ( sort( grep { $_ < $now } @action_times ) ) {
      Info('Found '.(scalar @{$actions{$action_time}}).'actions expiring at '.$action_time);
      foreach my $action ( @{$actions{$action_time}} ) {
        my $connection = $action->{connection};
        Info("Found action '$$action{message}'");
        handleMessage($connection, $$action{message});
      }
      delete $actions{$action_time};
    }
  } # end if have timed actions

# Allow connections to do their own timed actions
  foreach my $connection ( @connections ) {
    my $messages = $connection->timedActions();
    if ( defined($messages) ) {
      foreach my $message ( @$messages ) {
        handleMessage($connection, $message);
      }
    }
  }

  foreach my $connection ( values %spawned_connections ) {
    my $messages = $connection->timedActions();
    if ( defined($messages) ) {
      foreach my $message ( @$messages ) {
        handleMessage($connection, $message);
      }
    }
  }

  # Reload all monitors from the dB every MONITOR_RELOAD_INTERVAL
  if ( (time() - $monitor_reload_time) > MONITOR_RELOAD_INTERVAL ) {
    foreach my $monitor ( values(%monitors) ) {
      zmMemInvalidate( $monitor ); # Free up any used memory handle
    }
    loadMonitors();
    @needsReload = (); # We just reloaded all monitors so no need reload a specific monitor
  # If we have NOT just reloaded all monitors, reload a specific monitor if its shared mem changed
  } elsif ( @needsReload ) { 
    foreach my $monitor ( @needsReload ) {
      loadMonitor($monitor);
    }
    @needsReload = ();
  }

  # zmDbConnect will ping and reconnect if neccessary
  $dbh = zmDbConnect();
} # end while (!$zm_terminate)
Info('Trigger daemon exiting');
exit;

sub loadMonitor {
  my $monitor = shift;

  Debug('Loading monitor '.$monitor);
  zmMemInvalidate($monitor);

  if ( zmMemVerify($monitor) ) { # This will re-init shared memory
    $monitor->{LastState} = zmGetMonitorState($monitor);
    $monitor->{LastEvent} = zmGetLastEvent($monitor);
  }
} # end sub loadMonitor

sub loadMonitors {
  $monitor_reload_time = time();

  my %new_monitors = ();

  my $sql = 'SELECT * FROM `Monitors`
    WHERE find_in_set( `Function`, \'Modect,Mocord,Nodect,Record\' )'.
    ( $Config{ZM_SERVER_ID} ? ' AND `ServerId`=?' : '' )
    ;
  my $sth = $dbh->prepare_cached( $sql )
    or Fatal( "Can't prepare '$sql': ".$dbh->errstr() );
  my $res = $sth->execute( $Config{ZM_SERVER_ID} ? $Config{ZM_SERVER_ID} : () )
    or Fatal( "Can't execute: ".$sth->errstr() );

  while ( my $monitor = $sth->fetchrow_hashref() ) {
    if ( zmMemVerify($monitor) ) { # This will re-init shared memory
      $monitor->{LastState} = zmGetMonitorState($monitor);
      $monitor->{LastEvent} = zmGetLastEvent($monitor);
    }
    $new_monitors{$monitor->{Id}} = $monitor;
  } # end while fetchrow
  %monitors = %new_monitors;
} # end sub loadMonitors

sub handleMessage {
  my $connection = shift;
  my $message = shift;

  #  CUA  - Axis camera send the message quoted with"
  #  CUA  - Also Axis camera cannot save the plus sign which
  $message =~ s/^\"//g;
  $message =~ s/\"$//g;

  my ( $id, $action, $score, $cause, $text, $showtext )
    = split( /\|/, $message );
  $score = 0 if !defined($score);
  $cause = '' if !defined($cause);
  $text = '' if !defined($text);

  my $monitor = $monitors{$id};
  if ( !$monitor ) {
    loadMonitors();
    $monitor = $monitors{$id};
    if ( !$monitor ) {
      Warning("Can't find monitor '$id' for message '$message'");
      return;
    }
  }
  if ( !zmMemVerify($monitor) ) {
    Warning("Can't verify monitor '$id' for message '$message'");
    return;
  }

  Debug("Handling action '$action'");
  if ( $action =~ /^(enable|disable)(?:[\+ ](\d+))?$/ ) {
    my $state = $1;
    my $delay = $2;
    if ( $state eq 'enable' ) {
      zmMonitorEnable($monitor);
    } else {
      zmMonitorDisable($monitor);
    }
# Force a reload
    $monitor_reload_time = 0;
    Info('Set monitor to '.$state);
    if ( $delay ) {
      my $action_text = $id.'|'.(($state eq 'enable') ? 'disable' : 'enable');
      handleDelay($delay, $connection, $action_text);
    }
  } elsif ( $action =~ /^(on|off)(?:[ \+](\d+))?$/ ) {
    if ( !$monitor->{Enabled} ) {
      Info('Motion detection not enabled on monitor '.$id);
      return;
    }

    my $trigger = $1;
    my $delay = $2;
    my $trigger_data;
    if ( $trigger eq 'on' ) {
      if ( $score <= 0 ) {
        Warning('Triggering on with invalid score will have no result.');
        return;
      }
      zmTriggerEventOn($monitor, $score, $cause, $text);
      zmTriggerShowtext($monitor, $showtext) if defined($showtext);
      Info("Trigger '$trigger' '$cause'");
      if ( $delay ) {
        my $action_text = $id.'|cancel';
        handleDelay($delay, $connection, $action_text);
      }
    } elsif ( $trigger eq 'off' ) {
      if ( $delay ) {
        my $action_text = $id.'|off|0|'.$cause.'|'.$text;
        handleDelay($delay, $connection, $action_text);
      } else {
        my $last_event = zmGetLastEvent($monitor);
        zmTriggerEventOff($monitor);
        zmTriggerShowtext($monitor, $showtext) if defined($showtext);
        Info("Trigger '$trigger'");
# Wait til it's finished
        while ( zmInAlarm($monitor)
            && ($last_event == zmGetLastEvent($monitor))
            ) {
# Tenth of a second
          usleep(100000);
        }
        zmTriggerEventCancel($monitor);
      } # end if delay or not
    } # end if trigger is on or off
  } elsif ( $action eq 'cancel' ) {
    zmTriggerEventCancel($monitor);
    zmTriggerShowtext($monitor, $showtext) if defined($showtext);
    Info('Cancelled event');
  } elsif ( $action eq 'show' ) {
    zmTriggerShowtext( $monitor, $showtext );
    Info("Updated show text to '$showtext'");
  } else {
    Error("Unrecognised action '$action' in message '$message'");
  }
} # end sub handleMessage

sub handleDelay {
  my $delay = shift;
  my $connection = shift;
  my $action_text = shift;

  my $action_time = time()+$delay;

  # Need to check and cancel previous actions. See issue #2619
  foreach my $a_time ( keys %actions ) {
    if ( $a_time <= $action_time ) {
      for ( my $i = 0; $i < @{$actions{$a_time}}; $i ++ ) {
        my $action = $actions{$a_time}[$i];
        if ( $$action{message} eq $action_text ) {
          Info("Found duplicate action '$$action{message}' at $a_time, cancelling it");
          splice @{$actions{$a_time}}, $i, 1;
        }
      } # end foreach action
      delete $actions{$a_time} if !@{$actions{$a_time}};
    } # end if
  } # end foreach action_time

  my $action_array = $actions{$action_time};
  if ( !$action_array ) {
    $action_array = $actions{$action_time} = [];
  }
  push @$action_array, { connection=>$connection, message=>$action_text };
  Debug("Added timed event '$action_text', expires at $action_time (+$delay secs)");
}

1;
__END__

=head1 NAME

zmtrigger.pl - ZoneMinder External Trigger Script

=head1 DESCRIPTION

This script is used to trigger and cancel alarms from external connections
using an arbitrary text based format.

This script offers generic solution to external triggering of alarms. It
can handle external connections via either internet socket, unix socket or
file/device interfaces. You can either use it 'as is' if you can interface
with the existing format, or override connections and channels to customise
it to your needs.

If enabled by the OPT_TRIGGERS option, Zoneminder service start
zmtrigger.pl which listens for control messages on TCP port 6802.

=head1 TRIGGER MESSAGE FORMAT

B<id>|B<action>|B<score>|B<cause>|B<text>|B<showtext>

=over 4

=item B<id>

  is the id number or name of the ZM monitor.

=item B<action>

  Valid actions are 'on', 'off', 'cancel' or 'show' where
  'on' forces an alarm condition on;
  'off' forces an alarm condition off;
  'cancel' negates the previous 'on' or 'off';
  'show' updates the auxiliary text represented by the %Q 
  placeholder, which can optionally be added to the affected monitor's
  timestamp label format.
  
  Ordinarily you would use 'on' and 'cancel', 'off' would tend to be
  used to suppress motion based events. Additionally 'on' and 'off' can
  take an additional time offset, e.g. on+20 which automatically
  cancel's the previous action after that number of seconds.

=item B<score>

  is the score given to the alarm, usually to indicate it's
  importance. For 'on' triggers it should be non-zero, otherwise it should
  be zero.

=item B<cause>

  is a 32 char max string indicating the reason for, or source of
  the alarm e.g. 'Relay 1 open'. This is saved in the 'Cause' field of the
  event. Ignored for 'off' or 'cancel' messages.

=item B<text>

  is a 256 char max additional info field, which is saved in the
  'Description' field of an event. Ignored for 'off' or 'cancel' messages.

=item B<showtext>

  is up to 32 characters of text that can be displayed in the
  timestamp that is added to images. The 'show' action is designed to
  update this text without affecting alarms but the text is updated, if
  present, for any of the actions. This is designed to allow external input
  to appear on the images captured, for instance temperature or personnel
  identity etc.

=back

  Note that multiple messages can be sent at once and should be LF or CRLF
  delimited. This script is not necessarily intended to be a solution in
  itself, but is intended to be used as 'glue' to help ZoneMinder interface
  with other systems. It will almost certainly require some customisation
  before you can make any use of it. If all you want to do is generate alarms
  from external sources then using the ZoneMinder::SharedMem perl module is
  likely to be easier.

=head1 EXAMPLES

  3|on+10|1|motion|text|showtext

Triggers 'alarm' on camera #3 for 10 seconds with score=1, cause='motion'.

=cut
