mirror of
https://github.com/fhem/fhem-mirror.git
synced 2025-02-01 01:09:47 +00:00
bced2c7104
git-svn-id: https://svn.fhem.de/fhem/trunk@24401 2b470e98-0d58-463d-a4d8-8e2adae1ed80
2122 lines
69 KiB
Perl
2122 lines
69 KiB
Perl
# $Id$
|
||
##############################################################################
|
||
#
|
||
# 98_SoftliqCloud.pm
|
||
# An FHEM Perl module that retrieves information from SoftliqCloud
|
||
#
|
||
# Copyright by KernSani
|
||
#
|
||
# Fhem 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.
|
||
#
|
||
# Fhem 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.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with fhem. If not, see <http://www.gnu.org/licenses/>.
|
||
#
|
||
##############################################################################
|
||
# Changelog:
|
||
# 0.1.07: - 2021-05-08 - Optimize garbage JSON processing
|
||
# 0.1.06: - 2021-04-26 - Split JSON strings to avoid processing multiple root nodes
|
||
# 0.1.05: Fixed setting numeric parameters
|
||
# 0.1.04: ANother fix to avoid "garbage" in JSON
|
||
# 0.1.03: Improve error handling
|
||
# Hide access- & refreshToken
|
||
# 0.1.02: Suppress Log message "opening device..."
|
||
# 0.1.01: Small Fix to avoid "garbage" leading to invalid JSON
|
||
# 0.1.00: Initial Release
|
||
##############################################################################
|
||
##############################################################################
|
||
# Todo:
|
||
# * identify more parameters
|
||
#
|
||
##############################################################################
|
||
package main;
|
||
use strict;
|
||
use warnings;
|
||
|
||
package FHEM::Gruenbeck::SoftliqCloud;
|
||
|
||
use List::Util qw(any first);
|
||
use HttpUtils;
|
||
use Data::Dumper;
|
||
use FHEM::Meta;
|
||
use GPUtils qw(GP_Import GP_Export);
|
||
use utf8;
|
||
use POSIX qw( strftime );
|
||
use DevIo;
|
||
use B qw(svref_2object);
|
||
use utf8;
|
||
use Digest::MD5 qw(md5);
|
||
|
||
use FHEM::Core::Authentication::Passwords qw(:ALL);
|
||
|
||
my $version = "0.1.07";
|
||
|
||
my $missingModul = '';
|
||
eval 'use MIME::Base64::URLSafe;1' or $missingModul .= 'MIME::Base64::URLSafe ';
|
||
eval 'use Digest::SHA qw(sha256);1;' or $missingModul .= 'Digest::SHA ';
|
||
|
||
#eval 'use Protocol::WebSocket::Client;1' or $missingModul .= 'Protocol::WebSocket::Client ';
|
||
|
||
# Taken from RichardCZ https://gl.petatech.eu/root/HomeBot/snippets/2
|
||
my $got_module = use_module_prio(
|
||
{ wanted => [ 'encode_json', 'decode_json' ],
|
||
priority => [
|
||
qw(JSON::MaybeXS
|
||
Cpanel::JSON::XS
|
||
JSON::XS JSON::PP
|
||
JSON::backportPP)
|
||
],
|
||
}
|
||
);
|
||
if ( !$got_module ) {
|
||
$missingModul .= 'a JSON module (e.g. JSON::XS) ';
|
||
}
|
||
|
||
# Readonly is recommended, but requires additional module
|
||
use constant {
|
||
SQ_MINIMUM_INTERVAL => 300,
|
||
LOG_CRITICAL => 0,
|
||
LOG_ERROR => 1,
|
||
LOG_WARNING => 2,
|
||
LOG_SEND => 3,
|
||
LOG_RECEIVE => 4,
|
||
LOG_DEBUG => 5,
|
||
TCPPACKETSIZE => 16384,
|
||
};
|
||
my $EMPTY = q{};
|
||
my $SPACE = q{ };
|
||
my $COMMA = q{,};
|
||
|
||
## Import der FHEM Funktionen
|
||
BEGIN {
|
||
GP_Import(
|
||
qw(
|
||
AttrVal
|
||
AttrNum
|
||
CommandDeleteReading
|
||
InternalTimer
|
||
InternalVal
|
||
readingsSingleUpdate
|
||
readingsBulkUpdate
|
||
readingsBulkUpdateIfChanged
|
||
readingsBeginUpdate
|
||
readingsDelete
|
||
readingsEndUpdate
|
||
ReadingsNum
|
||
ReadingsVal
|
||
RemoveInternalTimer
|
||
Log3
|
||
gettimeofday
|
||
deviceEvents
|
||
time_str2num
|
||
latin1ToUtf8
|
||
IsDisabled
|
||
HttpUtils_NonblockingGet
|
||
HttpUtils_BlockingGet
|
||
DevIo_IsOpen
|
||
DevIo_CloseDev
|
||
DevIo_OpenDev
|
||
DevIo_SimpleRead
|
||
DevIo_SimpleWrite
|
||
init_done
|
||
readingFnAttributes
|
||
setKeyValue
|
||
getKeyValue
|
||
getUniqueId
|
||
defs
|
||
HOURSECONDS
|
||
MINUTESECONDS
|
||
makeReadingName
|
||
)
|
||
);
|
||
}
|
||
|
||
#-- Export to main context with different name
|
||
GP_Export(
|
||
qw(
|
||
Initialize
|
||
)
|
||
);
|
||
|
||
my %paramTexts = (
|
||
pbuzzer => 'Audiosignal an/aus',
|
||
pbuzzfrom => 'Audiosignal von (Uhrzeit)',
|
||
pbuzzto => 'Audiosignal bis (Uhrzeit)',
|
||
pallowemail => 'Email-Benachrichtigung an/aus',
|
||
pallowpushnotification => 'Push-Benachrichtigung an/aus',
|
||
pmode => 'Arbeitsweise',
|
||
pmodemo => 'Arbeitsweise Montag (Individual)',
|
||
pmodetu => 'Arbeitsweise Dienstag (Individual)',
|
||
pmodewe => 'Arbeitsweise Mittwoch (Individual)',
|
||
pmodeth => 'Arbeitsweise Donnerstag (Individual)',
|
||
pmodefr => 'Arbeitsweise Freitag (Individual)',
|
||
pmodesa => 'Arbeitsweise Samstag (Individual)',
|
||
pmodesu => 'Arbeitsweise Sontag (Individual)',
|
||
pname => 'Service - Name',
|
||
ptelnr => 'Service - Tel.Nr.',
|
||
pmailadress => 'Service - EMail',
|
||
pmaintint => 'Service - Wartungsintervall',
|
||
pregmode => 'Regeneration - Regenerierungszeitpunkt',
|
||
pregmo1 => 'Regeneration - Regenerierungszeitpunkt Montag 1',
|
||
pregmo2 => 'Regeneration - Regenerierungszeitpunkt Montag 2',
|
||
pregmo3 => 'Regeneration - Regenerierungszeitpunkt Montag 3',
|
||
pregtu1 => 'Regeneration - Regenerierungszeitpunkt Dienstag 1',
|
||
pregtu2 => 'Regeneration - Regenerierungszeitpunkt Dienstag 2',
|
||
pregtu3 => 'Regeneration - Regenerierungszeitpunkt Dienstag 3',
|
||
pregwe1 => 'Regeneration - Regenerierungszeitpunkt Mittwoch 1',
|
||
pregwe2 => 'Regeneration - Regenerierungszeitpunkt Mittwoch 2',
|
||
pregwe3 => 'Regeneration - Regenerierungszeitpunkt Mittwoch 3',
|
||
pregth1 => 'Regeneration - Regenerierungszeitpunkt Donnerstag 1',
|
||
pregth2 => 'Regeneration - Regenerierungszeitpunkt Donnerstag 2',
|
||
pregth3 => 'Regeneration - Regenerierungszeitpunkt Donnerstag 3',
|
||
pregfr1 => 'Regeneration - Regenerierungszeitpunkt Freitag 1',
|
||
pregfr2 => 'Regeneration - Regenerierungszeitpunkt Freitag 2',
|
||
pregfr3 => 'Regeneration - Regenerierungszeitpunkt Freitag 3',
|
||
pregsa1 => 'Regeneration - Regenerierungszeitpunkt Samstag 1',
|
||
pregsa2 => 'Regeneration - Regenerierungszeitpunkt Samstag 2',
|
||
pregsa3 => 'Regeneration - Regenerierungszeitpunkt Samstag 3',
|
||
pregsu1 => 'Regeneration - Regenerierungszeitpunkt Sonntag 1',
|
||
pregsu2 => 'Regeneration - Regenerierungszeitpunkt Sonntag 2',
|
||
pregsu3 => 'Regeneration - Regenerierungszeitpunkt Sonntag 3',
|
||
prawhard => 'Wasser - Rohwasserhärte',
|
||
phunit => 'Wasser - Einheit ',
|
||
);
|
||
my %paramValueMap = (
|
||
pmode => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
'4' => 'Individual'
|
||
},
|
||
pmodemo => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodetu => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodewe => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodeth => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodefr => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodesa => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pmodesu => {
|
||
'1' => 'Eco',
|
||
'2' => 'Comfort',
|
||
'3' => 'Power',
|
||
},
|
||
pregmode => {
|
||
'0' => 'Auto',
|
||
'1' => 'Fix'
|
||
},
|
||
phunit => {
|
||
'1' => '°dH',
|
||
'2' => '°fH',
|
||
'3' => '°e',
|
||
'4' => 'mol/m³',
|
||
'5' => 'ppm'
|
||
}
|
||
|
||
);
|
||
|
||
sub Initialize {
|
||
my ($hash) = @_;
|
||
|
||
$hash->{SetFn} = \&Set;
|
||
$hash->{GetFn} = \&Get;
|
||
$hash->{DefFn} = \&Define;
|
||
$hash->{ReadyFn} = \&Ready;
|
||
$hash->{ReadFn} = \&wsReadDevIo;
|
||
$hash->{NotifyFn} = \&Notify;
|
||
$hash->{UndefFn} = \&Undefine;
|
||
$hash->{AttrFn} = \&Attr;
|
||
$hash->{RenameFn} = \&Rename;
|
||
my @SQattr = ( "sq_interval", "disable:0,1", "sq_duplex:0,1" );
|
||
|
||
$hash->{AttrList} = join( $SPACE, @SQattr ) . $SPACE . $readingFnAttributes;
|
||
|
||
#$hash->{AttrList} = $::readingFnAttributes;
|
||
|
||
return FHEM::Meta::InitMod( __FILE__, $hash );
|
||
}
|
||
###################################
|
||
sub Define {
|
||
my $hash = shift;
|
||
my $def = shift;
|
||
|
||
return $@ if ( !FHEM::Meta::SetInternals($hash) );
|
||
|
||
my @args = split m{\s+}, $def;
|
||
|
||
return "Cannot define device. Please install perl modules $missingModul."
|
||
if ($missingModul);
|
||
|
||
my $usage = qq (syntax: define <name> SoftliqCloud <loginName>);
|
||
return $usage if ( @args != 3 );
|
||
|
||
my ( $name, $type, $user ) = @args;
|
||
|
||
Log3 $name, LOG_SEND, "[$name] SoftliqCloud defined $name";
|
||
|
||
$hash->{NAME} = $name;
|
||
$hash->{USER} = $user;
|
||
$hash->{VERSION} = $version;
|
||
|
||
if ( ReadingsVal( $name, "accessToken", $EMPTY ) ne $EMPTY ) {
|
||
CommandDeleteReading( undef, $name . " accessToken" );
|
||
}
|
||
if ( ReadingsVal( $name, "refreshToken", $EMPTY ) ne $EMPTY ) {
|
||
CommandDeleteReading( undef, $name . " refreshToken" );
|
||
}
|
||
|
||
$hash->{helper}->{passObj} = FHEM::Core::Authentication::Passwords->new( $hash->{TYPE} );
|
||
|
||
# get password form old storage and save to new format
|
||
if ( !defined( ReadPassword($hash) ) ) {
|
||
if ( defined( ReadPasswordOld($hash) ) ) {
|
||
my ( $passResp, $passErr ) = $hash->{helper}->{passObj}->setStorePassword( $name, ReadPasswordOld($hash) );
|
||
if ( defined($passErr) ) {
|
||
Log3( $name, LOG_CRITICAL, qq([$name] Error while saving the password - $passErr) );
|
||
}
|
||
}
|
||
}
|
||
|
||
#start timer
|
||
if ( !IsDisabled($name) && $init_done && defined( ReadPassword($hash) ) ) {
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
}
|
||
if ( IsDisabled($name) ) {
|
||
readingsSingleUpdate( $hash, "state", "inactive", 1 );
|
||
$hash->{helper}{DISABLED} = 1;
|
||
}
|
||
return;
|
||
}
|
||
###################################
|
||
sub Undefine {
|
||
my $hash = shift;
|
||
RemoveInternalTimer($hash);
|
||
DevIo_CloseDev($hash);
|
||
return;
|
||
}
|
||
###################################
|
||
sub Notify {
|
||
my ( $hash, $dev ) = @_;
|
||
my $name = $hash->{NAME}; # own name / hash
|
||
my $events = deviceEvents( $dev, 1 );
|
||
|
||
return if ( IsDisabled($name) );
|
||
return if ( !any {m/^INITIALIZED|REREADCFG$/xsm} @{$events} );
|
||
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
return;
|
||
}
|
||
###################################
|
||
sub Set {
|
||
my $hash = shift;
|
||
my $name = shift;
|
||
my $cmd = shift // return qq (Set $name needs at least one argument);
|
||
my $arg = shift;
|
||
my $val = shift;
|
||
|
||
#delete $hash->{helper}{cmdQueue};
|
||
|
||
if ( $cmd eq 'param' ) {
|
||
|
||
return qq(Usage is 'set $name $cmd <parameter> <value>') if ( !$cmd || !$val );
|
||
|
||
if ( any {/^$arg$/xsm} @{ $hash->{helper}{params} } ) {
|
||
|
||
setParam( $hash, $arg, $val );
|
||
return;
|
||
}
|
||
return "Invalid Parameter";
|
||
}
|
||
if ( $cmd eq 'regenerate' ) {
|
||
regenerate($hash);
|
||
return;
|
||
}
|
||
if ( $cmd eq 'refill' ) {
|
||
refill($hash);
|
||
return;
|
||
}
|
||
if ( $cmd eq 'password' ) {
|
||
|
||
my $err = StorePassword( $hash, $arg );
|
||
if ( !IsDisabled($name) && defined( ReadPassword($hash) ) ) {
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
}
|
||
return $err;
|
||
|
||
}
|
||
|
||
return qq (Unknown argument $cmd, choose one of param regenerate:noArg refill:noArg password);
|
||
|
||
}
|
||
###################################
|
||
sub Get {
|
||
my $hash = shift;
|
||
my $name = shift;
|
||
my $cmd = shift // return "set $name needs at least one argument";
|
||
|
||
if ( !ReadPassword($hash) ) {
|
||
return qq(set password first);
|
||
}
|
||
|
||
delete $hash->{helper}{cmdQueue};
|
||
|
||
if ( $cmd eq 'query' ) {
|
||
return query($hash);
|
||
}
|
||
|
||
if ( $cmd eq 'water' || $cmd eq 'salt' ) {
|
||
getRefreshTokenDirect($hash) if isExpiredToken($hash);
|
||
return getMeasurements( $hash, $cmd );
|
||
}
|
||
|
||
if ( $cmd eq 'paramList' ) {
|
||
getRefreshTokenDirect($hash) if isExpiredToken($hash);
|
||
|
||
return getParamList($hash);
|
||
}
|
||
|
||
if ( $cmd eq 'realtime' ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getRefreshToken if isExpiredToken($hash);
|
||
push @{ $hash->{helper}{cmdQueue} }, \&negotiate;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
# those are just for testing
|
||
if ( $cmd eq 'authenticate' ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&authenticate;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&login;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getCode;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&initToken;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
if ( $cmd eq 'devices' ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getRefreshToken;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getDevices;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
if ( $cmd eq 'param' ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getRefreshToken;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getParam;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
if ( $cmd eq 'info' ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getRefreshToken;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getInfo;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
return
|
||
qq(Unknown argument $cmd, choose one of realtime:noArg water:noArg salt:noArg query:noArg paramList:noArg authenticate);
|
||
}
|
||
|
||
sub Attr {
|
||
my $cmd = shift;
|
||
my $name = shift;
|
||
my $attr = shift;
|
||
my $aVal = shift;
|
||
|
||
my $hash = $defs{$name};
|
||
|
||
if ( $cmd eq 'set' ) {
|
||
if ( $attr eq 'sq_interval' ) {
|
||
|
||
# restrict interval to 5 minutes
|
||
if ( $aVal > SQ_MINIMUM_INTERVAL ) {
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
return;
|
||
}
|
||
|
||
# message if interval is less than 5 minutes
|
||
if ( $aVal > 0 ) {
|
||
return qq (Interval for $name has to be > 5 minutes (300 seconds) or 0 to disable);
|
||
}
|
||
RemoveInternalTimer($hash);
|
||
return;
|
||
}
|
||
if ( $attr eq 'disable' ) {
|
||
if ( $aVal == 1 ) {
|
||
RemoveInternalTimer($hash);
|
||
DevIo_CloseDev($hash);
|
||
readingsSingleUpdate( $hash, "state", "inactive", 1 );
|
||
$hash->{helper}{DISABLED} = 1;
|
||
return;
|
||
}
|
||
if ( $aVal == 0 ) {
|
||
readingsSingleUpdate( $hash, "state", "initialized", 1 );
|
||
$hash->{helper}{DISABLED} = 0;
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
return;
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
if ( $cmd eq "del" ) {
|
||
if ( $attr eq "sq_interval" ) {
|
||
RemoveInternalTimer($hash);
|
||
return;
|
||
}
|
||
if ( $attr eq "disable" ) {
|
||
readingsSingleUpdate( $hash, "state", "initialized", 1 );
|
||
$hash->{helper}{DISABLED} = 0;
|
||
my $next = int( gettimeofday() ) + 1;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
return;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
###################################
|
||
sub refill {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
readingsSingleUpdate( $hash, "lastRefill", ReadingsVal( $name, 'msaltusage', 0 ), 1 );
|
||
return;
|
||
}
|
||
|
||
sub setParam {
|
||
my $hash = shift;
|
||
my $param = shift;
|
||
my $value = shift;
|
||
my $body;
|
||
my $name = $hash->{NAME};
|
||
if ( $value =~ /^-?\d+\.?\d*$/xsm ) {
|
||
my $num = $value * 1;
|
||
$body = encode_json( { $param => $num } );
|
||
}
|
||
else {
|
||
$body = encode_json( { $param => $value } );
|
||
}
|
||
|
||
Log3 $name, LOG_SEND, qq([$name] Setting parameter $body);
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json",
|
||
"User-Agent" => "Gruenbeck/360 CFNetwork/1220.1 Darwin/20.3.0",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache",
|
||
"Content-Type" => "application/json",
|
||
};
|
||
my $setparam = {
|
||
header => $header,
|
||
url => 'https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/softliQ.D/'
|
||
. ReadingsVal( $name, 'id', $EMPTY )
|
||
. '/parameters?api-version=2021-03-26',
|
||
callback => \&parseParam,
|
||
hash => $hash,
|
||
method => 'PATCH',
|
||
data => $body
|
||
};
|
||
HttpUtils_NonblockingGet($setparam);
|
||
return;
|
||
}
|
||
|
||
sub regenerate {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
Log3 $name, LOG_RECEIVE, qq([$name] Starting regeneration);
|
||
|
||
my $body = '{}';
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json, text/plain, */*",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache",
|
||
"Content-Type" => "application/json",
|
||
"Origin" => "file://",
|
||
"Content-Length" => 2
|
||
};
|
||
my $setparam = {
|
||
header => $header,
|
||
url => 'https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/'
|
||
. ReadingsVal( $name, 'id', $EMPTY )
|
||
. '/regenerate?api-version=2019-08-09',
|
||
callback => \&parseRegenerate,
|
||
hash => $hash,
|
||
method => 'POST',
|
||
data => $body
|
||
};
|
||
HttpUtils_NonblockingGet($setparam);
|
||
return;
|
||
|
||
}
|
||
|
||
sub parseRegenerate {
|
||
my $param = shift;
|
||
my $err = shift;
|
||
my $json = shift;
|
||
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
|
||
# we actually don't expect a response
|
||
return if ( $json eq $EMPTY );
|
||
|
||
$json = latin1ToUtf8($json);
|
||
|
||
my $data = safe_decode_json( $hash, $json );
|
||
|
||
#my $data = @$cdata[0];
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
|
||
if ( defined( $data->{error}{type} ) ) {
|
||
readingsBulkUpdate( $hash, "error", $data->{error}{type} );
|
||
readingsBulkUpdate( $hash, "error_description", '---' );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
|
||
}
|
||
return;
|
||
}
|
||
|
||
sub sqTimer {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
RemoveInternalTimer($hash);
|
||
query($hash);
|
||
Log3 $name, LOG_RECEIVE, qq([$name]: Starting Timer);
|
||
my $next = int( gettimeofday() ) + AttrNum( $name, 'sq_interval', HOURSECONDS );
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::sqTimer', $hash, 0 );
|
||
return;
|
||
}
|
||
|
||
sub query {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
if ( ReadingsVal( $name, '.accessToken', $EMPTY ) eq $EMPTY || isExpiredToken($hash) ) {
|
||
push @{ $hash->{helper}{cmdQueue} }, \&authenticate;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&login;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getCode;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&initToken;
|
||
}
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getRefreshToken;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getDevices;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getInfo;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&getParam;
|
||
push @{ $hash->{helper}{cmdQueue} }, \&negotiate;
|
||
processCmdQueue($hash);
|
||
return;
|
||
|
||
}
|
||
|
||
sub isExpiredToken {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $now = gettimeofday();
|
||
my $expires = ReadingsVal( $name, "expires_on", '1900-01-01' );
|
||
if ( time_str2num($expires) - MINUTESECONDS > $now ) {
|
||
return 1;
|
||
}
|
||
return;
|
||
}
|
||
|
||
sub authenticate {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
# if ( AttrVal( $name, 'sq_user', '' ) eq '' || AttrVal( $name, 'sq_password', '' ) eq '' ) {
|
||
# return "Please maintain user and password attributes first";
|
||
# }
|
||
|
||
if ( !exists &{"urlsafe_b64encode"} ) {
|
||
Log3 $name, 1, "urlsafe_b64encode doesn't exist. Exiting";
|
||
return;
|
||
}
|
||
|
||
my $auth_code_verifier
|
||
= urlsafe_b64encode( join( '', map { ( 'a' .. 'z', 'A' .. 'Z', 0 .. 9 )[ rand 62 ] } 0 .. 31 ) );
|
||
$auth_code_verifier =~ s/=//xsm;
|
||
$hash->{helper}{code_verifier} = $auth_code_verifier;
|
||
my $auth_code_challenge = urlsafe_b64encode( sha256($auth_code_verifier) );
|
||
$auth_code_challenge =~ s/\=//xsm;
|
||
readingsSingleUpdate( $hash, 'code_challenge', $auth_code_verifier, 0 );
|
||
|
||
my $param->{header} = {
|
||
"Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||
"Accept-Encoding" => "br, gzip, deflate",
|
||
"Connection" => "keep-alive",
|
||
"Accept-Language" => "de-de",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1"
|
||
};
|
||
my $url
|
||
= "https://gruenbeckb2c.b2clogin.com/a50d35c1-202f-4da7-aa87-76e51a3098c6/b2c_1a_signinup/oauth2/v2.0/authorize?state=NzZDNkNBRkMtOUYwOC00RTZBLUE5MkYtQTNFRDVGNTQ3MUNG"
|
||
. "&x-client-Ver=0.2.2"
|
||
. "&prompt=select_account"
|
||
. "&response_type=code"
|
||
. "&code_challenge_method=S256"
|
||
. "&x-client-OS=12.4.1"
|
||
. "&scope=https%3A%2F%2Fgruenbeckb2c.onmicrosoft.com%2Fiot%2Fuser_impersonation+openid+profile+offline_access"
|
||
. "&x-client-SKU=MSAL.iOS"
|
||
. "&code_challenge="
|
||
. $auth_code_challenge
|
||
. "&x-client-CPU=64"
|
||
. "&client-request-id=FDCD0F73-B7CD-4219-A29B-EE51A60FEE3E&redirect_uri=msal5a83cc16-ffb1-42e9-9859-9fbf07f36df8%3A%2F%2Fauth&client_id=5a83cc16-ffb1-42e9-9859-9fbf07f36df8&haschrome=1"
|
||
. "&return-client-request-id=true&x-client-DM=iPhone";
|
||
$param->{method} = "GET";
|
||
$param->{url} = $url;
|
||
$param->{callback} = \&parseAuthenticate;
|
||
$param->{hash} = $hash;
|
||
|
||
#$param->{ignoreredirects} = 1;
|
||
|
||
Log3 $name, LOG_DEBUG, "1st Generated URL is $param->{url}";
|
||
|
||
my ( $err, $data ) = HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub parseAuthenticate {
|
||
my ( $param, $err, $data ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
my $header = $param->{httpheader};
|
||
Log3 $name, LOG_RECEIVE, Dumper($header);
|
||
getCookies( $hash, $header );
|
||
|
||
#Log3 undef, 1, $err . " / " . $data;
|
||
my $cdata = $data;
|
||
my $regex = qq /"csrf":"(.*?)",.*"transId":"(.*?)",.*"tenant":"(.*?)",.*"policy":"(.*?)",/;
|
||
my @res = $cdata =~ /$regex/gmxs;
|
||
my $csrf = $res[0];
|
||
$hash->{helper}{csrf} = $csrf;
|
||
$hash->{helper}{tenant} = $res[2];
|
||
$hash->{helper}{policy} = $res[3];
|
||
$hash->{helper}{transId} = $res[1];
|
||
Log3 $name, LOG_RECEIVE, Dumper(@res); # . "\n-" . Dumper($header); # ."-". $tenant
|
||
|
||
readingsSingleUpdate( $hash, "tenant", $hash->{helper}{tenant}, 0 );
|
||
|
||
my $cookies;
|
||
if ( $hash->{HTTPCookieHash} ) {
|
||
foreach my $cookie ( sort keys %{ $hash->{HTTPCookieHash} } ) {
|
||
my $cPath = $hash->{HTTPCookieHash}{$cookie}{Path};
|
||
$cookies .= "; " if ($cookies);
|
||
$cookies .= $hash->{HTTPCookieHash}{$cookie}{Name} . '=' . $hash->{HTTPCookieHash}{$cookie}{Value};
|
||
}
|
||
}
|
||
$hash->{helper}{cookies} = $cookies;
|
||
Log3 $name, LOG_DEBUG, Dumper($cookies);
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub login {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $newheader = {
|
||
"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8",
|
||
"X-CSRF-TOKEN" => $hash->{helper}{csrf},
|
||
"Accept" => "application/json, text/javascript, */*; q=0.01",
|
||
"X-Requested-With" => "XMLHttpRequest",
|
||
"Origin" => "https://gruenbeckb2c.b2clogin.com",
|
||
"Referer" =>
|
||
"https://gruenbeckb2c.b2clogin.com/a50d35c1-202f-4da7-aa87-76e51a3098c6/b2c_1a_signinup/oauth2/v2.0/authorize?state=MTgxQUExQ0QtN0NFMi00NkE1LTgyQTQtNEY0NEREMDYzMTM2&x-client-Ver=0.2.2&prompt=select_account&response_type=code&code_challenge_method=S256&x-client-OS=13.3.1&scope=https%3A%2F%2Fgruenbeckb2c.onmicrosoft.com%2Fiot%2Fuser_impersonation+openid+profile+offline_access&x-client-SKU=MSAL.iOS&code_challenge=z3tSf1frNKpNB0TTGb6VKrLLHwNFvII7c75sv1CG9Is&x-client-CPU=64&client-request-id=1A472478-12F4-445D-81AC-170A578B4F37&redirect_uri=msal5a83cc16-ffb1-42e9-9859-9fbf07f36df8%3A%2F%2Fauth&client_id=5a83cc16-ffb1-42e9-9859-9fbf07f36df8&haschrome=1&return-client-request-id=true&x-client-DM=iPhone",
|
||
"Cookie" => $hash->{helper}{cookies},
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1"
|
||
};
|
||
my $newdata = {
|
||
"request_type" => 'RESPONSE',
|
||
"logonIdentifier" => InternalVal( $name, 'USER', $EMPTY ),
|
||
"signInName" => InternalVal( $name, 'USER', $EMPTY ),
|
||
"password" => ReadPassword($hash),
|
||
};
|
||
|
||
my $newparam = {
|
||
header => $newheader,
|
||
hash => $hash,
|
||
method => "POST",
|
||
httpversion => "1.1",
|
||
timeout => 10,
|
||
url => "https://gruenbeckb2c.b2clogin.com"
|
||
. $hash->{helper}{tenant}
|
||
. "/SelfAsserted?tx="
|
||
. $hash->{helper}{transId} . "&p="
|
||
. $hash->{helper}{policy},
|
||
callback => \&parseLogin,
|
||
data => $newdata
|
||
|
||
};
|
||
|
||
Log3 $name, LOG_DEBUG, "Generated URL is $newparam->{url} \n";
|
||
|
||
#Log3 $name, LOG_RECEIVE, Dumper($newparam);
|
||
|
||
HttpUtils_NonblockingGet($newparam);
|
||
return;
|
||
}
|
||
|
||
sub parseLogin {
|
||
|
||
my ( $param, $err, $data ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
my $header = $param->{httpheader};
|
||
|
||
# $data should be {"status":"200"}
|
||
Log3 $name, LOG_RECEIVE, $err . " / " . $data . Dumper($header);
|
||
my $json = safe_decode_json( $hash, $data );
|
||
if ( $json->{status} ne "200" ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $json->{status} );
|
||
readingsBulkUpdate( $hash, "error_description", $json->{message} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
|
||
my $cookies = getCookies( $hash, $header );
|
||
if ( $hash->{HTTPCookieHash} ) {
|
||
foreach my $cookie ( sort keys %{ $hash->{HTTPCookieHash} } ) {
|
||
my $cPath = $hash->{HTTPCookieHash}{$cookie}{Path};
|
||
$cookies .= "; " if ($cookies);
|
||
$cookies .= $hash->{HTTPCookieHash}{$cookie}{Name} . "=" . $hash->{HTTPCookieHash}{$cookie}{Value};
|
||
}
|
||
}
|
||
|
||
Log3 $name, LOG_DEBUG, "=================" . Dumper($cookies) . "\nHeader:" . Dumper($header);
|
||
$cookies .= "; x-ms-cpim-csrf=" . $hash->{helper}{csrf};
|
||
Log3 $name, LOG_DEBUG, "=================" . Dumper($cookies) . "\n";
|
||
$hash->{helper}{cookies} = $cookies;
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub getCode {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $newparam->{header} = {
|
||
"Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||
"Accept-Encoding" => "br, gzip, deflate",
|
||
"Connection" => "keep-alive",
|
||
"Accept-Language" => "de-de",
|
||
"Cookie" => $hash->{helper}{cookies},
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Mobile/15E148 Safari/604.1"
|
||
};
|
||
$newparam->{url}
|
||
= "https://gruenbeckb2c.b2clogin.com"
|
||
. $hash->{helper}{tenant}
|
||
. "/api/CombinedSigninAndSignup/confirmed?csrf_token="
|
||
. $hash->{helper}{csrf} . "&tx="
|
||
. $hash->{helper}{transId} . "&p="
|
||
. $hash->{helper}{policy};
|
||
|
||
$newparam->{hash} = $hash;
|
||
|
||
$newparam->{callback} = \&parseCode;
|
||
$newparam->{httpversion} = "1.1";
|
||
$newparam->{ignoreredirects} = 1;
|
||
Log3 $name, LOG_DEBUG, qq(Calling $newparam->{url});
|
||
HttpUtils_NonblockingGet($newparam);
|
||
|
||
return;
|
||
}
|
||
|
||
sub parseCode {
|
||
my ( $param, $err, $data ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
my $header = $param->{httpheader};
|
||
|
||
my $cookies = getCookies( $hash, $header );
|
||
Log3 $name, LOG_RECEIVE, qq($err / $data);
|
||
|
||
my @code = $data =~ /code%3d(.*)\">here/xsm;
|
||
if ( $code[0] eq $EMPTY ) {
|
||
readingsSingleUpdate( $hash, 'error', 'no code found', 1 );
|
||
readingsSingleUpdate( $hash, 'error_description', '---', 1 );
|
||
return;
|
||
}
|
||
|
||
Log3 $name, LOG_DEBUG, Dumper(@code);
|
||
$hash->{helper}{code} = $code[0];
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub initToken {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
if ( !$hash->{helper}{tenant} ) {
|
||
$hash->{helper}{tenant} = ReadingsVal( $name, "tenant", undef );
|
||
}
|
||
|
||
my $newparam->{header} = {
|
||
"Host" => "gruenbeckb2c.b2clogin.com",
|
||
"x-client-SKU" => "MSAL.iOS",
|
||
"Accept" => "application/json",
|
||
"x-client-OS" => "12.4.1",
|
||
"x-app-name" => "Gruenbeck",
|
||
"x-client-CPU" => "64",
|
||
"x-app-ver" => "1.0.7",
|
||
"Accept-Language" => "de-de",
|
||
"Accept-Encoding" => "br, gzip, deflate",
|
||
"client-request-id" => "1A472478-12F4-445D-81AC-170A578B4F37",
|
||
"User-Agent" => "Gruenbeck/333 CFNetwork/1121.2.2 Darwin/19.3.0",
|
||
"x-client-Ver" => "0.2.2",
|
||
"x-client-DM" => "iPhone",
|
||
"return-client-request-id" => "true",
|
||
|
||
# "cache-control" => "no-cache",
|
||
"Connection" => "keep-alive",
|
||
"Content-Type" => "application/x-www-form-urlencoded",
|
||
"return-client-request-id" => "true"
|
||
};
|
||
$newparam->{url} = "https://gruenbeckb2c.b2clogin.com" . $hash->{helper}{tenant} . "/oauth2/v2.0/token";
|
||
|
||
my $newdata
|
||
= "client_info=1&scope=https%3A%2F%2Fgruenbeckb2c.onmicrosoft.com%2Fiot%2Fuser_impersonation+openid+profile+offline_access&"
|
||
. "code="
|
||
. $hash->{helper}{code}
|
||
. "&grant_type=authorization_code&"
|
||
. "code_verifier="
|
||
. ReadingsVal( $name, 'code_challenge', $EMPTY )
|
||
. "&redirect_uri=msal5a83cc16-ffb1-42e9-9859-9fbf07f36df8%3A%2F%2Fauth"
|
||
. "&client_id=5a83cc16-ffb1-42e9-9859-9fbf07f36df8";
|
||
|
||
$newparam->{httpversion} = '1.1';
|
||
$newparam->{data} = $newdata;
|
||
$newparam->{hash} = $hash;
|
||
$newparam->{method} = 'POST';
|
||
$newparam->{callback} = \&parseRefreshToken;
|
||
HttpUtils_NonblockingGet($newparam);
|
||
return;
|
||
}
|
||
|
||
sub parseRefreshToken {
|
||
my ( $param, $err, $json ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
my $header = $param->{httpheader};
|
||
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
|
||
my $data = safe_decode_json( $hash, $json );
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
$hash->{helper}{accessToken} = $data->{access_token};
|
||
$hash->{helper}{refreshToken} = $data->{refresh_token};
|
||
|
||
# seems like access token is valid for 14 days, refresg token for 1 hour
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, ".accessToken", $data->{access_token} );
|
||
readingsBulkUpdate( $hash, ".refreshToken", $data->{refresh_token} );
|
||
readingsBulkUpdate( $hash, "not_before", strftime( "%Y-%m-%d %H:%M:%S", localtime( $data->{not_before} ) ) );
|
||
readingsBulkUpdate( $hash, "expires_on", strftime( "%Y-%m-%d %H:%M:%S", localtime( $data->{expires_on} ) ) );
|
||
|
||
readingsEndUpdate( $hash, 1 );
|
||
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub getRefreshTokenHeader {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Host" => "gruenbeckb2c.b2clogin.com",
|
||
"x-client-SKU" => "MSAL.iOS",
|
||
"Accept" => "application/json",
|
||
"x-client-OS" => "12.4.1",
|
||
"x-app-name" => "Gruenbeck myProduct",
|
||
"x-client-CPU" => "64",
|
||
"x-app-ver" => "1.0.4",
|
||
"Accept-Language" => "de-de",
|
||
"client-request-id" => "E85BBC36-160D-48B0-A93A-2694F902BF19",
|
||
"User-Agent" => "Gruenbeck/320 CFNetwork/978.0.7 Darwin/18.7.0",
|
||
"x-client-Ver" => "0.2.2",
|
||
"x-client-DM" => "iPhone",
|
||
"return-client-request-id" => "true",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $newdata
|
||
= "client_id=5a83cc16-ffb1-42e9-9859-9fbf07f36df8&scope=https://gruenbeckb2c.onmicrosoft.com/iot/user_impersonation openid profile offline_access&"
|
||
. "refresh_token="
|
||
. ReadingsVal( $name, '.refreshToken', $EMPTY ) #$hash->{helper}{refreshToken}
|
||
. "&client_info=1&" . "grant_type=refresh_token";
|
||
my $param = {
|
||
header => $header,
|
||
data => $newdata,
|
||
hash => $hash,
|
||
method => "POST",
|
||
url => "https://gruenbeckb2c.b2clogin.com" . ReadingsVal( $name, 'tenant', $EMPTY ) . "/oauth2/v2.0/token"
|
||
};
|
||
|
||
return $param;
|
||
|
||
}
|
||
|
||
sub getRefreshTokenDirect {
|
||
my $hash = shift;
|
||
|
||
my $param = getRefreshTokenHeader($hash);
|
||
|
||
my ( $err, $data ) = HttpUtils_BlockingGet($param);
|
||
parseRefreshToken( $param, $err, $data );
|
||
return;
|
||
}
|
||
|
||
sub getRefreshToken {
|
||
my $hash = shift;
|
||
|
||
my $param = getRefreshTokenHeader($hash);
|
||
$param->{callback} = \&parseRefreshToken;
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub getDevices {
|
||
my ($hash) = @_;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json, text/plain, */*",
|
||
"User-Agent" => "Gruenbeck/358 CFNetwork/1220.1 Darwin/20.3.0",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices?api-version=2020-08-03",
|
||
callback => \&parseDevices,
|
||
hash => $hash
|
||
};
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub parseDevices {
|
||
my ( $param, $err, $json ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
$json = latin1ToUtf8($json);
|
||
|
||
my $data = safe_decode_json( $hash, $json );
|
||
my $dev = $data->[0];
|
||
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $dev->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $dev->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $dev->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
|
||
readingsBeginUpdate($hash);
|
||
|
||
#my @devices;
|
||
|
||
#foreach my $dev (@data) {
|
||
# Log3 undef, 1, Dumper($dev);
|
||
readingsBulkUpdate( $hash, "name", $dev->{name} );
|
||
readingsBulkUpdate( $hash, "id", $dev->{id} );
|
||
|
||
# push @devices, $dev->{id};
|
||
#}
|
||
#readingsBulkUpdate( $hash, "devices", join( ",", @devices ) );
|
||
readingsEndUpdate( $hash, 1 );
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub getMeasurements {
|
||
my $hash = shift;
|
||
my $type = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json, text/plain, */*",
|
||
"User-Agent" => "Gruenbeck/358 CFNetwork/1220.1 Darwin/20.3.0",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/softliQ.D/"
|
||
. ReadingsVal( $name, 'id', $EMPTY )
|
||
. "/measurements/"
|
||
. $type
|
||
. '/?api-version=2020-08-03/',
|
||
hash => $hash
|
||
};
|
||
|
||
my ( $err, $json ) = HttpUtils_BlockingGet($param);
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
$json = latin1ToUtf8($json);
|
||
|
||
#my $data = safe_decode_json( $hash, $json );
|
||
my $cdata = safe_decode_json( $hash, $json );
|
||
my $data = $cdata->[0];
|
||
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return $data->{error_description};
|
||
}
|
||
my $ret;
|
||
foreach my $d ( @{$cdata} ) {
|
||
$ret .= '<div>' . $d->{date} . ' : ' . $d->{value} . '</div>';
|
||
}
|
||
return $ret;
|
||
}
|
||
|
||
sub getInfo {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json, text/plain, */*",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . ReadingsVal( $name, '.accessToken', undef ),
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/" . ReadingsVal( $name, 'id', $EMPTY ),
|
||
callback => \&parseInfo,
|
||
hash => $hash
|
||
};
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub parseInfo {
|
||
my ( $param, $err, $json ) = @_;
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
$json = latin1ToUtf8($json);
|
||
|
||
my @cdata = safe_decode_json( $hash, $json );
|
||
my $data = $cdata[0];
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
|
||
readingsBeginUpdate($hash);
|
||
my %info = %{$data};
|
||
my $i = 0;
|
||
foreach my $key ( keys %info ) {
|
||
if ( ref( $info{$key} ) eq "ARRAY" ) {
|
||
if ( $key eq "water" || $key eq "salt" ) {
|
||
$i = 0;
|
||
foreach my $dp ( @{ $info{$key} } ) {
|
||
readingsBulkUpdate( $hash, $key . "_" . $i . "_date", $dp->{date} );
|
||
readingsBulkUpdate( $hash, $key . "_" . $i . "_value", $dp->{value} );
|
||
$i++;
|
||
}
|
||
}
|
||
elsif ( $key eq "errors" ) {
|
||
my $actMsg = 0;
|
||
foreach my $dp ( @{ $info{$key} } ) {
|
||
my $mkey = 'message_' . makeReadingName( unpack( 'L', md5( $dp->{date} ) ) );
|
||
|
||
#next if (ReadingsVal($name,$mkey.'_date','') eq '');
|
||
readingsBulkUpdate( $hash, $mkey . '_date', $dp->{date} );
|
||
readingsBulkUpdate( $hash, $mkey . '_isResolved', $dp->{isResolved} );
|
||
readingsBulkUpdate( $hash, $mkey . '_message', $dp->{message} );
|
||
readingsBulkUpdate( $hash, $mkey . '_type', $dp->{type} );
|
||
if ( $dp->{isResolved} == 0 ) {
|
||
$actMsg++;
|
||
}
|
||
|
||
}
|
||
readingsBulkUpdate( $hash, 'messageCount', $actMsg );
|
||
}
|
||
}
|
||
else {
|
||
readingsBulkUpdate( $hash, $key, $info{$key} );
|
||
}
|
||
}
|
||
readingsEndUpdate( $hash, 1 );
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub getParamList {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
if ( defined( $hash->{helper}{params} ) ) {
|
||
my $ret = '<table>';
|
||
foreach my $p ( sort @{ $hash->{helper}{params} } ) {
|
||
$ret .= qq (<tr><td>$p</td>);
|
||
$paramTexts{$p} //= 'N/A'
|
||
; #The slash slash operator in perl returns the left side value if it is defined, otherwise it returns the right side value.
|
||
$ret .= qq(<td>$paramTexts{$p}</td>);
|
||
my $rv = ReadingsVal( $name, ".$p", $EMPTY );
|
||
$ret .= qq(<td>$rv);
|
||
if ( defined( $paramValueMap{$p} ) ) {
|
||
$ret .= qq / ($paramValueMap{$p}{$rv})/;
|
||
}
|
||
$ret .= '</td></tr>';
|
||
}
|
||
$ret .= '</table>';
|
||
return $ret;
|
||
}
|
||
|
||
getParam($hash);
|
||
return qq (ParameterList has to be updated, please try again in a moment);
|
||
}
|
||
|
||
sub getParam {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Host" => "prod-eu-gruenbeck-api.azurewebsites.net",
|
||
"Accept" => "application/json, text/plain, */*",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/"
|
||
. ReadingsVal( $name, 'id', $EMPTY )
|
||
. '/parameters?api-version=2020-08-03',
|
||
callback => \&parseParam,
|
||
hash => $hash
|
||
};
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub parseParam {
|
||
my $param = shift;
|
||
my $err = shift;
|
||
my $json = shift;
|
||
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
$json = latin1ToUtf8($json);
|
||
|
||
my $data = safe_decode_json( $hash, $json );
|
||
|
||
#my $data = @$cdata[0];
|
||
Log3 $name, LOG_RECEIVE, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
|
||
if ( defined( $data->{error}{type} ) ) {
|
||
readingsBulkUpdate( $hash, "error", $data->{error}{type} );
|
||
readingsBulkUpdate( $hash, "error_description", '---' );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
|
||
}
|
||
|
||
readingsBeginUpdate($hash);
|
||
my %info = %{$data};
|
||
my $i = 0;
|
||
my @param;
|
||
foreach my $key ( keys %info ) {
|
||
if ( ref( $info{$key} ) eq 'ARRAY' || $key eq 'type' ) { #we don't want to overwrite the device type
|
||
|
||
#we'll have to check that
|
||
}
|
||
else {
|
||
readingsBulkUpdate( $hash, ".$key", $info{$key} );
|
||
push @param, $key;
|
||
}
|
||
}
|
||
readingsEndUpdate( $hash, 1 );
|
||
$hash->{helper}{params} = \@param;
|
||
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub negotiate {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
my $header = {
|
||
"Content-Type" => "text/plain;charset=UTF-8",
|
||
"Origin" => "file://",
|
||
"Accept" => "*/*",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/realtime/negotiate",
|
||
callback => \&parseNegotiate,
|
||
hash => $hash
|
||
};
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
}
|
||
|
||
sub parseNegotiate {
|
||
my $param = shift;
|
||
my $err = shift;
|
||
my $json = shift;
|
||
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
my $data = safe_decode_json( $hash, $json );
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
|
||
$hash->{helper}{wsAccessToken} = $data->{accessToken};
|
||
$hash->{helper}{wsUrl} = $data->{url};
|
||
Log3 $name, LOG_DEBUG, qq ([$name] wsUrl is $data->{url});
|
||
|
||
my $newheader = {
|
||
"Content-Type" => "text/plain;charset=UTF-8",
|
||
"Origin" => "file://",
|
||
"Accept" => "*/*",
|
||
"User-Agent" =>
|
||
" Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . $hash->{helper}{wsAccessToken},
|
||
"Accept-Language" => "de-de",
|
||
"X-Requested-With" => "XMLHttpRequest",
|
||
"Content-Length" => 0
|
||
};
|
||
my $newparam = {
|
||
header => $newheader,
|
||
url => "https://prod-eu-gruenbeck-signalr.service.signalr.net/client/negotiate?hub=gruenbeck",
|
||
callback => \&parseWebsocketId,
|
||
hash => $hash,
|
||
method => "POST"
|
||
};
|
||
HttpUtils_NonblockingGet($newparam);
|
||
|
||
#processCmdQueue($hash);
|
||
return;
|
||
|
||
}
|
||
|
||
sub parseWebsocketId {
|
||
my $param = shift;
|
||
my $err = shift;
|
||
my $json = shift;
|
||
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
Log3 $name, LOG_RECEIVE, qq($err / $json);
|
||
my $data = safe_decode_json( $hash, $json );
|
||
Log3 $name, LOG_DEBUG, Dumper($data);
|
||
|
||
if ( defined( $data->{error} ) ) {
|
||
readingsBeginUpdate($hash);
|
||
readingsBulkUpdate( $hash, "error", $data->{error} );
|
||
readingsBulkUpdate( $hash, "error_description", $data->{error_description} );
|
||
readingsEndUpdate( $hash, 1 );
|
||
return;
|
||
}
|
||
|
||
$hash->{helper}{wsId} = $data->{connectionId};
|
||
return if ( !$data->{connectionId} );
|
||
|
||
my $url
|
||
= "wss://prod-eu-gruenbeck-signalr.service.signalr.net/client/?hub=gruenbeck&id="
|
||
. $hash->{helper}{wsId}
|
||
. "&access_token="
|
||
. $hash->{helper}{wsAccessToken};
|
||
|
||
wsConnect2( $hash, $url );
|
||
|
||
realtime( $hash, "enter" );
|
||
realtime( $hash, "refresh" );
|
||
|
||
processCmdQueue($hash);
|
||
return;
|
||
}
|
||
|
||
sub realtime {
|
||
|
||
my ( $hash, $type ) = @_;
|
||
my $name = $hash->{NAME};
|
||
|
||
Log3 $name, LOG_RECEIVE, qq ([$name] Calling realtime for $type);
|
||
|
||
my $header = {
|
||
"Content-Length" => 0,
|
||
"Origin" => "file://",
|
||
"Accept" => "*/*",
|
||
"User-Agent" =>
|
||
"Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||
"Authorization" => "Bearer " . $hash->{helper}{accessToken},
|
||
"Accept-Language" => "de-de",
|
||
"cache-control" => "no-cache"
|
||
};
|
||
my $param = {
|
||
header => $header,
|
||
url => "https://prod-eu-gruenbeck-api.azurewebsites.net/api/devices/"
|
||
. ReadingsVal( $name, 'id', $EMPTY )
|
||
. "/realtime/$type?api-version=2020-08-03",
|
||
callback => \&parseRealtime,
|
||
hash => $hash,
|
||
method => "POST"
|
||
};
|
||
|
||
HttpUtils_NonblockingGet($param);
|
||
return;
|
||
|
||
}
|
||
|
||
sub parseRealtime {
|
||
my $param = shift;
|
||
my $err = shift;
|
||
my $json = shift;
|
||
|
||
my $hash = $param->{hash};
|
||
my $name = $hash->{NAME};
|
||
return;
|
||
}
|
||
### stolen from HTTPMOD
|
||
sub getCookies {
|
||
my $hash = shift;
|
||
my $header = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
delete $hash->{HTTPCookieHash};
|
||
|
||
foreach my $cookie ( $header =~ m/set-cookie: ?(.*)/gix ) {
|
||
if ( $cookie =~ /([^,; ]+)=([^,;\s\v]+)[;,\s\v]*([^\v]*)/x ) {
|
||
|
||
Log3 $name, LOG_RECEIVE, qq($name: GetCookies parsed Cookie: $1 Wert $2 Rest $3);
|
||
my $cname = $1;
|
||
my $value = $2;
|
||
my $rest = ( $3 ? $3 : $EMPTY );
|
||
my $path = $EMPTY;
|
||
if ( $rest =~ /path=([^;,]+)/xsm ) {
|
||
$path = $1;
|
||
}
|
||
my $key = $cname . ';' . $path;
|
||
$hash->{HTTPCookieHash}{$key}{Name} = $cname;
|
||
$hash->{HTTPCookieHash}{$key}{Value} = $value;
|
||
$hash->{HTTPCookieHash}{$key}{Options} = $rest;
|
||
$hash->{HTTPCookieHash}{$key}{Path} = $path;
|
||
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
sub processCmdQueue {
|
||
my $hash = shift;
|
||
|
||
my $name = $hash->{NAME};
|
||
|
||
return if ( !defined( $hash->{helper}{cmdQueue} ) );
|
||
|
||
my $cmd = shift @{ $hash->{helper}{cmdQueue} };
|
||
|
||
return if ref($cmd) ne "CODE";
|
||
my $cv = svref_2object($cmd);
|
||
my $gv = $cv->GV;
|
||
Log3 $name, LOG_RECEIVE, "[$name] Processing Queue: " . $gv->NAME;
|
||
$cmd->($hash);
|
||
return;
|
||
}
|
||
|
||
sub safe_decode_json {
|
||
my $hash = shift;
|
||
my $data = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $json = undef;
|
||
eval {
|
||
$json = decode_json($data);
|
||
1;
|
||
} or do {
|
||
my $error = $@ || 'Unknown failure';
|
||
Log3 $name, LOG_ERROR, "[$name] - Received invalid JSON: $error" . Dumper($data);
|
||
|
||
};
|
||
return $json;
|
||
}
|
||
|
||
# from RichardCz, https://gl.petatech.eu/root/HomeBot/snippets/2
|
||
|
||
sub use_module_prio {
|
||
my $args_hr = shift // return; # get named arguments hash or bail out
|
||
|
||
my $wanted_lr = $args_hr->{wanted} // []; # get list of wanted methods/functions
|
||
my $priority_lr = $args_hr->{priority} // []; # get list of modules from most to least wanted
|
||
|
||
for my $module ( @{$priority_lr} ) { # iterate the priorized list of wanted modules
|
||
my $success = eval "require $module"; # require module at runtime, undef if not there
|
||
if ($success) { # we catched ourselves a module
|
||
import $module @{$wanted_lr}; # perform the import of the wanted methods
|
||
return $module;
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
sub StorePassword {
|
||
my $hash = shift;
|
||
my $password = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my ( $passResp, $passErr );
|
||
( $passResp, $passErr ) = $hash->{helper}->{passObj}->setStorePassword( $name, $password );
|
||
|
||
# my $index = $hash->{TYPE} . "_" . $name . "_passwd";
|
||
# my $key = getUniqueId() . $index;
|
||
# my $enc_pwd = $EMPTY;
|
||
|
||
# if ( eval "use Digest::MD5;1" ) {
|
||
|
||
# $key = Digest::MD5::md5_hex( unpack "H*", $key );
|
||
# $key .= Digest::MD5::md5_hex($key);
|
||
# }
|
||
|
||
# for my $char ( split //, $password ) {
|
||
|
||
# my $encode = chop($key);
|
||
# $enc_pwd .= sprintf( "%.2x", ord($char) ^ ord($encode) );
|
||
# $key = $encode . $key;
|
||
# }
|
||
|
||
# my $err = setKeyValue( $index, $enc_pwd );
|
||
if ( defined($passErr) ) {
|
||
return "error while saving the password - $passErr";
|
||
}
|
||
|
||
return "password successfully saved";
|
||
}
|
||
|
||
sub ReadPassword {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
return $hash->{helper}->{passObj}->getReadPassword($name);
|
||
}
|
||
|
||
sub ReadPasswordOld {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
my $index = $hash->{TYPE} . "_" . $name . "_passwd";
|
||
my $key = getUniqueId() . $index;
|
||
my ( $password, $err );
|
||
|
||
Log3 $name, LOG_RECEIVE, "[$name] - Read password from file";
|
||
|
||
( $err, $password ) = getKeyValue($index);
|
||
|
||
if ( defined($err) ) {
|
||
|
||
Log3 $name, LOG_WARNING, "[$name] - unable to read password from file: $err";
|
||
return;
|
||
|
||
}
|
||
|
||
if ( defined($password) ) {
|
||
if ( eval "use Digest::MD5;1" ) {
|
||
|
||
$key = Digest::MD5::md5_hex( unpack "H*", $key );
|
||
$key .= Digest::MD5::md5_hex($key);
|
||
}
|
||
|
||
my $dec_pwd = $EMPTY;
|
||
|
||
for my $char ( map { pack( 'C', hex($_) ) } ( $password =~ /(..)/g ) ) {
|
||
|
||
my $decode = chop($key);
|
||
$dec_pwd .= chr( ord($char) ^ ord($decode) );
|
||
$key = $decode . $key;
|
||
}
|
||
|
||
return $dec_pwd;
|
||
|
||
}
|
||
else {
|
||
|
||
Log3 $name, LOG_WARNING, "[$name] - No password in file";
|
||
return;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
sub Rename {
|
||
my $new = shift;
|
||
my $old = shift;
|
||
|
||
my $hash = $defs{$new};
|
||
my $name = $hash->{NAME};
|
||
|
||
my $oldhash = $defs{$old};
|
||
Log3( $name, 1, Dumper($oldhash) );
|
||
|
||
my ( $passResp, $passErr ) = $hash->{helper}->{passObj}->setRename( $new, $old );
|
||
|
||
if ( defined($passErr) ) {
|
||
Log3( $name, LOG_WARNING,
|
||
"[$name]error while saving the password after rename - $passErr. Please set the password again." );
|
||
}
|
||
return;
|
||
}
|
||
|
||
sub wsConnect2 {
|
||
my ( $hash, $url ) = @_;
|
||
my $name = $hash->{NAME};
|
||
|
||
#$hash->{loglevel} = 1;
|
||
return if ( DevIo_IsOpen($hash) );
|
||
|
||
# Protocol::WebSocket takes a full URL, but IO::Socket::* uses only a host
|
||
# and port. This regex section retrieves host/port from URL.
|
||
my ( $proto, $host, $port, $path );
|
||
if ( $url =~ m/^(?:(?<proto>ws|wss):\/\/)?(?<host>[^\/:]+)(?::(?<port>\d+))?(?<path>\/.*)?$/xsm ) {
|
||
$host = $+{host};
|
||
$path = $+{path};
|
||
|
||
if ( defined $+{proto} && defined $+{port} ) {
|
||
$proto = $+{proto};
|
||
$port = $+{port};
|
||
}
|
||
elsif ( defined $+{port} ) {
|
||
$port = $+{port};
|
||
if ( $port == 443 ) { $proto = 'wss' }
|
||
else { $proto = 'ws' }
|
||
}
|
||
elsif ( defined $+{proto} ) {
|
||
$proto = $+{proto};
|
||
if ( $proto eq 'wss' ) { $port = 443 }
|
||
else { $port = 80 }
|
||
}
|
||
else {
|
||
$proto = 'ws';
|
||
$port = 80;
|
||
}
|
||
}
|
||
else {
|
||
Log3 $name, LOG_ERROR, "[$name] Failed to parse Host/Port from URL.";
|
||
}
|
||
|
||
#$url =~ s/wss:\/\//wss:/;
|
||
#$hash->{DeviceName} = $url;
|
||
$hash->{DeviceName} = 'wss:' . $host . ':' . $port . $path;
|
||
$hash->{SSL} = 1;
|
||
$hash->{devioLoglevel} = LOG_RECEIVE;
|
||
DevIo_OpenDev( $hash, 0, "FHEM::Gruenbeck::SoftliqCloud::wsStart", "FHEM::Gruenbeck::SoftliqCloud::wsFail" );
|
||
|
||
return;
|
||
}
|
||
|
||
sub wsStart {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
Log3( $name, LOG_RECEIVE, qq([$name] Websocket connected) );
|
||
DevIo_SimpleWrite( $hash, '{"protocol":"json","version":1}', 2 );
|
||
|
||
#succesfully connected - start a timer
|
||
my $next = int( gettimeofday() ) + MINUTESECONDS;
|
||
InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::wsClose', $hash, 0 );
|
||
|
||
return;
|
||
}
|
||
|
||
# based on https://greg-kennedy.com/wordpress/2019/03/11/writing-a-websocket-client-in-perl-5/
|
||
sub wsConnect {
|
||
my ( $hash, $url ) = @_;
|
||
my $name = $hash->{NAME};
|
||
|
||
# Protocol::WebSocket takes a full URL, but IO::Socket::* uses only a host
|
||
# and port. This regex section retrieves host/port from URL.
|
||
my ( $proto, $host, $port, $path );
|
||
if ( $url =~ m/^(?:(?<proto>ws|wss):\/\/)?(?<host>[^\/:]+)(?::(?<port>\d+))?(?<path>\/.*)?$/xsm ) {
|
||
$host = $+{host};
|
||
$path = $+{path};
|
||
|
||
if ( defined $+{proto} && defined $+{port} ) {
|
||
$proto = $+{proto};
|
||
$port = $+{port};
|
||
}
|
||
elsif ( defined $+{port} ) {
|
||
$port = $+{port};
|
||
if ( $port == 443 ) { $proto = 'wss' }
|
||
else { $proto = 'ws' }
|
||
}
|
||
elsif ( defined $+{proto} ) {
|
||
$proto = $+{proto};
|
||
if ( $proto eq 'wss' ) { $port = 443 }
|
||
else { $port = 80 }
|
||
}
|
||
else {
|
||
$proto = 'ws';
|
||
$port = 80;
|
||
}
|
||
}
|
||
else {
|
||
Log3 $name, LOG_ERROR, "[$name] Failed to parse Host/Port from URL.";
|
||
}
|
||
|
||
# create a connecting socket
|
||
# SSL_startHandshake is dependent on the protocol: this lets us use one socket
|
||
# to work with either SSL or non-SSL sockets.
|
||
# my $tcp_socket = IO::Socket::SSL->new(
|
||
# PeerAddr => $host,
|
||
# PeerPort => "$proto($port)",
|
||
# Proto => 'tcp',
|
||
# SSL_startHandshake => ( $proto eq 'wss' ? 1 : 0 ),
|
||
# Blocking => 1
|
||
# ) or Log3 $name, 1, "[$name] Failed to connect to socket: $@";
|
||
|
||
return if ( DevIo_IsOpen($hash) );
|
||
Log3 $name, LOG_RECEIVE, "[$name] Attempting to open SSL socket to $proto://$host:$port...";
|
||
$hash->{DeviceName} = $host . ':' . $port;
|
||
$hash->{helper}{url} = $url;
|
||
$hash->{SSL} = 1;
|
||
|
||
#$hash->{WEBSOCKET} = 1;
|
||
|
||
#DevIo_CloseDev($hash) if ( DevIo_IsOpen($hash) );
|
||
DevIo_OpenDev( $hash, 0, "FHEM::Gruenbeck::SoftliqCloud::wsHandshake", "FHEM::Gruenbeck::SoftliqCloud::wsFail" );
|
||
|
||
#my $conn = DevIo_OpenDev( $hash, 0, "FHEM::Gruenbeck::SoftliqCloud::wsHandshake");
|
||
#Log3 $name, 1, "[$name] Opening Websocket: $conn... $hash->{TCPDev}";
|
||
return;
|
||
}
|
||
|
||
sub parseWebsocketRead {
|
||
my $hash = shift;
|
||
my $buf = shift;
|
||
my $name = $hash->{NAME};
|
||
my $json = safe_decode_json( $hash, $buf );
|
||
if ( $json->{type} && $json->{type} ne '6' ) {
|
||
|
||
Log3 $name, LOG_RECEIVE, qq([$name] Received from socket: $buf);
|
||
readingsBeginUpdate($hash);
|
||
my @args = @{ $json->{arguments} };
|
||
my %info = %{ $args[0] };
|
||
my $i = 0;
|
||
foreach my $key ( keys %info ) {
|
||
|
||
if ( $key eq 'type' ) { # no use and there is already a type reading (machine type)
|
||
next;
|
||
}
|
||
|
||
if ( $key =~ /2$/x and AttrVal( $name, 'sq_duplex', '0' ) eq '0' ) {
|
||
next;
|
||
}
|
||
if ( $key eq 'msaltusage' ) {
|
||
my $diff = $info{$key} - ReadingsNum( $name, "lastRefill", 0 );
|
||
readingsBulkUpdate( $hash, 'saltUsageSinceRefill', $diff );
|
||
}
|
||
readingsBulkUpdate( $hash, $key, $info{$key} );
|
||
}
|
||
readingsEndUpdate( $hash, 1 );
|
||
|
||
}
|
||
return;
|
||
}
|
||
|
||
# sub wsHandshake {
|
||
# my $hash = shift;
|
||
# my $name = $hash->{NAME};
|
||
# my $tcp_socket = $hash->{TCPDev};
|
||
# my $url = $hash->{helper}{url};
|
||
|
||
# # create a websocket protocol handler
|
||
# # this doesn't actually "do" anything with the socket:
|
||
# # it just encodes / decode WebSocket messages. We have to send them ourselves.
|
||
# Log3 $name, LOG_RECEIVE, "[$name] Trying to create Protocol::WebSocket::Client handler for $url...";
|
||
# my $client = Protocol::WebSocket::Client->new(
|
||
# url => $url,
|
||
# version => "13",
|
||
# );
|
||
|
||
# # Set up the various methods for the WS Protocol handler
|
||
# # On Write: take the buffer (WebSocket packet) and send it on the socket.
|
||
# $client->on(
|
||
# write => sub {
|
||
# my $sclient = shift;
|
||
# my ($buf) = @_;
|
||
|
||
# syswrite $tcp_socket, $buf;
|
||
# }
|
||
# );
|
||
|
||
# # On Connect: this is what happens after the handshake succeeds, and we
|
||
# # are "connected" to the service.
|
||
# $client->on(
|
||
# connect => sub {
|
||
# my $sclient = shift;
|
||
|
||
# # You may wish to set a global variable here (our $isConnected), or
|
||
# # just put your logic as I did here. Or nothing at all :)
|
||
# Log3 $name, LOG_RECEIVE, "[$name] Successfully connected to service!" . Dumper($hash);
|
||
# $sclient->write('{"protocol":"json","version":1}');
|
||
|
||
# #succesfully connected - start a timer
|
||
# my $next = int( gettimeofday() ) + MINUTESECONDS;
|
||
# InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::wsClose', $hash, 0 );
|
||
# return;
|
||
|
||
# }
|
||
# );
|
||
|
||
# # On Error, print to console. This can happen if the handshake
|
||
# # fails for whatever reason.
|
||
# $client->on(
|
||
# error => sub {
|
||
# my $sclient = shift;
|
||
# my ($buf) = @_;
|
||
|
||
# Log3 $name, LOG_ERROR, qq([$name] ERROR ON WEBSOCKET: $buf);
|
||
# $tcp_socket->close;
|
||
# return qq([$name] ERROR ON WEBSOCKET: $buf);
|
||
# }
|
||
# );
|
||
|
||
# # On Read: This method is called whenever a complete WebSocket "frame"
|
||
# # is successfully parsed.
|
||
# # We will simply print the decoded packet to screen. Depending on the service,
|
||
# # you may e.g. call decode_json($buf) or whatever.
|
||
# $client->on(
|
||
# read => sub {
|
||
# my $sclient = shift;
|
||
# my ($buf) = @_;
|
||
# $buf =~ s///xsm;
|
||
# parseWebsocketRead( $hash, $buf );
|
||
|
||
# #Log3 $name, 3, "[$name] Received from socket: '$buf'";
|
||
# return;
|
||
# }
|
||
# );
|
||
|
||
# # Now that we've set all that up, call connect on $client.
|
||
# # This causes the Protocol object to create a handshake and write it
|
||
# # (using the on_write method we specified - which includes sysread $tcp_socket)
|
||
# Log3 $name, LOG_RECEIVE, "[$name] Calling connect on client...";
|
||
# $client->connect;
|
||
|
||
# # read until handshake is complete.
|
||
# while ( !$client->{hs}->is_done ) {
|
||
# my $recv_data;
|
||
|
||
# my $bytes_read = sysread $tcp_socket, $recv_data, TCPPACKETSIZE;
|
||
|
||
# if ( !defined $bytes_read ) {
|
||
# Log3 $name, LOG_ERROR, qq([$name] sysread on tcp_socket failed: $!);
|
||
# return qq([$name] sysread on tcp_socket failed: $!);
|
||
# }
|
||
# if ( $bytes_read == 0 ) {
|
||
# Log3 $name, LOG_ERROR, qq([$name] Connection terminated.);
|
||
# return qq([$name] Connection terminated.);
|
||
# }
|
||
|
||
# $client->read($recv_data);
|
||
# }
|
||
|
||
# # Create a Socket Set for Select.
|
||
# # We can then test this in a loop to see if we should call read.
|
||
# # my $set = IO::Select->new($tcp_socket);
|
||
|
||
# #$hash->{helper}{wsSet} = $set;
|
||
# $hash->{helper}{wsClient} = $client;
|
||
|
||
# # my $next = int( gettimeofday() ) + 1;
|
||
# # $hash->{helper}{wsCount} = 0;
|
||
# #InternalTimer( $next, 'FHEM::Gruenbeck::SoftliqCloud::wsRead', $hash, 0 );
|
||
# return;
|
||
# }
|
||
|
||
sub wsFail {
|
||
my $hash = shift;
|
||
my $error = shift;
|
||
my $name = $hash->{NAME};
|
||
|
||
#$error //= "Unknown Error";
|
||
return unless $error;
|
||
|
||
# create a log emtry with the error message
|
||
Log3 $name, LOG_ERROR, qq ([$name] - error while connecting to Websocket: $error);
|
||
|
||
return;
|
||
}
|
||
|
||
sub Ready {
|
||
my $hash = shift;
|
||
|
||
# try to reopen the connection in case the connection is lost
|
||
#return DevIo_OpenDev( $hash, 1, "FHEM::Gruenbeck::SoftliqCloud::wsHandshake", "FHEM::Gruenbeck::SoftliqCloud::wsFail" );
|
||
#negotiate($hash);
|
||
return;
|
||
}
|
||
|
||
sub splitTest {
|
||
my $buf = shift;
|
||
my $name = "sd18";
|
||
my @bufs;
|
||
$buf =~ s///xsm;
|
||
my $index = index( $buf, '}{' );
|
||
if ( $index > 0 ) {
|
||
Log3( $name, LOG_RECEIVE, "[$name] - Splitting double-JSON buffer" );
|
||
push( @bufs, decode_json(substr( $buf, 0, $index + 1 ) ));
|
||
push( @bufs, decode_json(substr( $buf, $index + 1 )));
|
||
}
|
||
else {
|
||
push( @bufs, $buf );
|
||
}
|
||
return Dumper(@bufs);
|
||
}
|
||
|
||
sub wsReadDevIo {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
my $client = $hash->{helper}{wsClient};
|
||
|
||
my $buf = DevIo_SimpleRead($hash);
|
||
if ( !$buf ) {
|
||
return;
|
||
}
|
||
$buf =~ s///xsm;
|
||
$buf =~ s/\\x\{1e\}//xsm;
|
||
|
||
#if ( !( $buf =~ /}$/xsm ) ) {
|
||
# $buf = substr( $buf, 0, rindex( $buf, "}" ) );
|
||
#}
|
||
if ( length($buf) == 0 ) {
|
||
return;
|
||
}
|
||
Log3( $name, LOG_DEBUG, qq([$name] Received from DevIo: $buf) );
|
||
|
||
my @bufs;
|
||
my $index = index( $buf, '}{' );
|
||
if ( $index > 0 ) {
|
||
Log3( $name, LOG_RECEIVE, "[$name] - Splitting double-JSON buffer" );
|
||
push( @bufs, substr( $buf, 0, $index + 1 ) );
|
||
push( @bufs, substr( $buf, $index + 1 ) );
|
||
}
|
||
else {
|
||
push( @bufs, $buf );
|
||
}
|
||
|
||
foreach my $bufi (@bufs) {
|
||
Log3( $name, LOG_RECEIVE, "[$name] - Extracted" . $bufi );
|
||
parseWebsocketRead( $hash, $bufi );
|
||
}
|
||
|
||
# $client->read($buf);
|
||
|
||
return;
|
||
}
|
||
|
||
sub wsClose {
|
||
my $hash = shift;
|
||
my $name = $hash->{NAME};
|
||
my $client = $hash->{helper}{wsClient};
|
||
Log3 $name, LOG_RECEIVE, qq ([$name] - Closing Websocket connection);
|
||
|
||
#$client->disconnect;
|
||
DevIo_CloseDev($hash);
|
||
readingsSingleUpdate( $hash, "state", "closed", 0 );
|
||
|
||
return;
|
||
}
|
||
1;
|
||
|
||
=pod
|
||
=item helper
|
||
=item summary Retrieve data from Softliq Cloud (Grünbeck)
|
||
=item summary_DE Daten aus der Softliq Cloud (Grünbeck) auslesen
|
||
|
||
=begin html
|
||
|
||
<a name="SoftliqCloud"></a>
|
||
<div>
|
||
<ul>
|
||
The module reads data from Grünbeck Cloud for Softliq (SD series) water softeners. It also allows setting parameters and controlling the water softener to a certain extent
|
||
<br><br><a name='SoftliqCloudDefine'></a>
|
||
<b>Define</b>
|
||
<ul>
|
||
define the module with <code>define <name> SoftliqCloud <loginName></code> where login name is the login name for the softliq cloud. After that, set your password <code>set <name> password <password></code>
|
||
</ul>
|
||
<a name='SoftliqCloudGet'></a>
|
||
<b>Get</b>
|
||
<ul>
|
||
<li><a name='authenticate'>authenticate</a>: usually not needed, but in rare cases it might be required to re-authenticate</li>
|
||
<li><a name='query'>query</a>: reads the data from the cloud</li>
|
||
<li><a name='realtime'>realtime</a>: starts the data streaming (similar to the refresh button in the app)</li>
|
||
<li><a name='salt/water'>salt/water</a>: display salt/water history</li>
|
||
<li><a name='paramList'>paramList</a>: shows a list of available parameters (readings) with current values. If the meaning is known there's a short explanation for it.</li>
|
||
</ul>
|
||
<a name='SoftliqCloudSet'></a>
|
||
<b>Set</b>
|
||
<ul>
|
||
<li><a name='param'>param</a>: Allows to set parameters (see paramList) <code>set meineSoftliq <parameterName> <parameterValue></code></li>
|
||
<li><a name='regenerate'>regenerate</a>: Immediately starts a regeneration (without warning)</li>
|
||
<li><a name='refill'>refill</a>: execute after you refilled (25kg) salt. Allows tracking of remaining salt </li>
|
||
<li><a name='password'>password</a>: usually only needed initially (or if you change your password in the cloud)</li>
|
||
</ul>
|
||
<a name='SoftliqCloudAttr'></a>
|
||
<b>Attributes</b>
|
||
<ul>
|
||
<li><a name='sq_duplex'>sq_duplex</a>: set to 1, if you own a duplex machine</li>
|
||
<li><a name='sq_interval'>sq_interval</a>: polling interval in seconds (defaults to 3600)</li>
|
||
</ul>
|
||
</ul>
|
||
</div>
|
||
=end html
|
||
|
||
=begin html_DE
|
||
|
||
<a name=></a>
|
||
<div>
|
||
<ul>
|
||
Das Modul liest Daten aus der Grünbeck Cloud für Softliq Wasserenthärter (SD Serie). Es ermöglicht auch das Setzen von Parametern, sowie eine gewisse Steuerung
|
||
<br><br><a name='Define'></a>
|
||
<b>Define</b>
|
||
<ul>
|
||
Definiere das Modul mit <code>define <name> SoftliqCloud <loginName></code> wobei login name dein login name für die softliq cloud ist. Danach Passwoert setzen: <code>set <name> password <password></code>
|
||
</ul>
|
||
<a name='SoftliqCloudGet'></a>
|
||
<b>Get</b>
|
||
<ul>
|
||
<li><a name='authenticate'>authenticate</a>: Braucht man im Normalfall nicht, beim Testen hatte ich allerdings Fälle, wo ich mich neu authorisieren musste</li>
|
||
<li><a name='query'>query</a>: holt alle Daten aus der Cloud</li>
|
||
<li><a name='realtime'>realtime</a>: triggert das "streaming" (entspricht mehr oder weniger dem refresh Button in der App)</li>
|
||
<li><a name='salt/water'>salt/water</a>: zeigt die Salz-/Wasser-Verbrauchshistorie an</li>
|
||
<li><a name='paramList'>paramList</a>: Zeigt die verfügbaren Einstellungen mit aktuellen Werten an (Readings). Wenn die Bedeutung bekannt ist, gibt es auch eine Erläuterung</li>
|
||
</ul>
|
||
<a name='SoftliqCloudSet'></a>
|
||
<b>Set</b>
|
||
<ul>
|
||
<li><a name='param'>param</a>: erlaubt das setzen von Einstellungen (siehe paramList) in der Form <code>set meineSoftliq <parameterName> <parameterValue></code></li>
|
||
<li><a name='regenerate'>regenerate</a>: startet die manuelle Regeneration (ohne Nachfrage - geht direkt los)</li>
|
||
<li><a name='refill'>refill</a>: Auszuführen wenn (25kg) Salz nachgefüllt wurden. Ermöglicht es das verbleibende Salz zu tracken</li>
|
||
<li><a name='password'>password</a>: Einmalig auszuführen, um das Passwort im sicheren Speicher zu setzen.</li>
|
||
</ul>
|
||
<a name='SoftliqCloudAttr'></a>
|
||
<b>Attributes</b>
|
||
<ul>
|
||
<li><a name='sq_duplex'>sq_duplex</a>: Auf 1 setzen, wenn es sich um einen Duplex Entkalker handelt</li>
|
||
<li><a name='sq_interval'>sq_interval</a>: Polling Intervall in Sekunden (Default Wert 3600)</li>
|
||
</ul>
|
||
</ul>
|
||
</div>
|
||
=end html_DE
|
||
=cut
|