########################################################################################
#
# MieleAtHome.pm
#
# FHEM module for Miele@home Devices
#
# Christian Hoenig
#
# $Id: 48_MieleAtHome.pm 27904 2023-08-27 08:10:06Z choenig $
#
########################################################################################
#
#  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: <option value="processAction">processAction</option>
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: <option value="light">light</option>
use constant LIGHT_ACTIONS => {
		0x01 => "enable",   # 1 Enable
		0x02 => "disable",  # 2 Disable
};

# DONE: <option value="ventilationStep">ventilationStep</option>
use constant VENTILATION_STEPS => {
		0x01 => "Step1",  # 1 Step1
		0x02 => "Step2",  # 2 Step2
		0x03 => "Step3",  # 3 Step3
		0x04 => "Step4",  # 4 Step4
};

# DONE: <option value="modes">modes</option>
use constant MODE_ACTIONS => {
		0x00 => "normalOperationMode",   # 0 Normal operation mode
		0x01 => "sabbathMode",           # 1 Sabbath mode
};

# DONE: <option value="startTime">startTime</option>
# DONE: <option value="powerOff">powerOff</option>
# DONE: <option value="powerOn">powerOn</option>
# DONE: <option value="targetTemperature">targetTemperature</option>

# TODO: <option value="programId">programId</option>
# TODO: <option value="deviceName">deviceName</option>
# TODO: <option value="colors">colors</option>

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 <name> 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 <secret>" if(@args != 1);
		MAH_saveClientSecret($hash, $args[0]);
		InternalTimer(gettimeofday()+0, "MAH_refreshAccessToken", $hash);
		return undef;
	}
	elsif( $cmd eq 'password' ) {
		return "usage: password <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 <OFFSET_H:MM>" 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 <step>" 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 <mode>" if(@args != 1);
		return MAH_setMode($hash, $args[0]);
	}
	elsif( $cmd =~ /targetTemperature_zone([0-9]+)/) {
		return "usage: targetTemperature_zone${1} <temp>" 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":<DATA>})
		# while the /1234/-request replys replies wwith "device[]" and "action[s]" ("device" without- and "actions" with the indirection {"1234":<DATA>})
		# 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) = (@_);

	# <form class="hidden" method="get" action="https://api.mcs3.miele.com/thirdparty/login/">
	# <input type="hidden" name="code" value="DE_...">
	# <input type="hidden" name="state" value="login">
	# <input id="button" class="waves-effect waves-light btn miele-red" type="submit"
	# value="ZULASSEN">
	# </form>

	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":<DATA>} instead of <DATA> 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":<DATA>} instead of <DATA> 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 "<xx>"
	$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

<a id="MieleAtHome"></a>
<h3>MieleAtHome</h3>
<ul>
	<u><b>MieleAtHome - Controls Miele@home Devices</b></u><br>
	<br>
	<b>About</b><br>
	<br> 
	The MieleAtHome module uses the Miele 3rd Party Cloud API. You need a Miele Developer Account to use it! See below for details.<br>
	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 <code>autocreate</code>-feature to create devices for your appliances.<br>
	<br>
	<b>Miele Developer Account:</b><br>
	<br>
	To use this module you need to register as a developer at <a href="https://www.miele.com/f/com/en/register_api.aspx">https://www.miele.com/f/com/en/register_api.aspx</a>. After you successfully registered, you will receive a <i>clientId</i> and a <i>clientSecret</i> which you'll need to configure in your <code>&lt;gateway&gt;</code>-device.<br>
	<br>
	<a id="MieleAtHome-define"></a>
	<b>Define</b><br>
	<br>
	<u>(1) Setup gateway:</u><br>
	<code>define &lt;gateway&gt; MieleAtHome</code><br>
	<br>
	<u>(2a) Autocreate devices:</u><br>
	<code>set &lt;gateway&gt; autocreate</code><br>
	<br>
	<u>(2b) Manually create devices:</u><br>
	<code>define &lt;MieleDevice&gt; MieleAtHome &lt;DeviceId&gt;@&lt;gateway&gt; [Interval]</code><br>
	<br>
	<b>Example</b><br>
	<br>
	<u>(1) Setup gateway:</u><br>
	<code>define MieleConnection MieleAtHome</code><br>
	<code>attr MieleConnection login mylogin@example.com</code><br>
	<code>attr MieleConnection clientId xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</code><br>
	<code>set  MieleConnection password mypassword</code><br>
	<code>set  MieleConnection clientSecret yyyyyyyyyyyyyyyyyyyy</code><br>
	<br>
	This instance (MieleConnection) will be used to share the credentials. You have to set the attributes <i>login</i> and <i>clientId</i>. Then you have to set <i>password</i> and <i>clientSecret</i> via <code>set MieleConnection</code>-command.<br>
	<br>
	<u>(2a) Autocreate devices:</u><br>
	<code>set MieleConnection autocreate</code><br>
	<br>
	This will create a device called <code>Miele_xxxxxxxxxxxxx</code>. You can rename autocreated devices afterwards.<br>
	<br>
	<u>(2b) Manually create devices:</u><br>
	<code>define Waschmaschine MieleAtHome 000123456789@MieleConnection 120</code><br>
	<br>
	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.<br>
	<br>
	<b>Remote starting appliences</b><br>
	<br>
	Appliences like wasching mashines can be started remotely via FHEM, but this is rather unintuitive:<br>
	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. <br>
	<br>

<!--
	<a name="MieleAtHomereadings"></a>
	<b>Readings</b>
	<ul>
		<li>
			<i>event</i><br>
			contains the last event that was triggered by the ALP-600. (<code>motion</code> or <code>ring</code>).
		</li>
		<li>
			<i>motion</i><br>
			the timestamp of the last <code>motion</code> event triggered by the ALP-600.
		</li>
		<li>
			<i>ping</i><br>
			The ping status of the ALP-600 (<code>disabled</code>, <code>ok</code> or <code>unreachable</code>).
		</li>
		<li>
			<i>ring</i><br>
			the timestamp of the last <code>ring</code> event triggered by the ALP-600.
		</li>
	</ul>
	<br>

		<li><a name="altitude"></a>
			<dt><code><b>attr</b> &lt;name&gt; <b>altitude </b>&lt;<b>height</b>&gt;</code></dt>
			Specifies the mean sea level in meters. Default is 0. Used to calculate the <code>pressureRel_calculated</code>-reading. If unset, the altitude from global is used.
		</li>

-->
	<a id="MieleAtHome-set"></a>
	<b>Set</b>
	<ul>
		<li><a id="MieleAtHome-set-autocreate"></a>
			<dt><code><b>autocreate</b></code></dt>
			autocreate fhem-devices for each Miele@home appliance found in your account. Needs <i>login</i>, <i>clientId</i>, <i>password</i> and <i>clientSecret</i> to be configured properly. Only available for the gateway device.
		</li>
		<li><a id="MieleAtHome-set-clientSecret"></a>
			<dt><code><b>clientSecret &lt;secret&gt;</b></code></dt>
			sets the <i>clientSecret</i> of your Miele@home-developer Account and stores it in a file (base64-encoded if you have MIME::Base64 installed).
		</li>
		<li><a id="MieleAtHome-set-light"></a>
			<dt><code><b>light [enable|disable]</b></code></dt>
			enable/disable the light of your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-mode"></a>
			<dt><code><b>mode &lt;mode&gt;</b></code></dt>
			set the <i>mode</i> of your applience. can be either <i>sabbathMode</i> or <i>normalOperationMode</i>.
		</li>
		<li><a id="MieleAtHome-set-on"></a>
			<dt><code><b>on</b></code></dt>
			power up your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-off"></a>
			<dt><code><b>off</b></code></dt>
			power off your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-password"></a>
			<dt><code><b>password &lt;pass&gt;</b></code></dt>
			set the <i>password</i> of your Miele@home Account and stores it in a file (base64-encoded if you have MIME::Base64 installed).
		</li>
		<li><a id="MieleAtHome-set-pause"></a>
			<dt><code><b>pause</b></code></dt>
			pause your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-start"></a>
			<dt><code><b>start</b></code></dt>
			start your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-startTime"></a>
			<dt><code><b>startTime &lt;[H]H:MM&gt;</b></code></dt>
			modify the start time of your device relative from current time. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-stop"></a>
			<dt><code><b>stop</b></code></dt>
			stop your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-startSuperFreezing"></a>
			<dt><code><b>startSuperFreezing</b></code></dt>
			start super freezing your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-stopSuperFreezing"></a>
			<dt><code><b>stopSuperFreezing</b></code></dt>
			stop super freezing your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-startSuperCooling"></a>
			<dt><code><b>startSuperCooling</b></code></dt>
			start super cooling your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-stopSuperCooling"></a>
			<dt><code><b>stopSuperCooling</b></code></dt>
			stop super cooling your device. only available depending on the type and state of your appliance.
		</li>
		<li><a id="MieleAtHome-set-targetTemperature"></a>
			<dt><code><b>targetTemperature_zoneN &lt;temp&gt;</b></code></dt>
			set the <i>targetTemperature</i> of zone <i>N</i> of your applience. The available temperature-range is determined by the Miele-API.
		</li>
		<li><a id="MieleAtHome-set-update"></a>
			<dt><code><b>update</b></code></dt>
			instantly update all readings.
		</li>
		<li><a id="MieleAtHome-set-ventilationStep"></a>
			<dt><code><b>ventilationStep [Step1|Step2|Step3|Step4]</b></code></dt>
			set the ventilation step of your device. only available depending on the type and state of your appliance.
		</li>
	</ul>
	<br>

	<a id="MieleAtHome-get"></a>
	<b>Get</b>
	<ul>
		<li><a id="MieleAtHome-get-listDevices"></a>
			<dt><code><b>listDevices</b></code></dt>
			lists the devices associated with your Miele@home-account. Needs <i>login</i>, <i>clientId</i>, <i>password</i> and <i>clientSecret</i> to be configured properly.
		</li>
	</ul>
	<br>

	<a id="MieleAtHome-attr"></a>
	<b>Attributes</b>
	<ul>
		<li><a id="MieleAtHome-attr-api"></a>
			<dt><code><b>api [poll|event]</b></code></dt>
			set to <i>poll</i> if you want FHEM to repeatedly poll the Miele server for updates. Set to <i>event</i> if you want to have a keepalive-connection to the Miele server. With <i>event</i>, updates are received as soon as they are updated on the Miele server. With <i>poll</i> updates are only received in the configured interval. <br><i>poll</i> is the current default but the default will be switched to <i>event</i> after a testing period.
		</li>
		<li><a id="MieleAtHome-attr-clientId"></a>
			<dt><code><b>clientId</b></code></dt>
			set the <i>clientId</i> of your Miele@home-developer account.
		</li>
		<li><a id="MieleAtHome-attr-country"></a>
			<dt><code><b>country</b></code></dt>
			set the <i>country</i> where you registered your Miele@home account.
		</li>
		<li><a id="MieleAtHome-attr-login"></a>
			<dt><code><b>login</b></code></dt>
			set the <i>login</i> of your Miele@home account.
		</li>
		<li><a id="MieleAtHome-attr-disable"></a>
			<dt><code><b>disable</b></code></dt>
			disables this MieleAtHome-instance.
		</li>
		<li><a id="MieleAtHome-attr-lang"></a>
			<dt><code><b>lang [de|en]</b></code></dt>
			request the readings in either german or english. <i>en</i> is default.
		</li>
	</ul>
</ul>

=end html
# =begin html_DE
#
#
# =end html_DE
=cut
