######################################################################################## # # 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 = "2.1.1"; my $MAH_hasMimeBase64 = 1; # DONE: 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 }; # DONE: use constant LIGHT_ACTIONS => { 0x01 => "enable", # 1 Enable 0x02 => "disable", # 2 Disable }; # DONE: use constant VENTILATION_STEPS => { 0x01 => "Step1", # 1 Step1 0x02 => "Step2", # 2 Step2 0x03 => "Step3", # 3 Step3 0x04 => "Step4", # 4 Step4 }; # DONE: use constant MODE_ACTIONS => { 0x00 => "normalOperationMode", # 0 Normal operation mode 0x01 => "sabbathMode", # 1 Sabbath mode }; # DONE: # DONE: # DONE: # DONE: # TODO: # TODO: # 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} .= "api:poll,event "; $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 MieleAtHome [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_start", $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_startPoll", $hash); # } return undef; } #------------------------------------------------------------------------------------------------------ # Undefine #------------------------------------------------------------------------------------------------------ sub MAH_UndefFn($$) { my ($hash, $name) = @_; RemoveInternalTimer($hash); MAH_closeEventSource($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"); InternalTimer(gettimeofday()+0, "MAH_stopPoll", $hash); InternalTimer(gettimeofday()+0, "MAH_closeEventSource", $hash); } elsif ($cmd eq "del") { readingsSingleUpdate ( $hash, "state", "active", 1 ); MAH_Log($hash, 3, "enabled"); InternalTimer(gettimeofday()+0, "MAH_start", $hash); } } ############### #### api ###### if ($attrName eq "api") { if ($cmd eq "set") { return "Invalid value for attribute $attrName" if ($attrVal ne "poll" && $attrVal ne "event"); } InternalTimer(gettimeofday()+0, "MAH_start", $hash) if ($init_done); } ################# #### 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 "autocreate needs a valid ACCESS_TOKEN, please try again" if (MAH_getAccessToken($hash) eq ""); return "use $cmd without arguments" if(@args != 0); InternalTimer(gettimeofday()+0, "MAH_autocreate", $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 'startTime') { return "usage: startTime " if(@args != 1); return MAH_setStartTime($hash, $args[0]); } elsif( $cmd eq 'update' ) { return "use $cmd without arguments" if(@args != 0); InternalTimer(gettimeofday()+0, "MAH_updateValues", $hash); return undef; } elsif( $cmd eq 'ventilationStep') { return "usage: ventilationStep " if(@args != 1); return MAH_setVentilationStep($hash, $args[0]); } elsif( $cmd eq 'light') { return "usage: light enable|disable" if(@args != 1); return MAH_setLight($hash, $args[0]); } elsif( $cmd eq 'mode') { return "usage: mode " if(@args != 1); return MAH_setMode($hash, $args[0]); } elsif( $cmd =~ /targetTemperature_zone([0-9]+)/) { return "usage: targetTemperature_zone${1} " if(@args != 1); return MAH_setTargetTemperature($hash, $1, $args[0]); } else { $list .= "autocreate:noArg " if (!defined($hash->{IODevName})); $list .= "update:noArg " if (defined($hash->{DEVICE_ID}) && !defined($hash->{EventSource})); $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 my $lightCmds = MAH_getAvailableCommands($hash, "actions_light", LIGHT_ACTIONS()); $list .= "light:${lightCmds} " if ($lightCmds ne ""); # ventilation steps my $ventilationStepCmds = MAH_getAvailableCommands($hash, "actions_ventilationStep", VENTILATION_STEPS()); $list .= "ventilationStep:${ventilationStepCmds} " if ($ventilationStepCmds ne ""); # modes my $modeCmds = MAH_getAvailableCommands($hash, "actions_modes", MODE_ACTIONS()); $list .= "mode:${modeCmds} " if ($modeCmds ne ""); # target temperatures my @availableTargetTemperatures = split(/ /, ReadingsVal($name, "actions_targetTemperature", "")); foreach my $targetTemperature (@availableTargetTemperatures) { if ($targetTemperature =~ /^([0-9]+)\[(-?[0-9]+),(-?[0-9]+)\]$/) { $list .= "targetTemperature_zone$1:slider,$2,1,$3 " } } return "Unknown argument $cmd, choose one of $list"; } } #------------------------------------------------------------------------------------------------------ # returns the strings of the commands from const ACTIONS that are currently available (via `actions_...`) #------------------------------------------------------------------------------------------------------ sub MAH_getAvailableCommands($$$) { my ($hash, $action_reading, $ACTIONS) = @_; my $name = $hash->{NAME}; my $cmds = ""; my @ids = split(/,/, ReadingsVal($name, $action_reading, "")); foreach my $id (@ids) { if (defined $ACTIONS->{$id}) { $cmds .= $ACTIONS->{$id} . ","; } } chop($cmds); # remove trailing ',' return $cmds; } #------------------------------------------------------------------------------------------------------ # 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' ) { return "listDevices needs a valid ACCESS_TOKEN, please try again" if (MAH_getAccessToken($hash) eq ""); 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 (!defined($hash->{IODevName}) && !MAH_isDisabled($hash)); 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); } #------------------------------------------------------------------------------------------------------ # start either poll- or event-based requests #------------------------------------------------------------------------------------------------------ sub MAH_start($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); return undef if (MAH_isDisabled($hash)); return undef unless (defined($hash->{DEVICE_ID})); return undef unless (MAH_hasLoginCredentials($hash)); my $api = AttrVal($name, "api", "poll"); MAH_Log($hash, 3, "starting $api"); if ($api eq "poll") { MAH_closeEventSource($hash); MAH_startPoll($hash); } elsif ($api eq "event") { MAH_stopPoll($hash); MAH_openEventSource($hash); } } #------------------------------------------------------------------------------------------------------ # stop polling the API #------------------------------------------------------------------------------------------------------ sub MAH_stopPoll($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); RemoveInternalTimer($hash, "MAH_startPoll"); } #------------------------------------------------------------------------------------------------------ # start polling the API #------------------------------------------------------------------------------------------------------ sub MAH_startPoll($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); MAH_stopPoll($hash); 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_startPoll", $hash) if (defined($interval)); # MAH_getAccessToken will request a new one, if there is none if (MAH_getAccessToken($hash) eq "") { return; } MAH_updateValues($hash); } sub MAH_updateValues($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); MAH_sendGetDeviceIdentAndState($hash); MAH_sendGetDeviceActionsRequest($hash); } #------------------------------------------------------------------------------------------------------ # MAH_closeEventSource #------------------------------------------------------------------------------------------------------ sub MAH_closeEventSource($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); RemoveInternalTimer($hash, "MAH_eventSourceKeepAlive"); return if(!defined($hash->{helper}{HTTP_CONNECTION})); HttpUtils_Close( $hash->{helper}{HTTP_CONNECTION} ); delete $hash->{helper}{HTTP_CONNECTION}; MAH_updateEventSourceReadings($hash, undef, undef, undef) } #------------------------------------------------------------------------------------------------------ # request values via event-api #------------------------------------------------------------------------------------------------------ sub MAH_openEventSource($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); MAH_closeEventSource($hash); return undef if (MAH_isDisabled($hash)); return undef unless (defined($hash->{DEVICE_ID})); return undef unless (MAH_hasLoginCredentials($hash)); my $deviceId = $hash->{DEVICE_ID}; if ($deviceId eq "") { return "Please set deviceId first"; } # after a new token is requested, MAH_start() is called again my $token = MAH_getAccessToken($hash); if ($token eq "") { return "Please authenticate first"; } MAH_updateEventSourceReadings($hash, "connecting", TimeNow(), undef); my $lang = AttrVal($name, "lang", "en"); my $params = { # loglevel => 1, url => "https://api.mcs3.miele.com/v1/devices/${deviceId}/events", httpversion => '1.1', method => 'GET', timeout => 60, # ping interval from Miele seems to be 20s incrementalTimeout => 1, noshutdown => 1, keepalive => 1, header => { "Accept" => "text/event-stream", "Accept-Language" => "${lang}", "Authorization" => "Bearer " . $token }, hash => $hash, callback => \&MAH_eventSourceDispatch, }; map { $hash->{helper}{HTTP_CONNECTION}{$_} = $params->{$_} } keys %{$params}; HttpUtils_NonblockingGet( $hash->{helper}{HTTP_CONNECTION} ); # start keepalive MAH_eventSourceKeepAlive($hash); } #------------------------------------------------------------------------------------------------------ sub MAH_eventSourceDispatch($$$) { my ($param, $err, $data) = @_; my $hash = $param->{hash}; my $name = $hash->{NAME}; MAH_LogReply($hash, $param, $err, $data); if ($err) { MAH_updateEventSourceReadings($hash, "error: ${err}", undef, undef); MAH_Log($hash, 1, "Error: $err"); # MAH_openEventSource() will close first MAH_openEventSource($hash); return undef; } MAH_updateEventSourceReadings($hash, "connected", undef, TimeNow()); # FORMAT: (https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) # event: ping # data: ping #while ($data ne "" && $data =~ m/event:\s*(.+)(?:\r?\n)?data:\s*(.+)(?:\r?\n)?(.*)/) { my $event = ""; my $eventData = ""; while($data =~ m/([^:]*):\s*(.+)(?:\r?\n)?(.*)/) { my $key = $1; my $value = $2; $data = $3; $event = $2 if ($key eq "event"); $eventData = $2 if ($key eq "data"); next unless ($event ne "" && $eventData ne ""); MAH_Log($hash, 5, "$event >> $eventData"); # Attention # currently the /all/-request replies with "devices" and "actions" (both with the indirection {"1234":}) # while the /1234/-request replys replies wwith "device[]" and "action[s]" ("device" without- and "actions" with the indirection {"1234":}) # but MAH_updateDeviceReadings() and MAH_updateActionReadings() remove the indirections if needed if ($event eq "device" || $event eq "devices") { MAH_updateDeviceReadings($hash, $eventData); } elsif ($event eq "action" || $event eq "actions") { MAH_updateActionReadings($hash, $eventData); } $event = ""; $eventData = ""; } } #------------------------------------------------------------------------------------------------------ # MAH_eventSourceKeepAlive #------------------------------------------------------------------------------------------------------ sub MAH_eventSourceKeepAlive($) { my ($hash) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); my $api = AttrVal($name, "api", "poll"); return unless ($api eq "event"); InternalTimer(gettimeofday()+30, "MAH_eventSourceKeepAlive", $hash); my $now = time(); my $timeout = 180; # if we received an update within last 10s, all is fine ... if (defined ($hash->{EventSourceLastUpdate})) { my $lastUpdate = time_str2num($hash->{EventSourceLastUpdate}); if ($now - $lastUpdate <= $timeout) { return; } } MAH_Log($hash, 5, "EventSourceLastUpdate is > 10s ..."); # if we started withing the last 10s, all is fine, too ... if (defined ($hash->{EventSourceLastConnect})) { my $lastConnect = time_str2num($hash->{EventSourceLastConnect}); if ($now - $lastConnect <= $timeout) { return; } } MAH_Log($hash, 5, "EventSourceLastConnect is > $[timeout}s ..."); # ... otherwise ... timeout MAH_Log($hash, 4, "timout in event stream, reconnecting..."); MAH_updateEventSourceReadings($hash, "timeout", undef, undef); MAH_openEventSource($hash); } #------------------------------------------------------------------------------------------------------ # MAH_updateEventSourceReadings #------------------------------------------------------------------------------------------------------ sub MAH_updateEventSourceReadings($$$$) { my ($hash, $status, $lastConnect, $lastUpdate) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); if (!$status && !$lastConnect && !$lastUpdate) { delete($hash->{EventSource}); delete($hash->{EventSourceLastUpdate}); delete($hash->{EventSourceLastConnect}); fhem("deletereading $name _EventSource.*"); return; } readingsBeginUpdate($hash); if ($status) { $hash->{EventSource} = $status; readingsBulkUpdate($hash, "_EventSource", $status); } if ($lastConnect) { $hash->{EventSourceLastConnect} = $lastConnect; readingsBulkUpdate($hash, "_EventSourceLastConnect", $lastConnect); } if ($lastUpdate) { $hash->{EventSourceLastUpdate} = $lastUpdate; readingsBulkUpdate($hash, "_EventSourceLastUpdate", $lastUpdate); } readingsEndUpdate($hash, 1); } #------------------------------------------------------------------------------------------------------ # 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_LogReply($hash, $param, $err, $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_LogReply($hash, $param, $err, $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 = MAH_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 MAH_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; } if ($bearerCode ne "") { readingsSingleUpdate($hash, "tokenRefreshCount_withBearer", ReadingsNum($name, "tokenRefreshCount_withBearer", 0) + 1, 1); } else { readingsSingleUpdate($hash, "tokenRefreshCount_withRefreshToken", ReadingsNum($name, "tokenRefreshCount_withRefreshToken", 0) + 1, 1); } 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_LogReply($hash, $param, $err, $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_start", $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_LogReply($hash, undef, $err, $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_LogReply($hash, $param, $err, $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}; } MAH_updateDeviceReadings($hash, $data); } sub MAH_updateDeviceReadings($$) { my ($hash, $data) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); 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; } # the "devices"-reply (from event-sourcing) might be in the # form {"12345":} instead of so get rid of this indirection my $deviceId = $hash->{DEVICE_ID}; if (exists($json->{$deviceId})) { MAH_Log($hash, 5, "dropping indirection for deviceId"); $json = $json->{$deviceId}; } # 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, "batteryLevel", encode_utf8($json->{state}->{batteryLevel})); 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, "remoteEnableMobileStart", $json->{state}->{remoteEnable}->{mobileStart}); readingsBulkUpdate($hash, "signalDoor", $json->{state}->{signalDoor}); readingsBulkUpdate($hash, "signalFailure", $json->{state}->{signalFailure}); readingsBulkUpdate($hash, "signalInfo", $json->{state}->{signalInfo}); # target temperature my @targetTemperatures = MAH_decodeTemperature($hash, @{$json->{state}->{targetTemperature}}); if (scalar(@targetTemperatures) == 1) { readingsBulkUpdate($hash, "targetTemperature", ${targetTemperatures[0]}); } else { for (my $i = 0; $i < scalar(@targetTemperatures); $i++) { readingsBulkUpdate($hash, "targetTemperature_zone".($i+1), ${targetTemperatures[$i]}); } } # temperature my @temperatures = MAH_decodeTemperature($hash, @{$json->{state}->{temperature}}); if (scalar(@temperatures) == 1) { readingsBulkUpdate($hash, "temperature", ${temperatures[0]}); } else { for (my $i = 0; $i < scalar(@temperatures); $i++) { readingsBulkUpdate($hash, "temperature_zone".($i+1), ${temperatures[$i]}); } } #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_LogReply($hash, $param, $err, $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; } MAH_updateActionReadings($hash, $data) } sub MAH_updateActionReadings($$) { my ($hash, $data) = @_; my $name = $hash->{NAME}; MAH_Log($hash, 5, "called"); 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; } # the "actions"-reply (from event-sourcing) might be in the # form {"12345":} instead of so get rid of this indirection my $deviceId = $hash->{DEVICE_ID}; if (exists($json->{$deviceId})) { MAH_Log($hash, 5, "dropping indirection for deviceId"); $json = $json->{$deviceId}; } no strict "refs"; readingsBeginUpdate($hash); readingsBulkUpdate($hash, "actions_processAction", join(",", @{$json->{processAction}})); readingsBulkUpdate($hash, "actions_light", join(",", @{$json->{light}})); readingsBulkUpdate($hash, "actions_startTime", join(",", MAH_parseActionsStartTime($json->{startTime}))); readingsBulkUpdate($hash, "actions_ventilationStep", join(",", @{$json->{ventilationStep}})); readingsBulkUpdate($hash, "actions_programId", join(",", @{$json->{programId}})); readingsBulkUpdate($hash, "actions_targetTemperature", MAH_parseActionsTargetTemperature($hash,$json->{targetTemperature})); readingsBulkUpdate($hash, "actions_deviceName", $json->{deviceName}); readingsBulkUpdate($hash, "actions_powerOff", defined($json->{powerOff}) ? $json->{powerOff} : "0"); readingsBulkUpdate($hash, "actions_powerOn", defined($json->{powerOn}) ? $json->{powerOn} : "0"); # readingsBulkUpdate($hash, "actions_colors", ); readingsBulkUpdate($hash, "actions_modes", join(",", @{$json->{modes}})); readingsEndUpdate($hash, 1 ); use strict "refs"; return undef; } #------------------------------------------------------------------------------------------------------ # format time from array # "targetTemperature":[ # {"value_raw":600,"value_localized":6.0,"unit":"Celsius"}, # {"value_raw":-1800,"value_localized":-18.0,"unit":"Celsius"}, # {"value_raw":-32768,"value_localized":null,"unit":"Celsius"}], # "temperature":[ # {"value_raw":593,"value_localized":5.93,"unit":"Celsius"}, # {"value_raw":-1800,"value_localized":-18.0,"unit":"Celsius"}, # {"value_raw":-32768,"value_localized":null,"unit":"Celsius"}], #------------------------------------------------------------------------------------------------------ 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 @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 "[?]"; } #------------------------------------------------------------------------------------------------------ # parse the target temperature from actions # [ # { # "zone": 1, # "min": 1, # "max": 9 # }, # { # "zone": 2, # "min": -26, # "max": -16 # } # ] #------------------------------------------------------------------------------------------------------ sub MAH_parseActionsTargetTemperature($$) { my ($hash,$json) = @_; no strict "refs"; my $retval = ""; my @zones = @{$json}; for (my $i = 0; $i < scalar(@zones); $i++) { my $zone = $zones[$i]; $retval .= $zone->{zone} . "[" . $zone->{min} . "," . $zone->{max} . "] "; } use strict "refs"; chop($retval); # remove trailing space return $retval; } #------------------------------------------------------------------------------------------------------ # 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); $etaHR = "Ende" if ($remainingHour == 0 && $remainingMinute == 0); 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 = "+" . MAH_formatTime($remainingHour, $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", defined($hour) ? $hour : 0, defined($minute) ? $minute : 0); } #------------------------------------------------------------------------------------------------------ # 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_setMode #------------------------------------------------------------------------------------------------------ sub MAH_setMode($$) { my ($hash, $modeActionName) = @_; my $name = $hash->{NAME}; my ($modeActionId) = grep{ MODE_ACTIONS->{$_} eq $modeActionName } keys %{MODE_ACTIONS()}; if (!defined $modeActionId) { return "invalid mode action: '${modeActionName}'"; } my @availableModeActions = split(/,/, ReadingsVal($name, "actions_modes", "")); if (! grep {$_ eq $modeActionId} @availableModeActions) { return "'${modeActionName}' is currently not available"; } return MAH_setAction($hash, "modes", "${modeActionId}"); } #------------------------------------------------------------------------------------------------------ # MAH_setTargetTemperature #------------------------------------------------------------------------------------------------------ sub MAH_setTargetTemperature($$$) { my ($hash, $zone, $temp) = @_; my $name = $hash->{NAME}; my @availableTargetTemperatures = split(/ /, ReadingsVal($name, "actions_targetTemperature", "")); foreach my $targetTemperature (@availableTargetTemperatures) { if ($targetTemperature =~ /^([0-9]+)\[(-?[0-9]+),(-?[0-9]+)\]$/) { if ($1 eq $zone) { if ($2 <= int($temp) && int($temp) <= $3) { return MAH_setAction($hash, "targetTemperature", "[{\"zone\": $zone, \"value\": $temp}]"); } else { return "temperature for zone ${zone} out of range, must be between ${2} and ${3}"; } } } } return "zone ${zone} not setable"; } #------------------------------------------------------------------------------------------------------ # 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}', offset 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_LogReply($hash, $param, $err, $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 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); } # if we could not find a token, refrehs it async 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 replys of network requests #------------------------------------------------------------------------------------------------------ sub MAH_LogReply($$$$) { my ($hash, $param, $err, $data) = @_; my $logLevel = 5; # prevent warnings with uninitialized variables my $logMessage = "received reply"; $logMessage .= ", err:$err" if ($err); $logMessage .= ", code:$param->{code}" if ($param && defined($param->{code})); $logMessage .= ", headers:$param->{httpheader}" if ($param && defined($param->{httpheader})); $logMessage .= ", data:$data" if ($data); my $line = ( caller(0) )[2]; my $modAndSub = ( caller(1) )[3]; my $subroutine = ( split(':', $modAndSub) )[2]; MAH_LogPrivate($hash, $logLevel, $logMessage, $line, $subroutine); } #------------------------------------------------------------------------------------------------------ # Util: Log #------------------------------------------------------------------------------------------------------ sub MAH_Log($$$) { my ($hash, $logLevel, $logMessage) = @_; my $line = ( caller(0) )[2]; my $modAndSub = ( caller(1) )[3]; my $subroutine = ( split(':', $modAndSub) )[2]; MAH_LogPrivate($hash, $logLevel, $logMessage, $line, $subroutine); } #------------------------------------------------------------------------------------------------------ # Util: LogPrivates #------------------------------------------------------------------------------------------------------ sub MAH_LogPrivate($$$$$) { my ($hash, $logLevel, $logMessage, $line, $subroutine) = @_; my $name = ( ref($hash) eq "HASH" ) ? $hash->{NAME} : "MieleAtHome"; # replace non-printable characters by "" $logMessage =~ s/([\x{00}-\x{1f}\x{7f}-\x{7fffffff}])/'<'.unpack('(H2)',$1).'>'/ge; 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.

    Remote starting appliences

    Appliences like wasching mashines can be started remotely via FHEM, but this is rather unintuitive:
    Therefore the user loads the applience, selects a start or end time within the next 24 hours and presses the start button. The applience then waits for a "remote start" and can be started by FHEM before the start or end time has elapsed. If this is not received within the specified start or end time, it starts itself to be ready in time.

    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.
    • mode <mode>
      set the mode of your applience. can be either sabbathMode or normalOperationMode.
    • 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.
    • startTime <[H]H:MM>
      modify the start time of your device relative from current time. 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.
    • targetTemperature_zoneN <temp>
      set the targetTemperature of zone N of your applience. The available temperature-range is determined by the Miele-API.
    • 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
    • api [poll|event]
      set to poll if you want FHEM to repeatedly poll the Miele server for updates. Set to event if you want to have a keepalive-connection to the Miele server. With event, updates are received as soon as they are updated on the Miele server. With poll updates are only received in the configured interval.
      poll is the current default but the default will be switched to event after a testing period.
    • 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