# $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 UnifiProtect_Initialize($) { my ($hash) = @_; $hash->{ReadFn} = "UnifiProtect_Read"; $hash->{DefFn} = "UnifiProtect_Define"; $hash->{NotifyFn} = "UnifiProtect_Notify"; $hash->{UndefFn} = "UnifiProtect_Undefine"; $hash->{SetFn} = "UnifiProtect_Set"; $hash->{GetFn} = "UnifiProtect_Get"; $hash->{AttrFn} = "UnifiProtect_Attr"; $hash->{AttrList} = "disable filePath user password ". "sshUser ". $readingFnAttributes; $hash->{FW_detailFn} = "UnifiProtect_detailFn"; $data{FWEXT}{"/protect"}{FUNC} = "UnifiProtect_CGI"; #$data{FWEXT}{"/protect"}{FORKABLE} = 1; } ##################################### sub UnifiProtect_Define($$) { my ($hash, $def) = @_; my @a = split("[ \t][ \t]*", $def); return "Usage: define UnifiProtect " if(@a < 3); my $name = $a[0]; $hash->{NAME} = $name; my $host = $a[2]; 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 user $a[3]" ) if( defined( $a[3]) ); CommandAttr(undef,"$name password $a[4]" ) if( defined( $a[4]) ); if( $init_done ) { UnifiProtect_Connect($hash); } else { readingsSingleUpdate($hash, 'state', 'initialized', 1 ); } return undef; } sub UnifiProtect_Notify($$) { my ($hash,$dev) = @_; return if($dev->{NAME} ne "global"); return if(!grep(m/^INITIALIZED|REREADCFG$/, @{$dev->{CHANGED}})); UnifiProtect_Connect($hash); return undef; } sub UnifiProtect_Undefine($$) { my ($hash, $arg) = @_; UnifiProtect_killLogWatcher($hash); RemoveInternalTimer($hash, "UnifiProtect_Connect"); delete $modules{$hash->{TYPE}}{defptr}; return undef; } sub UnifiProtect_detailFn() { my ($FW_wname, $d, $room, $extPage) = @_; # extPage is set for summaryFn. my $hash = $defs{$d}; return UnifiProtect_2html($hash); } sub UnifiProtect_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 $auth = $hash->{helper}{auth}; 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}) { next if( $entry->{deleted} ); next if( $entry->{state} eq 'DISCONNECTED' ); my $auth = ''; $auth = "auth='$hash->{helper}{auth}'" if( $hash->{helper}{auth} ); my $nvrIp = $FW_ME; $nvrIp = $hash->{HOST} if( $hash->{helper}{auth} ); my $n = ''; $n = "name='$name'" if( $hash->{helper}{isUnifiOS} ); 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 UnifiProtect_CGI(@) { my ($cgi) = @_; my ($cmd, $c) = FW_digestCgi($cgi); my $name = $FW_webArgs{name}; $c = $defs{$FW_cname}->{CD}; if( !$name || !defined($defs{$name}) || $defs{$name}->{TYPE} ne 'UnifiProtect' ) { print $c "HTTP/1.1 400 Bad Request\r\n". "Content-Length: 11\r\n\r\n"; print $c "Bad Request"; return undef; } my $hash = $defs{$name}; Log3 $name, 5, "$name: CGI:". Dumper \%FW_webArgs; $c = $defs{$FW_cname}->{CD}; my $json = $hash->{helper}{json}; return "not jet connected" if( !$json ); my $cam = $FW_webArgs{cam}; my $width = $FW_webArgs{width}; return "usage: snapshot cam= [width=] [fileName=]" if( !defined($cam) ); my $i = 0; my $found; foreach my $entry (@{$json}) { 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}; $found = 1; #Log 1, "$i $entry->{name}: $entry->{id}"; last; } ++$i; } return "no such cam: $cam" if( !$found ); my $url = "https://$hash->{HOST}". ($hash->{helper}{isUnifiOS} ? "/proxy/protect/api/cameras/$cam/snapshot" : ":7443/api/cameras/$cam/snapshot"); $url .= "?w=$width" if( $width ); my $param = { url => $url, method => 'GET', timeout => 5, hash => $hash, key => 'snap', cname => $FW_cname, header => { 'Authorization' => "Bearer $hash->{helper}{auth}", 'X-CSRF-Token' => $hash->{helper}{csrfToken}, 'Cookie' => $hash->{helper}{cookie} }, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); return undef; my ($err,$ret) = HttpUtils_BlockingGet( $param ); print $c "HTTP/1.1 200 OK\r\n", "Content-Type: image/jpeg\r\n", "Content-Length: ". length($ret) ."\r\n", "Connection: close\r\n", "\r\n", $ret; return undef; } sub UnifiProtect_Set($$@) { my ($hash, $name, $cmd, @args) = @_; my $list = "reconnect:noArg snapshot"; if( $cmd eq 'reconnect' ) { $hash->{".triggerUsed"} = 1; UnifiProtect_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; my $found; foreach my $entry (@{$json}) { 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}; $found = 1; #Log 1, "$i $entry->{name}: $entry->{id}"; last; } ++$i; } return "no such cam: $cam" if( !$found ); my $url = "https://$hash->{HOST}". ($hash->{helper}{isUnifiOS} ? "/proxy/protect/api/cameras/$cam/snapshot" : ":7443/api/cameras/$cam/snapshot"); $url .= "?w=$width" if( $width ); my $param = { url => $url, method => 'GET', timeout => 5, hash => $hash, key => $cmd, cam => $cam, fileName => $param_h->{fileName} , index => $i, header => { 'Authorization' => "Bearer $hash->{helper}{auth}", 'X-CSRF-Token' => $hash->{helper}{csrfToken}, 'Cookie' => $hash->{helper}{cookie} }, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); return undef; } elsif( $cmd eq 'user' ) { return CommandAttr(undef,"$name $cmd $args[0]" ); } elsif( $cmd eq 'password' ) { return CommandAttr(undef,"$name $cmd $args[0]" ); } return "Unknown argument $cmd, choose one of $list"; } sub UnifiProtect_Get($$@) { my ($hash, $name, $cmd) = @_; my $list = "user:noArg password:noArg"; if( $cmd eq 'user' ) { my $user = AttrVal($name, 'user', undef); return 'no user set' if( !$user ); $user = UnifiProtect_decrypt( $user ); return "user: $user"; } elsif( $cmd eq 'password' ) { my $password = AttrVal($name, 'password', undef); return 'no password set' if( !$password ); $password = UnifiProtect_decrypt( $password ); return "password: $password"; } elsif( $cmd eq 'events' ) { my $url = "https://$hash->{HOST}". ($hash->{helper}{isUnifiOS} ? "/proxy/protect/api/events" : ":7443/api/events"); $url .= '?type=motion'; $url .= '&limit=2'; #$url .= '&start='; #$url .= '&end='; my $param = { url => $url, method => 'GET', timeout => 5, hash => $hash, key => 'events', header => { 'Authorization' => "Bearer $hash->{helper}{auth}", 'X-CSRF-Token' => $hash->{helper}{csrfToken}, 'Cookie' => $hash->{helper}{cookie} }, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); return undef; } return "Unknown argument $cmd, choose one of $list"; } sub UnifiProtect_Parse($$;$) { my ($hash,$data,$peerhost) = @_; my $name = $hash->{NAME}; } sub UnifiProtect_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 ); my $decoded; $decoded = eval { JSON->new->utf8(0)->decode($data) } if( $data =~ m/\{.*\}/s ); Log3 $name, 5, Dumper $param; Log3 $name, 5, "$name: received $data"; if( $param->{key} eq 'auth' ) { if( $decoded && $decoded->{errors} ) { Log3 $name, 2, "$name: failed to get authorization: ". join( ',', @{$decoded->{errors}} ); } elsif( $param->{httpheader} =~ m/X-CSRF-Token:\s?(.*)\r\n/i ) { $hash->{helper}{csrfToken} = $1; if( $param->{httpheader} =~ m/Set-Cookie:\s?(.*)\r\n/i ) { $hash->{helper}{cookie} = $1; } my $url = "https://$hash->{HOST}/proxy/protect/api/cameras"; my $param = { url => $url, method => 'GET', timeout => 5, hash => $hash, key => 'cameras', header => { 'X-CSRF-Token' => $hash->{helper}{csrfToken}, 'Cookie' => $hash->{helper}{cookie} }, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); } elsif( $param->{httpheader} =~ m/Authorization: (.*)\r/ ) { $hash->{helper}{auth} = $1; $hash->{STATE} = 'connected'; Log3 $name, 4, "$name: got authorization: $hash->{helper}{auth}"; my $url = "https://$hash->{HOST}:7443/api/cameras"; my $param = { url => $url, method => 'GET', timeout => 5, hash => $hash, key => 'cameras', header => { 'Authorization' => "Bearer $hash->{helper}{auth}" }, }; Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); } else { Log3 $name, 2, "$name: failed to get authorization"; } } elsif( $param->{key} eq 'cameras' ) { my $json = eval { decode_json($data) }; Log3 $name, 2, "$name: json error: $@ in $json" if( $@ ); Log3 $name, 2, "$name: error: $json->{error}" if( ref($json) eq 'HASH' && defined($json->{error} ) ); $hash->{helper}{json} = $json; readingsBeginUpdate($hash); my $i = 0; foreach my $entry (@{$json}) { 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); } ++$i; } readingsBulkUpdateIfChanged($hash, 'totalCount', $i, 1); readingsEndUpdate($hash,1); RemoveInternalTimer($hash, "UnifiProtect_Connect"); InternalTimer(gettimeofday() + 900, "UnifiProtect_Connect", $hash); } elsif( $param->{key} eq 'snap' ) { if( !defined($defs{$param->{cname}}) ) { Log 1, "gone"; return; } my $c = $defs{$param->{cname}}->{CD}; print $c "HTTP/1.1 200 OK\r\n", "Content-Type: image/jpeg\r\n", "Content-Length: ". length($data) ."\r\n", "Connection: close\r\n", "\r\n", $data; } 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" ); } elsif( $param->{key} eq 'events' ) { my $json = eval { decode_json($data) }; Log3 $name, 2, "$name: json error: $@ in $json" if( $@ ); Log3 $name, 2, "$name: error: $json->{error}" if( ref($json) eq 'HASH' && defined($json->{error} ) ); Log 1, Dumper $json; #/api/thumbnails/[hex thumbnail id]?accessKey=[key returned from 'access-key' request above] } else { Log3 $name, 2, "parseHttpAnswer: unhandled key $param->{key}"; } return undef; } sub UnifiProtect_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}) ) { UnifiProtect_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/ ) { UnifiProtect_killLogWatcher($hash); } elsif( $line =~ m/motion.start ([^[])* / ) { $cam = $1; $type = 'start'; } elsif( $line =~ m/motion.stop ([^[])* / ) { $cam = $1; $type = 'stop'; } else { Log3 $name, 4, "$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}) { last if( $entry->{name} eq $cam ); ++$i; } if( $i != 1 ) { 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 #UnifiProtect_Parse($hash, $buf, $hash->{CD}->peerhost); } sub UnifiProtect_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}); } sub UnifiProtect_startLogWatcher($) { my ($hash) = @_; my $name = $hash->{NAME}; UnifiProtect_killLogWatcher($hash); my $user = AttrVal($name, "sshUser", undef); return if( !$user ); my $logfile = AttrVal($name, "logfile", "/srv/unifi-protect/logs/events.cameras.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 UnifiProtect_isUnifiOS($) { my ($hash) = @_; my $name = $hash->{NAME}; my $url = "https://$hash->{HOST}/"; my $param = { url => $url, method => 'GET', timeout => 2, #sslargs => { SSL_verify_mode => 0 }, hash => $hash, key => 'check', }; my ($err,$ret) = HttpUtils_BlockingGet( $param ); if( defined($err) && $err ) { Log3 $name, 3, "UnifiProtect_isUnifiOS: error detecting OS: ".$err; return; } delete $hash->{helper}{auth}; delete $hash->{helper}{cookie}; if( $param->{httpheader} =~ m/X-CSRF-Token:\s?(.*)\r\n/i ) { $hash->{helper}{isUnifiOS} = 1; $hash->{helper}{csrfToken} = $1; } else { $hash->{helper}{isUnifiOS} = 0; delete $hash->{helper}{csrfToken}; } Log3 $name, 3, "$name: is UnifiOS: $hash->{helper}{isUnifiOS}"; } sub UnifiProtect_Connect($) { my ($hash) = @_; my $name = $hash->{NAME}; delete $hash->{helper}{auth}; return if( IsDisabled($name) ); UnifiProtect_isUnifiOS( $hash ); my $user = AttrVal($name, 'user', undef); my $password = AttrVal($name, 'password', undef); if( !$user ) { $hash->{STATE} = 'disconnected'; Log3 $name, 2, "$name: can't connect without user"; return undef; } if( !$password ) { $hash->{STATE} = 'disconnected'; Log3 $name, 2, "$name: can't connect without password"; return undef; } $user = UnifiProtect_decrypt( $user ); $password = UnifiProtect_decrypt( $password ); my $url = "https://$hash->{HOST}". ($hash->{helper}{isUnifiOS} ? "/api/auth/login" : ":7443/api/auth"); my $param = { url => $url, method => 'POST', timeout => 5, hash => $hash, key => 'auth', header => { 'Content-Type' => 'application/json' }, data => "{ \"username\": \"$user\", \"password\": \"$password\" }", }; if( $hash->{helper}{isUnifiOS} ) { $param->{header}{'X-CSRF-Token'} = $hash->{helper}{csrfToken}; } Log3 $name, 4, "$name: fetching data from $url"; $param->{callback} = \&UnifiProtect_parseHttpAnswer; HttpUtils_NonblockingGet( $param ); UnifiProtect_startLogWatcher( $hash ) if( !$hash->{PID} ); return undef; } sub UnifiProtect_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 UnifiProtect_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 UnifiProtect_Attr($$$) { my ($cmd, $name, $attrName, $attrVal) = @_; my $orig = $attrVal; my $hash = $defs{$name}; if( $attrName eq 'disable' ) { if( $cmd eq "set" && $attrVal ) { UnifiProtect_killLogWatcher($hash); readingsSingleUpdate($hash, 'state', 'disabled', 1 ); } else { readingsSingleUpdate($hash, 'state', 'running', 1 ); $attr{$name}{$attrName} = 0; UnifiProtect_Connect($hash); } } elsif( $attrName eq 'sshUser' ) { if( $cmd eq "set" && $attrVal ) { $attr{$name}{$attrName} = $attrVal; } else { delete $attr{$name}{$attrName}; UnifiProtect_killLogWatcher($hash); } UnifiProtect_Connect($hash); } elsif( $attrName eq 'user' || $attrName eq 'password' ) { if( $cmd eq "set" && $attrVal ) { return if( $attrVal =~ m/^crypt:/ ); $attrVal = UnifiProtect_encrypt($attrVal); if( $orig ne $attrVal ) { $attr{$name}{$attrName} = $attrVal; UnifiProtect_Connect($hash); return "stored obfuscated $attrName"; } } } if( $cmd eq 'set' ) { } else { delete $attr{$name}{$attrName}; } return; } 1; =pod =item summary Module to integrate FHEM with UnifiProtect =item summary_DE Modul zur Integration von FHEM mit UnifiProtect =begin html

UnifiProtect

    Module to integrate UnifiProtect devices with FHEM.

    define <name> UnifiProtect <ip> <user> <password>

    Notes:
    • JSON has to be installed on the FHEM host.
    • create protect read only user: users->invite users->local access only
    • define <name> webLink htmlCode {UnifiProtect_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
    • user
      shows the configured user.
    • password
      shows the configured password.
    Attr
    • filePath
      path to store the snapshot images to. default: .../www/snapshots
    • user
      user to use for nvr access
    • password
      password to use for nvr access

=end html =cut