# $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->{infix} = $hash->{INFIX}; $chash->{CD}->blocking(0); return; } $infix = $hash->{infix}; # 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