#!/usr/bin/perl # # Server to take snapshots with a Reolink IP camera every so often. # # Includes client that interacts with the server. use strict; use warnings; use Scalar::Util qw/looks_like_number/; my %default_config = ( socket => '/var/run/reolink.sock', config => '/etc/reolink.conf', spool_dir => '/var/spool/reolink' ); my %debug_config = ( socket => "$ENV{HOME}/reolink.sock", config => "$ENV{HOME}/reolink.conf", spool_dir => "$ENV{HOME}/spool" ); my $global_config = \%default_config; # 30s is 1920 shots over 16 hours. At 24fps, this makes a 1:20 video. my ($min_interval, $interval, $max_interval) = (10, 30, 600); # Retain images in the spool 72 hours by default my $spool_retention_time = 72; my $default_video_range = '0530-2130'; my ($video_format, $video_extension) = ('h264', 'mp4'); # ('vp9', 'webm') also supported my @video_extensions = qw(mp4 webm); # enumeration of all supported types - used to generate HTML my %commands = ( interval => { args => 1, server => \&Server::set_interval, validate => sub { looks_like_number($_[0]) && $_[0] >= $min_interval && $_[0] <= $max_interval }, help => 'Set snapshot interval' }, snapshot => { args => 0, server => \&Server::snapshot, help => 'Take a snapshot now' }, spooltime => { args => 1, server => \&Server::set_spooltime, validate => sub { looks_like_number ($_[0]) && $_[0] > 0 && int($_[0]) == $_[0] }, help => 'Set spool retention time (in hours)' }, status => { args => 0, server => \&Server::server_status, help => 'Send status of the server' }, ping => {alias => 'status'}, pid => { args => 0, server => \&Server::server_pid, help => 'Send the pid of the server' }, video => { args => 1, server => \&Server::video, validate => \&Process::validate_video_times, help => 'Time range for daily time-lapse video' }, timelapse => { args => 1, server => \&Server::timelapse, validate => sub { $_[-1] =~ m/^\d{8}$/ }, help => 'Generate a timelapse for YYYYMMDD' }, exit => { args => 0, server => \&Server::server_terminate, help => 'Stop the server' }, quit => {alias => 'exit'}, terminate => {alias => 'exit'}, term => {alias => 'exit'}, die => {alias => 'exit'} ); # Load parameters from the config file sub load_params { my $params = $_[0]; $params->{spool_dir} = $global_config->{spool_dir} if not defined $params->{spool_dir}; open (my $fh, '<', $global_config->{config}) or return; foreach my $line (<$fh>) { chomp $line; $line =~ s/^\s*#.*//; if ($line =~ m/^\s*(\S+)\s*[=:]\s*(\S+)/) { my ($key, $value) = ($1, $2); if ($key eq 'interval' and $commands{interval}->{validate} ($value)) { $params->{interval} = $value; } elsif ($key =~ m/^spool(?:_dir)?$/) { $params->{spool_dir} = $value; } elsif ($key =~ m/^spool[-_]time$/ and $commands{spooltime}->{validate} ($value)) { $params->{spooltime} = $value; } elsif ($key =~ m/^ntp(?:_ip)?$/) { $params->{ntp_ip} = $value; } elsif ($key =~ m/^video(?:[-_]?times?)?$/ and $commands{video}->{validate} ($value)) { $params->{video} = $value; } } } close ($fh); } package Server; use Errno qw/EINTR/; use File::Basename; use File::Path 'make_path'; use File::Spec; use Getopt::Long; use Image::Magick; use IO::Select; use OpenBSD::Pledge; use OpenBSD::Unveil; use POSIX qw(mktime setsid :signal_h :sys_wait_h); use Socket; use Sys::Syslog qw/:standard :macros/; BEGIN { # If we're running out of the dev tree (or otherwise have the module # installed with the executable), prefer the local version. my $wd = File::Spec->rel2abs ($0); $wd =~ s|/[^/]+$||; if (-f "$wd/Reolink.pm") { unshift @INC, $wd; } require Reolink; Reolink->import(); } my %server_params = (interval => $interval, ntp_ip => '192.168.127.1', spooltime => $spool_retention_time, video => $default_video_range); # See dhcpd.conf for IP assignments my %cameras = ('camera1' => {display => 'Camera 1', spool => 'camera_1', ip => '192.168.127.10', args => {}}, 'camera2' => {display => 'Camera 2', spool => 'camera_2', ip => '192.168.127.11', args => {image_quality_workaround => 1}}); my ($debug, $local) = (0, 0); sub message { my $priority = @_ > 1 ? shift : LOG_INFO; my $msg = join ('', @_); if ($debug) { print "$msg\n"; } else { syslog ($priority, $msg); } } sub error { my $line = (caller (1))[3]; message (LOG_ERR, "error: @_ at $line"); } sub debug { message (@_) if $debug; } sub save_params { my $fh; unless (open ($fh, '>', $global_config->{config})) { error ("failed to open $global_config->{config}: $!"); return; } print $fh "# Autogenerated ".localtime.".\n"; print $fh "interval: $server_params{interval}\n"; print $fh "spool_dir: $server_params{spool_dir}\n"; print $fh "spool_time: $server_params{spooltime}\n"; print $fh "ntp_ip: $server_params{ntp_ip}\n"; print $fh "video: $server_params{video}\n"; close ($fh); message ("saved $global_config->{config}"); } sub daemonize { chdir ('/') or die "failed to chdir(/): $!"; open (STDIN, '/dev/null') or die "failed to reopen stdout as /dev/null: $!"; my $pid = fork(); die "fork error: $!" unless defined $pid; exit if $pid; # parent die "setsid failed: $!" if setsid () == -1; open (STDERR, '>&', STDOUT) or die "failed to dup stdout: $!"; } sub open_socket { socket (my $server_socket, PF_UNIX, SOCK_STREAM, 0) || die "socket error: $!"; debug ("open($global_config->{socket})"); unlink $global_config->{socket}; my $umask = umask 0117; # rw-rw---- bind ($server_socket, sockaddr_un ($global_config->{socket})) || die "bind error($global_config->{socket}): $!"; umask $umask; listen ($server_socket, SOMAXCONN) || die "listen error: $!"; $server_socket; } sub spool { File::Spec->catdir ($server_params{spool_dir}, $cameras{$_[0]}->{spool}); } sub host { $cameras{$_[0]}->{ip}; } sub setup_reolink { my $camera = $_[0]; my ($resp, $code); my $r = Reolink->new (host => host ($camera), %{ $cameras{$camera}->{args} }); unless ($r->Login) { error ("Failed to login to $camera"); return; } $r->Errors (1); $code = $r->SetNtp (1, $server_params{ntp_ip}); $code = $r->SetTime; # just enable DST $code = $r->SetOsd (0, 0, "AySpy", "Upper Left", 0, "Upper Right"); $r->Logout; } my $keep_going = 1; sub server_terminate { $keep_going = 0; 'bye'; } sub set_interval { $server_params{interval} = $_[1]; save_params; "ok"; } sub set_spooltime { $server_params{spooltime} = $_[1]; save_params; "ok"; } sub video { $server_params{video} = $_[1]; save_params; "ok"; } sub snapshot { my @cameras = @_ ? @_ : keys (%cameras); foreach my $camera (@cameras) { my $r = Reolink->new (host => host ($camera), %{ $cameras{$camera}->{args} }); unless ($r->Login) { error ("Failed to login to $camera"); next; } $r->Errors (1); my @t = localtime; my $fn = File::Spec->catfile (spool ($camera), sprintf ("%04d%02d%02d-%02d%02d%02d.jpg", $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0])); my $sn = $fn; $sn =~ s|/([^/]+)$|/sm-$1|; # Could have changed since last time through make_path (spool ($camera), { mode => 0755 }) unless -d spool ($camera); debug ("snapshot => $fn"); # take snapshot if ($r->Snap ($fn)) { warn "Error creating snapshot\n"; } else { my $image = Image::Magick->new (); $image->Read ($fn); $image->Resize ('1024x768'); # 4:3 - use 1280x960 if this is too small $image->Write (filename => $sn); } $r->Logout; } "snap"; } my @saved_argv; sub server_reload { exec @saved_argv or die "exec for reload failed: $!"; } sub server_status { 'alive'; } sub server_pid { "$$"; } sub process_command { accept (my $s, $_[0]) || die "accept error: $!"; $s->autoflush; my $command = <$s>; my $resp = 'error'; my @command = split ' ', $command; my $cmd = shift @command; unless (defined $commands{$cmd}) { error ("unknown command from client '$cmd'\n"); goto out; } if (@command < $commands{$cmd}->{args}) { error ("insufficient args from client for '$cmd' (expect $commands{$cmd}->{args}, have ".@command.")"); goto out; } if (@command && $commands{$cmd}->{validate} && ! $commands{$cmd}->{validate} (@command)) { error ("invalid args from client for '$cmd'"); goto out; } $resp = $commands{$cmd}->{server} ($s, @command); out: print $s "$resp\n"; close $s; } my %process_children; sub maybe_generate_video { # [camera list] [time] my @cameras; foreach my $a (0..$#_) { if (defined $cameras{$_[$a]}) { push @cameras, $_[$a]; delete $_[$a]; } } @cameras = keys (%cameras) unless @cameras; my $date = $_[0] if $_[0] && $_[0] =~ m/^\d{8}$/; # if current time has passed $end_time and no video exists for this date and there is no pending video process CAMERA: foreach my $camera (@cameras) { foreach my $pid (keys %process_children) { my $res = waitpid ($pid, WNOHANG); debug ("$pid -> $process_children{$pid}: $res"); if ($res == 0) { debug ("child process $pid ($process_children{$pid}) running..."); next CAMERA; } else { debug ("child process $pid ($process_children{$pid}) complete"); delete $process_children{$pid}; } } my ($start_time, $end_time) = $commands{video}->{validate} ($server_params{video}); my @t = localtime; my $time = sprintf ('%d%02d', $t[2], $t[1]); if ($time < $end_time && @_ == 0) { # an arg means we have a particular date in mind so skip this check debug ("too early to generate video ($time < $end_time)"); return; } my $video_prefix = $date // sprintf ('%04d%02d%02d', $t[5]+1900, $t[4]+1, $t[3]); foreach my $ext (@video_extensions) { my $out = File::Spec->catfile (spool ($camera), "$video_prefix.$ext"); if (-f $out && -s $out) { debug ("already generated for $camera $out"); next CAMERA; } } message ("generating video for $camera $video_prefix"); # extract program name and any "--debug" from @saved_argv my @process_args; foreach (@saved_argv) { next if m/server/; push @process_args, $_; } push @process_args, ('--process' => $video_prefix, '--range' => $server_params{video}, '--spool', spool ($camera)); my $process_child = fork (); unless (defined ($process_child)) { error ("failed to fork video process for $camera"); next; } if ($process_child == 0) { exec @process_args or die "failed to launch child for $camera: @process_args: $!"; } $process_children{$process_child} = $camera; debug ("launched video process for $camera as pid $process_child"); } } sub timelapse { maybe_generate_video ($_[1]); "ok" } sub respool_and_generate_slideshow { my @cameras; foreach my $a (0..$#_) { if (defined $cameras{$_[$a]}) { push @cameras, $_[$a]; delete $_[$a]; } } @cameras = (sort { $cameras{$a}->{display} cmp $cameras{$b}->{display} } keys (%cameras)) unless @cameras; # Retain only past 24 hours of stills my $t = time - $server_params{spooltime} * 60 * 60; # Generate a slideshow web page my $fh; unless (open ($fh, '>', "$server_params{spool_dir}/index.html")) { error ("open(index.html): $!"); } my $generated = localtime; if ($fh) { print $fh <<"SLIDESHOW";