diff --git a/fhem/contrib/AutomowerConnect/98_AMConnectTools.pm b/fhem/contrib/AutomowerConnect/98_AMConnectTools.pm new file mode 100644 index 000000000..645932051 --- /dev/null +++ b/fhem/contrib/AutomowerConnect/98_AMConnectTools.pm @@ -0,0 +1,788 @@ +############################################################################### +# +# $Id$ +# +# This script 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 +# any later version. +# +# The GNU General Public License can be found at +# http://www.gnu.org/copyleft/gpl.html. +# A copy is found in the textfile GPL.txt and important notices to the license +# from the author is found in LICENSE.txt distributed with these scripts. +# +# This script 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. +# +# +# +# Husqvarnas unofficial app API is used to in crease websocket events +# +################################################################################ + +package FHEM::AMConnectTools; +our $cvsid = '$Id$'; +use strict; +use warnings; +use POSIX; +use GPUtils qw(:all); +use FHEM::Core::Authentication::Passwords qw(:ALL); +use Time::HiRes qw(gettimeofday); +use Time::Local; +use Storable qw(dclone retrieve store); + +BEGIN { + GP_Import( + qw( + AttrVal + CommandAttr + CommandDeleteReading + FmtDateTime + FW_ME + getKeyValue + InternalTimer + InternalVal + IsDisabled + Log3 + Log + attr + defs + devspec2array + deviceEvents + init_done + minNum + maxNum + modules + readingFnAttributes + readingsBeginUpdate + readingsBulkUpdate + readingsBulkUpdateIfChanged + readingsDelete + readingsEndUpdate + ReadingsNum + readingsSingleUpdate + ReadingsVal + RemoveInternalTimer + setKeyValue + setNotifyDev + ) + ); +} + +GP_Export( + qw( + Initialize + ) +); + +my $missingModul = ""; + +eval "use JSON;1" or $missingModul .= "JSON "; +require HttpUtils; + +use constant { + AUTHURL => 'https://iam-api.dss.husqvarnagroup.net/api/v3/', + APIURL => 'https://amc-api.dss.husqvarnagroup.net/app/v1/', +}; + +############################################################## +sub Initialize() { + my ($hash) = @_; + + $hash->{DefFn} = \&Define; + $hash->{UndefFn} = \&Undefine; + $hash->{DeleteFn} = \&Delete; + $hash->{RenameFn} = \&Rename; + $hash->{NotifyFn} = \&Notify; + $hash->{SetFn} = \&Set; + $hash->{AttrFn} = \&Attr; + $hash->{AttrList} = "disable:1,0 " . + "disabledForIntervals " . + "notifiedByMowerDevices " . + $::readingFnAttributes; + + return undef; +} + +######################### +sub Define{ + my ( $hash, $def ) = @_; + my @val = split( "[ \t]+", $def ); + my $name = $val[0]; + my $type = $val[1]; + my $iam = "$type $name Define:"; + my $username = ''; + + return "$iam Cannot define $type device. Perl modul $missingModul is missing." if ( $missingModul ); + + return "$iam too few parameters: define $type " if( @val < 3 ); + + $username = $val[2]; + + %$hash = (%$hash, + helper => { + passObj => FHEM::Core::Authentication::Passwords->new($type), + calltype => 'status', + interval => 440, + timeout_apiauth => 5, + timeout_getmower => 5, + retry_interval_apiauth => 120, + retry_count_apiauth => 0, + retry_max_apiauth => 10, + retry_interval_getmower => 60, + retry_int_getmowerstatus => 60, + username => $username + } + ); + + $attr{$name}{room} = 'AutomowerConnect' if( !defined( $attr{$name}{room} ) ); + $attr{$name}{icon} = 'helper_automower' if( !defined( $attr{$name}{icon} ) ); + $attr{$name}{notifiedByMowerDevices} = 'TYPE=AutomowerConnect' if( !defined( $attr{$name}{notifiedByMowerDevices} ) ); + ( $hash->{VERSION} ) = $cvsid =~ /\.pm (.*)Z/; + setNotifyDev( $hash, 'global,' . $attr{$name}{notifiedByMowerDevices} ); + + if( $hash->{helper}->{passObj}->getReadPassword($name) ) { + + RemoveInternalTimer($hash); + InternalTimer( gettimeofday() + 2, \&APIAuth, $hash, 1); + + readingsSingleUpdate( $hash, 'state', 'defined', 1 ); + + } else { + + readingsSingleUpdate( $hash, 'state', 'defined - client_secret missing', 1 ); + + } + if ( $init_done ) { + + my $paw = join( ' ', devspec2array( "TYPE=AutomowerConnect" ) ); + readingsSingleUpdate( $hash, '.associatedWith', $paw, 0 ); + + + } + + return undef; + +} + +######################### +sub Notify { + my ($hash, $dev_hash) = @_; + my $name = $hash->{NAME}; # me + return if ( IsDisabled( $name ) == 1 ); + my $dev_name = $dev_hash->{NAME}; + + my $events = deviceEvents($dev_hash,1); + return if( !$events ); + + my $runningDevices = ReadingsVal( $name, 'runningDevices', '' ); + + foreach my $event (@{$events}) { + + $event = '' if(!defined($event)); + + if ( $event =~ /^mower_activity: (LEAVING|MOWING|GOING_HOME)$/ ) { + + readingsBeginUpdate($hash); + + if ( !$runningDevices ) { + + RemoveInternalTimer( $hash ); + InternalTimer( gettimeofday() + 2, \&APIAuth, $hash, 0 ); + readingsBulkUpdateIfChanged( $hash, 'state', 'update' ); + + } + + $runningDevices .= $dev_name . ' ' if ( $runningDevices !~ /(^|\s)$dev_name\s/ ); + readingsBulkUpdateIfChanged( $hash, 'runningDevices', $runningDevices ); + + readingsEndUpdate( $hash, 1); + + } elsif ( $event =~ /^mower_activity: PARKED_IN_CS$/ ) { + + $runningDevices =~ s/(^|\s)$dev_name\s/$1/; + + if ( !$runningDevices ) { + + readingsDelete( $hash, 'runningDevices' ); + readingsSingleUpdate( $hash, 'state', 'inactive', 1 ); + + } + + } elsif ( $dev_name eq 'global' && $event =~ /^(INITIALIZED|MODIFIED $name|ATTR $name disable 0|DELETEATTR $name disable)$/ ) { + + my $runningDevices = ''; + my @mowers = devspec2array( AttrVal( $name, 'notifiedByMowerDevices', '' ) ); + + for my $item ( @mowers ) { + + if ( ReadingsVal( $item, 'mower_activity', '' ) =~ /^(LEAVING|MOWING|GOING_HOME)$/ ) { + + $runningDevices .= $item . ' '; + + } + + } + + if ( !$runningDevices ) { + + readingsDelete( $hash, 'runningDevices' ); + readingsSingleUpdate( $hash, 'state', 'inactive', 1 ); + + } else { + + readingsSingleUpdate( $hash, 'runningDevices', $runningDevices, 1 ); + + } + + } + + } + +} + +######################### +sub APIAuth { + my ( $hash, $update ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam = "$type $name APIAuth:"; + my @states = ('undefined', 'disabled', 'temporarily disabled', 'inactive' ); + + if( IsDisabled( $name ) ) { + readingsSingleUpdate( $hash, 'state', $states[ IsDisabled( $name ) ], 1 ) if ( ReadingsVal( $name, 'state', '' ) !~ /disabled|inactive/ ); + RemoveInternalTimer( $hash ); + InternalTimer( gettimeofday() + $hash->{helper}{interval}, \&APIAuth, $hash, 0 ); + return undef; + + } + + if ( !$update && $::init_done ) { + + if ( ReadingsVal( $name,'.token','' ) and gettimeofday() < (ReadingsVal( $name, '.token_expires', 0 ) - $hash->{helper}{interval} - 45 ) ) { + + readingsSingleUpdate( $hash, 'state', 'update', 1 ); + + if ( !ReadingsVal( $name,'.apiMowerFound','' ) ) { + + getMower( $hash ); + + } else { + + getMowerStatus( $hash ); + + } + + } else { + + readingsSingleUpdate( $hash, 'state', 'authentification', 1 ); + my $username = $hash->{helper}->{username}; + my $password = $hash->{helper}->{passObj}->getReadPassword( $name ); + my $timeout = $hash->{helper}->{timeout_apiauth}; + + my $header = "Content-Type: application/json\r\nAccept: application/json"; + my $data = '{ + "data" : { + "type" : "token", + "attributes" : { + "username" : "' . $username. '", + "password" : "' . $password. '" + } + } + }'; + + ::HttpUtils_NonblockingGet( { + url => AUTHURL . 'token', + timeout => $timeout, + hash => $hash, + method => 'POST', + header => $header, + data => $data, + callback => \&APIAuthResponse, + t_begin => scalar gettimeofday() + } ); + + } + + } else { + + RemoveInternalTimer( $hash, \&APIAuth ); + InternalTimer( gettimeofday() + $hash->{helper}{interval}, \&APIAuth, $hash, 0 ); + + } + return undef; +} + +######################### +sub APIAuthResponse { + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $statuscode = $param->{code} // ''; + my $iam = "$type $name APIAuthResponse:"; + + Log3 $name, 4, "$iam response time ". sprintf( "%.2f", ( gettimeofday() - $param->{t_begin} ) ) . ' s'; + Log3 $name, 4, "$iam \$statuscode >$statuscode< \$err >$err< \$param->url $param->{url}\n\$data >$data<\n"; + + if( !$err && $statuscode == 201 && $data ) { + + my $result = eval { decode_json( $data ) }; + if ($@) { + + Log3 $name, 2, "$iam JSON error [ $@ ]"; + readingsSingleUpdate( $hash, 'state', 'error JSON', 1 ); + + } else { + + $hash->{helper}->{auth} = $result->{data}; + $hash->{helper}{auth}{expires} = gettimeofday() + $hash->{helper}{auth}{attributes}{expires_in}; + my $paw = join( ' ', devspec2array( "TYPE=AutomowerConnect" ) ); + $hash->{helper}{retry_count_apiauth} = 0; + + # Update readings + readingsBeginUpdate($hash); + readingsBulkUpdateIfChanged($hash,'.associatedWith', $paw, 0 ) if ( $paw ); + readingsBulkUpdateIfChanged($hash,'.token', $hash->{helper}{auth}{id}, 0 ); + readingsBulkUpdateIfChanged($hash,'.provider', $hash->{helper}{auth}{attributes}{provider}, 0 ); + readingsBulkUpdateIfChanged($hash,'.user_id', $hash->{helper}{auth}{attributes}{user_id}, 0 ); + readingsBulkUpdateIfChanged($hash,'.scope', $hash->{helper}{auth}{attributes}{scope}, 0 ); + readingsBulkUpdateIfChanged($hash,'.client_id', $hash->{helper}{auth}{attributes}{client_id}, 0 ); + readingsBulkUpdateIfChanged($hash,'.refresh_token', $hash->{helper}{auth}{attributes}{expires_in}, 0 ); + readingsBulkUpdateIfChanged($hash,'.expires_in', $hash->{helper}{auth}{attributes}{expires_in}, 0 ); + + readingsBulkUpdateIfChanged($hash,'.token_expires',$ hash->{helper}{auth}{expires}, 0 ); + readingsBulkUpdateIfChanged($hash,'token_expires_fmt', FmtDateTime( $hash->{helper}{auth}{expires}), 0 ); + readingsBulkUpdateIfChanged($hash,'state', 'authenticated'); + readingsEndUpdate($hash, 1); + + RemoveInternalTimer( $hash, \&getMower ); + InternalTimer( gettimeofday() + 1.5, \&getMower, $hash, 0 ); + return undef; + } + + } else { + + readingsSingleUpdate( $hash, 'state', "error statuscode $statuscode", 1 ); + Log3 $name, 1, "$iam \$statuscode >$statuscode< \$err >$err< \$param->url $param->{url}\n\$data >$data<\n"; + + if ( $statuscode == 400 ) { + + $hash->{helper}{retry_count_apiauth}++; + + if ( $hash->{helper}{retry_count_apiauth} > $hash->{helper}{retry_max_apiauth} ) { + + CommandAttr( $hash, "$name disable 1" ); + $hash->{helper}{retry_count_apiauth} = 0; + + } + + } elsif ( $statuscode == 429 ) { + + CommandAttr( $hash, "$name disable 1" ); + $hash->{helper}{retry_count_apiauth} = 0; + + } + + RemoveInternalTimer( $hash, \&APIAuth ); + InternalTimer( gettimeofday() + $hash->{helper}{retry_interval_apiauth}, \&APIAuth, $hash, 0 ); + Log3 $name, 1, "$iam failed retry in $hash->{helper}{retry_interval_apiauth} seconds."; + return undef; + + } + +} + +######################### +sub getMower { + + my ( $hash ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam = "$type $name getMower:"; + my $token = ReadingsVal($name,'.token',''); + my $provider = ReadingsVal($name,'.provider',''); + my $client_id = $hash->{helper}->{client_id}; + my $timeout = $hash->{helper}->{timeout_getmower}; + + my $header = "Content-Type: application/json\r\nAccept: application/json\r\nAuthorization: Bearer " . $token . "\r\nAuthorization-Provider: " . $provider; + Log3 $name, 5, "$iam \$header >$header<"; + + ::HttpUtils_NonblockingGet({ + url => APIURL . 'mowers', + timeout => $timeout, + hash => $hash, + method => "GET", + header => $header, + callback => \&getMowerResponse, + t_begin => scalar gettimeofday() + }); + + return undef; +} + +######################### +sub getMowerResponse { + + my ( $param, $err, $data ) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $statuscode = $param->{code} // ''; + my $iam = "$type $name getMowerResponse:"; + + Log3 $name, 4, "$iam response time ". sprintf( "%.2f", ( gettimeofday() - $param->{t_begin} ) ) . ' s'; + Log3 $name, 4, "$iam response \$statuscode >$statuscode<, \$err >$err<, \$param->url $param->{url} \n\$data >$data<"; + + if( !$err && $statuscode == 200 && $data) { + + if ( $data eq "[]" ) { + + Log3 $name, 2, "$iam no mower data present"; + + } else { + + my $result = eval { decode_json($data) }; + if ($@) { + + Log3( $name, 2, "$iam - JSON error while request: $@"); + + } else { + + my @mowers = @{ dclone( $result ) }; + my $mcnt = @mowers; + my $fMs = ''; + + map { + + $fMs .= $_->{id} .' '; + + } @mowers; + + chop $fMs; + Log3 $name, 4, "$iam found $fMs"; + + readingsBeginUpdate($hash); + + readingsBulkUpdateIfChanged($hash, '.apiMowerFound', $fMs, 0 ); + readingsBulkUpdate($hash, 'state', 'connected' ); + + readingsEndUpdate($hash, 1); + + RemoveInternalTimer( $hash, \&getMowerStatus ); + InternalTimer( gettimeofday() + 1.5, \&getMowerStatus, $hash, 0 ); + + return undef; + + } + + } + + } else { + + readingsSingleUpdate( $hash, 'state', "error statuscode $statuscode", 1 ); + Log3 $name, 1, "$iam \$statuscode >$statuscode<, \$err >$err<, \$param->url $param->{url} \n\$data >$data<"; + + } + + RemoveInternalTimer( $hash, \&APIAuth ); + InternalTimer( gettimeofday() + $hash->{helper}{retry_interval_getmower}, \&APIAuth, $hash, 0 ); + Log3 $name, 1, "$iam failed retry in $hash->{helper}{retry_interval_getmower} seconds."; + return undef; + +} + +######################### +sub getMowerStatus { + + my ( $hash ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam = "$type $name getMowerStatus:"; + my $token = ReadingsVal($name,'.token',''); + my $provider = ReadingsVal($name,'.provider',''); + my @mower_id = split( / /, ReadingsVal( $name,'.apiMowerFound','' ) ); + my $timeout = $hash->{helper}{timeout_getmower}; + + my $header = "Content-Type: application/json\r\nAccept: application/json\r\nAuthorization: Bearer " . $token . "\r\nAuthorization-Provider: " . $provider; + Log3 $name, 5, "$iam \$header >$header<"; + + ::HttpUtils_NonblockingGet({ + url => APIURL . 'mowers/' . $mower_id[0] . '/status', + timeout => $timeout, + hash => $hash, + method => "GET", + header => $header, + callback => \&getMowerStatusResponse, + t_begin => scalar gettimeofday() + }); + + return undef; +} + +######################### +sub getMowerStatusResponse { + + my ( $param, $err, $data ) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $statuscode = $param->{code} // ''; + my $iam = "$type $name getMowerStatusResponse:"; + + Log3 $name, 4, "$iam response time ". sprintf( "%.2f", ( gettimeofday() - $param->{t_begin} ) ) . ' s'; + Log3 $name, 4, "$iam response \$statuscode >$statuscode<, \$err >$err<, \$param->url $param->{url} \n\$data >$data<"; + + if( !$err && $statuscode == 200 && $data) { + + if ( !$data ) { + + Log3 $name, 2, "$iam no mower data present"; + + } else { + + my $result = eval { decode_json($data) }; + if ($@) { + + Log3( $name, 2, "$iam - JSON error while request: $@"); + + } else { + + readingsBeginUpdate($hash); + + readingsBulkUpdate($hash,'nextRound', FmtDateTime( time() + $hash->{helper}{interval} ) ); + readingsBulkUpdate($hash, 'state', 'connected' ); + + readingsEndUpdate($hash, 1); + + # schedule next round + RemoveInternalTimer( $hash, \&APIAuth ); + InternalTimer( gettimeofday() + $hash->{helper}{interval}, \&APIAuth, $hash, 0 ); + + return undef; + + } + + } + + } else { + + readingsSingleUpdate( $hash, 'state', "error statuscode $statuscode", 1 ); + Log3 $name, 1, "$iam \$statuscode >$statuscode<, \$err >$err<, \$param->url $param->{url} \n\$data >$data<"; + + } + + RemoveInternalTimer( $hash, \&APIAuth ); + InternalTimer( gettimeofday() + $hash->{helper}{retry_int_getmowerstatus}, \&APIAuth, $hash, 0 ); + Log3 $name, 1, "$iam failed retry in $hash->{helper}{retry_int_getmowerstatus} seconds."; + return undef; + +} + +######################### +sub Set { + my ($hash,@val) = @_; + my $type = $hash->{TYPE}; + my $name = $hash->{NAME}; + my $iam = "$type $name Set:"; + + return "$iam: needs at least one argument" if ( @val < 2 ); + return "Unknown argument, $iam is disabled, choose one of none:noArg" if ( IsDisabled( $name ) ); + + my ($pname,$setName,$setVal,$setVal2,$setVal3) = @val; + + Log3 $name, 4, "$iam called with $setName " . ($setVal ? $setVal : "") if ($setName !~ /^(\?|password)$/); + + if ( $setName eq 'password' ) { + if ( $setVal ) { + + my ($passResp, $passErr) = $hash->{helper}->{passObj}->setStorePassword($name, $setVal); + Log3 $name, 1, "$iam error: $passErr" if ($passErr); + return "$iam $passErr" if( $passErr ); + + readingsBeginUpdate($hash); + readingsBulkUpdateIfChanged( $hash, '.access_token', '', 0 ); + readingsBulkUpdateIfChanged( $hash, 'state', 'initialized'); + readingsEndUpdate($hash, 1); + + RemoveInternalTimer($hash, \&APIAuth); + APIAuth($hash); + return undef; + } + + } + + my $ret = " password "; + return "Unknown argument $setName, choose one of".$ret; + +} + +######################### +sub Undefine { + + my ( $hash, $arg ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + + RemoveInternalTimer( $hash ); + readingsSingleUpdate( $hash, 'state', 'disconnected', 1 ); + return undef; +} + +########################## +sub Delete { + + my ( $hash, $arg ) = @_; + my $name = $hash->{NAME}; + my $type = $hash->{TYPE}; + my $iam ="$type $name Delete: "; + Log3( $name, 5, "$iam called" ); + + my ($passResp,$passErr) = $hash->{helper}->{passObj}->setDeletePassword($name); + Log3( $name, 1, "$iam error: $passErr" ) if ($passErr); + + return; +} + +########################## +sub Rename { + + my ( $newname, $oldname ) = @_; + my $hash = $defs{$newname}; + my $type = $hash->{TYPE}; + + my ( $passResp, $passErr ) = $hash->{helper}->{passObj}->setRename( $newname, $oldname ); + Log3 $newname, 2, "$newname password rename error: $passErr" if ($passErr); + + return undef; +} + +######################### +sub Attr { + + my ( $cmd, $name, $attrName, $attrVal ) = @_; + my $hash = $defs{$name}; + my $type = $hash->{TYPE}; + my $iam = "$type $name Attr:"; + ########## + if ( $attrName eq "disable" ) { + + if( $cmd eq "set" and $attrVal eq "1" ) { + + RemoveInternalTimer( $hash ); + readingsSingleUpdate( $hash,'state','disabled',1); + readingsDelete( $hash, 'runningDevices' ); + Log3 $name, 3, "$iam $cmd $attrName disabled"; + + } elsif( $cmd eq "del" or $cmd eq 'set' and !$attrVal ) { + + RemoveInternalTimer( $hash, \&APIAuth); + InternalTimer( gettimeofday() + 1, \&APIAuth, $hash, 0 ); + Log3 $name, 3, "$iam $cmd $attrName enabled"; + + } + + } + ########## + if ( $attrName eq "notifiedByMowerDevices" ) { + + if( $cmd eq "set" ) { + + setNotifyDev( $hash, 'global,' . $attrVal ); + my $paw = join( ' ', devspec2array( "TYPE=AutomowerConnect" ) ); + readingsSingleUpdate( $hash, '.associatedWith', $paw, 0 ); + Log3 $name, 3, "$iam $cmd $attrName $attrVal"; + + } elsif( $cmd eq "del" ) { + + setNotifyDev( $hash, 'global' ); + readingsDelete( $hash, 'runningDevices' ); + Log3 $name, 3, "$iam $cmd $attrName deleted"; + + } + + } + ########## +} +############################################################## + + +1; + +__END__ + +=pod + +=item helper +=item summary Increases the number of websocket events for the module AutomowerConnect +=item summary_DE Erhöht die Zahl der Websocketevents für das Modul AutomowerConnect + +=begin html + + +

AMConnectTools

+ + +=end html