diff --git a/fhem/CHANGED b/fhem/CHANGED index 658f764bb..94a991647 100644 --- a/fhem/CHANGED +++ b/fhem/CHANGED @@ -1,5 +1,6 @@ # Add changes at the top of the list. Keep it in ASCII, and 80-char wide. # Do not insert empty lines here, update check depends on it. + - new: 48_MieleAtHome: Module to integrate the Miele@Home API - feature: 59_Twilight: add experimental option: myUtils forecast code - bugfix: 76_SMAPortal: only four consumer are shown in set drop down list - change: 76_SMAPortal: some improvements, avoid login trouble in some cases diff --git a/fhem/FHEM/48_MieleAtHome.pm b/fhem/FHEM/48_MieleAtHome.pm new file mode 100644 index 000000000..215563bee --- /dev/null +++ b/fhem/FHEM/48_MieleAtHome.pm @@ -0,0 +1,2044 @@ +######################################################################################## +# +# MieleAtHome.pm +# +# FHEM module for Miele@home Devices +# +# Christian Hoenig +# +# $Id$ +# +######################################################################################## +# +# This programm 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 +# (at your option) 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. +# +######################################################################################## +package main; + +use strict; +use warnings; +use utf8; +use Encode qw(encode_utf8); +use List::Util qw[min max]; +use JSON; + +my $version = "1.0.0"; + +my $MAH_hasMimeBase64 = 1; + +use constant PROCESS_ACTIONS => { + 0x01 => "start", # 1 START + 0x02 => "stop", # 2 STOP + 0x03 => "pause", # 3 PAUSE + 0x04 => "startSuperFreezing", # 4 START SUPERFREEZING + 0x05 => "stopSuperFreezing", # 5 STOP SUPERFREEZING + 0x06 => "startSuperCooling", # 6 START SUPERCOOLING + 0x07 => "stopSuperCooling", # 7 STOP SUPERCOOLING +}; + +use constant LIGHT_ACTIONS => { + 0x01 => "enable", # 1 Enable + 0x02 => "disable", # 2 Disable +}; + +use constant VENTILATION_STEPS => { + 0x01 => "Step1", # 1 Step1 + 0x02 => "Step2", # 2 Step2 + 0x03 => "Step3", # 3 Step3 + 0x04 => "Step4", # 4 Step4 +}; + +# TODO +# +# +# +# +# + +use constant COUNTRIES => { + "Miele-Deutschland" => "de-DE", + "Miele-Eesti" => "et-EE", + "Miele-Norge" => "no-NO", + "Miele-Serbien" => "sr-RS", # "Miele-Србија" => "sr-RS", + "Miele-Belgie" => "nl-BE", # "Miele-België" => "nl-BE", + "Miele-Suomi" => "fi-FI", + "Miele-Hong-Kong" => "zh-HK", + "Miele-Russland" => "ru-RU", # "Miele-Россия" => "ru-RU", + "Miele-United-Arab-Emirates" => "en-AE", + "Miele-Portugual" => "pt-PT", + "Miele-Bulgarien" => "bg-BG", # "Miele-България" => "bg-BG", + "Miele-Schweiz" => "de-CH", + "Miele-India" => "en-IN", + "Miele-Semi-Pro" => "de-SX", + "Miele-Nihon" => "ja-JP", + "Miele-Danmark" => "da-DK", + "Miele-Hanguk" => "ko-KR", + "Miele-South-Africa" => "en-ZA", + "Miele-Lietuva" => "lt-LT", + "Miele-Chile" => "es-CL", + "Miele-Luxemburg" => "de-LU", + "Miele-Croatia" => "hr-HR", + "Miele-Latvija" => "lv-LV", + "Miele-China" => "zh-CN", # "Miele-Zhōngguó" => "zh-CN", + "Miele-Griechenland" => "el-GR", # "Miele-Ελλάδα" => "el-GR", + "Miele-Italia" => "it-IT", + "Miele-Mexico" => "es-MX", # "Miele-México" => "es-MX", + "Miele-France" => "fr-FR", + "Miele-Malaysia" => "en-MY", + "Miele-New-Zealand" => "en-NZ", + "Miele-Ukraine" => "ru-UA", # "Miele-Україна" => "ru-UA", + "Miele-Magyarorszag" => "hu-HU", # "Miele-Magyarország" => "hu-HU", + "Miele-Espana" => "es-ES", # "Miele-España" => "es-ES", + "Miele-Kasachstan" => "ru-KZ", # "Miele-Казахстан" => "ru-KZ", + "Miele-Sverige" => "sv-SE", + "Miele-Oesterreich" => "de-AT", # "Miele-Österreich" => "de-AT", + "Miele-Australia" => "en-AU", + "Miele-Singapore" => "en-SG", + "Miele-Thailand" => "en-TH", + "Miele-Kypros" => "el-CY", + "Miele-Slovenia" => "sl-SI", + "Miele-Weissrussland" => "ru-BY", # "Miele-Беларуси" => "ru-BY", + "Miele-Czechia" => "cs-CZ", + "Miele-Slovensko" => "sk-SK", + "Miele-UK" => "en-GB", + "Miele-Ireland" => "en-IE", + "Miele-Polska" => "pl-PL", + "Miele-Romania" => "ro-RO", # "Miele-România" => "ro-RO", + "Miele-Canada" => "en-CA", + "Miele-Nederland" => "nl-NL", + "Miele-Tuerkiye" => "tr-TR", # "Miele-Türkiye" => "tr-TR" + "Miele-USA" => "en-US", +}; + +#------------------------------------------------------------------------------------------------------ +# Initialize +#------------------------------------------------------------------------------------------------------ +sub MieleAtHome_Initialize($) +{ + my ($hash) = @_; + + MAH_Log(undef, 5, "called"); + + eval "use MIME::Base64"; + $MAH_hasMimeBase64 = 0 if($@); + + $hash->{DefFn} = "MAH_DefFn"; + $hash->{UndefFn} = "MAH_UndefFn"; + $hash->{DeleteFn} = "MAH_DeleteFn"; + $hash->{AttrFn} = "MAH_AttrFn"; + $hash->{SetFn} = "MAH_SetFn"; + $hash->{GetFn} = "MAH_GetFn"; + $hash->{RenameFn} = "MAH_RenameFn"; + + $hash->{AttrList} = ""; + $hash->{AttrList} .= "clientId "; + $hash->{AttrList} .= "disable:1 "; + $hash->{AttrList} .= "login "; + $hash->{AttrList} .= "lang:de,en "; + $hash->{AttrList} .= "country:" . join(",", keys %{COUNTRIES()}) . " "; + $hash->{AttrList} .= $readingFnAttributes; + + # maintenance + foreach my $d (sort keys %{$modules{MieleAtHome}{defptr}}) { + my $hash = $modules{MieleAtHome}{defptr}{$d}; + + # update version in devices + $hash->{VERSION} = $version; + + # rename IODev -> IODevName (0.12.0) + if (defined($hash->{IODev})) { + $hash->{IODevName} = $hash->{IODev}; + delete($hash->{IODev}); + } + } +} + +#------------------------------------------------------------------------------------------------------ +# Define +#------------------------------------------------------------------------------------------------------ +sub MAH_DefFn($$) +{ + my ( $hash, $def ) = @_; + + my @a = split( "[ \t]+", $def ); + splice( @a, 1, 1 ); + + # check syntax + my $pCount = int(@a); + if ($pCount < 1 || $pCount > 3) { + return "Wrong syntax: use define MAH [deviceId] [interval]"; + } + + my $name = shift(@a); + my $deviceId = shift(@a); + my $interval = shift(@a); + MAH_Log($hash, 5, "called"); + + my $ioDevName = ""; + if ($deviceId && $deviceId =~ /([0-9]+)+@(.+)/) { + $deviceId = $1; + $ioDevName = $2; + } + + if ($deviceId) { $hash->{DEVICE_ID} = $deviceId; } + else { delete($hash->{DEVICE_ID}); } + if ($ioDevName) { $hash->{IODevName} = $ioDevName; } + else { delete($hash->{IODevName}); } + if ($interval) { $hash->{INTERVAL} = $interval; } + elsif ($deviceId) { $hash->{INTERVAL} = 120; } # default: 120 + else { delete($hash->{INTERVAL}); } + $hash->{VERSION} = $version; + + $hash->{HAS_MimeBase64} = $MAH_hasMimeBase64; + + MAH_restoreOAuth2Credentials($hash); + + $attr{$name}{room} = "MieleAtHome" if(!defined($attr{$name}{room})); + $attr{$name}{devStateIcon} = ".*:noIcon" if(!defined($attr{$name}{devStateIcon})); + + $modules{MieleAtHome}{defptr}{"mah_".$name} = $hash; + + if (defined($deviceId)) { + # check if $deviceId exists already + my $d = $modules{MieleAtHome}{defptr}{"deviceid_".$deviceId}; + if (defined($d) && $d->{NAME} ne $name) { + $hash->{STATE} = 'Error'; + readingsSingleUpdate($hash, "lastError", "MAH device with DeviceId $deviceId already defined as $d->{NAME}.", 1); + $hash->{DUPLICATE_INSTANCE} = "1"; + return; + } + + # remember our deviceId + $modules{MieleAtHome}{defptr}{"deviceid_".$deviceId} = $hash; + + MAH_Log($hash, 4, "finished define with deviceId: $deviceId"); + } + + fhem("deletereading $name lastError"); + + if (MAH_isDisabled($hash)) { + readingsSingleUpdate( $hash, "state", "disabled", 1 ); + return undef; + } + $hash->{STATE} = 'Initialized'; + + if (defined($deviceId)) { + # this will call MAH_refreshAccessToken itself if required + InternalTimer(gettimeofday()+($init_done ? 0 : 10), "MAH_updateValues", $hash); + } else { + InternalTimer(gettimeofday()+($init_done ? 0 : 10), "MAH_refreshAccessToken", $hash); + } + + # if MAH_getAccessToken returns "", it will request a new token on its own + if (MAH_getAccessToken($hash) ne "") { + InternalTimer(gettimeofday()+0, "MAH_updateValues", $hash); + } + + return undef; +} + + +#------------------------------------------------------------------------------------------------------ +# Undefine +#------------------------------------------------------------------------------------------------------ +sub MAH_UndefFn($$) +{ + my ($hash, $name) = @_; + + RemoveInternalTimer($hash); + + delete($modules{MieleAtHome}{defptr}{"mah_".$name}); + + my $deviceId = $hash->{DEVICE_ID}; + if (defined($deviceId)) { + MAH_Log($hash, 4, "undefined with deviceId: $deviceId"); + delete($modules{MieleAtHome}{defptr}{"deviceid_".$deviceId}); + } + + MAH_Log($hash, 4, "undefined"); + return undef; +} + +#------------------------------------------------------------------------------------------------------ +# Delete +#------------------------------------------------------------------------------------------------------ +sub MAH_DeleteFn($$) +{ + my ($hash, $name) = @_; + + MAH_deletePassword($hash); + MAH_deleteClientSecret($hash); + MAH_deleteOAuth2Credentials($hash); + + MAH_Log($hash, 4, "deleted"); + return undef; +} + +#------------------------------------------------------------------------------------------------------ +# AttrFn +#------------------------------------------------------------------------------------------------------ +sub MAH_AttrFn(@) +{ + my ($cmd, $name, $attrName, $attrVal) = @_; + my $hash = $defs{$name}; + + ###################### + #### disable ######### + + if ($attrName eq "disable") { + if ($cmd eq "set" && $attrVal eq "1") { + readingsSingleUpdate ( $hash, "state", "disabled", 1 ); + MAH_Log($hash, 3, "disabled"); + } + elsif ($cmd eq "del") { + readingsSingleUpdate ( $hash, "state", "active", 1 ); + MAH_Log($hash, 3, "enabled"); + InternalTimer(gettimeofday()+0, "MAH_updateValues", $hash); + } + } + + ################# + #### lang ###### + + if ($attrName eq "lang") { + if ($cmd eq "set") { + return "Invalid value for attribute $attrName" if ($attrVal ne "de" && $attrVal ne "en"); + } + } + + ################# + #### login ###### + + if ($attrName eq "login") { + if ($cmd eq "set") { + return "Invalid value for attribute $attrName" if (!$attrVal); + #MAH_Log($hash, 1, "setting 'login' calls 'MAH_refreshAccessToken' ($init_done)"); + InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash) if ($init_done); + } + } + + #################### + #### clientId ###### + + if ($attrName eq "clientId") { + if ($cmd eq "set") { + return "Invalid value for attribute $attrName" if (!$attrVal); + #MAH_Log($hash, 1, "setting 'clientId' calls 'MAH_refreshAccessToken' ($init_done)"); + InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash) if ($init_done); + } + } + + #################### + #### country ###### + + if ($attrName eq "country") { + if ($cmd eq "set") { + if (!$attrVal || + (!defined(COUNTRIES->{$attrVal}) && + !grep { $_ eq $attrVal } values %{COUNTRIES()})) { + return "Invalid value for attribute $attrName" + } + #InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash) if ($init_done); + } + } + + return undef; +} + +#------------------------------------------------------------------------------------------------------ +# SetFn +#------------------------------------------------------------------------------------------------------ +sub MAH_SetFn($$@) +{ + my ($hash, $name, @aa) = @_; + my ($cmd, @args) = @aa; + + # password and clientSecret are allowed even when 'disabled' (but only if we don't use an IODev) + my $list = ""; + $list .= "password " if (!defined($hash->{IODevName})); + $list .= "clientSecret " if (!defined($hash->{IODevName})); + + if ($cmd eq "?") { + return "Unknown argument $cmd, choose one of $list" if (MAH_isDisabled($hash)); + } + + if( $cmd eq 'clientSecret' ) { + return "usage: callback " if(@args != 1); + MAH_saveClientSecret($hash, $args[0]); + InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash); + return undef; + } + elsif( $cmd eq 'password' ) { + return "usage: password " if(@args != 1); + MAH_savePassword($hash, $args[0]); + InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash); + return undef; + } + elsif( $cmd eq 'autocreate' ) { + return "use $cmd without arguments" if(@args != 0); + InternalTimer(gettimeofday()+0, "MAH_autocreate", $hash); + return undef; + } + elsif( $cmd eq 'update' ) { + return "use $cmd without arguments" if(@args != 0); + InternalTimer(gettimeofday()+0, "MAH_updateValues", $hash); + return undef; + } + elsif( $cmd eq 'on' || $cmd eq 'off' ) { + return "use $cmd without arguments" if(@args != 0); + return MAH_setPower($hash, $cmd) + } + elsif( $cmd eq 'start' || $cmd eq 'stop' || $cmd eq 'pause' || + $cmd eq 'startSuperFreezing' || $cmd eq 'stopSuperFreezing' || + $cmd eq 'startSuperCooling' || $cmd eq 'stopSuperCooling' ) { + return "use $cmd without arguments" if(@args != 0); + return MAH_setProcessAction($hash, $cmd) + } + elsif( $cmd eq 'light') { + return "usage: light enable|disable" if(@args != 1); + return MAH_setLight($hash, $args[0]) + } + elsif( $cmd eq 'ventilationStep') { + return "usage: ventilationStep " if(@args != 1); + return MAH_setVentilationStep($hash, $args[0]) + } + elsif( $cmd eq 'startTime') { + return "usage: startTime " if(@args != 1); + return MAH_setStartTime($hash, $args[0]) + } + else + { + $list .= "autocreate:noArg " if (!defined($hash->{IODevName}) && MAH_getAccessToken($hash) ne ""); + $list .= "update:noArg " if (defined($hash->{DEVICE_ID})); + + $list .= "on:noArg " if (defined($hash->{DEVICE_ID}) && ReadingsNum($name, "actions_powerOn", 0) == 1); + $list .= "off:noArg " if (defined($hash->{DEVICE_ID}) && ReadingsNum($name, "actions_powerOff", 0) == 1); + $list .= "startTime " if (defined($hash->{DEVICE_ID}) && ReadingsNum($name, "actions_startTime", 0) == 1); + + # process actions + my @processActionIds = split(/,/, ReadingsVal($name, "actions_processAction", "")); + foreach my $processActionId (@processActionIds) { + if (defined PROCESS_ACTIONS->{$processActionId}) { + $list .= PROCESS_ACTIONS->{$processActionId} . ":noArg "; + } + } + + # light actions + my $lightCmds = ""; + my @lightIds = split(/,/, ReadingsVal($name, "actions_light", "")); + foreach my $lightId (@lightIds) { + if (defined LIGHT_ACTIONS->{$lightId}) { + $lightCmds .= LIGHT_ACTIONS->{$lightId} . ","; + } + } + chop($lightCmds); # remove trailing ',' + $list .= "light:${lightCmds} " if ($lightCmds ne ""); + + # ventilation steps + my $ventilationStepCmds = ""; + my @ventilationStepIds = split(/,/, ReadingsVal($name, "actions_ventilationStep", "")); + foreach my $ventilationStepId (@ventilationStepIds) { + if (defined VENTILATION_STEPS->{$ventilationStepId}) { + $ventilationStepCmds .= VENTILATION_STEPS->{$ventilationStepId} . ","; + } + } + chop($ventilationStepCmds); # remove trailing ',' + $list .= "ventilationStep:${ventilationStepCmds} " if ($ventilationStepCmds ne ""); + + return "Unknown argument $cmd, choose one of $list"; + } +} + +#------------------------------------------------------------------------------------------------------ +# SetFn +#------------------------------------------------------------------------------------------------------ +sub MAH_GetFn($$@) +{ + my ($hash, $name, $opt, @args ) = @_; + + my $list = ""; + + if ($opt eq "?") { + return "Unknown argument $opt, choose one of $list" if MAH_isDisabled($hash); + } + + if( $opt eq 'listDevices' ) { + my $devices = MAH_blockingGetAllDevicesRequest($hash); + if(ref($devices) ne 'ARRAY') { + readingsSingleUpdate($hash, "lastError", "listDevices failed: $devices", 1); + return; + } + + my $retval; + for my $d (@{$devices}) { + $retval .= sprintf("%s (%s)", @{$d}[0], @{$d}[1]); + } + return $retval; + } + else + { + # these are only allowed when MAH is not 'disabled' + $list .= "listDevices:noArg " if (MAH_getAccessToken($hash) ne ""); + return "Unknown argument $opt, choose one of $list"; + } +} + +#------------------------------------------------------------------------------------------------------ +# MAH_RenameFn +#------------------------------------------------------------------------------------------------------ +sub MAH_RenameFn($$) +{ + my ($newName, $oldName) = @_; + + return unless (defined($defs{$newName})); + my $newHash = $defs{$newName}; + + # rename mah_-reference + if (defined($modules{MieleAtHome}{defptr}{"mah_".$oldName})) { + $modules{MieleAtHome}{defptr}{"mah_".$newName} = $newHash; + delete($modules{MieleAtHome}{defptr}{"mah_".$oldName}); + } + + MAH_renameClientSecret($newHash, $oldName, $newName); + MAH_renamePassword($newHash, $oldName, $newName); + MAH_renameOAuth2Credentials($newHash, $oldName, $newName); +} + +#------------------------------------------------------------------------------------------------------ +# request values from 3rd party api +#------------------------------------------------------------------------------------------------------ +sub MAH_updateValues($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called"); + + RemoveInternalTimer($hash, "MAH_updateValues"); + + return undef if (MAH_isDisabled($hash)); + return undef unless (defined($hash->{DEVICE_ID})); + return undef unless (MAH_hasLoginCredentials($hash)); + + my $interval = $hash->{INTERVAL}; + # force interval of 60s while != Off + $interval = min($interval, 60) if ReadingsNum($name, "statusRaw", 1) != 1; # != Off + InternalTimer(gettimeofday()+$interval, "MAH_updateValues", $hash) if (defined($interval)); + + # MAH_getAccessToken will request a new one, if there is none + if (MAH_getAccessToken($hash) eq "") { + return; + } + + MAH_sendGetDeviceIdentAndState($hash); + MAH_sendGetDeviceActionsRequest($hash); +} + + +#------------------------------------------------------------------------------------------------------ +# MAH_refreshAccessToken +#------------------------------------------------------------------------------------------------------ +sub MAH_refreshAccessToken($); # workaround for perl warning +sub MAH_refreshAccessToken($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called"); + + # let the IODev update the token + my $iohash = MAH_getIODevHash($hash); + return MAH_refreshAccessToken($iohash) if (defined($iohash)); + + # only refresh the token once + if (defined($hash->{TOKEN_REFRESH_IN_PROGRESS}) && $hash->{TOKEN_REFRESH_IN_PROGRESS} == 1) { + MAH_Log($hash, 4, "token refresh already in progress, skipping"); + return; + } + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 1; + + if (MAH_getAccessTokenPrivate($hash) ne "" && MAH_getRemainingTokenLifetime($hash) > 24 * 60 * 60) { + MAH_Log($hash, 4, "access-token still valid, skipping refresh. Call '{delete(\$defs{$name}{OAUTH2_ACCESS_TOKEN})}' in command bar to force refresh"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + if (!MAH_hasLoginCredentials($hash)) { + readingsSingleUpdate($hash, "lastError", "please set login, password, clientId and clientSecret", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } else { + fhem("deletereading $name lastError"); + } + + my $refreshToken = MAH_getRefreshTokenPrivate($hash); + if ($refreshToken ne "" && MAH_getRemainingTokenLifetime($hash) > 0) { + MAH_Log($hash, 4, "already have a refresh-token, using this for token-refresh"); + MAH_doThirdpartyTokenRequest($hash, "", $refreshToken) + } else { + MAH_doThirdpartyLoginRequest($hash); + } +} + +#------------------------------------------------------------------------------------------------------ +sub MAH_doThirdpartyLoginRequest($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called"); + + my $clientId = MAH_getClientId($hash); + if (!defined($clientId)) { + readingsSingleUpdate($hash, "lastError", "clientId missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + # Step 1: Authorization + my $url = "https://api.mcs3.miele.com/thirdparty/login/" + . "?response_type=code" + . "&state=login" + . "&client_id=" . urlEncode($clientId) + . "&scope=" + . "&redirect_uri=https%3A%2F%2Fapi.mcs3.miele.com%2Fthirdparty%2Flogin%2F"; + + my ($err, $reply) = HttpUtils_NonblockingGet({ + url => $url, + timeout => 5, + hash => $hash, + method => "GET", + callback => \&MAH_onThirdpartyLoginReply, + }); +} + +sub MAH_onThirdpartyLoginReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, headers:$param->{httpheader}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return $err; + } + + MAH_doOauthLoginRequest($hash); +} + +#------------------------------------------------------------------------------------------------------ +sub MAH_doOauthLoginRequest($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called"); + + my $login = MAH_getLogin($hash); + if (!defined($login)) { + readingsSingleUpdate($hash, "lastError", "login missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + my $password = MAH_getPassword($hash); + if (!defined($password)) { + readingsSingleUpdate($hash, "lastError", "password missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + my $clientId = AttrVal($name, "clientId", ""); + if (!defined($clientId)) { + readingsSingleUpdate($hash, "lastError", "clientId missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + my $country = COUNTRIES->{AttrVal($name, "country", "Miele-Deutschland")}; + MAH_Log($hash, 5, "country for /oauth/auth is $country"); + + # Step 2: oauth + my $url = "https://api.mcs3.miele.com/oauth/auth"; + my $data = "email=" . urlEncode($login) + . "&password=" . urlEncode($password) + . "&state=login" + . "&response_type=code" + . "&client_id=" . urlEncode($clientId) + . "&vgInformationSelector=$country" + . "&redirect_uri=https%3A%2F%2Fapi.mcs3.miele.com%2Fthirdparty%2Flogin%2F"; + + my ($err, $reply) = HttpUtils_NonblockingGet({ + url => $url, + data => $data, + timeout => 5, + hash => $hash, + method => "POST", + ignoreredirects => 1, + callback => \&MAH_onOauthLoginReply, + }); +} + +sub MAH_onOauthLoginReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, headers:$param->{httpheader}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + my $code = ""; + my $headers = $param->{httpheader}; + if ($headers =~ /(?s)code=([A-Z]{2}_[0-9a-f]+)/) { + MAH_Log($hash, 5, "Bearer found in headers"); + $code = $1; + } + + if ($code eq "") { + $code = scrapeGrantAccessPage($hash, $data); + if ($code ne "") { + MAH_Log($hash, 5, "Bearer found in HTML"); + } + } + + if ($code eq "") { + MAH_Log($hash, 2, "Error: Bearer code not found, giving up"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + MAH_doThirdpartyTokenRequest($hash, $code, ""); +} + +sub scrapeGrantAccessPage($$) +{ + my ($hash, $data) = (@_); + + # + + if ($data !~ /name="code" value="([^"]+)"/) { + MAH_Log($hash, 5, "code not found"); + return ""; + } + my $code = $1; + MAH_Log($hash, 2, "code found: $code"); + + # check if it looks like the right page (this could be removed!) + if (index($data, 'method="get" action="https://api.mcs3.miele.com/thirdparty/login/"') == -1) { + MAH_Log($hash, 2, "get-action not found"); + return ""; + } + + return $code; +} + +#------------------------------------------------------------------------------------------------------ +# either use 2nd or 3rd parameter: +# 2nd: do authorization_code +# 3rd: do refresh_token +sub MAH_doThirdpartyTokenRequest($$$) +{ + my ($hash, $bearerCode, $refreshToken) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called"); + + my $clientId = AttrVal($name, "clientId", ""); + if ($clientId eq "") { + readingsSingleUpdate($hash, "lastError", "clientId missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + my $clientSecret = MAH_getClientSecret($hash); + if ($clientSecret eq "") { + readingsSingleUpdate($hash, "lastError", "clientSecret missing", 1); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + # Step 3: token + my $url = "https://api.mcs3.miele.com/thirdparty/token/"; + my $data = "client_id=" . urlEncode($clientId) + . "&client_secret=" . urlEncode($clientSecret); + + if ($bearerCode ne "") { + $data .= "&grant_type=authorization_code" + . "&code=" . urlEncode($bearerCode) + . "&redirect_uri=https%3A%2F%2Fapi.mcs3.miele.com%2Fthirdparty%2Flogin%2F"; + } elsif ($refreshToken ne "") { + $data .= "&grant_type=refresh_token" + . "&refresh_token=" . urlEncode($refreshToken); + } else { + MAH_Log($hash, 1, "ERROR: called with neither bearerCode nor refreshToken, this is a bug. plz report!"); + return; + } + + my ($err, $reply) = HttpUtils_NonblockingGet({ + url => $url, + data => $data, + timeout => 5, + hash => $hash, + method => "POST", + ignoreredirects => 1, + callback => \&MAH_onThirdpartyTokenReply, + }); +} +sub MAH_onThirdpartyTokenReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, headers:$param->{httpheader}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + if ($param->{code} != 200) { + MAH_Log($hash, 3, "Error: code != 200: $param->{code}"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + my $json = eval{decode_json($data)}; + if ($@) { + MAH_Log($hash, 3, "JSON error while request: $@"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + if (ref($json) ne "HASH") { + MAH_Log($hash, 3, "got wrong message for $name: $json"); + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + return; + } + + no strict "refs"; + + $hash->{OAUTH2_ACCESS_TOKEN} = $json->{access_token}; + $hash->{OAUTH2_REFRESH_TOKEN} = $json->{refresh_token}; + $hash->{OAUTH2_EXPIRES_IN} = $json->{expires_in}; + $hash->{OAUTH2_EXPIRES_AT} = POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime(time + $json->{expires_in})); + + use strict "refs"; + + # store in key/value so that they survive restart + MAH_saveOAuth2Credentials($hash); + + # success + $hash->{TOKEN_REFRESH_IN_PROGRESS} = 0; + + if (MAH_getAccessToken($hash) ne "") { + InternalTimer(gettimeofday()+0, "MAH_updateValues", $hash); + } +} + +#------------------------------------------------------------------------------------------------------ +# MAH_blockingGetAllDevicesRequest +#------------------------------------------------------------------------------------------------------ +sub MAH_blockingGetAllDevicesRequest($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $token = MAH_getAccessToken($hash); + if ($token eq "") { + return "Please authenticate first"; + } + + my $lang = AttrVal($name, "lang", "en"); + my $url = "https://api.mcs3.miele.com/v1/devices/?language=${lang}"; + my $header = { "accept" => "application/json; charset=utf-8", + "Authorization" => "Bearer " . $token }; + my ($err, $data) = HttpUtils_BlockingGet({ + url => $url, + header => $header, + timeout => 5, + hash => $hash, + method => "GET", + }); + + MAH_Log($hash, 5, "reply: err:$err, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + return $err; + } + + my $decoded = eval{decode_json($data)}; + if ($@) { + MAH_Log($hash, 3, "JSON error while request: $@"); + return; + } + + if (ref($decoded) ne "HASH") { + MAH_Log($hash, 3, "got wrong message for $name: $decoded"); + return; + } + + no strict "refs"; + + my @retval; + foreach my $id (keys %{$decoded}) { + push(@retval, [$id, $decoded->{$id}->{ident}->{type}->{value_localized}]); + } + + use strict "refs"; + + return \@retval; +} + +#------------------------------------------------------------------------------------------------------ +# MAH_sendGetDeviceIdentAndState +#------------------------------------------------------------------------------------------------------ +sub MAH_sendGetDeviceIdentAndState($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $deviceId = $hash->{DEVICE_ID}; + if ($deviceId eq "") { + return "Please set deviceId first"; + } + + my $token = MAH_getAccessToken($hash); + if ($token eq "") { + return "Please authenticate first"; + } + + my $lang = AttrVal($name, "lang", "en"); + my $url = "https://api.mcs3.miele.com/v1/devices/${deviceId}?language=${lang}"; + my $header = { "accept" => "application/json; charset=utf-8", + "Authorization" => "Bearer " . $token }; + my ($err, $data) = HttpUtils_NonblockingGet({ + url => $url, + header => $header, + timeout => 5, + hash => $hash, + method => "GET", + callback => \&MAH_onGetDeviceIdentAndStateReply, + }); +} +sub MAH_onGetDeviceIdentAndStateReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + return $err; + } + + if ($param->{code} != 200) { + MAH_Log($hash, 3, "Error: code != 200: $param->{code}"); + return "invalid status code: " . $param->{code}; + } + + my $json = eval{decode_json($data)}; + if ($@) { + MAH_Log($hash, 3, "JSON error while request: $@"); + return; + } + + if (ref($json) ne "HASH") { + MAH_Log($hash, 3, "got wrong message for $name: $json"); + return; + } + + # {"code":500,"message":"There was an error processing your request. It has been logged (ID xx)."} + if (exists($json->{code})) { + MAH_Log($hash, 3, "got error code: $json"); + return; + } + + # decode_utf8() is required due do something like: + # dein json ist utf8 aber das problem scheint zu sein das decode_json bei zeichen + # <255 aus dem \u ein \x{..} macht. d.h. es erzeugt kein utf8 2-byte zeichen wie + # es richtig wäre sondern macht aus dem code point ein 1-byte zeichen das dann + # als latin-1 erscheint. + + no strict "refs"; + + readingsBeginUpdate($hash); + readingsBulkUpdate($hash, "communicationModuleReleaseVersion", encode_utf8($json->{ident}->{xkmIdentLabel}->{releaseVersion})); + readingsBulkUpdate($hash, "communicationModuleTechType", encode_utf8($json->{ident}->{xkmIdentLabel}->{techType})); + readingsBulkUpdate($hash, "deviceHardwareFabIndex", encode_utf8($json->{ident}->{deviceIdentLabel}->{fabIndex})); + readingsBulkUpdate($hash, "deviceHardwareFabNumber", encode_utf8($json->{ident}->{deviceIdentLabel}->{fabNumber})); + readingsBulkUpdate($hash, "deviceHardwareMatNumber", encode_utf8($json->{ident}->{deviceIdentLabel}->{matNumber})); + readingsBulkUpdate($hash, "deviceHardwareTechType", encode_utf8($json->{ident}->{deviceIdentLabel}->{techType})); + readingsBulkUpdate($hash, "deviceName", encode_utf8($json->{ident}->{deviceName})); + readingsBulkUpdate($hash, "deviceType", encode_utf8($json->{ident}->{type}->{value_localized})); + + readingsBulkUpdate($hash, "elapsedTime", MAH_formatTime(@{$json->{state}->{elapsedTime}})); + readingsBulkUpdate($hash, "remainingTime", MAH_formatTime(@{$json->{state}->{remainingTime}})); + readingsBulkUpdate($hash, "startTime", MAH_formatTime(@{$json->{state}->{startTime}})); + + readingsBulkUpdate($hash, "dryingStep", encode_utf8($json->{state}->{dryingStep}->{value_localized})); + readingsBulkUpdate($hash, "light", encode_utf8($json->{state}->{light})); + readingsBulkUpdate($hash, "programID", encode_utf8($json->{state}->{ProgramID}->{value_localized})); + readingsBulkUpdate($hash, "programPhase", encode_utf8($json->{state}->{programPhase}->{value_localized})); + readingsBulkUpdate($hash, "programType", encode_utf8($json->{state}->{programType}->{value_localized})); + readingsBulkUpdate($hash, "spinningSpeed", encode_utf8($json->{state}->{spinningSpeed}->{value_localized})); + readingsBulkUpdate($hash, "status", encode_utf8($json->{state}->{status}->{value_localized})); + readingsBulkUpdate($hash, "statusRaw", $json->{state}->{status}->{value_raw}); + readingsBulkUpdate($hash, "ventilationStep", encode_utf8($json->{state}->{ventilationStep}->{value_localized})); + + # not documented yet + #readingsBulkUpdate($hash, "plateStep", @{$json->{state}->{plateStep}}); + + # not documented yet + readingsBulkUpdate($hash, "ecoFeedbackCurrentWaterConsumption", encode_utf8($json->{state}->{ecoFeedback}->{currentWaterConsumption}->{value})); + readingsBulkUpdate($hash, "ecoFeedbackCurrentEnergyConsumption", encode_utf8($json->{state}->{ecoFeedback}->{currentEnergyConsumption}->{value})); + readingsBulkUpdate($hash, "ecoFeedbackWaterForecast", encode_utf8($json->{state}->{ecoFeedback}->{waterForecast})); + readingsBulkUpdate($hash, "ecoFeedbackEnergyForecast", encode_utf8($json->{state}->{ecoFeedback}->{energyForecast})); + + readingsBulkUpdate($hash, "remoteEnableFullRC", $json->{state}->{remoteEnable}->{fullRemoteControl}); + readingsBulkUpdate($hash, "remoteEnableSmartGrid", $json->{state}->{remoteEnable}->{smartGrid}); + readingsBulkUpdate($hash, "signalDoor", $json->{state}->{signalDoor}); + readingsBulkUpdate($hash, "signalFailure", $json->{state}->{signalFailure}); + readingsBulkUpdate($hash, "signalInfo", $json->{state}->{signalInfo}); + + # temperature + readingsBulkUpdate($hash, "targetTemperature", MAH_decodeTemperature($hash, @{$json->{state}->{targetTemperature}})); + readingsBulkUpdate($hash, "temperature", MAH_decodeTemperature($hash, @{$json->{state}->{temperature}})); + + #eta & state + my ($eta, $etaHR) = MAH_calculateETA($json->{state}->{remainingTime}, + $json->{state}->{startTime}, + $json->{state}->{status}->{value_raw}); + readingsBulkUpdate($hash, "eta", $eta); + readingsBulkUpdate($hash, "etaHR", $etaHR); + readingsBulkUpdate($hash, "state", sprintf("%s (%s)", + encode_utf8($json->{state}->{status}->{value_localized}), $eta)); + + readingsEndUpdate($hash, 1 ); + + use strict "refs"; + + return undef; +} + +#------------------------------------------------------------------------------------------------------ +# MAH_sendGetDeviceActionsRequest +#------------------------------------------------------------------------------------------------------ +sub MAH_sendGetDeviceActionsRequest($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $deviceId = $hash->{DEVICE_ID}; + if ($deviceId eq "") { + return "Please set deviceId first"; + } + + my $token = MAH_getAccessToken($hash); + if ($token eq "") { + return "Please authenticate first"; + } + + my $lang = AttrVal($name, "lang", "en"); + my $url = "https://api.mcs3.miele.com/v1/devices/${deviceId}/actions?language=${lang}"; + my $header = { "accept" => "application/json; charset=utf-8", + "Authorization" => "Bearer " . $token }; + my ($err, $data) = HttpUtils_NonblockingGet({ + url => $url, + header => $header, + timeout => 5, + hash => $hash, + method => "GET", + callback => \&MAH_onGetDeviceActionsReply, + }); +} +sub MAH_onGetDeviceActionsReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + return $err; + } + + if ($param->{code} != 200) { + MAH_Log($hash, 3, "Error: code != 200: $param->{code}"); + return; + } + + my $json = eval{decode_json($data)}; + if ($@) { + MAH_Log($hash, 3, "JSON error while request: $@"); + return; + } + + if (ref($json) ne "HASH") { + MAH_Log($hash, 3, "got wrong message for $name: $json"); + return; + } + + # {"code":500,"message":"There was an error processing your request. It has been logged (ID xx)."} + if (exists($json->{code})) { + MAH_Log($hash, 3, "got error code: $json"); + return; + } + + # possible processAction out of + # 1 START + # 2 STOP + # 3 PAUSE + # 4 START SUPERFREEZING + # 5 STOP SUPERFREEZING + # 6 START SUPERCOOLING + # 7 STOP SUPERCOOLING + + no strict "refs"; + + readingsBeginUpdate($hash); + readingsBulkUpdate($hash, "actions_processAction", join(",", @{$json->{processAction}})); + readingsBulkUpdate($hash, "actions_light", join(",", @{$json->{light}})); + readingsBulkUpdate($hash, "actions_startTime", join(",", @{$json->{startTime}})); + readingsBulkUpdate($hash, "actions_ventilationStep", join(",", @{$json->{ventilationStep}})); + readingsBulkUpdate($hash, "actions_programId", join(",", @{$json->{programId}})); + readingsBulkUpdate($hash, "actions_startTime", join(",", MAH_parseActionsStartTime($json->{startTime}))); + readingsBulkUpdate($hash, "actions_deviceName", $json->{deviceName}); + readingsBulkUpdate($hash, "actions_powerOn", defined($json->{powerOn}) ? $json->{powerOn} : "0"); + readingsBulkUpdate($hash, "actions_powerOff", defined($json->{powerOff}) ? $json->{powerOff} : "0"); + readingsEndUpdate($hash, 1 ); + + use strict "refs"; + + return undef; +} + +#------------------------------------------------------------------------------------------------------ +# format time from array +#------------------------------------------------------------------------------------------------------ +sub MAH_decodeTemperature($@) +{ + my ($hash, @temps) = @_; + my $name = $hash->{NAME}; + + my @retval; + foreach my $t (@temps) { + if ($t->{value_raw} != -32768) { + push(@retval, $t->{value_localized}); + } + } + + return join(", ", @retval); +} + +#------------------------------------------------------------------------------------------------------ +# parse the startTime from actions which is either [] or [[0,0],[23,59]] +#------------------------------------------------------------------------------------------------------ +sub MAH_parseActionsStartTime($) +{ + my ($startTime) = @_; + + my @startTimeArray = @{$startTime}; + if (scalar(@startTimeArray) == 0) { + return ""; + } + + if (scalar(@startTimeArray) == 2) { + return MAH_formatTime(@{$startTimeArray[0]}) . "-" . MAH_formatTime(@{$startTimeArray[1]}); + } + + return "[?]"; +} + +#------------------------------------------------------------------------------------------------------ +# calculate the estimated time of arrival (as HH:MM and as human readable version) +#------------------------------------------------------------------------------------------------------ +sub MAH_calculateETA($$$) +{ + my ($remaining, $start, $statusRaw) = @_; + + # 1 = OFF + # 2 = ON + # 3 = PROGRAMMED + # 4 = PROGRAMMED WAITING TO START + # 5 = RUNNING + # 6 = PAUSE + # 7 = END PROGRAMMED + # 8 = FAILURE + # 9 = PROGRAMME INTERRUPTED + # 10 = IDLE + # 11 = RINSE HOLD + # 12 = SERVICE + # 13 = SUPERFREEZING + # 14 = SUPERCOOLING + # 15 = SUPERHEATING + # 146 = SUPERCOOLING_SUPERFREEZING + # 255 = NOT_CONNECTED + + my ($remainingHour, $remainingMinute) = @{$remaining}; + my ($startHour, $startMinute) = @{$start}; + + return ("-:-", "-:-") if ($statusRaw == 1 || $statusRaw == 255); # Off + + my $startOffsetSecs = $startHour * 3600 + $startMinute * 60; + my $remainingSecs = $remainingHour * 3600 + $remainingMinute * 60; + + if ($statusRaw == 4) { # delay active + my $eta = POSIX::strftime("%H:%M", localtime(time + $startOffsetSecs + $remainingSecs)); + my $etaHR = $eta; + return ($eta, $etaHR); + } + + if ($statusRaw == 2 || $statusRaw == 7) { # On (but not running) or End + my $eta = POSIX::strftime("%H:%M", localtime(time + $remainingSecs)); # ignore startOffsetSecs here as this is very strange + my $etaHR = "+" . MAH_formatTime($remainingHour, $remainingMinute); + return ($eta, $etaHR); + } + + # if ($statusRaw == 5) { # In Betrieb + my $eta = POSIX::strftime("%H:%M", localtime(time + $remainingSecs)); # ignore startOffsetSecs here as this is very strange + my $etaHR = $eta; + + # write remaining minutes in the last 15 minutes instead of + $etaHR = sprintf("+0:%02d", ${remainingMinute}) if ($remainingSecs <= 15 * 60); + + return ($eta, $etaHR); + # } + # return POSIX::strftime("%Y-%m-%d %H:%M", localtime(time + $offset)); +} + +#------------------------------------------------------------------------------------------------------ +# format time from array +#------------------------------------------------------------------------------------------------------ +sub MAH_formatTime(@) +{ + my ($hour, $minute) = @_; + return sprintf("%d:%02d", $hour, $minute); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_autocreate +#------------------------------------------------------------------------------------------------------ +sub MAH_autocreate($) +{ + my ($hash) = @_; + my $name = $hash->{NAME}; + + my $devices = MAH_blockingGetAllDevicesRequest($hash); + if(ref($devices) ne 'ARRAY') { + readingsSingleUpdate($hash, "lastError", "autocreate failed: $devices", 1); + return; + } + + for my $d (@{$devices}) { + my $deviceId = @{$d}[0]; + if (defined($modules{MieleAtHome}{defptr}{"deviceid_".$deviceId})) { + MAH_Log($hash, 3, "autocreate - device with deviceId $deviceId already exists"); + } else { + my $nameOfDevice = "Miele_${deviceId}"; + if (IsDevice($nameOfDevice)) { + MAH_Log($hash, 3, "not autocreating device, as device with proposed name already exists (${nameOfDevice})"); + } else { + fhem("define $nameOfDevice MieleAtHome ${deviceId}\@${name}"); + if (IsDevice($nameOfDevice)) { + # return "Can't create, device $nameOfDevice already existing." + # unless (IsDevice($nameOfDevice, "IPCAM")); + + fhem("attr ".$nameOfDevice." comment Auto-created by $name") + unless (defined($attr{$nameOfDevice}{comment})); + + MAH_Log($hash, 3, "created device ${nameOfDevice}, with deviceId ${deviceId}"); + } + } + } + } +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setPower +#------------------------------------------------------------------------------------------------------ +sub MAH_setPower($$) +{ + my ($hash, $onOrOff) = @_; + my $name = $hash->{NAME}; + + if ($onOrOff eq "on") { + return "power 'on' is currently not available" if (ReadingsNum($name, "actions_powerOn", 0) != 1); + return MAH_setAction($hash, "powerOn", "true"); + } elsif ($onOrOff eq "off") { + return "power 'off' is currently not available" if (ReadingsNum($name, "actions_powerOff", 0) != 1); + return MAH_setAction($hash, "powerOff", "true"); + } else { + return "use either 'on' or 'off'"; + } +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setProcessAction +#------------------------------------------------------------------------------------------------------ +sub MAH_setProcessAction($$) +{ + my ($hash, $processActionName) = @_; + my $name = $hash->{NAME}; + + my $processActionId = grep{ PROCESS_ACTIONS->{$_} eq $processActionName } keys %{PROCESS_ACTIONS()}; + if (!defined $processActionId) { + return "invalid processAction: '${processActionName}'"; + } + + my @availableProcessActions = split(/,/, ReadingsVal($name, "actions_processAction", "")); + if (! grep {$_ eq $processActionId} @availableProcessActions) { + return "'${processActionName}' is currently not available"; + } + + return MAH_setAction($hash, "processAction", "${processActionId}"); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setLight +#------------------------------------------------------------------------------------------------------ +sub MAH_setLight($$) +{ + my ($hash, $lightActionName) = @_; + my $name = $hash->{NAME}; + + my $lightActionId = grep{ LIGHT_ACTIONS->{$_} eq $lightActionName } keys %{LIGHT_ACTIONS()}; + if (!defined $lightActionId) { + return "invalid light action: '${lightActionName}'"; + } + + my @availableLightActions = split(/,/, ReadingsVal($name, "actions_light", "")); + if (! grep {$_ eq $lightActionId} @availableLightActions) { + return "'${lightActionName}' is currently not available"; + } + + return MAH_setAction($hash, "light", "${lightActionId}"); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setVentilationStep +#------------------------------------------------------------------------------------------------------ +sub MAH_setVentilationStep($$) +{ + my ($hash, $ventilationStepName) = @_; + my $name = $hash->{NAME}; + + my $ventilationStepId = grep{ VENTILATION_STEPS->{$_} eq $ventilationStepName } keys %{VENTILATION_STEPS()}; + if (!defined $ventilationStepId) { + return "invalid ventilation step: '${ventilationStepName}'"; + } + + my @availableVentilationStepIds = split(/,/, ReadingsVal($name, "actions_ventilationStep", "")); + if (! grep {$_ eq $ventilationStepId} @availableVentilationStepIds) { + return "'${ventilationStepName}' is currently not available"; + } + + return MAH_setAction($hash, "ventilationStep", "${ventilationStepId}"); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setStartTime +#------------------------------------------------------------------------------------------------------ +sub MAH_setStartTime($$) +{ + my ($hash, $startTimeString) = @_; + my $name = $hash->{NAME}; + + if ($startTimeString =~ m/$[0-9]+:[0-9]+]^/) { + return "invalid startTime format: '${startTimeString}', must be [h]h:mm"; + } + + if (ReadingsNum($name, "actions_startTime", 0) != 1) { + return "'startTime' is currently not setable"; + } + + $startTimeString =~ s/:/,/; + return MAH_setAction($hash, "startTime", "[${startTimeString}]"); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_setAction +#------------------------------------------------------------------------------------------------------ +sub MAH_setAction($$$) +{ + my ($hash, $action, $value) = @_; + + my $actionJson = "{\"$action\":$value}"; + return MAH_sendSetActionRequest($hash, $actionJson); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_sendSetActionRequest, $action needs to be the json-encoded action like »{"powerOn":true}« +#------------------------------------------------------------------------------------------------------ +sub MAH_sendSetActionRequest($$) +{ + my ($hash, $action) = @_; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "called with action $action"); + + my $deviceId = $hash->{DEVICE_ID}; + if ($deviceId eq "") { + return "Please set deviceId first"; + } + + my $token = MAH_getAccessToken($hash); + if ($token eq "") { + return "Please authenticate first"; + } + + my $url = "https://api.mcs3.miele.com/v1/devices/${deviceId}/actions"; + my $header = { "accept" => "*/*", + "Content-Type" => "application/json", + "Authorization" => "Bearer " . $token }; + my ($err, $reply) = HttpUtils_NonblockingGet({ + url => $url, + header => $header, + timeout => 30, # this somethimes takes soooome time + hash => $hash, + method => "PUT", + data => $action, + callback => \&MAH_onSetActionReply, + }); + + return undef; +} +sub MAH_onSetActionReply($$$) +{ + my ($param, $err, $data) = @_; + my $hash = $param->{hash}; + my $name = $hash->{NAME}; + + MAH_Log($hash, 5, "reply: err:$err, code:$param->{code}, data:$data"); + + if ($err) { + MAH_Log($hash, 3, "Error: $err"); + return $err; + } + + # it generally takes some time for the API to react to changes + InternalTimer(gettimeofday()+5, "MAH_sendGetDeviceActionsRequest", $hash); +} + +#------------------------------------------------------------------------------------------------------ +# * if it is a duplicate instance -> bah +# * if disabled -> ... +#------------------------------------------------------------------------------------------------------ +sub MAH_isDisabled($) +{ + my ($hash) = @_; + return $hash->{DUPLICATE_INSTANCE} || + AttrVal($hash->{NAME}, "disable", "") || + IsDisabled($hash->{NAME}); +} + +#------------------------------------------------------------------------------------------------------ +# MAH_hasLoginCredentials +#------------------------------------------------------------------------------------------------------ +sub MAH_hasLoginCredentials($) +{ + my ($hash) = @_; + + return 0 unless (defined(MAH_getClientId($hash))); + return 0 unless (defined(MAH_getLogin($hash))); + return 0 unless (defined(MAH_getPassword($hash))); + return 0 unless (defined(MAH_getClientSecret($hash))); + return 1; +} + +#------------------------------------------------------------------------------------------------------ +# MAH_getClientId(), MAH_getLogin(), MAH_getPassword(), MAH_getClientSecret() +#------------------------------------------------------------------------------------------------------ +sub MAH_getClientId($); # workaround for perl warning +sub MAH_getClientId($) +{ + my ($hash) = @_; + + my $retval = AttrVal($hash->{NAME}, "clientId", ""); + return $retval if ($retval ne ""); + + my $iohash = MAH_getIODevHash($hash); + return MAH_getClientId($iohash) if (defined($iohash)); + + return undef; +} +sub MAH_getLogin($); # workaround for perl warning +sub MAH_getLogin($) +{ + my ($hash) = @_; + + my $retval = AttrVal($hash->{NAME}, "login", ""); + return $retval if ($retval ne ""); + + my $iohash = MAH_getIODevHash($hash); + return MAH_getLogin($iohash) if (defined($iohash)); + + return undef; +} +sub MAH_getPassword($); # workaround for perl warning +sub MAH_getPassword($) +{ + my ($hash) = @_; + + my $retval = MAH_loadPassword($hash); + return $retval if (defined($retval)); + + my $iohash = MAH_getIODevHash($hash); + return MAH_getPassword($iohash) if (defined($iohash)); + + return undef; +} +sub MAH_getClientSecret($); # workaround for perl warning +sub MAH_getClientSecret($) +{ + my ($hash) = @_; + + my $retval = MAH_loadClientSecret($hash); + return $retval if (defined($retval)); + + my $iohash = MAH_getIODevHash($hash); + return MAH_getClientSecret($iohash) if (defined($iohash)); + + return undef; +} +sub MAH_getAccessToken($); # workaround for perl warning +sub MAH_getAccessToken($) +{ + my ($hash) = @_; + + # try to find local token + my $accessToken = MAH_getAccessTokenPrivate($hash); + if ($accessToken ne "") { + my $secs = MAH_getRemainingTokenLifetime($hash); + MAH_Log($hash, 4, "found local token with remaining lifetime of ${secs} seconds"); + MAH_refreshAccessToken($hash) if ($secs < 24 * 60 * 60); + return $accessToken if ($secs > 0); + } + + # try to find token in IODev + my $iohash = MAH_getIODevHash($hash); + return MAH_getAccessToken($iohash) if (defined($iohash)); + + #MAH_refreshAccessToken($hash); + return ""; +} +sub MAH_getAccessTokenPrivate($); # workaround for perl warning +sub MAH_getAccessTokenPrivate($) +{ + my ($hash) = @_; + + # try to find local token + if (defined($hash->{OAUTH2_ACCESS_TOKEN})) { + return $hash->{OAUTH2_ACCESS_TOKEN}; + } + + # try to find token in IODev + my $iohash = MAH_getIODevHash($hash); + return MAH_getAccessTokenPrivate($iohash) if (defined($iohash)); + + return ""; +} +sub MAH_getRefreshTokenPrivate($); # workaround for perl warning +sub MAH_getRefreshTokenPrivate($) +{ + my ($hash) = @_; + + # try to find local token + if (defined($hash->{OAUTH2_REFRESH_TOKEN})) { + return $hash->{OAUTH2_REFRESH_TOKEN}; + } + + # try to find token in IODev + my $iohash = MAH_getIODevHash($hash); + return MAH_getRefreshTokenPrivate($iohash) if (defined($iohash)); + + return ""; +} +sub MAH_getRemainingTokenLifetime($); # workaround for perl warning +sub MAH_getRemainingTokenLifetime($) +{ + my ($hash) = @_; + + if (defined($hash->{OAUTH2_EXPIRES_AT})) { + my $secs = time_str2num($hash->{OAUTH2_EXPIRES_AT}) - time; + return $secs; + } + + # try to find token in IODev + my $iohash = MAH_getIODevHash($hash); + return MAH_getRemainingTokenLifetime($iohash) if (defined($iohash)); + + return 0; +} + + +#------------------------------------------------------------------------------------------------------ +# MAH_getIODevHash +#------------------------------------------------------------------------------------------------------ +sub MAH_getIODevHash($) +{ + my ($hash) = @_; + + return undef unless (defined($hash->{IODevName})); + return undef unless (defined($defs{$hash->{IODevName}})); + return $defs{$hash->{IODevName}}; +} + +#------------------------------------------------------------------------------------------------------ +# Util: clientSecret +#------------------------------------------------------------------------------------------------------ +sub MAH_saveClientSecret($$) +{ + my ($hash,$clientSecret) = @_; + return MAH_setKeyValue($hash, "clientSecret", $clientSecret); +} +sub MAH_loadClientSecret($) +{ + my ($hash) = @_; + return MAH_getKeyValue($hash, "clientSecret"); +} +sub MAH_renameClientSecret($$$) +{ + my ($newHash,$oldName,$newName) = @_; + MAH_renameKeyValue($newHash, $oldName, $newName, "clientSecret"); +} +sub MAH_deleteClientSecret($) +{ + my ($hash) = @_; + return MAH_deleteKeyValue($hash, "clientSecret"); +} + +#------------------------------------------------------------------------------------------------------ +# Util: password +#------------------------------------------------------------------------------------------------------ +sub MAH_savePassword($$) +{ + my ($hash,$password) = @_; + return MAH_setKeyValue($hash, "passwd", $password); +} +sub MAH_loadPassword($) +{ + my ($hash) = @_; + return MAH_getKeyValue($hash, "passwd"); +} +sub MAH_renamePassword($$$) +{ + my ($newHash,$oldName,$newName) = @_; + MAH_renameKeyValue($newHash, $oldName, $newName, "passwd"); +} +sub MAH_deletePassword($) +{ + my ($hash) = @_; + return MAH_deleteKeyValue($hash, "passwd"); +} + +#------------------------------------------------------------------------------------------------------ +# Util: oauth2 credentials +#------------------------------------------------------------------------------------------------------ +sub MAH_saveOAuth2Credentials($) +{ + my ($hash) = @_; + + MAH_setKeyValue($hash, "OAUTH2_ACCESS_TOKEN", $hash->{OAUTH2_ACCESS_TOKEN}); + MAH_setKeyValue($hash, "OAUTH2_REFRESH_TOKEN", $hash->{OAUTH2_REFRESH_TOKEN}); + MAH_setKeyValue($hash, "OAUTH2_EXPIRES_IN", $hash->{OAUTH2_EXPIRES_IN}); + MAH_setKeyValue($hash, "OAUTH2_EXPIRES_AT", $hash->{OAUTH2_EXPIRES_AT}); +} +sub MAH_restoreOAuth2Credentials($) +{ + my ($hash) = @_; + + my $v = MAH_getKeyValue($hash, "OAUTH2_ACCESS_TOKEN"); + if (defined($v) && !defined($hash->{OAUTH2_ACCESS_TOKEN})) { + $hash->{OAUTH2_ACCESS_TOKEN} = $v; + } + + $v = MAH_getKeyValue($hash, "OAUTH2_REFRESH_TOKEN"); + if (defined($v) && !defined($hash->{OAUTH2_REFRESH_TOKEN})) { + $hash->{OAUTH2_REFRESH_TOKEN} = $v; + } + + $v = MAH_getKeyValue($hash, "OAUTH2_EXPIRES_IN"); + if (defined($v) && !defined($hash->{OAUTH2_EXPIRES_IN})) { + $hash->{OAUTH2_EXPIRES_IN} = $v; + } + + $v = MAH_getKeyValue($hash, "OAUTH2_EXPIRES_AT"); + if (defined($v) && !defined($hash->{OAUTH2_EXPIRES_AT})) { + $hash->{OAUTH2_EXPIRES_AT} = $v; + } +} +sub MAH_renameOAuth2Credentials($$$) +{ + my ($newHash,$oldName,$newName) = @_; + MAH_renameKeyValue($newHash, $oldName, $newName, "OAUTH2_ACCESS_TOKEN"); + MAH_renameKeyValue($newHash, $oldName, $newName, "OAUTH2_REFRESH_TOKEN"); + MAH_renameKeyValue($newHash, $oldName, $newName, "OAUTH2_EXPIRES_IN"); + MAH_renameKeyValue($newHash, $oldName, $newName, "OAUTH2_EXPIRES_AT"); +} +sub MAH_deleteOAuth2Credentials($) +{ + my ($hash) = @_; + MAH_deleteKeyValue($hash, "OAUTH2_ACCESS_TOKEN"); + MAH_deleteKeyValue($hash, "OAUTH2_REFRESH_TOKEN"); + MAH_deleteKeyValue($hash, "OAUTH2_EXPIRES_IN"); + MAH_deleteKeyValue($hash, "OAUTH2_EXPIRES_AT"); +} + +#------------------------------------------------------------------------------------------------------ +# Util: MAH_setKeyValue +#------------------------------------------------------------------------------------------------------ +sub MAH_setKeyValue($$$) +{ + my ($hash,$subkey,$value) = @_; + my $type = $hash->{TYPE}; + my $name = $hash->{NAME}; + + my $key = "${type}_${name}_${subkey}"; + + # always prepend passwords with '=' to allow to upgrade from having no + # base64 encoding to using base64. decode_base64() ignores everything + # after the '=' so if we try to decode a not decoded password (starting + # with '='), this will result in an empty value which can be detected. + $value = "=" . $value; + + # base64 encode if possible + $value = encode_base64($value) if ($MAH_hasMimeBase64); + + my $err = setKeyValue($key, $value); + MAH_Log($hash, 3, "Error when setting $key: $err") if ($err); + return $err; +} + +#------------------------------------------------------------------------------------------------------ +# Util: MAH_getKeyValue +#------------------------------------------------------------------------------------------------------ +sub MAH_getKeyValue($$) +{ + my ($hash,$subkey) = @_; + my $type = $hash->{TYPE}; + my $name = $hash->{NAME}; + + my $key = "${type}_${name}_${subkey}"; + my ($err, $value) = getKeyValue($key); + + # error + if ($err) { + MAH_Log($hash, 3, "Error when fetching $key: $err"); + return undef; + } + + # no value found + return undef unless (defined($value)); + + my $retval = $value; + if ($MAH_hasMimeBase64) { + # try to base64-decode the retval. + $retval = decode_base64($value); + + # if it is empty, it was not encoded (as decode_base64() ignores everything + # after our initial '=') + $retval = $value if ($retval eq ""); + } + + # our retval is always stored with a leading '=' + if ($retval !~ /^=.*/) { + MAH_Log($hash, 3, "failed to fetch retval: $retval"); + return undef; + } + + # remove the leading '=' which was added in MAH_setBasicAuth() + return substr($retval, 1); +} + +#------------------------------------------------------------------------------------------------------ +# Util: MAH_renameKeyValue +#------------------------------------------------------------------------------------------------------ +sub MAH_renameKeyValue($$$$) +{ + my ($newHash,$oldName,$newName,$subkey) = @_; + my $type = $newHash->{TYPE}; + + my $oldKey = "${type}_${oldName}_${subkey}"; + my $newKey = "${type}_${newName}_${subkey}"; + + my ($err, $data) = getKeyValue($oldKey); + return undef unless(defined($data)); + + setKeyValue($newKey, $data); + setKeyValue($oldKey, undef); +} + +#------------------------------------------------------------------------------------------------------ +# Util: MAH_deleteKeyValue +#------------------------------------------------------------------------------------------------------ +sub MAH_deleteKeyValue($$) +{ + my ($hash,$subkey) = @_; + my $type = $hash->{TYPE}; + my $name = $hash->{NAME}; + + my $key = "${type}_${name}_${subkey}"; + my $err = setKeyValue($key, undef); +} + +#------------------------------------------------------------------------------------------------------ +# Util: Log +#------------------------------------------------------------------------------------------------------ +sub MAH_Log($$$) +{ + my ($hash, $logLevel, $logMessage) = @_; + my $line = ( caller(0) )[2]; + my $modAndSub = ( caller(1) )[3]; + my $subroutine = ( split(':', $modAndSub) )[2]; + my $name = ( ref($hash) eq "HASH" ) ? $hash->{NAME} : "MieleAtHome"; + + Log3($hash, $logLevel, "${name} (MieleAtHome::${subroutine}:${line}) " . $logMessage); + #Log3($hash, $logLevel, "${name} (MieleAtHome::${subroutine}:${line}) Stack was: " . MAH_getStacktrace()); +} + +#------------------------------------------------------------------------------------------------------ +# Util: returns a stacktrace as a string (for debbugging) +#------------------------------------------------------------------------------------------------------ +sub MAH_getStacktrace($$$) +{ + my ($package, $filename, $line, $subroutine, $hasargs, $wantarray, $evaltext, $is_require, $hints, $bitmask, $hinthash); + my $i = 2; # skip MAH_getStacktrace() and MAH_Log() + my @r; + my $retval = ""; + while (@r = caller($i)) { + ($package, $filename, $line, $subroutine, $hasargs, $wantarray, $evaltext, $is_require, $hints, $bitmask, $hinthash) = @r; + $subroutine = ( split( ':', $subroutine ) )[2]; + $retval = "->${line}:${subroutine}${retval}"; + $i++; + } + return $retval; +} + + +# must be last +1; + + +=pod +=item device +=item summary Module to control Miele@home-devices via their 3rd party API +=item summary_DE Modul zur Steuerung von Miele@home-Geräten mittels 3rd Party API + +=begin html + + +

MieleAtHome

+
    + MieleAtHome - Controls Miele@home Devices
    +
    + About
    +
    + The MieleAtHome module uses the Miele 3rd Party Cloud API. You need a Miele Developer Account to use it! See below for details.
    + To use the MieleAtHome module you first have to define a device which will act als shared provider for your credentials. When this one is set up, you can use the autocreate-feature to create devices for your appliances.
    +
    + Miele Developer Account:
    +
    + To use this module you need to register as a developer at https://www.miele.com/f/com/en/register_api.aspx. After you successfully registered, you will receive a clientId and a clientSecret which you'll need to configure in your <gateway>-device.
    +
    + + Define
    +
    + (1) Setup gateway:
    + define <gateway> MieleAtHome
    +
    + (2a) Autocreate devices:
    + set <gateway> autocreate
    +
    + (2b) Manually create devices:
    + define <MieleDevice> MieleAtHome <DeviceId>@<gateway> [Interval]
    +
    + Example
    +
    + (1) Setup gateway:
    + define MieleConnection MieleAtHome
    + attr MieleConnection login mylogin@example.com
    + attr MieleConnection clientId xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    + set MieleConnection password mypassword
    + set MieleConnection clientSecret yyyyyyyyyyyyyyyyyyyy
    +
    + This instance (MieleConnection) will be used to share the credentials. You have to set the attributes login and clientId. Then you have to set password and clientSecret via set MieleConnection-command.
    +
    + (2a) Autocreate devices:
    + set MieleConnection autocreate
    +
    + This will create a device called Miele_xxxxxxxxxxxxx. You can rename autocreated devices afterwards.
    +
    + (2b) Manually create devices:
    + define Waschmaschine MieleAtHome 000123456789@MieleConnection 120
    +
    + This statement creates the instance of your specific Miele@home appliance with the name Waschmaschine and the DeviceId 000123456789 and a refresh-interval of 120 seconds. The interval is optional, its default is 120 seconds.
    +
    + + + + Set +
      +
    • +
      autocreate
      + autocreate fhem-devices for each Miele@home appliance found in your account. Needs login, clientId, password and clientSecret to be configured properly. Only available for the gateway device. +
    • +
    • +
      clientSecret <secret>
      + sets the clientSecret of your Miele@home-developer Account and stores it in a file (base64-encoded if you have MIME::Base64 installed). +
    • +
    • +
      light [enable|disable]
      + enable/disable the light of your device. only available depending on the type and state of your appliance. +
    • +
    • +
      on
      + power up your device. only available depending on the type and state of your appliance. +
    • +
    • +
      off
      + power off your device. only available depending on the type and state of your appliance. +
    • +
    • +
      password <pass>
      + set the password of your Miele@home Account and stores it in a file (base64-encoded if you have MIME::Base64 installed). +
    • +
    • +
      pause
      + pause your device. only available depending on the type and state of your appliance. +
    • +
    • +
      start
      + start your device. only available depending on the type and state of your appliance. +
    • +
    • +
      stop
      + stop your device. only available depending on the type and state of your appliance. +
    • +
    • +
      startSuperFreezing
      + start super freezing your device. only available depending on the type and state of your appliance. +
    • +
    • +
      stopSuperFreezing
      + stop super freezing your device. only available depending on the type and state of your appliance. +
    • +
    • +
      startSuperCooling
      + start super cooling your device. only available depending on the type and state of your appliance. +
    • +
    • +
      stopSuperCooling
      + stop super cooling your device. only available depending on the type and state of your appliance. +
    • +
    • +
      update
      + instantly update all readings. +
    • +
    • +
      ventilationStep [Step1|Step2|Step3|Step4]
      + set the ventilation step of your device. only available depending on the type and state of your appliance. +
    • +
    +
    + + + Get +
      +
    • +
      listDevices
      + lists the devices associated with your Miele@home-account. Needs login, clientId, password and clientSecret to be configured properly. +
    • +
    +
    + + + Attributes +
      +
    • +
      clientId
      + set the clientId of your Miele@home-developer account. +
    • +
    • +
      country
      + set the country where you registered your Miele@home account. +
    • +
    • +
      login
      + set the login of your Miele@home account. +
    • +
    • +
      disable
      + disables this MieleAtHome-instance. +
    • +
    • +
      lang [de|en]
      + request the readings in either german or english. en is default. +
    • +
    +
+ +=end html +# =begin html_DE +# +# +# =end html_DE +=cut diff --git a/fhem/MAINTAINER.txt b/fhem/MAINTAINER.txt index 5cf898768..b182292d6 100644 --- a/fhem/MAINTAINER.txt +++ b/fhem/MAINTAINER.txt @@ -256,6 +256,7 @@ FHEM/46_TRX_LIGHT.pm KernSani RFXTRX FHEM/46_TRX_SECURITY.pm KernSani RFXTRX FHEM/46_TRX_WEATHER.pm KernSani RFXTRX FHEM/47_OBIS.pm icinger Sonstige Systeme +FHEM/48_MieleAtHome.pm choenig Sonstige Systeme FHEM/49_Arlo.pm maluk Sonstige Systeme FHEM/49_IPCAM.pm mfr69bs Sonstiges FHEM/49_SSCam.pm DS_Starter Sonstiges