From 55c0f59756b63067057f57169d37267e760434a1 Mon Sep 17 00:00:00 2001 From: nasseeder1 Date: Fri, 24 May 2024 21:36:41 +0000 Subject: [PATCH] 02_HTTPAPI: contrib version git-svn-id: https://svn.fhem.de/fhem/trunk@28901 2b470e98-0d58-463d-a4d8-8e2adae1ed80 --- fhem/contrib/DS_Starter/02_HTTPAPI.pm | 507 ++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 fhem/contrib/DS_Starter/02_HTTPAPI.pm diff --git a/fhem/contrib/DS_Starter/02_HTTPAPI.pm b/fhem/contrib/DS_Starter/02_HTTPAPI.pm new file mode 100644 index 000000000..6ba3f7924 --- /dev/null +++ b/fhem/contrib/DS_Starter/02_HTTPAPI.pm @@ -0,0 +1,507 @@ +# $Id: 02_HTTPAPI.pm 26361 2022-08-30 09:21:46Z klaus.schauer $ +# HTTP API commands +# set command: http://://set?device=&action= +# get command: http://://get?device=&action= +# read reading: http://://read?device=&reading= +# write reading: http:////write?device=&reading=&value= + +package main; +use Encode qw(decode encode); +use SetExtensions; +use strict; +use TcpServerUtils; +use warnings; + +my $gPath = ''; +BEGIN { + $gPath = substr($0, 0, rindex($0, '/')); +} +if (lc(substr($0, -7)) eq 'fhem.pl') { + $gPath = $attr{global}{modpath}.'/FHEM'; +} +use lib ($gPath.'/lib', $gPath.'/FHEM/lib', './FHEM/lib', './lib', './FHEM', './', '/usr/local/FHEM/share/fhem/FHEM/lib'); + +my $encoding = 'UTF-8'; +my $infix = 'api'; +my $linkPattern = "^\/?(([^\/]*(\/[^\/]+)*)\/?)\$"; +my $tcpServAdr = 'global'; +my $tcpServPort = 8087; + +sub HTTPAPI_Initialize { + my ($hash) = @_; + $hash->{AttrFn} = "HTTPAPI_Attr"; + $hash->{DefFn} = "HTTPAPI_Define"; + $hash->{ReadFn} = "HTTPAPI_Read"; + $hash->{UndefFn} = "HTTPAPI_Undef"; + $hash->{AttrList} = "disable:0,1 devicesCtrl " . $readingFnAttributes; + $hash->{parseParams} = 1; + return undef; +} + +sub HTTPAPI_Define { + my ($hash, $a, $h) = @_; + my $name = $a->[0]; + if (defined $a->[2]) { + $hash->{INFIX} = $a->[2]; + $infix = $a->[2]; + } elsif (exists $h->{infix}) { + $hash->{INFIX} = $h->{infix}; + $infix = $h->{infix}; + } else { + $hash->{INFIX} = $infix; + } + # check if valid folder name + if ($hash->{INFIX} !~ /^[^\\\/\?\*\"\'\>\<\:\|]*$/) { + return "HTTPAPI: wrong syntax, correct is: define HTTPAPI [infix=] [port=][[IPV6:]] [global=][global|local|]"; + } + my ($pport, $port); + if (defined $a->[3]) { + $pport = $a->[3]; + } elsif (exists $h->{port}) { + $pport = $h->{port}; + } else { + $pport = $tcpServPort; + } + $port = $pport; + $port =~ s/^IPV6://; + return "HTTPAPI: wrong syntax, correct is: define HTTPAPI [infix=] [port=][[IPV6:]] [global=][global|local|]" if ($port !~ m/^\d+$/); + if (defined $a->[4]) { + $hash->{GLOBAL} = $a->[4]; + } elsif (exists $h->{global}) { + $hash->{GLOBAL} = $h->{global}; + } else { + $hash->{GLOBAL} = $tcpServAdr; + } + + # open TCP server for HTTP API service + my $ret = TcpServer_Open($hash, $pport, (($hash->{GLOBAL} eq 'local') ? undef : $hash->{GLOBAL})); + if($ret && !$init_done) { + Log3 $name, 1, "HTTPAPI $ret already exists"; + exit(1); + } + readingsSingleUpdate($hash, "state", "initialized", 1); + Log3 $name, 2, "HTTPAPI $name initialized"; + return $ret; +} + +sub HTTPAPI_Attr { + my ($cmd, $name, $aName, $aVal) = @_; + if ($cmd eq "set") { + if ($aName =~ "devicesCtrl") { + if ($aVal !~ /^[A-Z_a-z0-9\,]+$/) { + Log3 $name, 2, "HTTPAPI $name invalid reading list in attr $name $aName $aVal (only A-Z, a-z, 0-9, _ and , allowed)"; + return "Invalid reading name $aVal (only A-Z, a-z, 0-9, _ and , allowed)"; + } + #addToDevAttrList($name, $aName); + } + } + return undef; +} + +sub HTTPAPI_CGI { + # execute request to http://:/$infix? + my ($hash, $name, $request) = @_; + my $apiCmd; + my $apiCmdString; + my $fhemDevName; + my $link; + return($hash, 503, 'close', "text/plain; charset=utf-8", encode($encoding, "error=503 Service Unavailable")) if(IsDisabled($name)); + + if($request =~ m/^(\/$infix)\/(set|get|read|readtimestamp|readinternal|write)\?(.*)$/) { + $link = $1; + $apiCmd = $2; + $apiCmdString = $3; + + # url decoding + $request =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + readingsSingleUpdate($defs{$name}, 'request', $request, 0); + + if ($apiCmdString =~ /&device(\=[^&]*)?(?=&|$)|^device(\=[^&]*)?(&|$)/) { + $fhemDevName = substr(($1 // $2), 1); + # url decoding + $fhemDevName =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + if (IsDevice($fhemDevName) && !IsDisabled($fhemDevName) && !IsIgnored($fhemDevName)) { + # Control of the device allowed? + my $devicesCtrl = AttrVal($name, 'devicesCtrl', undef); + my $allowedDev; + if (defined $devicesCtrl) { + my @devicesCtrl = split(',', $devicesCtrl); + foreach (@devicesCtrl) { + next if($_ ne $fhemDevName); + $allowedDev = $fhemDevName; + last; + } + return($hash, 403, 'close', "text/plain; charset=utf-8", encode($encoding, "error=403 Forbidden, $request > control of the device $fhemDevName not allowed")) if (!defined($allowedDev)) + } + if ($apiCmd eq 'get') { + my $getCmd; + if ($apiCmdString =~ /&action(\=[^&]*)?(?=&|$)|^action(\=[^&]*)?(&|$)/) { + $getCmd = substr(($1 // $2), 1); + # url decoding + $getCmd =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + my $ret = CommandGet(undef, "$fhemDevName $getCmd"); + if ($ret) { + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$getCmd=$ret")) + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > get $fhemDevName $getCmd")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > attribute action is missing")) + } + } elsif ($apiCmd =~ /^read|readtimestamp|readinternal$/) { + my $valName; + if ($apiCmdString =~ /&reading(\=[^&]*)?(?=&|$)|^reading(\=[^&]*)?(&|$)|&internal(\=[^&]*)?(&|$)/) { + $valName = substr(($1 // $2 // $3 // $4), 1); + # url decoding + $valName =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + my $val = $apiCmd eq 'readtimestamp' ? ReadingsTimestamp($fhemDevName, $valName, undef) : + $apiCmd eq 'readinternal' ? InternalVal($fhemDevName, $valName, undef) : + ReadingsVal($fhemDevName, $valName, undef); + #readingsSingleUpdate($defs{$name}, 'reponse', "$valName=$val", 1); + if (defined $val) { + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$valName=$val")); + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > reading $valName unknown")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > attribute reading/internal is missing")) + } + } elsif ($apiCmd eq 'set') { + my $setCmd; + if ($apiCmdString =~ /&action(\=[^&]*)?(?=&|$)|^action(\=[^&]*)?(&|$)/) { + $setCmd = substr(($1 // $2), 1); + # url decoding + $setCmd =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + #my $ret = CommandSet(undef, "$fhemDevName $setCmd"); + #my $ret = AnalyzeCommand($defs{$hash->{SNAME}}, "set $fhemDevName $setCmd"); + my $ret = AnalyzeCommand($defs{$name}, "set $fhemDevName $setCmd"); + if ($ret) { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > $ret")) + } else { + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$fhemDevName=$setCmd")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > attribute action is missing")) + } + + } elsif ($apiCmd eq 'write') { + my $readingName; + if ($apiCmdString =~ /&reading(\=[^&]*)?(?=&|$)|^reading(\=[^&]*)?(&|$)/) { + $readingName = substr(($1 // $2), 1); + # url decoding + $readingName =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + my $readingVal; + + if ($apiCmdString =~ /&value(\=[^&]*)?(?=&|$)|^value(\=[^&]*)?(&|$)/) { + $readingVal = substr(($1 // $2), 1); + # url decoding + $readingVal =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; + if ($readingVal ne '') { + my $result = readingsSingleUpdate($defs{$fhemDevName}, $readingName, $readingVal, 1); + if ($readingName eq 'state') { + $readingVal = $result; + } else { + ($readingName, $readingVal) = split(/:\s?/, $result); + } + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$readingName=$readingVal")) + } else { + readingsDelete($defs{$fhemDevName}, $readingName); + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$readingName=")) + } + } else { + # delete reading if value is not found + readingsDelete($defs{$fhemDevName}, $readingName); + return($hash, 200, 'close', "text/plain; charset=utf-8", encode($encoding, "$readingName=")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > attribute reading is missing")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > action $apiCmd unknown")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > device $fhemDevName unknown, disabled or ignored by the user")) + } + } else { + return($hash, 400, 'close', "text/plain; charset=utf-8", encode($encoding, "error=400 Bad Request, $request > attribute device missing")) + } + + } else { + return HTTPAPI_CommandRef($hash); + } + return; +} + +sub HTTPAPI_CommandRef { + my ($hash) = @_; + my $fileName = $gPath . '/02_HTTPAPI.pm'; + my ($err, @contents) = FileRead({FileName => $fileName, ForceType => 'file'}); + return ($hash, 404, 'close', "text/plain; charset=utf-8", encode($encoding, "error=404 Not Found, file $fileName not found")) if ($err); + my $contents = join("\n", @contents); + $contents =~ /\n=begin.html([\s\S]*)\n=end.html/gs; + return ($hash, 200, 'close', "text/html; charset=utf-8", encode($encoding, $1)); +} + +sub HTTPAPI_Read { + my ($hash) = @_; + my $name = $hash->{NAME}; + + # accept request and create a child + if ($hash->{SERVERSOCKET}) { + my $chash = TcpServer_Accept($hash, "HTTPAPI"); + return if (!$chash); + $chash->{encoding} = $encoding; + $chash->{cname} = $name; + $chash->{CD}->blocking(0); + return; + } + + # read data + my $buf; + my $ret = sysread($hash->{CD}, $buf, 2048); + + # connection error, 0=EOF, undef=error + if (!defined($ret) || $ret <= 0) { + TcpServer_Close($hash, 1); + delete $hash->{BUF}; + return; + } + + $hash->{BUF} .= $buf; + my $content = ''; + my $contentType = ''; + + while(exists($hash->{BUF}) && length($hash->{BUF}) > 0) { + if (!$hash->{HDR}) { + last if ($hash->{BUF} !~ m/^(.*?)(\n\n|\r\n\r\n)(.*)$/s); + $hash->{HDR} = $1; + $hash->{BUF} = $3; + if ($hash->{HDR} =~ m/Content-Length:\s*([^\r\n]*)/si) { + $hash->{CONTENT_LENGTH} = $1; + } + if ($hash->{HDR} =~ m/Content-Type:\s*([^\r\n]*)/si) { + $contentType = $1; + } + } + + if ($hash->{CONTENT_LENGTH}) { + return if (length($hash->{BUF}) < $hash->{CONTENT_LENGTH}); + $content = substr($hash->{BUF}, 0, $hash->{CONTENT_LENGTH}); + $hash->{BUF} = substr($hash->{BUF}, $hash->{CONTENT_LENGTH}); + } + my @httpheader = split(/[\r\n]+/, $hash->{HDR}); + my ($method, $url, $httpvers) = split(" ", $httpheader[0], 3) if ($httpheader[0]); + $method = "" if (!$method); + delete ($hash->{HDR}); + + if ($method !~ m/^(GET|POST)$/i) { + $ret = HTTPAPI_TcpServerWrite($hash, 405, 'close', "text/plain; charset=utf-8", "error=405 Method Not Allowed"); + delete $hash->{CONTENT_LENGTH}; + next; + } elsif ($method eq 'POST' && $contentType ne 'application/xml') { + $ret = HTTPAPI_TcpServerWrite($hash, 400, 'close', "text/plain; charset=utf-8", "error=400 Bad Request"); + delete $hash->{CONTENT_LENGTH}; + next; + } elsif ($url eq "/favicon.ico") { + $ret = HTTPAPI_TcpServerWrite($hash, 404, 'close', "text/plain; charset=utf-8", "error=404 Not Found"); + delete $hash->{CONTENT_LENGTH}; + next; + } elsif ($url !~ m/\/$infix\//i) { + $ret = HTTPAPI_TcpServerWrite($hash, 400, 'close', "text/plain; charset=utf-8", "error=400 Bad Request - wrong infix"); + delete $hash->{CONTENT_LENGTH}; + next; + } else { + $url =~ m/\/$infix\/(.*)\?(.*)$/i; + my ($requestCmd, $cmdString) = ($1, $2); + # CGI Aufruf + $ret = HTTPAPI_TcpServerWrite(HTTPAPI_CGI($hash, $hash->{cname}, $url)); + delete $hash->{CONTENT_LENGTH}; + next: + } + + } + TcpServer_Close($hash, 1); + delete $hash->{BUF}; + return; +} + +sub HTTPAPI_TcpServerWrite { + my ($hash, $httpState, $connection, $contentType, $content) = @_; + my ($contentLength, $header) = (0, "HTTP/1.1 "); + my %httpState = ( + 200 => '200 OK', + 400 => '400 Bad Request', + 403 => '403 Forbidden', + 404 => '404 Not Found', + 405 => '405 Method Not Allowed', + 503 => '503 Service Unavailable' + ); + $content = $content // ''; + $contentLength = length($content); + $header .= "$httpState{$httpState}\r\nContent-Length: $contentLength\r\n"; + $header .= "Allow: GET, POST\r\n" if ($httpState == 405); + $header .= "Connection: $connection\r\n" if (defined $connection); + $header .= "Content-Type: $contentType\r\n" if (defined $contentType); + $header .= "\r\n"; + return TcpServer_WriteBlocking($hash, $header . $content); +} + +sub HTTPAPI_Undef { + my ($hash) = @_; + return TcpServer_Close($hash, 1); +} + +1; + +=pod +=item device +=item summary HTTP API server that executes set/get commands and sets/reads readings +=item summary_DE HTTP API-Server, der set-/get-Befehle ausführt und Readings setzt/liest +=begin html + + +

HTTPAPI

+
    + HTTPAPI is a compact HTML API server that performs http requests to execute set and get commands + and reads and writes readings.

    + + + Define +
      + define <name> HTTPAPI [infix=][<apiName>] [port=][[IPV6:]<port>] [global=][global|local|<hostname>]

      + + Defines the HTTP API server.
      +
        +
      • + <apiName> is the portion behind the base URL (usually http://<hostname>:<port>/<apiName>).
        + [<apiName>] = api is default. +
      • +
      • + [[IPV6:]<port>] = 8087 is default.
        + To use IPV6, specify the portNumber as IPV6:<number>, in this case the perl module IO::Socket:INET6 will be requested. + On Linux you may have to install it with cpan -i IO::Socket::INET6 or apt-get libio-socket-inet6-perl. +
      • +
      • + [global|local|<hostname>] = global is default.
        + If the parameter is set to local, the server will only listen to localhost connections. If is set to global, the server + will listen on all interfaces, else it wil try to resolve the parameter as a hostname, and listen only on this interface. +
      • +
      +
      + + Example: +
        + define httpapi HTTPAPI (Configuration with default values)
        + or
        + define httpapi HTTPAPI api 8087 global
        + or
        + define httpapi HTTPAPI infix=api1 port=8089 global=local
        +
      +
    +

    + + + Get +
      +
    • API command line for executing a get command
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/get?device=<devname>&action=<cmd>
        +
      + Response: +
        + <action>=<response>|error=<error message>
        +
      +
    • +
    +

    + + + Set +
      +
    • API command line for executing a set command
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/set?device=<devname>&action=<cmd>
        +
      + Response: +
        + <device>=<cmd>|error=<error message>
        +
      +
    • +
    +

    + + + Generated events +
      +
    • API command line for setting a reading
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/write?device=<devname>&reading=<name>&value=<val>
        +
      + Response: +
        + <reading name>=<val>|error=<error message>
        +
      +
    • +
    • API command line for querying a reading
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/read?device=<devname>&reading=<name>
        +
      + Response: +
        + <reading name>=<val>|error=<error message>
        +
      +
    • +
    • API command line for querying the timestamp of a reading
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/readtimestamp?device=<devname>&reading=<name>
        +
      + Response: +
        + <reading name>=<timestamp>|error=<error message>
        +
      +
    • +
    • API command line for deleting a reading
      + Request: +
        + http://<ip-addr>:<port>/<apiName>/write?device=<devname>&reading=<name>&value=
        + http://<ip-addr>:<port>/<apiName>/write?device=<devname>&reading=<name>
        +
      + Response: +
        + <reading name>=|error=<error message>
        +
      +
    • +
    +

    + + Usage information +
      +
    • All links are relative to http://<ip-addr>:<port>/.
    • +
    • Commands are not executed if the disable or ignore attribute of the device is set. See also devicesCtrl.
    • +
    • The http://<ip-addr>:<port>/<apiName>/ command displays the module-specific commandref.
    • +
    • The response message is encoded to UTF-8.
    • +
    +

    + + + Attributes +
      +
    • devicesCtrl <device_1>,...,<device_n>, + A comma separated list all devices to be controlled.
      + [devicesCtrl] = < > is default, all devices can be controlled. +
    • +
    • disable 0|1
      + If applied commands will not be executed. +
    • +
    • verbose 0...5 +
    • +
    +
+ +=end html +=cut