# $Id$ package main; use strict; use warnings; use JSON; use Data::Dumper; use HttpUtils; use vars qw(%modules); use vars qw(%defs); use vars qw(%attr); use vars qw($readingFnAttributes); sub Log($$); sub Log3($$$); sub UnifiVideo_Initialize($) { my ($hash) = @_; $hash->{ReadFn} = "UnifiVideo_Read"; $hash->{DefFn} = "UnifiVideo_Define"; $hash->{NotifyFn} = "UnifiVideo_Notify"; $hash->{UndefFn} = "UnifiVideo_Undefine"; $hash->{SetFn} = "UnifiVideo_Set"; $hash->{GetFn} = "UnifiVideo_Get"; $hash->{AttrFn} = "UnifiVideo_Attr"; $hash->{AttrList} = "disable filePath apiKey ". "logfile ". "sshUser ". $readingFnAttributes; $hash->{FW_detailFn} = "UnifiVideo_detailFn"; } ##################################### sub UnifiVideo_Define($$) { my ($hash, $def) = @_; my @a = split("[ \t][ \t]*", $def); return "Usage: define UnifiVideo []" if(@a < 3); my $name = $a[0]; my $host = $a[2]; $hash->{NAME} = $name; my $d = $modules{$hash->{TYPE}}{defptr}; return "$hash->{TYPE} device already defined as $d->{NAME}." if( defined($d) && $name ne $d->{NAME} ); $modules{$hash->{TYPE}}{defptr} = $hash; $hash->{NOTIFYDEV} = "global"; $hash->{HOST} = $host; $hash->{DEF} = $host; $hash->{STATE} = 'active'; CommandAttr(undef,"$name apiKey $a[3]" ) if( defined($a[3]) ); if( $init_done ) { UnifiVideo_Connect($hash); } else { readingsSingleUpdate($hash, 'state', 'initialized', 1 ); } return undef; } sub UnifiVideo_Notify($$) { my ($hash,$dev) = @_; return if($dev->{NAME} ne "global"); return if(!grep(m/^INITIALIZED|REREADCFG$/, @{$dev->{CHANGED}})); UnifiVideo_Connect($hash); return undef; } sub UnifiVideo_Undefine($$) { my ($hash, $arg) = @_; UnifiVideo_killLogWatcher($hash); RemoveInternalTimer($hash, "UnifiVideo_Connect"); delete $modules{$hash->{TYPE}}{defptr}; return undef; } sub UnifiVideo_detailFn() { my ($FW_wname, $d, $room, $extPage) = @_; # extPage is set for summaryFn. my $hash = $defs{$d}; return UnifiVideo_2html($hash); } sub UnifiVideo_2html($;$$) { my ($hash,$cams,$width) = @_; $hash = $defs{$hash} if( ref($hash) ne 'HASH' ); return undef if( !defined($hash) ); $width = 200 if( !$width ); my $name = $hash->{NAME}; my @cams; @cams = split(',', $cams) if( defined($cams) ); my $apiKey = AttrVal($name, 'apiKey', undef); return undef if( !$apiKey ); $apiKey = UnifiVideo_decrypt( $apiKey ); my $json = $hash->{helper}{json}; return undef if( !$json ); my $javascriptText = ""; $javascriptText =~ s/\n/ /g; $javascriptText =~ s/ +/ /g; my $html = "$javascriptText
"; $html .= "\n" if( $html ); $html .= ''; my $i = 0; foreach my $entry (@{$json->{data}}) { next if( $entry->{deleted} ); next if( $entry->{state} eq 'DISCONNECTED' ); if( defined($cams) ) { foreach my $cam (@cams) { if( ( $cam =~ m/^[0-9]+$/ && int($cam) == $i ) || $entry->{_id} eq $cam || $entry->{name} =~ m/$cam/ ) { $html .= "\n" if( $html ); $html .= " "; } } } else { $html .= "\n" if( $html ); $html .= " "; } ++$i; } $html .= "\n" if( $html ); $html .= "
"; #Log 1, $html; return $html; } sub UnifiVideo_Set($$@) { my ($hash, $name, $cmd, @args) = @_; my $list = "reconnect:noArg snapshot apiKey"; if( $cmd eq 'reconnect' ) { $hash->{".triggerUsed"} = 1; UnifiVideo_Connect($hash); return undef; } elsif( $cmd eq 'snapshot' ) { my $json = $hash->{helper}{json}; return "not jet connected" if( !$json ); my ($param_a, $param_h) = parseParams(\@args); my $cam = $param_h->{cam}; my $width = $param_h->{width}; return "usage: snapshot cam= [width=] [fileName=]" if( !defined($cam) ); my $i = 0; foreach my $entry (@{$json->{data}}) { next if( $entry->{deleted} ); next if( $entry->{state} eq 'DISCONNECTED' ); if( ( $cam =~ m/^[0-9]+$/ && int($cam) == $i ) || $entry->{_id} eq $cam || $entry->{name} =~ m/$cam/ ) { $cam = $entry->{_id}; #Log 1, "$i $entry->{name}: $entry->{_id}"; last; } ++$i; } return "no such cam: $cam" if( $i >= $json->{meta}{totalCount} ); my $apiKey = AttrVal($name, 'apiKey', undef); $apiKey = UnifiVideo_decrypt( $apiKey ); my $url = "http://$hash->{HOST}:7080/api/2.0/snapshot/camera/$cam?force=true&apiKey=$apiKey"; $url .= "&width=$width" if( $width ); my $param = { url => $url, method => 'GET', timeout => 5, noshutdown => 0, hash => $hash, key => 'snapshot', cam => $cam, fileName => $param_h->{fileName} , index => $i, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiVideo_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); return undef; } elsif( $cmd eq 'apiKey' ) { return CommandAttr(undef,"$name apiKey $args[0]" ); } return "Unknown argument $cmd, choose one of $list"; } sub UnifiVideo_Get($$@) { my ($hash, $name, $cmd) = @_; my $list = "apiKey:noArg"; if( $cmd eq 'apiKey' ) { my $apiKey = AttrVal($name, 'apiKey', undef); return 'no apiKey set' if( !$apiKey ); $apiKey = UnifiVideo_decrypt( $apiKey ); return "apiKey: $apiKey"; } return "Unknown argument $cmd, choose one of $list"; } sub UnifiVideo_Parse($$;$) { my ($hash,$data,$peerhost) = @_; my $name = $hash->{NAME}; } sub UnifiVideo_parseHttpAnswer($$$) { my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if( $err ) { Log3 $name, 2, "$name: http request ($param->{url}) failed: $err"; return undef; } return undef if( !$data ); Log3 $name, 5, "$name: received $data"; if( $param->{key} eq 'json' ) { my $json = eval { decode_json($data) }; Log3 $name, 2, "$name: json error: $@ in $json" if( $@ ); #Log 1, Dumper $json; $hash->{helper}{json} = $json; if( !defined( $json->{meta} ) ) { Log3 $name, 2, "$name: received unknown data"; return undef; } my $apiKey = AttrVal($name, 'apiKey', undef); $apiKey = UnifiVideo_decrypt( $apiKey ); my $totalCount = $json->{meta}{totalCount}; readingsBeginUpdate($hash); readingsBulkUpdate($hash, 'state', $hash->{PID}?'watching':'running', 1 ); readingsBulkUpdateIfChanged($hash, 'totalCount', $totalCount, 1); my $i = 0; foreach my $entry (@{$json->{data}}) { if( !$entry->{deleted} ) { #Log 1, Dumper $entry->{_id}; readingsBulkUpdateIfChanged($hash, "cam${i}name", $entry->{name}, 1); readingsBulkUpdateIfChanged($hash, "cam${i}id", $entry->{_id}, 1); readingsBulkUpdateIfChanged($hash, "cam${i}state", $entry->{state}, 1); #readingsBulkUpdateIfChanged($hash, "cam${i}snapshotURL", "http://$hash->{HOST}:7080/api/2.0/snapshot/camera/$entry->{_id}?force=true&apiKey=$apiKey" , 1); } ++$i; } readingsEndUpdate($hash,1); RemoveInternalTimer($hash, "UnifiVideo_Connect"); InternalTimer(gettimeofday() + 900, "UnifiVideo_Connect", $hash); } elsif( $param->{key} eq 'snapshot' ) { my $modpath = $attr{global}{modpath}; my $filePath = AttrVal($name, 'filePath', "$modpath/www/snapshots" ); if(! -d $filePath) { my $ret = mkdir "$filePath"; if($ret == 0) { Log3 $name, 1, "Error while creating filePath $filePath $!"; return undef; } } my $fileName = $param->{fileName}; $fileName = $param->{cam} if( !$fileName ); $fileName .= '.jpg'; if(!open(FH, ">$filePath/$fileName")) { Log3 $name, 1, "Can't write $filePath/$fileName $!"; return undef; } print FH $data; close(FH); Log3 $name, 4, "snapshot $filePath/$fileName written."; DoTrigger( $name, "newSnapshot: $param->{index} $filePath/$fileName" ); } else { Log3 $name, 2, "parseHttpAnswer: unhandled key"; } return undef; } sub UnifiVideo_Read($) { my ($hash) = @_; my $name = $hash->{NAME}; my $buf; my $ret = sysread($hash->{FH}, $buf, 65536 ); my $err = int($!); if(!defined($ret) && $err == EWOULDBLOCK) { return; } #Log 1, $ret; #Log 1, $buf; #Log 1, $err; if( $ret == 0 && !defined($hash->{PARTIAL}) ) { UnifiVideo_killLogWatcher($hash); } my $data = $hash->{PARTIAL}; $data .= $buf; while($data =~ m/\n/) { my $line; ($line,$data) = split("\n", $data, 2); my($cam, $type); if( $line =~ m/password/ ) { UnifiVideo_killLogWatcher($hash); } elsif( $line =~ m/Camera\[([^\]]+)\].*type:([^\s]+)/ ) { $cam = $1; $type = $2; } elsif( $line =~ m/AnalyticsService[^[]+\[([^|]+).*type:([^\s]+)/ ) { $cam = $1; $type = $2; } else { Log3 $name, 2, "$name: got unknown event: $line"; } if( $cam && $type ) { if( $type eq 'start' ) { my $json = $hash->{helper}{json}; $json = [] if( !$json ); my $i = 0; foreach my $entry (@{$json->{data}}) { last if( $entry->{mac} eq $cam ); ++$i; } if( $i >= $json->{meta}{totalCount} ) { Log3 $name, 2, "$name: got motion event for unknown cam: $cam"; } else { readingsSingleUpdate($hash, "cam${i}motion", $type, 1); } } elsif( $type eq 'stop' ) { } else { Log3 $name, 2, "$name: got unknown event type from cam: $cam"; } } } $hash->{PARTIAL} = $data #UnifiVideo_Parse($hash, $buf, $hash->{CD}->peerhost); } sub UnifiVideo_killLogWatcher($) { my ($hash) = @_; my $name = $hash->{NAME}; kill( 9, $hash->{PID} ) if( $hash->{PID} ); close($hash->{FH}) if($hash->{FH}); delete($hash->{FH}); delete($hash->{FD}); return if( !$hash->{PID} ); delete $hash->{PID}; readingsSingleUpdate($hash, 'state', 'running', 1 ); Log3 $name, 3, "$name: stopped logfile watcher"; delete $hash->{PARTIAL}; delete($selectlist{$name}); Log 1, "4"; } sub UnifiVideo_startLogWatcher($) { my ($hash) = @_; my $name = $hash->{NAME}; UnifiVideo_killLogWatcher($hash); my $user = AttrVal($name, "sshUser", undef); return if( !$user ); my $logfile = AttrVal($name, "logfile", "/var/log/unifi-video/motion.log" ); my $cmd = qx(which ssh); chomp( $cmd ); $cmd .= ' -q '; $cmd .= $user."\@" if( defined($user) ); $cmd .= $hash->{HOST}; $cmd .= " tail -n 0 -F $logfile"; #my $cmd = "tail -f /tmp/x"; Log3 $name, 3, "$name: using $cmd to watch logfile"; if( my $pid = open( my $fh, '-|', $cmd ) ) { $fh->blocking(0); $hash->{FH} = $fh; $hash->{FD} = fileno($fh); $hash->{PID} = $pid; $selectlist{$name} = $hash; readingsSingleUpdate($hash, 'state', 'watching', 1 ); Log3 $name, 3, "$name: started logfile watcher"; } else { Log3 $name, 2, "$name: failed to start logfile watcher"; } } sub UnifiVideo_Connect($) { my ($hash) = @_; my $name = $hash->{NAME}; return if( IsDisabled($name) ); my $apiKey = AttrVal($name, 'apiKey', undef); if( !$apiKey ) { $hash->{STATE} = 'disconnected'; Log3 $name, 2, "$name: can't connect without apiKey"; return undef; } $apiKey = UnifiVideo_decrypt( $apiKey ); my $url = "http://$hash->{HOST}:7080/api/2.0/camera?apiKey=$apiKey"; my $param = { url => $url, method => 'GET', timeout => 5, noshutdown => 0, hash => $hash, key => 'json', }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiVideo_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); UnifiVideo_startLogWatcher( $hash ) if( !$hash->{PID} ); return undef; } sub UnifiVideo_encrypt($) { my ($decoded) = @_; my $key = getUniqueId(); my $encoded; return $decoded if( $decoded =~ m/^crypt:(.*)/ ); for my $char (split //, $decoded) { my $encode = chop($key); $encoded .= sprintf("%.2x",ord($char)^ord($encode)); $key = $encode.$key; } return 'crypt:'. $encoded; } sub UnifiVideo_decrypt($) { my ($encoded) = @_; my $key = getUniqueId(); my $decoded; $encoded = $1 if( $encoded =~ m/^crypt:(.*)/ ); for my $char (map { pack('C', hex($_)) } ($encoded =~ m/(..)/g)) { my $decode = chop($key); $decoded .= chr(ord($char)^ord($decode)); $key = $decode.$key; } return $decoded; } sub UnifiVideo_Attr($$$) { my ($cmd, $name, $attrName, $attrVal) = @_; my $orig = $attrVal; my $hash = $defs{$name}; if( $attrName eq 'disable' ) { if( $cmd eq "set" && $attrVal ) { UnifiVideo_killLogWatcher($hash); readingsSingleUpdate($hash, 'state', 'disabled', 1 ); } else { readingsSingleUpdate($hash, 'state', 'running', 1 ); $attr{$name}{$attrName} = 0; UnifiVideo_Connect($hash); } } elsif( $attrName eq 'sshUser' ) { if( $cmd eq "set" && $attrVal ) { $attr{$name}{$attrName} = $attrVal; } else { delete $attr{$name}{$attrName}; UnifiVideo_killLogWatcher($hash); } UnifiVideo_Connect($hash); } elsif( $attrName eq 'apiKey' ) { if( $cmd eq "set" && $attrVal ) { return if( $attrVal =~ m/^crypt:/ ); $attrVal = UnifiVideo_encrypt($attrVal); if( $orig ne $attrVal ) { $attr{$name}{$attrName} = $attrVal; UnifiVideo_Connect($hash); return "stored obfuscated apiKey"; } } } if( $cmd eq 'set' ) { } else { delete $attr{$name}{$attrName}; } return; } 1; =pod =item summary Module to integrate FHEM with UnifiVideo =item summary_DE Modul zur Integration von FHEM mit UnifiVideo =begin html

UnifiVideo

    Module to integrate UnifiVideo devices with FHEM.

    define <name> UnifiVideo <ip> [<apiKey>]

    Notes:
    • JSON has to be installed on the FHEM host.
    • create nvr api key: admin->my account->api access
    • define <name> webLink htmlCode {UnifiVideo_2html('<nvr>','<cam>[,<cam2>,..]'[,<width>])}

    Set
    • snapshot cam=<cam> width=<width> fileName=<fileName>
      takes a snapshot from <cam> with optional <width> and stores it with the optional <fileName>
      <cam> can be the number of the camera, its id or a regex that is matched against the name.
    • reconnect
    Get
    • apiKey
      shows the configured apiKey.
    Attr
    • filePath
      path to store the snapshot images to. default: .../www/snapshots
    • apiKey
      apiKey to use for nvr access
    • ssh_user
      ssh user for nvr logfile access. used to fhem events after motion detection.

=end html =cut