######################################################################### # $Id$ # fhem Modul für Geräte mit Web-Oberfläche / Webservices # # This file is part of fhem. # # 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 . # ############################################################################## # First version: 25.12.2013 # # Todo: # setXYHintExpression zum dynamischen Ändern / Erweitern der Hints # extractAllReadings mit Filter / Prefix # definierbarer prefix oder Suffix für Readingsnamen wenn sie von unterschiedlichen gets über readingXY erzeugt werden # reading mit Status je get (error, no match, ...) oder reading zum nachverfolgen der schritte, fehler, auth etc. # # In _Attr bei Prüfungen auf get auch set berücksichtigen wo nötig, ebenso in der Attr Liste (oft fehlt set) # featureAttrs aus hash verarbeiten # # Implement IMap und IExpr for get (input values to be passed for get requests) # # replacement scope attribute? # make extracting the sid after a get / update an attribute / option? # multi page log extraction? # Profiling von Modbus übernehmen? # # verwendung von defptr: # $hash->{defptr}{readingBase}{$reading} gibt zu einem Reading-Namen den Ursprung an, z.B. get oder reading # readingNum die zugehörige Nummer, z.B. 01 # readingSubNum ggf. eine Unternummer (bei reading01-001) # wird von MaxAge verwendet um schnell zu einem Reading die zugehörige MaxAge Definition finden zu können # # $hash->{defptr}{requestReadings}{$reqType}{$baseReading} # wird von DeleteOnError und DeleteIfUnmatched verwendet. # $reqType ist update, get01, set01 etc. # $baseReading ist der Reading Basisname wie im Attribute ...Name definiert, # aber ohne eventuelle Extension bei mehreren Matches. # Liefert "$context $num", also z.B. get 1 - dort wird nach DeleteOn.. gesucht # wichtig um z.B. von reqType "get01" baseReading "Temperatur" auf reading 02 zu kommen # falls get01 keine eigenen parsing definitions enthält # DeleteOn... wird dann beim reading 02 etc. spezifiziert. # package HTTPMOD; use strict; use warnings; use GPUtils qw(:all); use Time::HiRes qw(gettimeofday); use Encode qw(decode encode); use SetExtensions qw(:all); use HttpUtils; use FHEM::HTTPMOD::Utils qw(:all); use POSIX; use Data::Dumper; use Exporter ('import'); our @EXPORT_OK = qw(); our %EXPORT_TAGS = (all => [@EXPORT_OK]); BEGIN { GP_Import( qw( fhem CommandAttr CommandDeleteAttr addToDevAttrList AttrVal ReadingsVal ReadingsTimestamp readingsSingleUpdate readingsBeginUpdate readingsBulkUpdate readingsEndUpdate InternalVal makeReadingName Log3 RemoveInternalTimer InternalTimer deviceEvents EvalSpecials AnalyzePerlCommand CheckRegexp IsDisabled gettimeofday FmtDateTime GetTimeSpec fhemTimeLocal time_str2num min max minNum maxNum abstime2rel defInfo trim ltrim rtrim UntoggleDirect UntoggleIndirect IsInt fhemNc round sortTopicNum Svn_GetFile WriteFile DevIo_OpenDev DevIo_SimpleWrite DevIo_SimpleRead DevIo_CloseDev SetExtensions HttpUtils_NonblockingGet featurelevel defs modules attr init_done )); GP_Export( qw( Initialize )); }; my $Module_Version = '4.1.12 - 19.4.2022'; my $AttrList = join (' ', 'reading[0-9]+(-[0-9]+)?Name', '(get|set)[0-9]+(-[0-9]+)?Name', '(reading|get|set)[0-9]*(-[0-9]+)?Expr:textField-long', # old '(reading|get|set)[0-9]*(-[0-9]+)?Map', # old '(reading|get|set)[0-9]*(-[0-9]+)?OExpr:textField-long', '(reading|get|set)[0-9]*(-[0-9]+)?OMap:textField-long', '(get|set)[0-9]*(-[0-9]+)?IExpr:textField-long', '(get|set)[0-9]*(-[0-9]+)?IMap:textField-long', '(reading|get|set)[0-9]*(-[0-9]+)?Format', '(reading|get|set)[0-9]*(-[0-9]+)?Decode', '(reading|get|set)[0-9]*(-[0-9]+)?Encode', '(reading|get)[0-9]*(-[0-9]+)?MaxAge', '(reading|get)[0-9]*(-[0-9]+)?MaxAgeReplacementMode:text,reading,internal,expression,delete', '(reading|get)[0-9]*(-[0-9]+)?MaxAgeReplacement', '(reading|get|set)[0-9]+Regex', '(reading|get|set)[0-9]*RegOpt', # see http://perldoc.perl.org/perlre.html#Modifiers '(reading|get|set)[0-9]+XPath', '(reading|get|set)[0-9]+XPath-Strict', '(reading|get|set)[0-9]+JSON', '(reading|get|set)[0-9]*RecombineExpr:textField-long', '(reading|get|set)[0-9]*AutoNumLen', '(reading|get|set)[0-9]*AlwaysNum', '(reading|get|set)[0-9]*DeleteIfUnmatched', '(reading|get|set)[0-9]*DeleteOnError', 'extractAllJSON:0,1,2', 'extractAllJSONFilter', 'readingsName.*', # old 'readingsRegex.*', # old 'readingsExpr.*', # old 'requestHeader.*', 'requestData.*:textField-long', 'noShutdown:0,1', 'httpVersion', 'sslVersion', 'sslArgs', 'timeout', 'queueDelay', 'queueMax', 'alignTime', 'minSendDelay', 'showMatched:0,1', 'showError:0,1', 'showBody:0,1', # expose the http response body as internal 'preProcessRegex', 'parseFunction1', 'parseFunction2', 'set[0-9]+Local', # don't create a request and just set a reading '(get|set)[0-9]*URL', '(get|set)[0-9]*Data.*:textField-long', '(get|set)[0-9]*NoData.*', # make sure it is an HTTP GET without data - even if a more generic data is defined '(get|set)[0-9]*Header.*:textField-long', '(get|set)[0-9]*CheckAllReadings:0,1', '(get|set)[0-9]*ExtractAllJSON:0,1,2', '(get|set)[0-9]*URLExpr:textField-long', # old '(get|set)[0-9]*DatExpr:textField-long', # old '(get|set)[0-9]*HdrExpr:textField-long', # old 'get[0-9]*Poll:0,1', 'get[0-9]*PollDelay', 'set[0-9]+Min', # todo: min, max und hint auch für get, Schreibweise der Liste auf (get|set) vereinheitlichen 'set[0-9]+Max', 'set[0-9]+Hint', # Direkte Fhem-spezifische Syntax für's GUI, z.B. '6,10,14' bzw. slider etc. 'set[0-9]*NoArg:0,1', # don't expect a value - for set on / off and similar. (default for get) '(get|set)[0-9]*TextArg:0,1', # just pass on a raw text value without validation / further conversion 'set[0-9]*ParseResponse:0,1', # parse response to set as if it was a get 'set[0-9]*Method:GET,POST,PUT', # select HTTP method for the set '(get|set)[0-9]*FollowGet', # do a get after the set/get to update readings / create chains 'maxGetChain', # max length of chains 'reAuthRegex', 'reAuthAlways:0,1', 'reAuthJSON', 'reAuthXPath', 'reAuthXPath-Strict', '(get|set)[0-9]*ReAuthRegex', '(get|set)[0-9]*ReAuthJSON', '(get|set)[0-9]*ReAuthXPath', '(get|set)[0-9]*ReAuthXPath-Strict', 'idRegex', 'idJSON', 'idXPath', 'idXPath-Strict', '(get|set|sid)[0-9]*IDRegex', # old '(get|set|sid)[0-9]*IdRegex', '(get|set|sid)[0-9]*IdJSON', '(get|set|sid)[0-9]*IdXPath', '(get|set|sid)[0-9]*IdXPath-Strict', 'sid[0-9]*URL', 'sid[0-9]*Header.*', 'sid[0-9]*Data.*:textField-long', 'sid[0-9]*IgnoreRedirects:0,1', 'sid[0-9]*ParseResponse:0,1', # parse response as if it was a get 'clearSIdBeforeAuth:0,1', 'authRetries', 'errLogLevelRegex', 'errLogLevel', 'replacement[0-9]+Regex', 'replacement[0-9]+Mode:reading,internal,text,expression,key', # defaults to text 'replacement[0-9]+Value:textField-long', # device:reading, device:internal, text, replacement expression '(get|set)[0-9]*Replacement[0-9]+Value:textField-long', # can overwrite a global replacement value - todo: auch für auth? 'do_not_notify:1,0', 'disable:0,1', 'disabledForIntervals', 'enableControlSet:0,1', 'enableCookies:0,1', 'useSetExtensions:1,0 '. 'handleRedirects:0,1', # own redirect handling outside HttpUtils 'enableXPath:0,1', # old 'enableXPath-Strict:0,1', # old 'enforceGoodReadingNames', 'dontRequeueAfterAuth', 'dumpBuffers', # debug -> write buffers to files 'fileHeaderSplit', # debug -> read file including header 'memReading', # debuf -> create a reading for the virtual Memory of the Fhem process together with BufCounter if it is used 'model', # for attr templates 'regexDecode', 'bodyDecode', 'regexCompile', $main::readingFnAttributes); ######################################################################### # FHEM module intitialisation - defines functions to be called from FHEM # GP_Export automatically exports this as Package_Initialize so is not necessary sub Initialize { my $hash = shift; $hash->{DefFn} = \&HTTPMOD::DefineFn; $hash->{UndefFn} = \&HTTPMOD::UndefFn; $hash->{SetFn} = \&HTTPMOD::SetFn; $hash->{GetFn} = \&HTTPMOD::GetFn; $hash->{AttrFn} = \&HTTPMOD::AttrFn; $hash->{NotifyFn} = \&HTTPMOD::NotifyFn; $hash->{AttrList} = $AttrList; return; } ######################################################################### # Define command # init internal values, # set internal timer get Updates sub DefineFn { my $hash = shift; # reference to the Fhem device hash my $def = shift; # definition string my @a = split( /[ \t]+/, $def ); # the above string split at space or tab my $name = $a[0]; # first item in the definition is the name of the new Fhem device return 'wrong syntax: define HTTPMOD URL interval' if ( @a < 3 ); if ($a[2] eq 'none') { Log3 $name, 3, "$name: URL is none, periodic updates will be limited to explicit GetXXPoll attribues (if defined)"; $hash->{MainURL} = ""; } else { $hash->{MainURL} = $a[2]; } if(int(@a) > 3) { # numeric interval specified if ($a[3] > 0) { return 'interval too small, please use something > 5, default is 300' if ($a[3] < 5); $hash->{Interval} = $a[3]; } else { Log3 $name, 3, "$name: interval is 0, no periodic updates will done."; $hash->{Interval} = 0; } } else { # default if no interval specified Log3 $name, 3, "$name: no valid interval specified, use default 300 seconds"; $hash->{Interval} = 300; } Log3 $name, 3, "$name: Defined " . ($hash->{MainURL} ? "with URL $hash->{MainURL}" : "without URL") . ($hash->{Interval} ? " and interval $hash->{Interval}" : "") . " featurelevel $featurelevel"; UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'start'); $hash->{NOTIFYDEV} = "global"; # NotifyFn nur aufrufen wenn global events (INITIALIZED) $hash->{ModuleVersion} = $Module_Version; $hash->{'.getList'} = ''; $hash->{'.setList'} = ''; $hash->{'.updateHintList'} = 1; $hash->{'.updateReadingList'} = 1; $hash->{'.updateRequestHash'} = 1; return; } ######################################################################### # undefine command when device is deleted sub UndefFn { my $hash = shift; # reference to the Fhem device hash my $name = shift; # name of the Fhem device RemoveInternalTimer ("timeout:$name"); StopQueueTimer($hash, {silent => 1}); UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'stop'); return; } ############################################################## # Notify Funktion - reagiert auf Änderung des Featurelevel sub NotifyFn { my $hash = shift; # reference to the HTTPMOD Fhem device hash my $source = shift; # reference to the Fhem device hash that created the event my $name = $hash->{NAME}; # device name of the HTTPMOD Fhem device return if($source->{NAME} ne 'global'); # only interested in global events my $events = deviceEvents($source, 1); return if(!$events); # no events #Log3 $name, 5, "$name: Notify called for source $source->{NAME} with events: @{$events}"; foreach my $event (@{$events}) { #Log3 $name, 5, "$name: event $event"; if ($event =~ /ATTR global featurelevel/) { # update hint list in case featurelevel change implies new defaults $hash->{'.updateHintList'} = 1; } } #return if (!grep(m/^INITIALIZED|REREADCFG|(MODIFIED $name)|(DEFINED $name)$/, @{$source->{CHANGED}})); # DEFINED is not triggered if init is not done. return; } ################################################################################# sub LogOldAttr { my $hash = shift; # reference to the HTTPMOD Fhem device hash my $old = shift; # old attr name my $new = shift; # new attr name my $name = $hash->{NAME}; # name of the Fhem device Log3 $name, 1, "$name: the attribute $old should no longer be used." . ($new ? " Please use $new instead" : ""); Log3 $name, 1, "$name: For most old attributes you can specify enableControlSet and then set device upgradeAttributes to automatically modify the configuration"; return; } ######################################################################### # precompile regex attr value # called from GetRegex if regex is not yet compiled and stored in a hash sub PrecompileRegexAttr { my $hash = shift; # reference to the HTTPMOD Fhem device hash my $aName = shift; # name of the object that contains the regex (e.g. attr name) my $aVal = shift; # the regex my $name = $hash->{NAME}; # Fhem device name my $regopt = ''; my $regDecode = AttrVal($name, 'regexDecode', ""); if ($regDecode && $regDecode !~ /^[Nn]one$/) { $aVal = decode($regDecode, $aVal); Log3 $name, 5, "$name: PrecompileRegexAttr is decoding regex $aName as $regDecode"; } if ($aName =~ /^(reading|get|set)([0-9]+).*Regex$/) { # get context and num so we can look for corespondig regOpt attribute my $context = $1; my $num = $2; $regopt = GetFAttr($name, $context, $num, "RegOpt", ""); $regopt =~ s/[gceor]//g; # remove gceor options - they will be added when using the regex # see https://www.perlmonks.org/?node_id=368332 } local $SIG{__WARN__} = sub { Log3 $name, 3, "$name: PrecompileRegexAttr for $aName $aVal created warning: @_"; }; if ($regopt) { eval "\$hash->{CompiledRegexes}{\$aName} = qr/$aVal/$regopt"; ## no critic - some options need to be compiled in - special syntax needed -> better formulate options as part of regex ... } else { eval {$hash->{CompiledRegexes}{$aName} = qr/$aVal/}; # no options - use easy way. } if (!$@) { if ($aVal =~ /^xpath:(.*)/ || $aVal =~ /^xpath-strict:(.*)/) { Log3 $name, 3, "$name: PrecompileRegexAttr cannot store precompiled regex because outdated xpath syntax is used in attr $aName $aVal. Please upgrade attributes"; delete $hash->{CompiledRegexes}{$aName}; } else { #Log3 $name, 5, "$name: PrecompileRegexAttr precompiled $aName /$aVal/$regopt to $hash->{CompiledRegexes}{$aName}"; } } return; } ######################################################################### # Attr command # simple attributes like requestHeader and requestData need no special treatment here # readingsExpr, readingsRegex.* or reAuthRegex need validation though. # if validation fails, return something so CommandAttr in fhem.pl doesn't assign a value to $attr sub AttrFn { my $cmd = shift; # 'set' or 'del' my $name = shift; # the Fhem device name my $aName = shift; # attribute name my $aVal = shift // ''; # attribute value my $hash = $defs{$name}; # reference to the Fhem device hash Log3 $name, 5, "$name: attr $name $aName $aVal"; if ($cmd eq 'set') { if ($aName =~ /^regexDecode$/) { delete $hash->{CompiledRegexes}; # recompile everything with the right decoding #Log3 $name, 4, "$name: Attr got DecodeRegexAttr -> delete all potentially precompiled regexs"; } if ($aName =~ /Regex/) { # catch all Regex like attributes delete $hash->{CompiledRegexes}{$aName}; #Log3 $name, 4, "$name: Attr got regex attr -> delete potentially precompiled regex for $aName"; my $regexErr = CheckRegexp($aVal, "attr $aName"); # check if Regex is valid return "$name: $aName Regex: $regexErr" if ($regexErr); if ($aName =~ /((get|set)[0-9]*)?[Rr]eplacement[0-9]*Regex$/) { $hash->{'.ReplacementEnabled'} = 1; } if ($aName =~ /(.+)IDRegex$/) { # conversions for legacy things LogOldAttr($hash, $aName, "${1}IdRegex"); } if ($aName =~ /readingsRegex.*/) { LogOldAttr($hash, $aName, "reading01Regex syntax"); } } elsif ($aName =~ /readingsName.*/) { LogOldAttr($hash, $aName, "reading01Name syntax"); } elsif ($aName =~ /RegOpt$/) { if ($aVal !~ /^[msxdualsig]*$/) { Log3 $name, 3, "$name: illegal RegOpt in attr $name $aName $aVal"; return "$name: illegal RegOpt in attr $name $aName $aVal"; } } elsif ($aName =~ /Expr/) { my $timeDiff = 0; # only for expressions using it my @matchlist; return "Invalid Expression $aVal" if (!EvalExpr($hash, {expr => $aVal, '$timeDiff' => $timeDiff, '@matchlist' => \@matchlist, checkOnly => 1, action => "attr $aName"} )); if ($aName =~ /readingsExpr.*/) { LogOldAttr($hash, $aName, "reading01Expr syntax"); } elsif ($aName =~ /^(get[0-9]*)Expr/) { LogOldAttr($hash, $aName, "${1}OExpr"); } elsif ($aName =~ /^(reading[0-9]*)Expr/) { LogOldAttr($hash, $aName, "${1}OExpr"); } elsif ($aName =~ /^(set[0-9]*)Expr/) { LogOldAttr($hash, $aName, "${1}IExpr"); } } elsif ($aName =~ /Map$/) { if ($aName =~ /^(get[0-9]*)Map/) { LogOldAttr($hash, $aName, "${1}OMap"); } elsif ($aName =~ /^(reading[0-9]*)Map/) { LogOldAttr($hash, $aName, "${1}OMap"); } elsif ($aName =~ /^(set[0-9]*)Map/) { LogOldAttr($hash, $aName, "${1}IMap"); } } elsif ($aName =~ /replacement[0-9]*Mode/) { if ($aVal !~ /^(reading|internal|text|expression|key)$/) { Log3 $name, 3, "$name: illegal mode in attr $name $aName $aVal"; return "$name: illegal mode in attr $name $aName $aVal"; } } elsif ($aName =~ /((get|set)[0-9]*)?[Rr]eplacement([0-9]*)Value/) { Log3 $name, 5, "$name: validating attr $name $aName $aVal"; if (AttrVal($name, "replacement${2}Mode", "text") eq "expression") { return "Invalid Expression $aVal" if (!EvalExpr($hash, {expr => $aVal, action => "attr $aName", checkOnly => 1})); } } elsif ($aName =~ /(get|reading)[0-9]*JSON$/ || $aName =~ /[Ee]xtractAllJSON$/ || $aName =~ /[Rr]eAuthJSON$/ || $aName =~ /[Ii]dJSON$/) { eval "use JSON"; ## no critic - need this at runtime! if($@) { return "Please install JSON Library to use JSON (apt-get install libjson-perl) - error was $@"; } $hash->{'.JSONEnabled'} = 1; } elsif ($aName eq "enableCookies") { if ($aVal eq "0") { delete $hash->{HTTPCookieHash}; } } elsif ($aName eq "showBody") { if ($aVal eq "0") { delete $hash->{httpbody}; } } elsif ($aName eq "enableXPath" || $aName =~ /(get|reading)[0-9]+XPath$/ || $aName =~ /[Rr]eAuthXPath$/ || $aName =~ /[Ii]dXPath$/) { eval "use HTML::TreeBuilder::XPath"; ## no critic - need this at runtime! if($@) { return "Please install HTML::TreeBuilder::XPath to use the xpath-Option (apt-get install libxml-TreeBuilder-perl libhtml-treebuilder-xpath-perl) - error was $@"; } $hash->{'.XPathEnabled'} = ($aVal ? 1 : 0); } elsif ($aName eq "enableXPath-Strict" || $aName =~ /(get|reading)[0-9]+XPath-Strict$/ || $aName =~ /[Rr]eAuthXPath-Strict$/ || $aName =~ /[Ii]dXPath-Strict$/) { eval "use XML::XPath;use XML::XPath::XMLParser"; ## no critic - need this at runtime! if($@) { return "Please install XML::XPath and XML::XPath::XMLParser to use the xpath-strict-Option (apt-get install libxml-parser-perl libxml-xpath-perl) - error was $@"; } $XML::XPath::SafeMode = 1; $hash->{'.XPathStrictEnabled'} = ($aVal ? 1 : 0); } elsif ($aName =~ /^(reading|get)[0-9]*(-[0-9]+)?MaxAge$/) { if ($aVal !~ '([0-9]+)') { Log3 $name, 3, "$name: wrong format in attr $name $aName $aVal"; return "Invalid Format $aVal in $aName"; } $hash->{'.MaxAgeEnabled'} = 1; } elsif ($aName =~ /^(reading|get)[0-9]*(-[0-9]+)?MaxAgeReplacementMode$/) { if ($aVal !~ /^(text|reading|internal|expression|delete)$/) { Log3 $name, 3, "$name: illegal mode in attr $name $aName $aVal"; return "$name: illegal mode in attr $name $aName $aVal, choose on of text, expression"; } } elsif ($aName =~ /^(reading|get|set)[0-9]*(-[0-9]+)?DeleteOnError$/) { if ($aVal !~ '([0-9]+)') { Log3 $name, 3, "$name: wrong format in attr $name $aName $aVal"; return "Invalid Format $aVal in $aName"; } $hash->{DeleteOnError} = ($aVal ? 1 : 0); } elsif ($aName =~ /^(reading|get|set)[0-9]*(-[0-9]+)?DeleteIfUnmatched$/) { if ($aVal !~ '([0-9]+)') { Log3 $name, 3, "$name: wrong format in attr $name $aName $aVal"; return "Invalid Format $aVal in $aName"; } $hash->{DeleteIfUnmatched} = ($aVal ? 1 : 0); } elsif ($aName eq 'alignTime') { my ($alErr, $alHr, $alMin, $alSec, undef) = GetTimeSpec($aVal); return "Invalid Format $aVal in $aName : $alErr" if ($alErr); my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(); $hash->{'.TimeAlign'} = fhemTimeLocal($alSec, $alMin, $alHr, $mday, $mon, $year); #$hash->{TimeAlignFmt} = FmtDateTime($hash->{'.TimeAlign'}); UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'start'); # change timer for alignment } elsif ($aName =~ /^(reading|get)([0-9]+)(-[0-9]+)?Name$/) { $hash->{".updateRequestHash"} = 1; } my $err = ManageUserAttr($hash, $aName); # todo: handle deletion as well return $err if ($err); } elsif ($cmd eq 'del') { # Deletion of Attributes #Log3 $name, 5, "$name: del attribute $aName"; if ($aName =~ /((reading|get)[0-9]*JSON$) | [Ee]xtractAllJSON$ | [Rr]eAuthJSON$ | [Ii]dJSON$/xms) { if (!(grep {!/$aName/} grep {/((reading|get)[0-9]*JSON$) | [Ee]xtractAllJSON$ | [Rr]eAuthJSON$ | [Ii]dJSON$/xms} keys %{$attr{$name}} )) { delete $hash->{'.JSONEnabled'}; } } elsif ($aName =~ /(get|reading)[0-9]+XPath$ | enableXPath | [Rr]eAuthXPath$ | [Ii]dXPath$/xms) { if (!(grep {!/$aName/} grep {/(get|reading)[0-9]+XPath$ | enableXPath | [Rr]eAuthXPath$ | [Ii]dXPath$/xms} keys %{$attr{$name}})) { delete $hash->{'.XPathEnabled'}; } } elsif ($aName =~ /(get|reading)[0-9]+XPath-Strict$ | enableXPath-Strict | [Rr]eAuthXPath-Strict$ | [Ii]dXPath-Strict$/xms) { if (!(grep {!/$aName/} grep {/(get|reading)[0-9]+XPath-Strict$ | enableXPath-Strict | [Rr]eAuthXPath-Strict$ | [Ii]dXPath-Strict$/xms} keys %{$attr{$name}})) { delete $hash->{'.XPathStrictEnabled'}; } } elsif ($aName eq 'enableCookies') { delete $hash->{HTTPCookieHash}; } elsif ($aName eq 'showBody') { delete $hash->{httpbody}; } elsif ($aName =~ /(reading|get)[0-9]*(-[0-9]+)?MaxAge$/) { if (!(grep {!/$aName/} grep {/(reading|get)[0-9]*(-[0-9]+)?MaxAge$/} keys %{$attr{$name}})) { delete $hash->{'.MaxAgeEnabled'}; } } elsif ($aName =~ /((get|set)[0-9]*)?[Rr]eplacement[0-9]*Regex/) { if (!(grep {!/$aName/} grep {/((get|set)[0-9]*)?[Rr]eplacement[0-9]*Regex/} keys %{$attr{$name}})) { delete $hash->{'.ReplacementEnabled'}; } } elsif ($aName =~ /^(reading|get|set)[0-9]*(-[0-9]+)?DeleteOnError$/) { if (!(grep {!/$aName/} grep {/^(reading|get|set)[0-9]*(-[0-9]+)?DeleteOnError$/} keys %{$attr{$name}})) { delete $hash->{DeleteOnError}; } } elsif ($aName =~ /^(reading|get|set)[0-9]*(-[0-9]+)?DeleteIfUnmatched$/) { if (!(grep {!/$aName/} grep {/^(reading|get|set)[0-9]*(-[0-9]+)?DeleteIfUnmatched$/} keys %{$attr{$name}})) { delete $hash->{DeleteIfUnmatched}; } } elsif ($aName eq 'alignTime') { delete $hash->{'.TimeAlign'}; #delete $hash->{TimeAlignFmt}; } } if ($aName =~ /^(get|set)/ || $aName eq "enableControlSet") { $hash->{".updateHintList"} = 1; } if ($aName =~ /^(get|reading)/) { $hash->{".updateReadingList"} = 1; } return; } ############################################## # Upgrade attribute names from older versions sub UpgradeAttributes { my $hash = shift; my $name = $hash->{NAME}; my %dHash; my %numHash; foreach my $aName (keys %{$attr{$name}}) { if ($aName =~ /(.+)IDRegex$/) { my $new = $1 . "IdRegex"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(.+)Regex$/) { my $ctx = $1; my $val = $attr{$name}{$aName}; #Log3 $name, 3, "$name: upgradeAttributes check attr $aName, val $val"; if ($val =~ /^xpath:(.*)/) { $val = $1; my $new = $ctx . "XPath"; CommandAttr(undef, "$name $new $val"); CommandAttr(undef, "$name $ctx" . "RecombineExpr join(\",\", \@matchlist)"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } if ($val =~ /^xpath-strict:(.*)/) { $val = $1; my $new = $ctx . "XPath-Strict"; CommandAttr(undef, "$name $new $val"); CommandAttr(undef, "$name $ctx" . "RecombineExpr join(\",\", \@matchlist)"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } } elsif ($aName eq "enableXPath" || $aName eq "enableXPath-Strict" ) { CommandDeleteAttr(undef, "$name $aName"); Log3 $name, 3, "$name: removed attribute name $aName"; } elsif ($aName =~ /(set[0-9]*)Expr$/) { my $new = $1 . "IExpr"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(get[0-9]*)Expr$/) { my $new = $1 . "OExpr"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(reading[0-9]*)Expr$/) { my $new = $1 . "OExpr"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(set[0-9]*)Map$/) { my $new = $1 . "IMap"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(get[0-9]*)Map$/) { my $new = $1 . "OMap"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /(reading[0-9]*)Map$/) { my $new = $1 . "OMap"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } elsif ($aName =~ /^readings(Name|Expr|Regex)(.*)$/) { my $typ = $1; my $sfx = $2; my $num; if (defined($numHash{$sfx})) { $num = $numHash{$sfx}; } else { my $max = 0; foreach my $a (keys %{$attr{$name}}) { if ($a =~ /^reading([0-9]+)\D+$/) { $max = $1 if ($1 > $max); } } $num = sprintf("%02d", $max + 1); $numHash{$sfx} = $num; } my $new = "reading${num}${typ}"; my $val = $attr{$name}{$aName}; CommandAttr(undef, "$name $new $val"); CommandDeleteAttr(undef, "$name $aName"); $dHash{$aName} = 1; Log3 $name, 3, "$name: upgraded attribute name $aName to new sytax $new"; } } $dHash{"enableXpath"} = 1; $dHash{"enableXpath-Strict"} = 1; my $ualist = $attr{$name}{userattr} // ''; my %uahash; foreach my $a (split(" ", $ualist)) { if (!$dHash{$a}) { $uahash{$a} = 1; } else { Log3 $name, 3, "$name: dropping $a from userattr list"; } } $attr{$name}{userattr} = join(" ", sort keys %uahash); #Log3 $name, 3, "$name: UpgradeAttribute done, userattr list is $attr{$name}{userattr}"; return; } ############################################################# # get attribute based specification # for format, map or similar # with generic and absolute default (empty variable num part) # if num is like 1-1 then check for 1 if 1-1 not found sub GetFAttr { my ($name, $prefix, $num, $type, $val) = @_; # first look for attribute with the full num in it if (defined ($attr{$name}{$prefix . $num . $type})) { $val = $attr{$name}{$prefix . $num . $type}; # if not found then check if num contains a subnum # (for regexes with multiple capture groups etc) and look for attribute without this subnum } elsif (($num =~ /^([0-9]+)-[0-9]+$/) && defined ($attr{$name}{$prefix .$1 . $type})) { $val = $attr{$name}{$prefix . $1 . $type}; # if again not found then look for generic attribute without num } elsif (defined ($attr{$name}{$prefix . $type})) { $val = $attr{$name}{$prefix . $type}; } return $val; } ######################################################################### # replace strings as defined in Attributes for URL, Header and Data # type is request type and can be set01, get03, auth01, update # corresponding context is set, get (or reading, but here we use '' instead) sub DoReplacement { my $hash = shift; # reference to the Fhem device hash my $type = shift; # type of replacement (get / set / auth with number) my $string = shift; # source string my $name = $hash->{NAME}; # name of the fhem device my $context = ''; # context of replacement (type without the number) my $input = $string; # save for logging at the end if ($type =~ /(auth|set|get)(.*)/) { $context = $1; # context is type without num # for type update there is no num so no individual replacement - only one for the whole update request } #Log3 $name, 4, "$name: Replace called for request type $type"; # Loop through all Replacement Regex attributes foreach my $rr (sort keys %{$attr{$name}}) { next if ($rr !~ /^replacement([0-9]*)Regex$/); my $rNum = $1; my $regex = GetRegex($name, "replacement", $rNum, "Regex", ""); my $mode = AttrVal($name, "replacement${rNum}Mode", "text"); #Log3 $name, 5, "$name: Replace: rr=$rr, rNum $rNum, look for ${type}Replacement${rNum}Value"; next if (!$regex); my $value = ""; # value can be specific for a get / set / auth step (with a number in $type) #Log3 $name, 5, "$name: Replace: check value as ${type}Replacement${rNum}Value"; if ($context && defined ($attr{$name}{"${type}Replacement${rNum}Value"})) { # get / set / auth mit individuellem Replacement für z.B. get01 $value = $attr{$name}{"${type}Replacement${rNum}Value"}; } else { #Log3 $name, 5, "$name: Replace: check value as ${context}Replacement${rNum}Value"; if ($context && defined ($attr{$name}{"${context}Replacement${rNum}Value"})) { # get / set / auth mit generischem Replacement für alle gets / sets (without the number) $value = $attr{$name}{"${context}Replacement${rNum}Value"}; } else { #Log3 $name, 5, "$name: Replace: check value as replacement${rNum}Value"; if (defined ($attr{$name}{"replacement${rNum}Value"})) { # ganz generisches Replacement $value = $attr{$name}{"replacement${rNum}Value"}; } else { #Log3 $name, 5, "$name: Replace: no matching value attribute found"; } } } Log3 $name, 5, "$name: Replace called for type $type, regex $regex, mode $mode, " . ($value ? "value $value" : "empty value") . " input: $string"; my $match = 0; if ($mode eq 'text') { $match = ($string =~ s/$regex/$value/g); } elsif ($mode eq 'reading') { my $device = $name; my $reading = $value; if ($value =~ /^([^\:]+):(.+)$/) { $device = $1; $reading = $2; } my $rvalue = ReadingsVal($device, $reading, ""); if ($string =~ s/$regex/$rvalue/g) { Log3 $name, 5, "$name: Replace: reading value is $rvalue"; $match = 1; } } elsif ($mode eq 'internal') { my $device = $name; my $internal = $value; if ($value =~ /^([^\:]+):(.+)$/) { $device = $1; $internal = $2; } my $rvalue = InternalVal($device, $internal, ""); if ($string =~ s/$regex/$rvalue/g) { Log3 $name, 5, "$name: Replace: internal value is $rvalue"; $match = 1; } } elsif ($mode eq 'expression') { $value = 'package main; ' . ($value // ''); # contains the expression local $SIG{__WARN__} = sub { Log3 $name, 3, "$name: Replacement $rNum with expression $value and regex $regex created warning: @_"; }; # if expression calls other fhem functions, creates readings or other, then the warning handler will create misleading messages! $match = eval { $string =~ s/$regex/$value/gee }; if ($@) { Log3 $name, 3, "$name: Replace: invalid regex / expression: /$regex/$value/gee - $@"; } } elsif ($mode eq 'key') { my $rvalue = ReadKeyValue($hash, $value); if ($string =~ s/$regex/$rvalue/g) { Log3 $name, 5, "$name: Replace: key $value value is $rvalue"; $match = 1; } } Log3 $name, 5, "$name: Replace: match for type $type, regex $regex, mode $mode, " . ($value ? "value $value," : "empty value,") . " input: $input, result is $string" if ($match); } return $string; } ######################################################################### sub PrepareRequest { my $hash = shift; # reference to Fhem device hash my $context = shift; # get / set / reading my $num = shift // 0; # number of get / set / ... my $name = $hash->{NAME}; # Fhem device name my ($url, $header, $data, $exp); if ($context eq 'reading') { # if called from GetUpdate - not get / set / auth $url = $hash->{MainURL}; $header = join ("\r\n", map {$attr{$name}{$_}} sort grep {/requestHeader/} keys %{$attr{$name}}); $data = join ("\r\n", map {$attr{$name}{$_}} sort grep {/requestData/} keys %{$attr{$name}}); } else { # called for get / set / auth # hole alle Header bzw. generischen Header ohne Nummer $header = join ("\r\n", map {$attr{$name}{$_}} sort grep {/${context}${num}Header/} keys %{$attr{$name}}); if (length $header == 0) { $header = join ("\r\n", map {$attr{$name}{$_}} sort grep {/${context}Header/} keys %{$attr{$name}}); } if (! GetFAttr($name, $context, $num, "NoData")) { # hole Bestandteile der Post data $data = join ("\r\n", map {$attr{$name}{$_}} sort grep {/${context}${num}Data/} keys %{$attr{$name}}); if (length $data == 0) { $data = join ("\r\n", map {$attr{$name}{$_}} sort grep {/${context}Data/} keys %{$attr{$name}}); } } # hole URL $url = GetFAttr($name, $context, $num, "URL"); $url = $hash->{MainURL} if (!$url); } #Log3 $name, 5, "$name: PrepareRequest got url $url, header $header and data $data"; $header = EvalExpr($hash, {expr => GetFAttr($name, $context, $num, "HdrExpr"), val => $header, action => 'HdrExpr'}); $data = EvalExpr($hash, {expr => GetFAttr($name, $context, $num, "DatExpr"), val => $data, action => 'DatExpr'}); $url = EvalExpr($hash, {expr => GetFAttr($name, $context, $num, "URLExpr"), val => $url, action => 'URLExpr'}); my $type; if ($context eq 'reading') { $type = "update"; } elsif ($context eq 'sid') { $type = "auth$num"; } else { $type = "$context$num"; } return {'url' => $url, 'header' => $header, 'data' => $data, 'type' => $type, 'context' => $context, 'num' => $num}; } ######################################################################### # create a new authenticated session by queueing the sid stuff sub DoAuth { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $name = $hash->{NAME}; # fhem device name # get all authentication steps my %steps; foreach my $attr (keys %{$attr{$name}}) { if ($attr =~ /^sid([0-9]+).+/) { $steps{$1} = 1; } } Log3 $name, 4, "$name: DoAuth called with Steps: " . join (" ", sort keys %steps); $hash->{sid} = '' if AttrVal($name, "clearSIdBeforeAuth", 0); foreach my $step (sort {$b cmp $a} keys %steps) { # reverse sort because requests are prepended my $request = PrepareRequest($hash, "sid", $step); if ($request->{'url'}) { $request->{'ignoreRedirects'} = GetFAttr($name, 'sid', $step, 'IgnoreRedirects', 0); $request->{'priority'} = 1; # prepend at front of queue AddToSendQueue($hash, $request); # todo: http method for sid steps? } else { Log3 $name, 3, "$name: no URL for Auth $step"; } } $hash->{LastAuthTry} = FmtDateTime(gettimeofday()); HandleSendQueue("direct:".$name); # AddToQueue with priority did not call this. return; } ######################################## # create hint list for set / get ? sub UpdateHintList { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $name = $hash->{NAME}; # fhem device name Log3 $name, 5, "$name: UpdateHintList called"; $hash->{'.getList'} = ''; my $fDefault = ($featurelevel > 5.9 ? 1 : 0); if (AttrVal($name, 'enableControlSet', $fDefault)) { # spezielle Sets freigeschaltet? $hash->{".setList"} = "interval reread:noArg stop:noArg start:noArg clearCookies:noArg upgradeAttributes:noArg storeKeyValue "; #Log3 $name, 5, "$name: UpdateHintList added control sets"; } else { #Log3 $name, 5, "$name: UpdateHintList ignored control sets ($featurelevel, $fDefault)"; $hash->{'.setList'} = ''; } foreach my $aName (keys %{$attr{$name}}) { next if ($aName !~ /^([gs]et)([0-9]+)Name$/); my $context = $1; my $num = $2; my $oName = $attr{$name}{$aName}; my $opt; if ($context eq 'set') { my $map = ''; $map = AttrVal($name, "${context}${num}Map", ''); # old Map for set is now IMap (Input) $map = GetFAttr($name, $context, $num, 'IMap', $map); # new syntax overrides old one if ($map) { my $hint = MapToHint($map); # create hint from map $opt = $oName . ":$hint"; # opt is Name:Hint (from Map) } elsif (GetFAttr($name, $context, $num, 'NoArg')) { # NoArg explicitely specified for a set? $opt = $oName . ':noArg'; } else { $opt = $oName; # nur den Namen für opt verwenden. } } elsif ($context eq 'get') { if (GetFAttr($name, $context, $num, 'TextArg')) { # TextArg explicitely specified for a get? $opt = $oName; # nur den Namen für opt verwenden. } else { $opt = $oName . ':noArg'; # sonst noArg bei get } } if (GetFAttr($name, $context, $num, 'Hint')) { # gibt es einen expliziten Hint? $opt = $oName . ":" . GetFAttr($name, $context, $num, 'Hint'); } $hash->{".${context}List"} .= $opt . ' '; # save new hint list } delete $hash->{'.updateHintList'}; Log3 $name, 5, "$name: UpdateHintList: setlist = " . $hash->{'.setList'}; Log3 $name, 5, "$name: UpdateHintList: getlist = " . $hash->{'.getList'}; return; } ######################################################################################## # update hashes to point back from reading name to attr defining its name and properties # called after Fhem restart or attribute changes to handle existing readings sub UpdateRequestHash { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $name = $hash->{NAME}; # fhem device name Log3 $name, 5, "$name: UpdateRequestHash called"; return if (!$hash->{READINGS}); my @readingList = sort keys %{$hash->{READINGS}}; my @attrList = sort keys %{$attr{$name}}; ATTRLOOP: # go through all attributes like reading|get|set...Name foreach my $aName (@attrList) { # need reregx match inside loop to get capture groups! next ATTRLOOP if ($aName !~ m{\A (reading|get|set) ([0-9]+) (-[0-9]+)? Name \z}xms); my $context = $1; # split attr name in reading/get/set, a num and potentially -subnum my $num = $2; my $nSubNum = $3 // ''; # named SubReading? my $reqType = ($context eq 'reading' ? 'update' : $context . $num); my $baseReading = $attr{$name}{$aName}; # ...Name attribute: base reading Name or explicitely named subreading if ($defs{$name}{READINGS}{$baseReading}) { # reading with name from attr exists Log3 $name, 5, "$name: UpdateRequestHash for direct reading $baseReading from attr $aName $baseReading"; $hash->{defptr}{readingBase}{$baseReading} = $context; $hash->{defptr}{readingNum}{$baseReading} = $num; $hash->{defptr}{readingSubNum}{$baseReading} = $nSubNum if ($nSubNum); $hash->{defptr}{requestReadings}{$reqType}{$baseReading} = "$context ${num}" . ($nSubNum ? "-$nSubNum" : ''); } if (!$nSubNum) { # if given "Name"-attribute doesn't have a subNum READINGLOOP: # go through the potential subreadings derived from the above ..Name attribute with added -Num foreach my $reading (@readingList) { next READINGLOOP if ($reading !~ m{\A ${baseReading} (-[0-9]+) \z}xms); my $subNum = $1; Log3 $name, 5, "$name: UpdateRequestHash for reading $reading from attr $aName $baseReading with automatic subNum $subNum"; $hash->{defptr}{readingBase}{$reading} = $context; $hash->{defptr}{readingNum}{$reading} = $num; $hash->{defptr}{readingSubNum}{$reading} = $subNum; $hash->{defptr}{requestReadings}{$reqType}{$reading} = "$context ${num}${subNum}"; # deleteOn ... will later check for e.g. reading02-001DeleteOnError but also for reading02-DeleteOnError (without subNum) } } if ($aName =~ m{\A (get|set) ([0-9]+) Name \z}xms && # special Handling for get / set with CheckAllReadings GetFAttr($name, $context, $num, 'CheckAllReadings')) { ATTRLOOP2: foreach my $raName (@attrList) { next ATTRLOOP2 if ($aName !~ m{\A (reading) ([0-9]+) (-[0-9]+)? Name \z}xms); my $rbaseReading = $attr{$name}{$raName}; # common base reading Name my $rNum = $2; my $rnSubNum = ($3 ? $3 : ""); # named SubReading? if ($defs{$name}{READINGS}{$rbaseReading}) { $hash->{defptr}{requestReadings}{$reqType}{$rbaseReading} = "reading $rNum" . ($rnSubNum ? "-$rnSubNum" : ""); } if (!$rnSubNum) { # go through the potential subreadings - the Name attribute was for a base Reading without explicit subNum foreach my $reading (@readingList) { next if ($reading !~ m{\A ${rbaseReading} (-[0-9]+) \z}xms); # point from reqType get/set and reading Name like "Temp-001" to the definition in readingXX or even potential readingXX-YYDeleteOnError $hash->{defptr}{requestReadings}{$reqType}{$reading} = "reading ${rNum}$1"; } } } } } delete $hash->{'.updateRequestHash'}; return; } ################################################ # SET command - handle predefined control sets sub ControlSet { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $setName = shift; # name of set option my $setVal = shift; # value to set my $name = $hash->{NAME}; # fhem device name if ($setName eq 'interval') { if (!$setVal || $setVal !~ /^[0-9\.]+/) { Log3 $name, 3, "$name: no interval (sec) specified in set, continuing with $hash->{Interval} (sec)"; return "No Interval specified"; } if (int $setVal <= 5) { Log3 $name, 3, "$name: interval $setVal (sec) to small (must be >5), continuing with $hash->{Interval} (sec)"; return "interval too small"; } $hash->{Interval} = $setVal; Log3 $name, 3, "$name: timer interval changed to $hash->{Interval} seconds"; UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'start'); # set timer for new interval return "0"; } if ($setName eq 'reread') { GetUpdate("reread:$name"); return "0"; } if ($setName eq 'stop') { UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'stop'); return "0"; } if ($setName eq 'start') { UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'start'); # set timer for new interval return "0"; } if ($setName eq 'clearCookies') { delete $hash->{HTTPCookieHash}; return "0"; } if ($setName eq 'upgradeAttributes') { UpgradeAttributes($hash); return "0"; } if ($setName eq 'storeKeyValue') { my $key; if ($setVal !~ /([^ ]+) +(.*)/) { return "Please give a key and a value to storeKeyValue"; } $key = $1; my $err = StoreKeyValue($hash, $key, $2); return $err if ($err); return "0"; } return; # no control set identified - continue with other sets } ######################################################################### # SET command sub SetFn { my @setValArr = @_; # remainder is set values my $hash = shift @setValArr; # reference to Fhem device hash my $name = shift @setValArr; # Fhem device name my $setName = shift @setValArr; # name of the set option my $setVal = join(' ', @setValArr); # set values as one string my (%rmap, $setNum, $setOpt, $rawVal); return "\"set $name\" needs at least an argument" if (!$setName); Log3 $name, 5, "$name: set called with $setName " . ($setVal ? $setVal : "") if ($setName ne "?"); my $fDefault = ($featurelevel > 5.9 ? 1 : 0); if (AttrVal($name, "enableControlSet", $fDefault)) { # spezielle Sets freigeschaltet? my $error = ControlSet($hash, $setName, $setVal); if (defined ($error)) { return if ($error eq "0"); # control set found and done. return $error if ($error); # error } # continue if function returned undef } # Vorbereitung: suche den übergebenen setName in den Attributen und setze setNum foreach my $aName (keys %{$attr{$name}}) { if ($aName =~ /^set([0-9]+)Name$/) { # ist das Attribut ein "setXName" ? if ($setName eq $attr{$name}{$aName}) { # ist es der im konkreten Set verwendete setName? $setNum = $1; # gefunden -> merke Nummer X im Attribut } } } if(!defined ($setNum)) { # gültiger set Aufruf? ($setNum oben schon gesetzt?) UpdateHintList($hash) if ($hash->{".updateHintList"}); if (AttrVal($name, "useSetExtensions", 1)) { return SetExtensions($hash, $hash->{".setList"}, $name, $setName, @setValArr); } else { return "Unknown argument $setName, choose one of " . $hash->{".setList"}; } } Log3 $name, 5, "$name: set found option $setName in attribute set${setNum}Name"; if (IsDisabled($name)) { Log3 $name, 4, "$name: set called with $setName but device is disabled" if ($setName ne "?"); return; } if (!GetFAttr($name, 'set', $setNum, 'NoArg')) { # soll überhaupt ein Wert übergeben werden? if (!defined($setVal)) { # Ist ein Wert übergeben? Log3 $name, 3, "$name: set without value given for $setName"; return "no value given to set $setName"; } $rawVal = $setVal; # now work with $rawVal # Eingabevalidierung von Sets mit Definition per Attributen # 1. Schritt, falls definiert, per Umkehrung der Map umwandeln (z.B. Text in numerische Codes) my $map = AttrVal($name, "set${setNum}Map", ''); # old Map for set is now IMap (Input) $map = GetFAttr($name, 'set', $setNum, 'IMap', $map); # new syntax ovverides old one $rawVal = MapConvert ($hash, {map => $map, val => $rawVal, reverse => 1, undefIfNoMatch => 1}); return "set value $setVal did not match defined map" if (!defined($rawVal)); # make sure $rawVal is numeric unless textArg is specified if (!$map && !GetFAttr($name, 'set', $setNum, 'TextArg') && $rawVal !~ /^-?\d+\.?\d*$/) { Log3 $name, 3, "$name: set - value $rawVal is not numeric"; return "set value $rawVal is not numeric"; } if (!GetFAttr($name, 'set', $setNum, 'TextArg') && !CheckRange($hash, {val => $rawVal, min => GetFAttr($name, 'set', $setNum. 'Min'), max => GetFAttr($name, 'set', $setNum, 'Max')} ) ) { return "set value $rawVal is not within defined range"; } # Konvertiere input mit IExpr falls definiert my $exp = AttrVal($name, "set${setNum}Expr", ""); # old syntax for input in set $exp = GetFAttr($name, 'set', $setNum, 'IExpr', $exp); # new syntax overrides old one #Log3 $name, 5, "$name: set calls EvalExpr with exp $exp"; $rawVal = EvalExpr($hash, {expr => $exp, val => $rawVal, '@setValArr' => \@setValArr, action => "set${setNum}IExpr"}); Log3 $name, 4, "$name: set will now set $setName -> $rawVal"; } else { # NoArg $rawVal = 0; Log3 $name, 4, "$name: set will now set $setName"; } if (!GetFAttr($name, 'set', $setNum, 'Local')) { # soll überhaupt ein Request erzeugt werden? my $request = PrepareRequest($hash, "set", $setNum); if ($request->{'url'}) { DoAuth $hash if (AttrVal($name, "reAuthAlways", 0)); $request->{'value'} = $rawVal; $request->{'method'} = GetFAttr($name, 'set', $setNum, 'Method', ''); AddToSendQueue($hash, $request ); } else { Log3 $name, 3, "$name: no URL for set $setNum"; } } else { readingsSingleUpdate($hash, makeReadingName($setName), $rawVal, 1); } ChainGet($hash, 'set', $setNum); return; } ######################################################################### # GET command sub GetFn { my @getValArr = @_; # rest is optional values my $hash = shift @getValArr; # reference to device hash my $name = shift @getValArr; # device name my $getName = shift @getValArr; # get option name my $getVal = join(' ', @getValArr); # optional value after get name - might be used in HTTP request my $getNum; return "\"get $name\" needs at least one argument" if (!$getName); if (IsDisabled($name)) { Log3 $name, 5, "$name: get called with $getName but device is disabled" if ($getName ne "?"); return; } Log3 $name, 5, "$name: get called with $getName " if ($getName ne "?"); # Vorbereitung: # suche den übergebenen getName in den Attributen, setze getNum falls gefunden foreach my $aName (keys %{$attr{$name}}) { if ($aName =~ /^get([0-9]+)Name$/) { # ist das Attribut ein "getXName" ? if ($getName eq $attr{$name}{$aName}) { # ist es der im konkreten get verwendete getName? $getNum = $1; # gefunden -> merke Nummer X im Attribut } } } # gültiger get Aufruf? ($getNum oben schon gesetzt?) if(!defined ($getNum)) { UpdateHintList($hash) if ($hash->{".updateHintList"}); return "Unknown argument $getName, choose one of " . $hash->{".getList"}; } Log3 $name, 5, "$name: get found option $getName in attribute get${getNum}Name"; Log3 $name, 4, "$name: get will now request $getName" . ($getVal ? ", value = $getVal" : ", no optional value"); my $request = PrepareRequest($hash, "get", $getNum); if ($request->{'url'}) { DoAuth $hash if (AttrVal($name, "reAuthAlways", 0)); $request->{'value'} = $getVal; AddToSendQueue($hash, $request); } else { Log3 $name, 3, "$name: no URL for Get $getNum"; } ChainGet($hash, 'get', $getNum); return "$getName requested, watch readings"; } ########################################## # chain a get after a set or another get # if specified by attr sub ChainGet { my $hash = shift; my $type = shift; my $num = shift; my $name = $hash->{NAME}; my $get = GetFAttr($name, $type, $num, 'FollowGet'); if (!$get) { delete $hash->{GetChainLength}; return; } $hash->{GetChainLength} = ($hash->{GetChainLength} // 0) + 1; if ($hash->{GetChainLength} > AttrVal($name, "maxGetChain", 10)) { Log3 $name, 4, "$name: chaining to get $get due to attr ${type}${num}FollowGet suppressed because chain would get longer than maxGetChain"; return; } Log3 $name, 4, "$name: chaining to get $get due to attr ${type}${num}FollowGet, Level $hash->{GetChainLength}"; GetFn($hash, $name, $get); return; } ################################### # request new data from device # calltype can be update and reread sub GetUpdate { my $arg = shift; # called with a string type:$name my ($calltype, $name) = split(':', $arg); my $hash = $defs{$name}; my $now = gettimeofday(); my ($url, $header, $data, $count); Log3 $name, 4, "$name: GetUpdate called ($calltype)"; $hash->{'.LastUpdate'} = $now; # note the we were called - even when not as 'update' and UpdateTimer is not called afterwards UpdateTimer($hash, \&HTTPMOD::GetUpdate, 'next') if ($calltype eq 'update'); # set update timer for next round if (IsDisabled($name)) { Log3 $name, 5, "$name: GetUpdate called but device is disabled"; return; } if ($hash->{MainURL}) { DoAuth($hash) if (AttrVal($name, 'reAuthAlways', 0)); my $request = PrepareRequest($hash, 'reading'); AddToSendQueue($hash, $request); # no need to copy the request - the hash has been created in prepare above } LOOP: foreach my $getAttr (sort keys %{$attr{$name}}) { # check if additional readings with individual URLs need to be requested next LOOP if ($getAttr !~ /^get([0-9]+)Name$/); my $getNum = $1; my $getName = AttrVal($name, $getAttr, ''); next LOOP if (!GetFAttr($name, 'get', $getNum, "Poll")); Log3 $name, 5, "$name: GetUpdate checks if poll required for $getName ($getNum)"; my $lastPoll = 0; $lastPoll = $hash->{lastpoll}{$getName} if ($hash->{lastpoll} && $hash->{lastpoll}{$getName}); my $dueTime = $lastPoll + GetFAttr($name, 'get', $getNum, "PollDelay",0); if ($now < $dueTime) { Log3 $name, 5, "$name: GetUpdate will skip $getName, delay not over"; next LOOP; } Log3 $name, 4, "$name: GetUpdate will request $getName"; $hash->{lastpoll}{$getName} = $now; my $request = PrepareRequest($hash, "get", $getNum); if (!$request->{url}) { Log3 $name, 3, "$name: no URL for Get $getNum"; next LOOP; } DoAuth $hash if (AttrVal($name, "reAuthAlways", 0)); AddToSendQueue($hash, $request); } return; } ######################################### # Try to call a parse function if defined sub EvalFunctionCall { my ($hash, $buffer, $fName, $type) = @_; my $name = $hash->{NAME}; my $callName = AttrVal($name, $fName, undef); if ($callName) { Log3 $name, 5, "$name: Read is calling $fName as $callName for HTTP Response to $type"; no strict "refs"; ## no critic - function name needs to be string becase it comes from an attribute eval { &{$callName}($hash, $buffer) }; Log3 $name, 3, "$name: error calling $callName: $@" if($@); use strict "refs"; } return; } ################################################ # get a regex from attr and compile if not done # called from DoReplacement, ExtractReading, ExtractSid, # CheckAuth and ReadCallback sub GetRegex { my ($name, $context, $num, $type, $default) = @_; my $hash = $defs{$name}; my $val; my $regDecode = AttrVal($name, 'regexDecode', ""); # implement this even when not compiled my $regCompile = AttrVal($name, 'regexCompile', 1); #Log3 $name, 5, "$name: Look for Regex $context$num$type"; # first look for attribute with the full num in it if ($num && defined ($attr{$name}{$context . $num . $type})) { # specific regex attr exists return $attr{$name}{$context . $num . $type} if (!$regCompile); # regex string from attr if no compilation wanted if ($hash->{CompiledRegexes}{$context . $num . $type}) { # compiled specific regex exists $val = $hash->{CompiledRegexes}{$context . $num . $type}; #Log3 $name, 5, "$name: GetRegex found precompiled $type for $context$num as $val"; } else { # not compiled (yet) $val = $attr{$name}{$context . $num . $type}; PrecompileRegexAttr($hash, $context . $num . $type, $val); $val = $hash->{CompiledRegexes}{$context . $num . $type}; } # if not found then look for generic attribute without num } elsif (defined ($attr{$name}{$context . $type})) { # generic regex attr exists return $attr{$name}{$context . $type} if (!$regCompile); # regex string from attr if no compilation wanted if ($hash->{CompiledRegexes}{$context . $type}) { $val = $hash->{CompiledRegexes}{$context . $type}; #Log3 $name, 5, "$name: GetRegex found precompiled $type for $context as $val"; } else { $val = $attr{$name}{$context . $type}; # not compiled (yet) PrecompileRegexAttr($hash, $context . $type, $val); $val = $hash->{CompiledRegexes}{$context . $type}; } } else { # no attribute defined $val = $default; return if (!$val) # default is not compiled - should only be "" or similar } return $val; } ################################### # format a reading value sub FormatReading { my ($hash, $context, $num, $val, $reading) = @_; my $name = $hash->{NAME}; my ($format, $decode, $encode); my $expr = ""; my $map = ""; $decode = GetFAttr($name, $context, $num, 'Decode'); $encode = GetFAttr($name, $context, $num, 'Encode'); $map = GetFAttr($name, $context, $num, 'Map') if ($context ne 'set'); # not for set! $map = GetFAttr($name, $context, $num, 'OMap', $map); # new syntax $format = GetFAttr($name, $context, $num, 'Format'); $expr = AttrVal($name, 'readingsExpr' . $num, '') if ($context eq 'reading'); # very old syntax, not for set $expr = GetFAttr($name, $context, $num, 'Expr', $expr) if ($context ne 'set'); # not for set! $expr = GetFAttr($name, $context, $num, 'OExpr', $expr); # new syntax # encode as utf8 by default if no encode is specified and body was decoded or no charset was seen in the header if (!$encode && (!$hash->{'.bodyCharset'} || $hash->{'.bodyCharset'} eq 'internal' )) { # body was decoded and encode not sepcified $encode = 'utf8'; Log3 $name, 5, "$name: FormatReading is encoding the reading value as utf-8 because no encoding was specified and the response body charset was unknown or decoded"; } $val = decode($decode, $val) if ($decode && $decode ne 'none'); $val = encode($encode, $val) if ($encode && $encode ne 'none'); if ($expr) { # variables to be available in Exprs my $timeStr = ReadingsTimestamp($name, $reading, 0); my $timeDiff = $timeStr ? ($hash->{".updateTime"} ? $hash->{".updateTime"} : gettimeofday()) - time_str2num($timeStr) : 0; $val = EvalExpr($hash, {expr => $expr, val => $val, '$timeDiff' => $timeDiff}); } $val = MapConvert ($hash, {map => $map, val => $val, undefIfNoMatch => 0}); # keep $val if no map or no match $val = FormatVal ($hash, {val => $val, format => $format}); return $val; } ################################### # extract reading for a buffer sub ExtractReading { my ($hash, $buffer, $context, $num, $reqType) = @_; # can't just use $request because update might extract additional gets as update # for get / set which use reading.* definitions for parsing reqType might be "get01" and context might be "reading" my $name = $hash->{NAME}; my ($reading, $regex) = ("", ""); my ($json, $xpath, $xpathst, $recomb, $regopt, $sublen, $alwaysn); my @subrlist = (); my @matchlist = (); my $try = 1; # was there any applicable parsing definition? my $regCompile = AttrVal($name, 'regexCompile', 1); my %namedRegexGroups; $json = GetFAttr($name, $context, $num, "JSON"); $xpath = GetFAttr($name, $context, $num, "XPath"); $xpathst = GetFAttr($name, $context, $num, "XPath-Strict"); $regopt = GetFAttr($name, $context, $num, "RegOpt"); $recomb = GetFAttr($name, $context, $num, "RecombineExpr"); $sublen = GetFAttr($name, $context, $num, "AutoNumLen", 0); $alwaysn = GetFAttr($name, $context, $num, "AlwaysNum"); # support for old syntax if ($context eq "reading") { $reading = AttrVal($name, 'readingsName'.$num, ($json ? $json : "reading$num")); $regex = AttrVal($name, 'readingsRegex'.$num, ""); } # new syntax overrides reading and regex $reading = GetFAttr($name, $context, $num, "Name", $reading); $regex = GetRegex($name, $context, $num, "Regex", $regex); if ($regex) { # old syntax for xpath and xpath-strict as prefix in regex - one result joined if (AttrVal($name, "enableXPath", undef) && $regex =~ /^xpath:(.*)/) { $xpath = $1; Log3 $name, 5, "$name: ExtractReading $reading with old XPath syntax in regex /$regex/, xpath = $xpath"; eval {@matchlist = $hash->{ParserData}{XPathTree}->findnodes_as_strings($xpath)}; Log3 $name, 3, "$name: error in findvalues for XPathTree: $@" if ($@); @matchlist = (join ",", @matchlist); # old syntax returns only one value } elsif (AttrVal($name, "enableXPath-Strict", undef) && $regex =~ /^xpath-strict:(.*)/) { $xpathst = $1; Log3 $name, 5, "$name: ExtractReading $reading with old XPath-strict syntax in regex /$regex/..."; my $nodeset; eval {$nodeset = $hash->{ParserData}{XPathStrictNodeset}->find($xpathst)}; if ($@) { Log3 $name, 3, "$name: error in find for XPathStrictNodeset: $@"; } else { foreach my $node ($nodeset->get_nodelist) { push @matchlist, XML::XPath::XMLParser::as_string($node); } } @matchlist = (join ",", @matchlist); # old syntax returns only one value } else { # normal regex if ($regopt) { Log3 $name, 5, "$name: ExtractReading $reading with regex $regex and options $regopt ..."; eval "\@matchlist = (\$buffer =~ m/\$regex/$regopt)"; ## no critic - see no other way to pass options to regex Log3 $name, 3, "$name: error in regex matching (with regex option $regopt): $@" if ($@); %namedRegexGroups = %+ if (%+); } else { # simple case without regex options Log3 $name, 5, "$name: ExtractReading $reading with regex /$regex/..."; @matchlist = ($buffer =~ /$regex/); %namedRegexGroups = %+ if (%+); } #Log3 $name, 5, "$name: " . @matchlist . " matches, " . # (%namedRegexGroups ? "named capture groups, " : "") . # "matchlist = " . join ",", @matchlist if (@matchlist); } } elsif ($json) { Log3 $name, 5, "$name: ExtractReading $reading with json $json ..."; if (defined($hash->{ParserData}{JSON}) && defined($hash->{ParserData}{JSON}{$json})) { @matchlist = ($hash->{ParserData}{JSON}{$json}); } elsif (defined ($hash->{ParserData}{JSON})) { Log3 $name, 5, "$name: ExtractReading $reading with json $json did not match a key directly - trying regex match to create a list"; my @keylist = sort grep {/^$json/} keys (%{$hash->{ParserData}{JSON}}); Log3 $name, 5, "$name: ExtractReading $reading with json /^$json/ got keylist @keylist"; @matchlist = map {$hash->{ParserData}{JSON}{$_}} @keylist; } } elsif ($xpath) { Log3 $name, 5, "$name: ExtractReading $reading with XPath $xpath"; eval { @matchlist = $hash->{ParserData}{XPathTree}->findnodes_as_strings($xpath) }; Log3 $name, 3, "$name: error in findvalues for XPathTree: $@" if ($@); } elsif ($xpathst) { Log3 $name, 5, "$name: ExtractReading $reading with XPath-Strict $xpathst"; my $nodeset; eval { $nodeset = $hash->{ParserData}{XPathStrictNodeset}->find($xpathst) }; if ($@) { Log3 $name, 3, "$name: error in find for XPathStrictNodeset: $@"; } else { if ($nodeset->isa('XML::XPath::NodeSet')) { foreach my $node ($nodeset->get_nodelist) { push @matchlist, XML::XPath::XMLParser::as_string($node); } } else { push @matchlist, $nodeset; } } } else { # neither regex, xpath nor json attribute found ... $try = 0; Log3 $name, 5, "$name: ExtractReading for context $context, num $num - no individual parse definition"; } my $match = @matchlist; if (!$match) { Log3 $name, 5, "$name: ExtractReading $reading did not match" if ($try); return ($try, $match, $reading, @subrlist); } if ($recomb) { Log3 $name, 5, "$name: ExtractReading is recombining $match matches with expression $recomb"; my $val = EvalExpr($hash, {expr => $recomb, '@matchlist' => \@matchlist}); Log3 $name, 5, "$name: ExtractReading recombined matchlist to $val"; @matchlist = ($val); $match = 1; } if (%namedRegexGroups) { Log3 $name, 5, "$name: experimental named regex group handling"; foreach my $subReading (keys %namedRegexGroups) { my $val = $namedRegexGroups{$subReading}; push @subrlist, $subReading; # search for group in -Name attrs (-group is sub number) ... my $group = 0; foreach my $aName (sort keys %{$attr{$name}}) { if ($aName =~ /^$context$num-([\d]+)Name$/) { if ($attr{$name}{$context.$num."-".$1."Name"} eq $subReading) { $group = $1; Log3 $name, 5, "$name: ExtractReading uses $context$num-$group attrs for named capture group $subReading"; } } } my $eNum = $num . ($group ? "-".$group : ""); $val = FormatReading($hash, $context, $eNum, $val, $subReading); Log3 $name, 5, "$name: ExtractReading for $context$num sets reading for named capture group $subReading to $val"; readingsBulkUpdate( $hash, $subReading, $val ); # point from reading name back to the parsing definition as reading01 or get02 ... $hash->{defptr}{readingBase}{$subReading} = $context; # used to find maxAge attr $hash->{defptr}{readingNum}{$subReading} = $num; # used to find maxAge attr $hash->{defptr}{requestReadings}{$reqType}{$subReading} = "$context $eNum"; # used by deleteOnError / deleteIfUnmatched delete $hash->{defptr}{readingOutdated}{$subReading}; # used by MaxAge as well } } else { # now assign readings from matchlist my $group = 1; foreach my $val (@matchlist) { my ($subNum, $eNum, $subReading); if ($match == 1) { # only one match $eNum = $num; $subReading = ($alwaysn ? "${reading}-" . ($sublen ? sprintf ("%0${sublen}d", 1) : "1") : $reading); } else { # multiple matches -> check for special name of readings $eNum = $num ."-".$group; # don't use GetFAttr here because we don't want to get the value of the generic attribute "Name" # but this name with -group number added as default if (defined ($attr{$name}{$context . $eNum . "Name"})) { $subReading = $attr{$name}{$context . $eNum . "Name"}; } else { if ($sublen) { $subReading = "${reading}-" . sprintf ("%0${sublen}d", $group); } else { $subReading = "${reading}-$group"; } $subNum = "-$group"; } } push @subrlist, $subReading; $val = FormatReading($hash, $context, $eNum, $val, $subReading); Log3 $name, 5, "$name: ExtractReading for $context$num-$group sets $subReading to $val"; Log3 $name, 5, "$name: ExtractReading value as hex is " . unpack ('H*', $val); readingsBulkUpdate( $hash, $subReading, $val ); # point from reading name back to the parsing definition as reading01 or get02 ... $hash->{defptr}{readingBase}{$subReading} = $context; # used to find maxAge attr $hash->{defptr}{readingNum}{$subReading} = $num; # used to find maxAge attr $hash->{defptr}{readingSubNum}{$subReading} = $subNum if ($subNum); # used to find maxAge attr $hash->{defptr}{requestReadings}{$reqType}{$subReading} = "$context $eNum"; # used by deleteOnError / deleteIfUnmathced # might be get01 Temp-02 reading 5 (where its parsing / naming was defined) delete $hash->{defptr}{readingOutdated}{$subReading}; # used by MaxAge as well $group++; } } return ($try, $match, $reading, @subrlist); } ################################### # delete a reading and its metadata sub DeleteReading { my $hash = shift; # reference to Fhem device hash my $reading = shift; # name of reading to delete my $name = $hash->{NAME}; # fhem device name delete($defs{$name}{READINGS}{$reading}); delete $hash->{defptr}{readingOutdated}{$reading}; delete $hash->{defptr}{readingBase}{$reading}; delete $hash->{defptr}{readingNum}{$reading}; delete $hash->{defptr}{readingSubNum}{$reading}; foreach my $rt (keys %{$hash->{defptr}{requestReadings}}) { delete $hash->{defptr}{requestReadings}{$rt}{$reading}; } return; } ################################### # check max age of all readings sub DoMaxAge { my $hash = shift; # reference to Fhem device hash my $name = $hash->{NAME}; # Fhem device name my ($base, $num, $sub, $max, $rep, $mode, $time, $now); my $readings = $hash->{READINGS}; return if (!$readings); $now = gettimeofday(); UpdateRequestHash($hash) if ($hash->{".updateRequestHash"}); LOOP: # go through alle readings of this device foreach my $reading (sort keys %{$readings}) { my $key = $reading; # start by checking full reading name as key in readingBase hash Log3 $name, 5, "$name: MaxAge: check reading $reading"; if ($hash->{defptr}{readingOutdated}{$reading}) { Log3 $name, 5, "$name: MaxAge: reading $reading was outdated before - skipping"; next LOOP; } $base = $hash->{defptr}{readingBase}{$reading}; # get base name of definig attribute like "reading" or "get" if (!$base && $reading =~ m{(.*) (-[0-9]+) \z}xms) { # reading name endet auf -Zahl und ist nicht selbst per attr Name definiert $key = $1; # -> suche nach attr Name mit Wert ohne -Zahl $base = $hash->{defptr}{readingBase}{$key}; Log3 $name, 5, "$name: MaxAge: no defptr for this name - reading name seems automatically created with $2 from $key and not updated recently"; } if (!$base) { Log3 $name, 5, "$name: MaxAge: reading $reading doesn't come from a -Name attr -> skipping"; next LOOP; } $num = $hash->{defptr}{readingNum}{$key}; if ($hash->{defptr}{readingSubNum}{$key}) { $sub = $hash->{defptr}{readingSubNum}{$key}; } else { $sub = ""; } Log3 $name, 5, "$name: MaxAge: reading definition comes from $base, $num" . ($sub ? ", $sub" : ""); $max = GetFAttr($name, $base, $num . $sub, "MaxAge"); if (!$max) { Log3 $name, 5, "$name: MaxAge: No MaxAge attr for $base, $num, $sub"; next LOOP; } $rep = GetFAttr($name, $base, $num . $sub, "MaxAgeReplacement", ""); $mode = GetFAttr($name, $base, $num . $sub, "MaxAgeReplacementMode", "text"); $time = ReadingsTimestamp($name, $reading, 0); Log3 $name, 5, "$name: MaxAge: max = $max, mode = $mode, rep = $rep"; if ($now - time_str2num($time) <= $max) { next LOOP; } if ($mode eq "expression") { my $val = ReadingsVal($name, $reading, ""); my $new = EvalExpr($hash, {expr => $rep, val => $val, '$reading' => $reading}); Log3 $name, 4, "$name: MaxAge: reading $reading too old - using Value $new from Perl expression as MaxAge replacement: $rep"; readingsBulkUpdate($hash, $reading, $new); } elsif ($mode eq "text") { Log3 $name, 4, "$name: MaxAge: reading $reading too old - using $rep instead"; readingsBulkUpdate($hash, $reading, $rep); } elsif ($mode eq 'reading') { my $device = $name; my $rname = $rep; if ($rep =~ /^([^\:]+):(.+)$/) { $device = $1; $rname = $2; } my $rvalue = ReadingsVal($device, $rname, ""); Log3 $name, 4, "$name: MaxAge: reading $reading too old - using reading $rname with value $rvalue instead"; readingsBulkUpdate($hash, $reading, $rvalue); } elsif ($mode eq 'internal') { my $device = $name; my $internal = $rep; if ($rep =~ /^([^\:]+):(.+)$/) { $device = $1; $internal = $2; } my $rvalue = InternalVal($device, $internal, ""); Log3 $name, 4, "$name: MaxAge: reading $reading too old - using internal $internal with value $rvalue instead"; readingsBulkUpdate($hash, $reading, $rvalue); } elsif ($mode eq "delete") { Log3 $name, 4, "$name: MaxAge: reading $reading too old - delete it"; DeleteReading($hash, $reading); } $hash->{defptr}{readingOutdated}{$reading} = 1 if ($mode ne "delete"); } return; } ###################################################### # check delete option on error # for readings that were created in the last reqType # e.g. get04 but maybe defined in reading02Regex sub DoDeleteOnError { my $hash = shift; my $reqType = shift; my $name = $hash->{NAME}; return if (!$hash->{READINGS}); UpdateRequestHash($hash) if ($hash->{".updateRequestHash"}); if (!$hash->{defptr}{requestReadings} || !$hash->{defptr}{requestReadings}{$reqType}) { Log3 $name, 5, "$name: DoDeleteOnError: no defptr pointing from request to readings - returning"; return; } # readings that were created during last request type reqType (e.g. get03) my $reqReadings = $hash->{defptr}{requestReadings}{$reqType}; foreach my $reading (sort keys %{$reqReadings}) { Log3 $name, 5, "$name: DoDeleteOnError: check reading $reading"; # get parsing / handling definition of this reading (e.g. reading02... or Get04...) my ($context, $eNum) = split (" ", $reqReadings->{$reading}); if (GetFAttr($name, $context, $eNum, "DeleteOnError")) { Log3 $name, 4, "$name: DoDeleteOnError: delete reading $reading created by $reqType ($context, $eNum)"; DeleteReading($hash, $reading); } } return; } ################################### # check delete option if unmatched sub DoDeleteIfUnmatched { my ($hash, $reqType, @matched) = @_; my $name = $hash->{NAME}; Log3 $name, 5, "$name: DoDeleteIfUnmatched called with request $reqType"; return if (!$hash->{READINGS}); UpdateRequestHash($hash) if ($hash->{".updateRequestHash"}); if (!$hash->{defptr}{requestReadings}) { Log3 $name, 5, "$name: DoDeleteIfUnmatched: no defptr pointing from request to readings - returning"; return; } my %matched; foreach my $m (@matched) { $matched{$m} = 1; } my $reqReadings = $hash->{defptr}{requestReadings}{$reqType}; my @rList = sort keys %{$reqReadings}; Log3 $name, 5, "$name: DoDeleteIfUnmatched: List from requestReadings is @rList"; RLOOP: foreach my $reading (@rList) { Log3 $name, 5, "$name: DoDeleteIfUnmatched: check reading $reading" . ($matched{$reading} ? " (matched)" : " (no match)"); next RLOOP if ($matched{$reading}); my ($context, $eNum) = split (" ", $reqReadings->{$reading}); Log3 $name, 5, "$name: DoDeleteIfUnmatched: check attr for reading $reading ($context, $eNum)"; if (GetFAttr($name, $context, $eNum, "DeleteIfUnmatched")) { Log3 $name, 4, "$name: DoDeleteIfUnmatched: delete reading $reading created by $reqType ($context, $eNum)"; DeleteReading($hash, $reading); } else { Log3 $name, 5, "$name: DoDeleteIfUnmatched: no DeleteIfUnmatched for reading $reading ($context, $eNum)"; } } return; } ########################################### # extract cookies from HTTP Response Header # called from ReadCallback sub GetCookies { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $header = shift; # http header read my $name = $hash->{NAME}; # fhem device name #Log3 $name, 5, "$name: looking for Cookies in $header"; Log3 $name, 5, "$name: GetCookies is looking for Cookies"; foreach my $cookie ($header =~ m/set-cookie: ?(.*)/gi) { #Log3 $name, 5, "$name: GetCookies found Set-Cookie: $cookie"; $cookie =~ /([^,; ]+)=([^,;\s\v]+)[;,\s\v]*([^\v]*)/; Log3 $name, 4, "$name: GetCookies parsed Cookie: $1 Wert $2 Rest $3"; my $name = $1; my $value = $2; my $rest = ($3 ? $3 : ""); my $path = ""; if ($rest =~ /path=([^;,]+)/) { $path = $1; } my $key = $name . ';' . $path; $hash->{HTTPCookieHash}{$key}{Name} = $name; $hash->{HTTPCookieHash}{$key}{Value} = $value; $hash->{HTTPCookieHash}{$key}{Options} = $rest; $hash->{HTTPCookieHash}{$key}{Path} = $path; } return; } ################################### # initialize Parsers # called from _Read sub InitParsers { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $body = shift; # http body read my $name = $hash->{NAME}; # fhem device name # initialize parsers if ($hash->{'.JSONEnabled'} && $body) { FlattenJSON($hash, $body); } if ($hash->{'.XPathEnabled'} && $body) { $hash->{ParserData}{XPathTree} = HTML::TreeBuilder::XPath->new; eval { $hash->{ParserData}{XPathTree}->parse($body) }; Log3 $name, ($@ ? 3 : 5), "$name: InitParsers: XPath parsing " . ($@ ? "error: $@" : "done."); } if ($hash->{'.XPathStrictEnabled'} && $body) { eval { $hash->{ParserData}{XPathStrictNodeset} = XML::XPath->new(xml => $body) }; Log3 $name, ($@ ? 3 : 5), "$name: InitParsers: XPath-Strict parsing " . ($@ ? "error: $@" : "done."); } return; } ################################### # cleanup Parsers # called from _Read sub CleanupParsers { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $name = $hash->{NAME}; # fhem device name if ($hash->{'.XPathEnabled'}) { if ($hash->{ParserData}{XPathTree}) { eval { $hash->{ParserData}{XPathTree}->delete() }; Log3 $name, 3, "$name: error deleting XPathTree: $@" if ($@); } } if ($hash->{'.XPathStrictEnabled'}) { if ($hash->{ParserData}{XPathStrictNodeset}) { eval {$hash->{ ParserData}{XPathStrictNodeset}->cleanup()} ; Log3 $name, 3, "$name: error deleting XPathStrict nodeset: $@" if ($@); } } delete $hash->{ParserData}; return; } ################################### # Extract SID # called from _Read sub ExtractSid { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device) my $buffer = shift; # whole http response buffer read my $request = $hash->{REQUEST}; # hash ref to the request that was sent my $context = $request->{'context'}; # attribute context (reading, get, set, sid) my $num = $request->{'num'}; my $name = $hash->{NAME}; my $regex = GetRegex($name, "", "", "idRegex", ""); my $json = AttrVal($name, "idJSON", ""); my $xpath = AttrVal($name, "idXPath", ""); my $xpathst = AttrVal($name, "idXPath-Strict", ""); Log3 $name, 5, "$name: ExtractSid called, context $context, num $num"; $regex = GetRegex($name, $context, $num, "IdRegex", $regex); $regex = GetRegex($name, $context, $num, "IDRegex", $regex); $json = GetFAttr($name, $context, $num, "IdJSON", $json); $xpath = GetFAttr($name, $context, $num, "IdXPath", $xpath); $xpathst = GetFAttr($name, $context, $num, "IdXPath-Strict", $xpathst); my @matchlist; if ($json) { Log3 $name, 5, "$name: Checking SID with JSON $json"; if (defined($hash->{ParserData}{JSON}) && defined($hash->{ParserData}{JSON}{$json})) { @matchlist = ($hash->{ParserData}{JSON}{$json}); } } elsif ($xpath) { Log3 $name, 5, "$name: Checking SID with XPath $xpath"; eval { @matchlist = $hash->{ParserData}{XPathTree}->findnodes_as_strings($xpath) }; Log3 $name, 3, "$name: error in findvalues for XPathTree: $@" if ($@); } elsif ($xpathst) { Log3 $name, 5, "$name: Checking SID with XPath-Strict $xpathst"; my $nodeset; eval { $nodeset = $hash->{ParserData}{XPathStrictNodeset}->find($xpathst) }; if ($@) { Log3 $name, 3, "$name: error in find for XPathStrictNodeset: $@"; } else { foreach my $node ($nodeset->get_nodelist) { push @matchlist, XML::XPath::XMLParser::as_string($node); } } } if (@matchlist) { $buffer = join (' ', @matchlist); if ($regex) { Log3 $name, 5, "$name: ExtractSid is replacing buffer to check with match: $buffer"; } else { $hash->{sid} = $buffer; Log3 $name, 4, "$name: ExtractSid set sid to $hash->{sid}"; return 1; } } if ($regex) { if ($buffer =~ $regex) { $hash->{sid} = $1; Log3 $name, 4, "$name: ExtractSid set sid to $hash->{sid}"; return 1; } else { Log3 $name, 5, "$name: ExtractSid could not match buffer to IdRegex $regex"; } } return; } ############################################################### # Check if Auth is necessary and queue auth steps if needed # called from _Read sub CheckAuth { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device) my $buffer = shift; # whole http response read my $request = $hash->{REQUEST}; # hash ref to the request that was sent my $context = $request->{'context'}; # attribute context (reading, get, set, sid) my $num = $request->{'num'}; my $name = $hash->{NAME}; my $doAuth; my $regex = GetRegex($name, "", "", "reAuthRegex", ""); my $json = AttrVal($name, "reAuthJSON", ""); my $xpath = AttrVal($name, "reAuthXPath", ""); my $xpathst = AttrVal($name, "reAuthXPath-Strict", ""); if ($context =~ /([gs])et/) { $regex = GetRegex($name, $context, $num, "ReAuthRegex", $regex); $json = GetFAttr($name, $context, $num, "ReAuthJSON", $json); $xpath = GetFAttr($name, $context, $num, "ReAuthXPath", $xpath); $xpathst = GetFAttr($name, $context, $num, "ReAuthXPath-Strict", $xpathst); } my @matchlist; if ($json) { Log3 $name, 5, "$name: Checking Auth with JSON $json"; if (defined($hash->{ParserData}{JSON}) && defined($hash->{ParserData}{JSON}{$json})) { @matchlist = ($hash->{ParserData}{JSON}{$json}); } } elsif ($xpath) { Log3 $name, 5, "$name: Checking Auth with XPath $xpath"; eval { @matchlist = $hash->{ParserData}{XPathTree}->findnodes_as_strings($xpath) }; Log3 $name, 3, "$name: error in findvalues for XPathTree: $@" if ($@); } elsif ($xpathst) { Log3 $name, 5, "$name: Checking Auth with XPath-Strict $xpathst"; my $nodeset; eval { $nodeset = $hash->{ParserData}{XPathStrictNodeset}->find($xpathst) }; if ($@) { Log3 $name, 3, "$name: error in find for XPathStrictNodeset: $@"; } else { foreach my $node ($nodeset->get_nodelist) { push @matchlist, XML::XPath::XMLParser::as_string($node); } } } if (@matchlist) { if ($regex) { $buffer = join (' ', @matchlist); Log3 $name, 5, "$name: CheckAuth is replacing buffer to check with match: $buffer"; } else { Log3 $name, 5, "$name: CheckAuth matched: $buffer"; $doAuth = 1; } } if ($regex) { Log3 $name, 5, "$name: CheckAuth is checking buffer with ReAuthRegex $regex"; $doAuth = 1 if ($buffer =~ $regex); } if ($doAuth) { Log3 $name, 4, "$name: CheckAuth decided new authentication required"; if ($request->{retryCount} < AttrVal($name, "authRetries", 1)) { if (!AttrVal($name, "dontRequeueAfterAuth", 0)) { AddToSendQueue ($hash, { %{$request}, 'priority' => 1, 'retryCount' => $request->{retryCount}+1, 'value' => $request->{value} } ); Log3 $name, 4, "$name: CheckAuth prepended request $request->{type} again before auth, retryCount $request->{retryCount} ..."; } DoAuth $hash; return 1; } else { Log3 $name, 4, "$name: Authentication still required but no retries left - did last authentication fail?"; } } else { Log3 $name, 5, "$name: CheckAuth decided no authentication required"; } return 0; } ################################### # update List of Readings to parse # during GetUpdate cycle sub UpdateReadingList { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $name = $hash->{NAME}; # Fhem device name my %khash; foreach my $a (sort keys %{$attr{$name}}) { if (($a =~ /^readingsName(.*)/) && defined ($attr{$name}{'readingsName' . $1})) { $khash{$1} = 1; # old syntax } elsif ($a =~ /^reading([0-9]+).*/) { $khash{$1} = 1; # new syntax } } my @list = sort keys %khash; $hash->{".readingParseList"} = \@list; Log3 $name, 5, "$name: UpdateReadingList created list of reading.* nums to parse during getUpdate as @list"; delete $hash->{".updateReadingList"}; return; } ################################### # Check for redirect headers sub CheckRedirects { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device hash) my $header = shift; # HTTP header read my $addr = shift; my $name = $hash->{NAME}; # fhem device name my $request = $hash->{REQUEST}; # reference to request hash my $type = $request->{type}; my $url = $request->{url}; if (!$header) { Log3 $name, 4, "$name: no header to look for redirects"; return; } my @header = split("[\r\n]+", $header); my @header0 = split(" ", shift @header); my $code = $header0[1]; Log3 $name, 4, "$name: checking for redirects, code=$code, ignore=$request->{ignoreredirects}"; if ($code !~ m{ \A 301 | 302 | 303 | 308 \z }xms) { Log3 $name, 4, "$name: no redirects to handle"; return; } $hash->{RedirCount} = 0 if (!$hash->{RedirCount}); if(++$hash->{RedirCount} > 5) { Log3 $name, 3, "$name: Too many redirects processing response to $url"; return; } my $redirAdr; map { $redirAdr = $1 if ( $_ =~ m{ [Ll]ocation: \s* (\S+) $ }xms ) } @header; if (!$redirAdr) { Log3 $name, 3, "$name: Error: got Redirect but no Location-Header from server"; return; } $redirAdr = "/$redirAdr" if($redirAdr !~ m/^http/ && $redirAdr !~ m/^\//); my $rurl = ($redirAdr =~ m/^http/) ? $redirAdr : $addr.$redirAdr; if ($request->{ignoreredirects}) { Log3 $name, 4, "$name: ignoring redirect to $rurl"; return; } Log3 $name, 4, "$name: $url: Redirect ($hash->{RedirCount}) to $rurl"; # todo: redirect with post possible / supported?? # prepend redirected request, copy from old request and overwrite some keys: AddToSendQueue($hash, { %{$request}, 'url' => $rurl, 'priority' => 1 } ); HandleSendQueue("direct:".$name); # AddToQueue with prio did not call this. return 1; } ########################################### # create automatic readings from JSON sub ExtractAllJSON { my $hash = shift; # hash reference passed to HttpUtils_NonblockingGet (our device) my $body = shift; # buffer read my $request = $hash->{REQUEST}; # hash ref to the request that was sent my $context = $request->{'context'}; # attribute context (reading, get, set, sid) my $num = $request->{'num'}; # attribute num my $type = $request->{'type'}; # type of request that was sent (like get01, update or auth01) my $name = $hash->{NAME}; # create a reading for each JSON object and use formatting options if a correspondig reading name / formatting is defined if ((AttrVal($name, "extractAllJSON", 0) == 2 || GetFAttr($name, $context, $num, "ExtractAllJSON", 0) == 2) && ($context =~/get|set/) && (AttrVal($name, "${context}${num}CheckAllReadings", "u") eq "u")) { # ExtractAllJSON mode 2 will create attributes, also CheckAllReadings to 1 for get/set unless already defined as 0 CommandAttr(undef, "$name ${context}${num}CheckAllReadings 1"); } my $fDefault = ($featurelevel > 5.9 ? 1 : ''); my $rNum = 100; # start value for extractAllJSON mode 2 my @matched; my $filter = AttrVal($name, "extractAllJSONFilter", ""); if (ref $hash->{ParserData}{JSON} ne "HASH") { Log3 $name, 3, "$name: no parsed JSON structure available"; return; } foreach my $object (keys %{$hash->{ParserData}{JSON}}) { next if ($filter && $object !~ $filter); my $rName = $object; $rName = makeReadingName($object) if (AttrVal($name, "enforceGoodReadingNames", $fDefault)); if (AttrVal($name, "extractAllJSON", 0) == 2 || (GetFAttr($name, $context, $num, "ExtractAllJSON") && GetFAttr($name, $context, $num, "ExtractAllJSON") == 2)) { # mode 2: create attributes with the readings to make renaming easier $rName = makeReadingName($object); # at least for this mode! my $existing = 0; # check if there already is an attribute reading[0-9]+JSON $object foreach my $a (grep { /reading[0-9]+JSON/ } keys %{$attr{$name}} ) { if ($attr{$name}{$a} eq $object) { $existing = $a; } } if ($existing) { Log3 $name, 5, "$name: Read with extractAllJSON mode 2 doesn't set a new attr for $object because $existing already exists with $object"; } else { # find free reading num while (AttrVal($name, "reading${rNum}Name", "u") ne "u" || AttrVal($name, "reading${rNum}JSON", "u") ne "u") { $rNum++; # skip until a number is unused } Log3 $name, 5, "$name: Read with extractAllJSON mode 2 is defining attribute reading${rNum}Name and reading${rNum}JSON for object $object"; CommandAttr(undef, "$name reading${rNum}Name $rName"); CommandAttr(undef, "$name reading${rNum}JSON $object"); } } else { # normal mode without attribute creation my $value = FormatReading($hash, $context, $num, $hash->{ParserData}{JSON}{$object}, $rName); Log3 $name, 5, "$name: Read sets reading $rName to value $value of JSON $object"; readingsBulkUpdate($hash, $rName, $value); push @matched, $rName; # unmatched is not filled for "ExtractAllJSON" delete $hash->{defptr}{readingOutdated}{$rName}; $hash->{defptr}{readingBase}{$rName} = $context; $hash->{defptr}{readingNum}{$rName} = $num; $hash->{defptr}{requestReadings}{$type}{$rName} = "$context $num"; } } if ((AttrVal($name, "extractAllJSON", 0) == 2) && $context eq "reading") { Log3 $name, 3, "$name: Read is done with JSON extractAllJSON mode 2 and now removes this attribute"; CommandDeleteAttr(undef, "$name extractAllJSON"); } elsif ((GetFAttr($name, $context, $num, "ExtractAllJSON") && GetFAttr($name, $context, $num, "ExtractAllJSON") == 2) && $context =~/get|set/) { Log3 $name, 3, "$name: Read is done with JSON ${context}${num}ExtractAllJSON mode 2 and now removes this attribute"; CommandDeleteAttr(undef, "$name ${context}${num}ExtractAllJSON"); } return @matched; } ################################################ # dump buffer and header to file for debugging sub DumpBuffer { my $hash = shift; my $body = shift; my $header = shift; my $name = $hash->{NAME}; my $fh; $hash->{BufCounter} = 0 if (!$hash->{BufCounter}); $hash->{BufCounter} ++; my $path = AttrVal($name, "dumpBuffers", '.'); Log3 $name, 4, "$name: dump buffer to $path/buffer$hash->{BufCounter}.txt"; open($fh, '>', "$path/buffer$hash->{BufCounter}.txt"); ## no critic if ($!) { Log3 $name, 3, "$name: error opening: $!"; return; } Log3 $name, 5, "$name: Filehandle is $fh"; if ($header) { print $fh $header; print $fh "\r\n\r\n"; } print $fh $body; close $fh; return; } ################################### # read / parse new data from device # - callback for non blocking HTTP sub ReadCallback { my $huHash = shift; # hash reference passed to HttpUtils_NonblockingGet my $err = shift; # error message from HttpUtils_NonblockingGet my $body = shift // ''; # HTTP body received my $hash = $huHash->{DEVHASH}; # our device hash my $name = $hash->{NAME}; # our device name my $request = $hash->{REQUEST}; # hash ref to the request that was sent my $context = $request->{'context'}; # attribute context (reading, get, set, sid) my $num = $request->{'num'}; my $type = $request->{'type'}; # type of request that was sent (like get01, update or auth01) my $header = $huHash->{httpheader} // ''; # HTTP headers received delete $huHash->{DEVHASH}; $hash->{HttpUtils} = $huHash; # make the httpUtils hash available in case anyone wants to use variables $hash->{BUSY} = 0; Log3 $name, 5, "$name: ReadCallback called from " . FhemCaller(); if (!$name || $hash->{TYPE} ne "HTTPMOD") { Log3 'HTTPMOD', 3, "HTTPMOD ReadCallback was called with illegal hash - this should never happen - problem in HttpUtils?"; return; } my $headerSplit = AttrVal($name, 'fileHeaderSplit', ''); # to allow testing header features if ($headerSplit && !$header && $body =~ m{ (.*) $headerSplit (.*) }xms ) { $header = $1; $body = $2 // ''; Log3 $name, 5, "$name: HTTPMOD ReadCallback split file body / header at $headerSplit"; } if ($err) { my $lvlRegex = GetRegex($name, '', '', 'errLogLevelRegex', ''); my $errLvl = AttrVal($name, 'errLogLevel', 3); # default error log level is 3, can be cahnged by attr Log3 $name, 5, "$name: Read callback Error LogLvl set to $errLvl, regex " . ($lvlRegex // ''); $errLvl = 3 if ($lvlRegex && $err !~ $lvlRegex); # reset log level to 3 if regex given and it doesnt match Log3 $name, $errLvl, "$name: Read callback: Error: $err"; } Log3 $name, 4, "$name: Read callback: request type was $type" . " retry $request->{retryCount}" . ($header ? ",\r\nheader: $header" : ", no headers") . ($body ? ", body length " . length($body) : ", no body"); Log3 $name, 5, "$name: Read callback: " . ($body ? "body\r\n$body" : "body empty"); MemReading($hash) if (AttrVal($name, "memReading", 0)); DumpBuffer($hash, $body, $header) if (AttrVal($name, "dumpBuffers", 0)); $body = BodyDecode($hash, $body, $header); # decode body according to attribute bodyDecode and content-type header my $ppr = AttrVal($name, "preProcessRegex", ""); # can't precompile a whole substitution so the GetRegex way doesn't work here. # we would need to split the regex into match/replace part and only compile the matching part ... # if a user s affected by Perl's memory he leak he might just add option a to his regex attr #Log3 $name, 5, "$name: Read preProcessRegex is $ppr"; if ($ppr) { my $pprexp = '$body=~' . $ppr; local $SIG{__WARN__} = sub { Log3 $name, 3, "$name: Read preProcessRegex created warning: @_"; }; eval $pprexp; ## no critic - user defined substitution needs evaluation as string Log3 $name, 5, "$name: Read - body after preProcessRegex: $ppr is $body"; } $hash->{httpbody} = $body if (AttrVal($name, "showBody", 0)); my $buffer; $buffer = ($header ? $header . "\r\n\r\n" . $body : $body); # for matching sid / reauth $buffer = $buffer . "\r\n\r\n" . $err if ($err); # for matching reauth my $fDefault = ($featurelevel > 5.9 ? 1 : 0); InitParsers($hash, $body); GetCookies($hash, $header) if (AttrVal($name, "enableCookies", $fDefault)); ExtractSid($hash, $buffer); return if (AttrVal($name, "handleRedirects", $fDefault) && CheckRedirects($hash, $header, $huHash->{addr})); delete $hash->{RedirCount}; readingsBeginUpdate($hash); readingsBulkUpdate($hash, "LAST_ERROR", $err) if (AttrVal($name, "showError", 0) && $err); readingsBulkUpdate($hash, "LAST_REQUEST", $type) if (AttrVal($name, "showMatched", 0)); Log3 $name, 5, "$name: Read callback sets LAST_REQUEST to $type"; DoMaxAge($hash) if ($hash->{'.MaxAgeEnabled'}); my $authQueued; $authQueued = CheckAuth($hash, $buffer) if ($context ne "sid"); if ($err || $authQueued || ($context =~ "set|sid" && !GetFAttr($name, $context, $num, "ParseResponse"))) { readingsEndUpdate($hash, 1); DoDeleteOnError($hash, $type) if ($hash->{DeleteOnError}); CleanupParsers($hash); return; # don't continue parsing response } my ($tried, $match, $reading); my @unmatched = (); my @matched = (); my @subrlist = (); my $checkAll = 1; if ($context =~ "get|set") { ($tried, $match, $reading, @subrlist) = ExtractReading($hash, $buffer, $context, $num, $type); push @matched, @subrlist if ($tried && $match); push @unmatched, $reading if ($tried && !$match); $checkAll = GetFAttr($name, $context, $num, 'CheckAllReadings', !$tried); # if ExtractReading2 could not find any parsing instruction (e.g. regex) then check all Readings } if (AttrVal($name, "extractAllJSON", "") || GetFAttr($name, $context, $num, "ExtractAllJSON")) { push @matched, ExtractAllJSON($hash, $body); } UpdateReadingList($hash) if ($hash->{".updateReadingList"}); if ($checkAll && defined($hash->{".readingParseList"})) { # check all defined readings and try to extract them Log3 $name, 5, "$name: Read starts parsing response to $type with defined readings: " . join (",", @{$hash->{".readingParseList"}}); foreach my $iNum (@{$hash->{".readingParseList"}}) { # try to parse readings defined in reading.* attributes # pass request $type so we know for later delete (undef, $match, $reading, @subrlist) = ExtractReading($hash, $buffer, 'reading', $iNum, $type); push @matched, @subrlist if ($match); push @unmatched, $reading if (!$match); } } if (AttrVal($name, "showMatched", undef)) { readingsBulkUpdate($hash, "MATCHED_READINGS", join ' ', @matched); readingsBulkUpdate($hash, "UNMATCHED_READINGS", join ' ', @unmatched); } if (!@matched) { Log3 $name, 4, "$name: Read response to $type didn't match any Reading"; } else { Log3 $name, 4, "$name: Read response matched " . scalar(@matched) .", unmatch " . scalar(@unmatched) . " Reading(s)"; Log3 $name, 5, "$name: Read response to $type matched " . join ' ', @matched; Log3 $name, 5, "$name: Read response to $type did not match " . join ' ', @unmatched if (@unmatched); } EvalFunctionCall($hash, $buffer, 'parseFunction1', $type); readingsEndUpdate($hash, 1); EvalFunctionCall($hash, $buffer, 'parseFunction2', $type); DoDeleteIfUnmatched($hash, $type, @matched) if ($hash->{DeleteIfUnmatched}); HandleSendQueue("direct:".$name); CleanupParsers($hash); return; } ################################### # add cookies to header sub PrepareCookies { my $hash = shift; my $url = shift; my $name = $hash->{NAME}; my $uriPath = ''; my $cookies = ''; if ($url =~ / ^(http|https):\/\/ # $1: proto (([^:\/]+):([^:\/]+)@)? # $2: auth, $3:user, $4:password ([^:\/]+|\[[0-9a-f:]+\]) # $5: host or IPv6 address (:\d+)? # $6: port (\/.*)$ # $7: path /xi ) { $uriPath = $7; } #Log3 $name, 5, "$name: DoCookies called, path=$uriPath"; return if (!$hash->{HTTPCookieHash}); foreach my $cookie ( sort keys %{ $hash->{HTTPCookieHash} } ) { my $cPath = $hash->{HTTPCookieHash}{$cookie}{Path}; my $idx = index( $uriPath, $cPath ); # Beginn des neuen URL-Pfads in einem Cooke-Pfad #Log3 $name, 5, "$name: DoCookies checking cookie $hash->{HTTPCookieHash}{$cookie}{Name} path $cPath"; if ( !$uriPath || !$cPath || $idx == 0 ) { Log3 $name, 5, "$name: HandleSendQueue is using Cookie $hash->{HTTPCookieHash}{$cookie}{Name} " . "with path $hash->{HTTPCookieHash}{$cookie}{Path} and Value " . "$hash->{HTTPCookieHash}{$cookie}{Value} (key $cookie, destination path is $uriPath)"; $cookies .= "; " if ($cookies); $cookies .= $hash->{HTTPCookieHash}{$cookie}{Name} . "=" . $hash->{HTTPCookieHash}{$cookie}{Value}; } else { Log3 $name, 5, "$name: DoCookies no cookie path match for $uriPath"; Log3 $name, 5, "$name: DoCookies is ignoring Cookie $hash->{HTTPCookieHash}{$cookie}{Name} "; Log3 $name, 5, "$name: " . unpack( 'H*', $cPath ); Log3 $name, 5, "$name: " . unpack( 'H*', $uriPath ); } } Log3 $name, 5, "$name: DoCookies is adding Cookie header: $cookies" if ($cookies); return $cookies; } ################################################################# # set parameters for HttpUtils from request into hash sub FillHttpUtilsHash { my $hash = shift; my $name = $hash->{NAME}; my $request = $hash->{REQUEST}; my $huHash = {}; my $fDefault = ($featurelevel > 5.9 ? 1 : 0); $huHash->{redirects} = 0; $huHash->{loglevel} = 4; $huHash->{callback} = \&ReadCallback; $huHash->{url} = $request->{url}; $huHash->{header} = $request->{header}; $huHash->{data} = $request->{data} // ''; $huHash->{timeout} = AttrVal( $name, "timeout", 2 ); $huHash->{httpversion} = AttrVal( $name, "httpVersion", "1.0" ); $huHash->{ignoreredirects} = (AttrVal($name, "handleRedirects", $fDefault) ? 1 : $request->{ignoreredirects}); $huHash->{noshutdown} = 1 if (AttrVal($name, "noShutdown", 0)); $huHash->{method} = $request->{method} if ($request->{method}); $huHash->{DEVHASH} = $hash; Log3 $name, 5, "$name: HandleSendQueue - call with HTTP METHOD: $huHash->{method}" if ($request->{method}); my $sslArgList = AttrVal( $name, "sslArgs", undef ); if ($sslArgList) { Log3 $name, 5, "$name: sslArgs is set to $sslArgList"; my %sslArgs = split( ',', $sslArgList ); Log3 $name, 5, "$name: sslArgs huHash keys: " . join( ",", keys %sslArgs ); Log3 $name, 5, "$name: sslArgs huHash values: " . join( ",", values %sslArgs ); $huHash->{sslargs} = \%sslArgs; } # do user defined replacements first if ( $hash->{'.ReplacementEnabled'} ) { $huHash->{header} = DoReplacement($hash, $request->{type}, $huHash->{header} ) if ($huHash->{header}); $huHash->{data} = DoReplacement($hash, $request->{type}, $huHash->{data} ) if ($huHash->{data}); $huHash->{url} = DoReplacement($hash, $request->{type}, $huHash->{url} ); } # then replace $val in header, data and URL with value from request (setVal) if it is still there my $value = $request->{value} // ''; $huHash->{header} =~ s/\$val/$value/g if ($huHash->{header}); $huHash->{data} =~ s/\$val/$value/g if ($huHash->{data});; $huHash->{url} =~ s/\$val/$value/g; # sid replacement is also done here - just before sending so changes in session while request was queued will be reflected if ( $hash->{sid} ) { $huHash->{header} =~ s/\$sid/$hash->{sid}/g if ($huHash->{header}); $huHash->{data} =~ s/\$sid/$hash->{sid}/g if ($huHash->{data}); $huHash->{url} =~ s/\$sid/$hash->{sid}/g; } if (AttrVal($name, "enableCookies", $fDefault)) { my $cookies = PrepareCookies($hash, $huHash->{url}); if ($cookies) { $huHash->{header} .= "\r\n" if ( $huHash->{header} ); $huHash->{header} .= "Cookie: " . $cookies; } } return $huHash; } ################################################## # can we send another request or is it too early? sub ReadyForSending { my $hash = shift; my $name = $hash->{NAME}; my $now = gettimeofday(); my $last = $hash->{'.LASTSEND'} // 0; if (!$init_done) { # fhem not initialized, wait with IO StartQueueTimer($hash, \&HTTPMOD::HandleSendQueue, {log => 'init not done, delay sending from queue'}); return; } if ($hash->{BUSY}) { # still waiting for reply to last request if ($now > $last + max(15, AttrVal($name, "timeout", 2) *2)) { Log3 $name, 5, "$name: HandleSendQueue - still waiting for reply, timeout is over twice - this should never happen. Stop waiting"; $hash->{BUSY} = 0; # waited long enough, clear busy flag and continue } else { my $qDelay = AttrVal( $name, "queueDelay", 1 ); $qDelay *= 2 if ($now > $last + ($qDelay *2)); StartQueueTimer($hash, \&HTTPMOD::HandleSendQueue, {delay => $qDelay, log => 'still waiting for reply to last request'}); return; } } my $minSendDelay = AttrVal($name, "minSendDelay", 0.2); if ($now < $last + $minSendDelay) { StartQueueTimer($hash, \&HTTPMOD::HandleSendQueue, {log => "minSendDelay $minSendDelay not over"}); return; } return 1; } ####################################### # Aufruf aus InternalTimer mit "queue:$name" # oder direkt mit $direct:$name sub HandleSendQueue { my $arg = shift; my ($calltype, $name) = split(':', $arg); my $hash = $defs{$name}; my $queue = $hash->{QUEUE}; my $qlen = ($hash->{QUEUE} ? scalar(@{ $hash->{QUEUE} }) : 0 ); my $now = gettimeofday(); my $qDelay = AttrVal( $name, "queueDelay", 1 ); my $request; Log3 $name, 5, "$name: HandleSendQueue called from " . FhemCaller() . ", qlen = $qlen"; StopQueueTimer($hash, {silent => 1}); CLEANLOOP: { # get first usable entry or return if(!$queue || !scalar(@{$queue})) { # nothing in queue -> return Log3 $name, 5, "$name: HandleSendQueue found no usable entry in queue"; return; } $request = $queue->[0]; # get top element from Queue #Log3 $name, 5, "$name: HandleSendQueue - next request is " . Dumper $request; next CLEANLOOP if (!$request || !$request->{url}); # skip invalid entry (should not happen) last CLEANLOOP; } continue { shift(@{$queue}); # remove unusable first element and iterate } return if (!ReadyForSending($hash)); # check busy and delays shift( @{$queue} ); # first element is good and will be used now, remove it from queue (after delays are ok) $hash->{BUSY} = 1; # queue is busy until response is received $hash->{'.LASTSEND'} = $now; # remember when last sent $hash->{REQUEST} = $request; $hash->{value} = $request->{value}; # make value accessible for user defined replacements / expressions my $huHash = FillHttpUtilsHash($hash); Log3 $name, 4, "$name: HandleSendQueue sends $request->{type} with timeout $huHash->{timeout} to " . "$huHash->{url}, " . ( $huHash->{data} ? "\r\ndata: $huHash->{data}, " : "No Data, " ) . ( $huHash->{header} ? "\r\nheader: $huHash->{header}" : "No Header" ); HttpUtils_NonblockingGet($huHash); StartQueueTimer($hash, \&HTTPMOD::HandleSendQueue); return; } ###################################################################################################### # queue requests sub AddToSendQueue { my $hash = shift; my $request = shift; my $name = $hash->{NAME}; $request->{retryCount} = 0 if (!$request->{retryCount}); $request->{ignoreredirects} = 0 if (!$request->{ignoreredirects}); $request->{context} = 'unknown' if (!$request->{context}); $request->{type} = 'unknown' if (!$request->{type}); $request->{num} = 'unknown' if (!$request->{num}); my $qlen = ($hash->{QUEUE} ? scalar(@{$hash->{QUEUE}}) : 0); #Log3 $name, 4, "$name: AddToQueue adds $request->{type}, initial queue len: $qlen" . ($request->{'priority'} ? ", priority" : ""); Log3 $name, 5, "$name: AddToQueue " . ($request->{'priority'} ? "prepends " : "adds ") . "type $request->{type} to " . "URL $request->{url}, " . ($request->{data} ? "data $request->{data}, " : "no data, ") . ($request->{header} ? "header $request->{header}, " : "no headers, ") . ($request->{ignoreredirects} ? "ignore redirects, " : "") . "retry " . ($request->{'retryCount'} // 0) . ", initial queue len: $qlen"; if(!$qlen) { $hash->{QUEUE} = [ $request ]; } else { if ($qlen > AttrVal($name, "queueMax", 20)) { Log3 $name, 3, "$name: AddToQueue - send queue too long ($qlen), dropping request ($request->{'type'}), BUSY = $hash->{BUSY}"; } else { if ($request->{'priority'}) { unshift (@{$hash->{QUEUE}}, $request); # an den Anfang } else { push(@{$hash->{QUEUE}}, $request); # ans Ende } } } HandleSendQueue("direct:".$name) if (!$request->{'priority'}); # if prio is set, wait until all steps are added to the front - Auth will call HandleSendQueue then. return; } 1; =pod =item device =item summary retrieves readings from devices with an HTTP Interface =item summary_DE fragt Readings von Geräten mit HTTP-Interface ab =begin html

HTTPMOD

=end html =cut