#=============================================================================== # # FILE: 98_TadoAPI.pm # # USAGE: Module for FHEM # Info: Turn verbose on for debugging # # REQUIREMENTS: Below modules should be pre-installed. # HTTP::Request::Common # HTTP::Headers # Data::Dumper; # JSON # # BUGS: --- # NOTES: --- # AUTHOR: Philipp Wolfmajer # ORGANIZATION: # VERSION: 1.0 # CREATED: 04/12/2019 07:55:44 PM # REVISION: 11/21/2019 05:17:22 PM #=============================================================================== package main; use strict; use warnings; use utf8; use HTTP::Request::Common qw (POST GET PUT); use HTTP::Headers; use JSON; use POSIX qw(strftime); ####DEFAULTS############ my $client_id='public-api-preview'; my $client_secret='4HJGRffVR8xb3XdEUQpjgZ1VplJi6Xgw'; my $scope='home.user'; my $AuthURL = qq{https://auth.tado.com/oauth/token}; my $DataURL = qq{https://my.tado.com/api/v2/me}; my $QueryURL = qq{https://my.tado.com/api/v2/homes}; my $tokenFile = "./FHEM/FhemUtils/TadoAPI_token"; my $header = {}; my $data = {}; my $TokenData = {}; # helpers my $apiStatus = 1; my %sets = ( "zoneUpdate" => "", "refreshToken" => "noArg", "password" => "", "update" => "noArg", "setGeo" => "", "setZoneOverlay" => "", "setAllOverlays" => "" ); my %gets = ( "getZoneDevices" => "noArg", "getZoneInfo" => "noArg", "getGeo" => "", #"getXTest" => "", "getMobileDevices" => "noArg" ); sub TadoAPI_Initialize($) { my ($hash) = @_; $hash->{DefFn} = "TadoAPI_Define"; $hash->{InitFn} = "TadoAPI_Init"; $hash->{SetFn} = "TadoAPI_Set"; $hash->{GetFn} = "TadoAPI_Get"; $hash->{AttrList} = "homeID " . "mobileID " . "updateIntervall " . $main::readingFnAttributes; } sub TadoAPI_Init($$) { my ($hash,$args) = @_; my $u = "wrong syntax: define TadoAPI []"; return $u if(int(@$args) < 2); return undef; } sub TadoAPI_Define($$) { my ($hash, $def) = @_; my @a = split( "[ \t]+", $def ); my $name = shift @a; my $type = shift @a; my $tokenFileName = $tokenFile."_".$name; return "Invalid number of arguments: " . "define TadoAPI []" if ( int(@a) < 1 ); my ( $user, $homeID ) = @a; Log3 $name, 3, "TadoAPI_Define $name: called "; $hash->{STATE}="defined"; # Initialize the device return $@ unless ( FHEM::Meta::SetInternals($hash) ); $hash->{TADO_USER} = $user; $hash->{TOKEN_FILE} = $tokenFileName; my @args = ($homeID); if ($main::init_done) { # do something? return TadoAPI_Catch($@) if $@; } my $password = TadoAPI_readPassword($name); if (defined($password)){ TadoAPI_LoadToken($hash); # start the status update timer RemoveInternalTimer($hash); InternalTimer( gettimeofday() + 15, "TadoAPI_Update", $hash, 0 ); if ( defined($homeID) && $homeID ne "" ) { $attr{$name}{homeID} = $homeID; } else{ my $id = TadoAPI_GetHomeId($hash); if ( defined($id) && $id ne "" ) { $attr{$name}{homeID} = $id; } } }else{ $hash->{STATE}="no password set"; } return undef; } sub TadoAPI_Set(@) { my ($hash, @a) = @_; return "Need at least one parameters" if(@a < 2); my $cmd = $a[1]; my $value = $a[2]; my $name = $hash->{NAME}; my $subcmd; my $message = undef; if(!defined($sets{$cmd})) { my @cmds = (); foreach my $key (sort keys %sets) { push @cmds, $sets{$key} ? $key.":".join(",",$sets{$key}) : $key; } return "Unknown argument $a[1], choose one of " . join(" ", @cmds); } if (($cmd ne "password")) { my $pwd = TadoAPI_readPassword($name); unless (defined $pwd) { $message = "Error: no tado password set. Please define it with 'set $name password Your_tado_Password'"; Log3 $name,2,"$name, $message"; $hash->{STATE}="no password set"; return $message; } } if( $cmd eq 'setGeo' ) { return "Need at least two parameters (mobileID, Setting)" if(@a < 4); if( $a[3] eq "on" ) { Log3 $name, 3, "TadoAPI: set $name: processing ($cmd)"; TadoAPI_SetGeoById($hash, $value, 1); } else { Log3 $name, 3, "TadoAPI: set $name: processing ($cmd)"; TadoAPI_SetGeoById($hash, $value, 0); } TadoAPI_GetGeoById($hash, $value); } elsif( $cmd eq 'setZoneOverlay' ) { Log3 $name, 5, "TadoAPI $name" . ": " . "processing ($cmd)"; return "Need at least two parameters (ZoneID, Setting) - Setting: off=delete overlay; 0=heating power off; 1<=desired temperature (overlay)" if(@a < 4); if( $a[3] eq "off" ) { TadoAPI_SetZoneOverlayById($hash, $value, "off"); } elsif (defined($a[4])) { TadoAPI_SetZoneOverlayById($hash, $value, $a[3], $a[4]); } elsif ($a[3] >= 0) { TadoAPI_SetZoneOverlayById($hash, $value, $a[3]); } Log3 $name, 4, "TadoAPI $name" . ": " . "$cmd finished"; } elsif( $cmd eq 'setAllOverlays' ) { Log3 $name, 5, "TadoAPI $name" . ": " . "processing ($cmd)"; return "Need at least one parameter (Setting) - Setting: off=delete overlay; 0=heating power off; 1<=desired temperature (overlay)" if(@a < 3); if( $value eq "off" ) { TadoAPI_SetAllOverlays($hash, "off"); } elsif ($value >= 0) { TadoAPI_SetAllOverlays($hash, $value); } Log3 $name, 4, "TadoAPI $name" . ": " . "$cmd finished\n"; } elsif( $cmd eq 'refreshToken' ) { Log3 $name, 3, "TadoAPI: set $name: processing ($cmd)"; RemoveInternalTimer($hash); InternalTimer( gettimeofday() + 10, "TadoAPI_Update", $hash, 0 ); TadoAPI_LoadToken($hash); Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; } elsif( $cmd eq 'update' ) { Log3 $name, 3, "TadoAPI: set $name: processing ($cmd)"; TadoAPI_UpdateFn($hash); Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; } elsif( $cmd eq 'zoneUpdate' ) { Log3 $name, 5, "TadoAPI $name" . ": " . "processing ($cmd)"; return "ZoneID as parameter needed" if (!$value); if( $value >= 1 ) { TadoAPI_GetZoneReadingsById($hash, $value); } else { return "Wrong ZoneID"; } Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; } elsif( $cmd eq 'password' ) { Log3 $name, 3, "TadoAPI $name" . ": " . "processing ($cmd)"; # name und cmd überspringen shift @a; shift @a; # den Rest der das passwort enthält, als ein String $subcmd = join(" ",@a); $message = TadoAPI_storePassword($name,$subcmd); # start the status update timer RemoveInternalTimer($hash); InternalTimer( gettimeofday() + 10, "TadoAPI_Update", $hash, 0 ); Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; } return $message if $message; return TadoAPI_Catch($@) if $@; } sub TadoAPI_Get(@) { my ($hash, @a) = @_; return "Need at least one parameters" if(@a < 2); my $cmd = $a[1]; my $value = $a[2]; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $message = undef; if(!defined($gets{$cmd})) { my @cmds = (); foreach my $key (sort keys %gets) { push @cmds, $gets{$key} ? $key.":".join(",",$gets{$key}) : $key; } return "Unknown argument $a[1], choose one of " . join(" ", @cmds); } my $pwd = TadoAPI_readPassword($name); unless (defined $pwd) { $message = "Error: no tado password set. Please define it with 'set $name password Your_tado_Password'"; Log3 $name,2,"$name, $message"; $hash->{STATE}="no password set"; return $message; } if($cmd =~ /\Qget\E/) { COMMAND_HANDLER: { $cmd eq "getGeo" and do { return "Need at least one parameter (mobileID)" if(@a < 3); return "Wrong MobileID" if (length($value) < 6); Log3 $name, 3, "TadoAPI $name" . ": " . "processing ($cmd)"; TadoAPI_GetGeoById($hash, $value); Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; last; }; $cmd eq "getMobileDevices" and do { Log3 $name, 3, "TadoAPI $name" . ": " . "processing ($cmd)"; my @data = TadoAPI_GetMobileDevices($hash); $message = "Device List:\n"; foreach my $item ( @data ){ $message .= $item->{'name'} . ": " . $item->{'id'} . "\n"; }; Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished"; last; }; # only for testing $cmd eq "getXTest" and do { Log3 $name, 5, "TadoAPI $name" . ": " . "processing ($cmd)"; my $zoneName = TadoAPI_GetZoneNameById($hash, $value); $zoneName = "wrong Zone ID" unless $zoneName; $message = "Name: " . $zoneName; Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; last; }; $cmd eq "getZoneDevices" and do { Log3 $name, 3, "TadoAPI $name" . ": " . "processing ($cmd)"; my @devArr = TadoAPI_GetTadoDevices($hash); my $devicecount = 0; $message = "Tado-Device(s):\n"; for (my $i=0; $i < @devArr; $i++){ my $tadodevices = $devArr[$i]->{'devices'}; $message .= "ZoneID: " . ($devArr[$i]->{'id'}); my $spacer = 0; foreach my $item ( @$tadodevices ){ $message .= "\t " if ($spacer > 0); $message .= " " . $item->{'serialNo'} . " Battery: " . $item->{'batteryState'} . "\n"; $devicecount++; $spacer++; } } $message .= "There are $devicecount Tado-Device(s) in this HomeID(" . $attr{$name}{homeID} . ")." ; Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; last; }; $cmd eq "getZoneInfo" and do { Log3 $name, 3, "TadoAPI $name" . ": " . "processing ($cmd)"; TadoAPI_GetZoneInfo($hash); my $zonecount = TadoAPI_GetZoneCount($hash); $message = "You have $zonecount Zones in Home " . $attr{$name}{homeID} . ".\n"; my @devArr = TadoAPI_GetTadoDevices($hash); for (my $i=0; $i < @devArr; $i++){ my $zoneid = $devArr[$i]->{'id'}; $message .= "Zone ID $zoneid: " . TadoAPI_GetZoneNameById($hash, $zoneid) . "\n"; } $message .= "See Logfile for more Info"; Log3 $name, 3, "TadoAPI $name" . ": " . "$cmd finished\n"; last; }; } return $message if $message; return TadoAPI_Catch($@) if $@; return undef; } } sub TadoAPI_Catch($) { my $exception = shift; if ($exception) { $exception =~ /^(.*)( at.*FHEM.*)$/; return $1; } return undef; } sub TadoAPI_Undefine($$) { my ( $hash, $name ) = @_; RemoveInternalTimer($hash); #todo remove tokenfile return undef; } sub TadoAPI_OnlineStatus(@){ my ($hash) = @_; my $name = $hash->{NAME}; # test api status my $param = { url => $AuthURL, timeout => 5, hash => $hash, method => "GET", header => "", callback => \&TadoAPI_callback }; #test if api is reachable HttpUtils_NonblockingGet($param); return undef; } sub TadoAPI_LoadToken(@){ my ($hash) = @_; my $name = $hash->{NAME}; my $tokenFileName = $tokenFile."_".$name; my $tokenLifeTime = $hash->{TOKEN_LIFETIME}; $tokenLifeTime = 0 if(!defined $tokenLifeTime || $tokenLifeTime eq ''); my $Token = undef; TadoAPI_OnlineStatus($hash); if($apiStatus){ eval { open(TOKENFILE, '<', $tokenFileName) or die("ERROR: $!"); $Token = decode_json()}; if($@ || $tokenLifeTime < gettimeofday()){ Log3 $name, 5, "TadoAPI $name" . ": " . "Error while loading: $@ ,requesting new one" if $@; Log3 $name, 5, "TadoAPI $name" . ": " . "Token is expired, requesting new one" if $tokenLifeTime < gettimeofday(); $Token = TadoAPI_NewTokenRequest($hash); }else{ Log3 $name, 5, "TadoAPI $name" . ": " . "Token expires at " . localtime($tokenLifeTime); # if token is about to expire, refresh him if(($tokenLifeTime-45) < gettimeofday()){ Log3 $name, 5, "TadoAPI $name" . ": " . "Token will expire soon, refreshing"; $Token = TadoAPI_TokenRefresh($hash); } } close(TOKENFILE); return $Token if $Token; } return undef; } sub TadoAPI_NewTokenRequest(@) { my ($hash) = @_; my $name = $hash->{NAME}; my $username = $hash->{TADO_USER}; my $password = TadoAPI_readPassword($name); my $tokenFileName = $tokenFile."_".$name; Log3 $name, 5, "TadoAPI $name" . ": " . "calling NewTokenRequest()"; $data = { client_id => $client_id, client_secret => $client_secret, username => $username, password => $password, scope => $scope, grant_type=>'password' }; my $param = { url => $AuthURL, method => 'POST', timeout => 5, hash => $hash, data => $data }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "NewTokenRequest: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "NewTokenRequest: decode_json failed, invalid json. error: $@ "; }else{ #write token data in file open(TOKENFILE,">$tokenFileName") or die("ERROR: $!"); print TOKENFILE $data . "\n"; close(TOKENFILE); # token lifetime management $hash->{TOKEN_LIFETIME} = gettimeofday() + $decoded_data->{'expires_in'}; $hash->{TOKEN_LIFETIME_HR} = localtime($hash->{TOKEN_LIFETIME}); Log3 $name, 5, "TadoAPI $name" . ": " . "Retrived new authentication token successfully. Valid until " . localtime($hash->{TOKEN_LIFETIME}); $hash->{STATE}="reachable"; return $decoded_data; } } } sub TadoAPI_TokenRefresh(@) { my ($hash) = @_; my $name = $hash->{NAME}; my $tokenFileName = $tokenFile."_".$name; # load token eval { open(TOKENFILE, '<', $hash->{TOKEN_FILE}) or die("ERROR: $!"); $TokenData = decode_json()}; $data = { client_id => $client_id, client_secret => $client_secret, scope => $scope, grant_type=>'refresh_token', refresh_token => $TokenData->{'refresh_token'} }; my $param = { url => $AuthURL, method => 'POST', timeout => 5, hash => $hash, data => $data }; #Log3 $name, 5, 'Blocking GET TokenRefresh: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "TokenRefresh: Error in token retrival while requesting ".$param->{url}." - $err"; $hash->{STATE}="error"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval{decode_json($data);}; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "TokenRefresh: decode_json failed, invalid json. error:$@\n" if $@; $hash->{STATE}="error"; }else{ #write token data in file open(TOKENFILE,">$tokenFileName") or die("ERROR: $!"); print TOKENFILE $data . "\n"; close(TOKENFILE); # token lifetime management $hash->{TOKEN_LIFETIME} = gettimeofday() + $decoded_data->{'expires_in'}; $hash->{TOKEN_LIFETIME_HR} = localtime($hash->{TOKEN_LIFETIME}); Log3 $name, 5, "TadoAPI $name" . ": " . "TokenRefresh: Refreshed authentication token successfully. Valid until " . localtime($hash->{TOKEN_LIFETIME}); $hash->{STATE}="reachable"; return $decoded_data; } } } sub TadoAPI_Update(@){ my ($hash) = @_; my $name = $hash->{NAME}; Log3 $name, 5, "TadoAPI $name" . ": " . "TadoAPI_Update called"; my $nextTimer = "none"; my $intervall = 300; $intervall = $attr{$name}{updateIntervall} if (defined($attr{$name}{updateIntervall}) && $attr{$name}{updateIntervall} =~ m/^-?\d+$/); # if api online, try again in xx minutes if ( $apiStatus ) { $nextTimer = gettimeofday() + $intervall; } Log3 $name, 5, "TadoAPI $name" . ": " . "Next Timer = $nextTimer"; RemoveInternalTimer($hash); InternalTimer( $nextTimer, "TadoAPI_Update", $hash, 0 ); # update subs TadoAPI_UpdateFn($hash); return undef; } ######################## tado methods ######################## ############################################################## sub TadoAPI_SetZoneOverlayById(@){ my ($hash, $zoneID, $setting, $duration) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL = $QueryURL . qq{/$homeID/zones/$zoneID/overlay}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $method = ""; my $myjson =undef; Log3 $name, 5, "TadoAPI $name" . ": SetOverlay for Zone $zoneID (Setting: " . $setting . ") - " . "query-URL: $URL"; my $dt = time(); $dt += $duration if defined($duration); # remove overlay if($setting eq "off"){ $method = "DELETE"; Log3 $name, 3, "TadoAPI $name" . ": " . "Deleting Overlay for Zone $zoneID"; } # turn heating of elsif($setting == 0){ # turn off for timer if(defined($duration) && $duration > 0){ $method = "PUT"; $myjson = { type => "MANUAL", setting => { type => "HEATING", power => "OFF" }, termination => { type => "TIMER", durationInSeconds => $duration, expiry => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($dt)) } }; Log3 $name, 3, "TadoAPI $name" . ": " . "Timer Overlay for Zone $zoneID . Power off for: $duration seconds"; } # infinite off else{ $method = "PUT"; $myjson = { setting => { type => "HEATING", power => "OFF", }, termination => { type => "MANUAL" }, }; } } # infinite temperature overlay elsif($setting > 0){ # timed overlay if(defined($duration) && $duration > 0){ $method = "PUT"; $myjson = { type => "MANUAL", setting => { type => "HEATING", power => "ON", temperature => { celsius => $setting } }, termination => { type => "TIMER", durationInSeconds => $duration, expiry => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($dt)), } }; Log3 $name, 3, "TadoAPI $name" . ": " . "Timer Overlay for Zone $zoneID . Duration: $duration Exire"; } else{ # infinite setting $method = "PUT"; $myjson = { setting => { type => "HEATING", power => "ON", temperature => { celsius => $setting }, }, termination => { type => "MANUAL" }, }; } } $myjson = encode_json($myjson) if (defined($myjson)); my $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => $method, timeout => 5, callback => \&Tado_UpdateZoneOverlayCallback, hash => $hash, setting => $setting, zoneID => $zoneID, data => $myjson }; #Log3 $name, 5, 'NonBlocking Request: ' . Dumper($request); HttpUtils_NonblockingGet($request); } } sub TadoAPI_SetAllOverlays(@){ my ($hash, $setting) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $message = ""; my $method = ""; my $myjson =""; my @devArr = TadoAPI_GetTadoDevices($hash); for (my $i=0; $i < @devArr; $i++) { my $zoneid = $devArr[$i]->{'id'}; TadoAPI_SetZoneOverlayById($hash, $zoneid, $setting); } } } sub TadoAPI_UpdateFn(@){ my ($hash) = @_; my $name = $hash->{NAME}; my $TokenData = TadoAPI_LoadToken($hash); my $homeID = $attr{$name}{homeID}; if($apiStatus == 1 && defined($TokenData)){ # zone specific updates my $URL = $QueryURL.qq{/$homeID/zones}; my $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 25, incrementalTimout => 1, hash => $hash, callback => \&TadoAPI_UpdateAllZoneReadingsCallback }; HttpUtils_NonblockingGet($request); # mobile devices $URL=$QueryURL . qq{/$homeID/mobileDevices}; $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 10, incrementalTimout => 1, callback => \&TadoAPI_UpdateMobileReadingsCallback, hash => $hash }; HttpUtils_NonblockingGet($request); } } ######################################################################################################################################################################## # Callback Subs ######################################################################################################################################################################## sub TadoAPI_callback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; $param->{code} = 0 unless defined $param->{code}; if($param->{code} == 401 || $param->{code} == 400){ $apiStatus = 1; $hash->{STATE}="reachable"; Log3 $name, 5, "TadoAPI $name" . ": " . "API is reachable. Callback Status: " . $param->{code}; }else{ $apiStatus = 0; $hash->{STATE}="error"; Log3 $name, 5, "TadoAPI $name" . ": " . "API error: apiStatus $apiStatus ($err)"; } return undef; } sub TadoAPI_UpdateAllZoneReadingsCallback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if($err ne "") { Log3 $name, 3, "Error in TadoAPI_UpdateZoneCallback while requesting ".$param->{url}." - $err"; return undef; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; # if api returns error eval { my $error = @$decoded_data; }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "UpdateAllZonesCallback: decode_json failed, invalid json. error:$@\n" if $@; Log3 $name, 3, "TadoAPI $name" . ": " . "UpdateAllZonesCallback: Error in decoded data, Code: " . $decoded_data->{'errors'}->[0]->{'code'} if (exists($decoded_data->{'errors'}->[0]->{'code'})); $hash->{LastRequest}="error"; }else{ foreach my $zone ( @$decoded_data ){ my $zoneid = $zone->{'id'}; Log3 $name, 5, "TadoAPI $name" . ": " . "Set Reading Update for Zone $zoneid "; my ($temperature, $humidity, $desiredTemp, $currentHeatingPower, $overlay ) = TadoAPI_GetZoneReadingsById($hash, $zoneid); my $zoneName = TadoAPI_GetZoneNameById($hash, $zoneid); if(defined($zoneName)){ readingsBeginUpdate($hash); readingsBulkUpdate($hash, "Temperatur_" . $zoneName, $temperature); readingsBulkUpdate($hash, "Luftfeuchtigkeit_" . $zoneName, $humidity); readingsBulkUpdate($hash, "Heizleistung_" . $zoneName, $currentHeatingPower); readingsBulkUpdate($hash, "OverlayType_" . $zoneName, $overlay); readingsBulkUpdate($hash, "DesiredTemp_" . $zoneName, $desiredTemp); readingsEndUpdate( $hash, 1 ); } # iterate through all devices in zone my $devices = $zone->{'devices'}; foreach my $device ( @$devices ){ readingsBeginUpdate($hash); readingsBulkUpdate($hash, "Battery_" . $device->{'serialNo'}, $device->{'batteryState'}); readingsEndUpdate( $hash, 1 ); $hash->{LastRequest}="OK"; } } } my $zonecount = TadoAPI_GetZoneCount($hash); readingsBeginUpdate($hash); readingsBulkUpdate($hash, "ActiveZones", $zonecount); readingsEndUpdate( $hash, 1 ); } } sub Tado_UpdateZoneOverlayCallback($) { my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; my $zoneID = $param->{zoneID}; my $setting = $param->{setting}; if($err ne "") { Log3 $name, 3, "Error in UpdateZoneOverlayCallback while requesting ".$param->{url}." - $err"; return undef; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; Log3 $name, 3, "TadoAPI $name" . ": " . "set (async) Overlay for Zone $zoneID to: $setting"; } # finaly update readings TadoAPI_GetZoneReadingsById($hash, $zoneID); } sub TadoAPI_LogInfoCallback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if($err ne "") { Log3 $name, 3, "Error in LogInfoCallback while requesting ".$param->{url}." - $err"; return undef; } elsif($data ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . $param->{infotext} .":\n" . $data . "\n"; } } sub TadoAPI_UpdateTadoDeviceInformationCallback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if($err ne "") { Log3 $name, 3, "Error in UpdateTadoDeviceInformationCallback while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; # if api returns error eval { my $error = @$decoded_data; }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "UpdateTadoDeviceInformationCallback: decode_json failed, invalid json. error:$@\n" if $@; Log3 $name, 3, "TadoAPI $name" . ": " . "UpdateTadoDeviceInformationCallback: Error in decoded data, Code: " . $decoded_data->{'errors'}->[0]->{'code'}; $hash->{LastRequest}="error"; }else{ foreach my $zone ( @$decoded_data ){ my $devices = $zone->{'devices'}; foreach my $device ( @$devices ){ readingsBeginUpdate($hash); readingsBulkUpdate($hash, "Battery_" . $device->{'serialNo'}, $device->{'batteryState'}); readingsEndUpdate( $hash, 1 ); $hash->{LastRequest}="OK"; } } } } } sub TadoAPI_SetGeoByIdCallback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if($err ne "") { Log3 $name, 3, "Error in TadoAPI_SetGeoByIdCallback while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 3, "url ".$param->{url}." returned: $data"; } } sub TadoAPI_UpdateMobileReadingsCallback($){ my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; if($err ne "") { Log3 $name, 3, "Error in UpdateMobileReadingsCallback while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; # if api returns error eval { my $error = @$decoded_data; }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "Decode_json failed, invalid json. error:$@\n" if $@; Log3 $name, 3, "TadoAPI $name" . ": " . "Error in UpdateMobileReadingsCallback, Code: " . $decoded_data->{'errors'}->[0]->{'code'}; $hash->{LastRequest}="error"; }else{ foreach my $item ( @$decoded_data ){ readingsBeginUpdate($hash); readingsBulkUpdate($hash, "GeoTracking_" . $item->{'id'}, $item->{'settings'}->{'geoTrackingEnabled'}); if(defined($item->{'location'}->{'atHome'}) && defined($item->{'location'}->{'atHome'}) eq "true"){ # present readingsBulkUpdate($hash, "GeoLocation_" . $item->{'id'}, "present"); }elsif(defined($item->{'location'}->{'atHome'})){ # away readingsBulkUpdate($hash, "GeoLocation_" . $item->{'id'}, "away"); }else{ # no state readingsDelete($hash, "GeoLocation_" . $item->{'id'}) } readingsEndUpdate( $hash, 1 ); $hash->{LastRequest}="OK"; } } } } sub TadoAPI_GetZoneInfo(@) { my ($hash) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ # HomeInfo my $URL = qq{https://my.tado.com/api/v2/me}; my $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 8, infotext => "HomeInfos", hash => $hash, callback => \&TadoAPI_LogInfoCallback }; HttpUtils_NonblockingGet($request); # TadoDevicesInfo $URL = $QueryURL . qq{/$homeID/zones}; $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 3, infotext => "Tado Devices Info", hash => $hash, callback => \&TadoAPI_LogInfoCallback }; HttpUtils_NonblockingGet($request); # Mobileinfo $URL = $QueryURL . qq{/$homeID/mobileDevices}; $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 3, infotext => "Mobile Devices Info", hash => $hash, callback => \&TadoAPI_LogInfoCallback }; HttpUtils_NonblockingGet($request); my @mobDev = TadoAPI_GetMobileDevices($hash); for (my $i=0; $i < @mobDev; $i++) { my $mobileID = $mobDev[$i]->{'id'}; $URL=$QueryURL . qq{/$homeID/mobileDevices/$mobileID/settings}; $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 3, infotext => "Mobile Device $mobileID", hash => $hash, callback => \&TadoAPI_LogInfoCallback }; HttpUtils_NonblockingGet($request); } # zones my @devArr = TadoAPI_GetTadoDevices($hash); for (my $i=0; $i < @devArr; $i++) { my $zoneid = $devArr[$i]->{'id'}; my $URL=$QueryURL . qq{/$homeID/zones/$zoneid/state}; my $infotext = "ZoneID $zoneid (" . TadoAPI_GetZoneNameById($hash, $zoneid) . ") Status"; my $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'GET', timeout => 3, infotext => $infotext, hash => $hash, callback => \&TadoAPI_LogInfoCallback }; HttpUtils_NonblockingGet($request); } } return undef; } sub TadoAPI_SetGeoById(@){ my ($hash, $mobileID, $geo) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL . qq{/$homeID/mobileDevices/$mobileID/settings}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ if($geo){ my $data = { geoTrackingEnabled=>"true" }; }else{ my $data = { geoTrackingEnabled=>"false" }; } $data = encode_json($data); my $request = { url => $URL, header => { "Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}" }, method => 'PUT', timeout => 3, mobileID => $mobileID, data => $data, hash => $hash, callback => \&TadoAPI_SetGeoByIdCallback }; HttpUtils_NonblockingGet($request); } } ###################################### ############ Helpers ################# ###################################### sub TadoAPI_ReplaceUmlaute(@) { my ($string) = @_; my %umlaute = ("ä" => "ae", "Ä" => "Ae", "ü" => "ue", "Ü" => "Ue", "ö" => "oe", "Ö" => "Oe", "ß" => "ss" ); my $umlautkeys = join ("|", keys(%umlaute)); $string =~ s/($umlautkeys)/$umlaute{$1}/g; return $string; } sub TadoAPI_GetHomeId(@){ # returns first home id only my ($hash) = @_; my $name = $hash->{NAME}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)) { my $param = { url => $DataURL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 2, hash => $hash, }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "GetHomeId: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "GetHomeId: Decode_json failed, invalid json. error:$@" if $@; $hash->{LastRequest}="error"; }else{ $hash->{LastRequest}="OK"; return $decoded_data->{'homes'}->[0]->{'id'} if (exists($decoded_data->{'homes'})); } } } return undef; } sub TadoAPI_GetGeoById(@){ my ($hash, $mobileID) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL.qq{/$homeID/mobileDevices/$mobileID/settings}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $param = { url => $URL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 4, hash => $hash, }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "TadoAPI_GetGeoById: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "TadoAPI_GetGeoById: decode_json failed, invalid json. error:$@\n"; }else{ my $setting = $decoded_data->{'geoTrackingEnabled'}; readingsBeginUpdate($hash); readingsBulkUpdate($hash, "GeoLocation_" . $mobileID, $setting); readingsEndUpdate( $hash, 1 ); return $setting; } } } return undef; } sub TadoAPI_GetMobileDevices(@) { my ($hash) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL . qq{/$homeID/mobileDevices}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $param = { url => $URL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 2, hash => $hash }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "GetMobileDevices: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { my @devices = (); Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "GetMobileDevices: decode_json failed, invalid json. error:$@\n"; }else{ if(ref($decoded_data) eq 'ARRAY'){ foreach my $item( @$decoded_data ) { push @devices, $item; } # default case return @devices; }elsif(ref($decoded_data) eq 'HASH'){ # error, api response is a hash in case of error Log3 $name, 3, "TadoAPI $name" . ": " . "GetMobileDevices: " . $decoded_data->{'errors'}->[0]->{'code'} if (exists($decoded_data->{'errors'})); } } } } return undef; } sub TadoAPI_GetZoneCount(@) { my ($hash) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL.qq{/$homeID/zones}; my $zonecount = 0; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $param = { url => $URL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 2, hash => $hash }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "GetZoneCount: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { my @devices = (); Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "GetZoneCount: decode_json failed, invalid json. error:$@\n"; }else{ if(ref($decoded_data) eq 'ARRAY'){ foreach my $item( @$decoded_data ) { $zonecount++; } return $zonecount; }elsif(ref($decoded_data) eq 'HASH'){ Log3 $name, 3, "TadoAPI $name" . ": " . "GetZoneCount: " . $decoded_data->{'errors'}->[0]->{'code'} if (exists($decoded_data->{'errors'})); } } } } return undef; } sub TadoAPI_GetZoneNameById(@) { my ($hash, $zoneID) = @_; my $name = $hash->{NAME}; my @devArr = TadoAPI_GetTadoDevices($hash); my $zoneName = undef; foreach my $zone ( @devArr ){ if ($zone->{'id'} == $zoneID){ $zoneName = TadoAPI_ReplaceUmlaute($zone->{'name'}); return $zoneName; } } Log3 $name, 3, "TadoAPI $name" . ": " . "Error GetZoneNameById: Wrong zone ID ($zoneID)"; return undef; } sub TadoAPI_GetZoneReadingsById(@){ my ($hash, $zoneID) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL.qq{/$homeID/zones/$zoneID/state}; my $temperature = 0; my $humidity = 0; my $desiredTemp = 0; my $currentHeatingPower = 0; my $overlay = 0; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $param = { url => $URL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 5, hash => $hash }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "GetZoneReadingsById: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "GetZoneReadingsById: Zone $zoneID decode_json failed, invalid json. error:$@\n"; }else{ my $zoneName = TadoAPI_GetZoneNameById($hash, $zoneID); if (defined($zoneName)){ $temperature = sprintf("%.1f", $decoded_data->{'sensorDataPoints'}->{'insideTemperature'}->{'celsius'}); $humidity = $decoded_data->{'sensorDataPoints'}->{'humidity'}->{'percentage'}; if($decoded_data->{'setting'}->{'power'} eq "OFF"){ $desiredTemp = "OFF"; }else{ $desiredTemp = $decoded_data->{'setting'}->{'temperature'}->{'celsius'}; } $currentHeatingPower = $decoded_data->{'activityDataPoints'}->{'heatingPower'}->{'percentage'}; $overlay = $decoded_data->{'overlayType'}; if (!defined $overlay) {$overlay = "no overlay"}; readingsBeginUpdate($hash); readingsBulkUpdate($hash, "Temperatur_" . $zoneName, $temperature); readingsBulkUpdate($hash, "Luftfeuchtigkeit_" . $zoneName, $humidity); readingsBulkUpdate($hash, "Heizleistung_" . $zoneName, $currentHeatingPower); readingsBulkUpdate($hash, "OverlayType_" . $zoneName, $overlay); readingsBulkUpdate($hash, "DesiredTemp_" . $zoneName, $desiredTemp); readingsEndUpdate( $hash, 1 ); } return ($temperature, $humidity, $desiredTemp, $currentHeatingPower, $overlay ); } } } return undef; } sub TadoAPI_GetTadoDevices(@) { # returns array with zonenames and zone devices my ($hash) = @_; my $name = $hash->{NAME}; my $homeID = $attr{$name}{homeID}; my $URL=$QueryURL . qq{/$homeID/zones}; my $TokenData = TadoAPI_LoadToken($hash); if(defined($TokenData)){ my $param = { url => $URL, header => {"Content-Type"=>"application/json;charset=UTF-8","Authorization" => "$TokenData->{'token_type'} $TokenData->{'access_token'}"}, method => 'GET', timeout => 5, hash => $hash }; #Log3 $name, 5, 'Blocking GET: ' . Dumper($param); my ($err, $data) = HttpUtils_BlockingGet($param); if($err ne "") { Log3 $name, 3, "TadoAPI $name" . ": " . "RequestTadoDevices: Error while requesting ".$param->{url}." - $err"; } elsif($data ne "") { Log3 $name, 5, "url ".$param->{url}." returned: $data"; my $decoded_data = eval { decode_json($data) }; if ($@){ Log3 $name, 3, "TadoAPI $name" . ": " . "RequestTadoDevices: decode_json failed, invalid json. error:$@\n"; }else{ if(ref($decoded_data) eq 'ARRAY'){ my @devices = (); foreach my $dev (@$decoded_data){ push @devices, $dev; } return @devices; } } } } return undef; } ###################################################### # storePW & readPW Code geklaut aus 96_SIP.pm :) ###################################################### sub TadoAPI_storePassword($$) { my ($name, $password) = @_; my $index = "TadoAPI_".$name."_passwd"; my $key = getUniqueId().$index; my $e_pwd = ""; if (eval "use Digest::MD5;1") { $key = Digest::MD5::md5_hex(unpack "H*", $key); $key .= Digest::MD5::md5_hex($key); } for my $char (split //, $password) { my $encode=chop($key); $e_pwd.=sprintf("%.2x",ord($char)^ord($encode)); $key=$encode.$key; } my $error = setKeyValue($index, $e_pwd); return "error while saving TadoAPI password : $error" if(defined($error)); return "TadoAPI password successfully saved in FhemUtils/uniqueID Key $index"; } sub TadoAPI_readPassword($) { my ($name) = @_; my $index = "TadoAPI_".$name."_passwd"; my $key = getUniqueId().$index; my ($password, $error); #Log3 $name,5,"$name, read user password from FhemUtils/uniqueID Key $key"; ($error, $password) = getKeyValue($index); if ( defined($error) ) { Log3 $name,3, "$name, cant't read Tado password from FhemUtils/uniqueID: $error"; return undef; } if ( defined($password) ) { if (eval "use Digest::MD5;1") { $key = Digest::MD5::md5_hex(unpack "H*", $key); $key .= Digest::MD5::md5_hex($key); } my $dec_pwd = ''; for my $char (map { pack('C', hex($_)) } ($password =~ /(..)/g)) { my $decode=chop($key); $dec_pwd.=chr(ord($char)^ord($decode)); $key=$decode.$key; } return $dec_pwd; } else { Log3 $name,3,"$name, no Tado password found in FhemUtils/uniqueID"; return undef; } } 1; =pod =item helper =item summary integration of the Tado API =item summary_DE Anbindung der Tado Heizungssteuerung über API =begin html

TadoAPI

    Module to control the integration of tado devices with FHEM.
    Notes:
    • JSON has to be installed on the FHEM host.
    Define
      The username and password must match the username and password used on the Tado website.
      After successful define, store PASSWORD with set <name> password <your-tado-password>.
      Note: Password is encrypted and saved in FHEM uniqueID file. All requests to the API are handeld via oauth2 token.
      Examples:
        define <name> TadoAPI mail@example.com [<homeID>]

    Set
    • set <name> <tado password>
      Stores password from tado account encrypted in FHEM.
      Without stored password all functions are blocked !
      IMPORTANT : if you rename the fhem Device you must set the password again!
    • set <name> update
      Reloads all information from the tado installation (devices, battery state, geolocation,...).
    • set <name> setZoneOverlay <zoneID> <setting> [<duration>]
      Setting: off delete overlay; 0 heating power off; > 1 sets desired temperature to given value (overlay)
    • set <name> setAllOverlays <setting>
      Same as above, but for all zones
      Setting: off delete overlay; 0 heating power off; > 1 sets desired temperature to given value (overlay)


    Attributes
    • homeID
      Home ID that will be used for API querys.
    • updateIntervall
      Intervall that is used for API querys in seconds.

=end html # Ende der Commandref =cut