Mittwoch, 20. Februar 2019

FHEM HTTP Client

Der eingebaute Client Modus in der fhem.pl funktioniert nur über die Telnet Schnittstelle von FHEM, diese wird aber per default nicht gar nicht mehr definiert.
Ich habe mal in 3 Varianten einen FHEM Client gebaut: als Bash-, Powershell- und Perlscript.

Der FHEM Client verfügt einheitlich über folgende Möglichkeiten:
  • Angabe komplette URL oder nur Portnummer (lokaler Zugriff) [http://<Username>:<Password>@<hostName>:]<portNummer>
    • Zugriff über Standard WEB sofort nach FHEM Installation möglich, csrf Token wird verwendet.
    • Angabe von Basic Auth in der URL möglich.
  • FHEM Befehle als:
    • Argument(e) (analog fhem.pl)
    • Dateiname der Befehlsdatei mit Codeschnipseln (z.B. ganz oder teilweise fhem.cfg).
    • Zeilen über die Pipeline (cat Befehlsdatei | fhemcl 8083)
    • Mehrzeilige Definitionen (Zeilenende mit "\")werden verarbeitet.
  • Ausgabe der FHEM Antwort, z.B. bei list Befehlen, analog zum Client Modus in fhem.pl
  • Kurze Hinweise zur Verwendung
Getestet habe ich die Bash und Perl Scripts unter Raspbian, das Powershell Script unter Windows 10. Mit Sicherheit ist es noch nicht völlig frei von bugs! Verwendung also auf eigene Gefahr!

Verwendung

Ich habe alle Scripte auf GitHub abgelegt und halte sie dort auch aktuell.
Am einfachsten kann man sich die Scripts per Download auf das System holen, das geht ziemlich einheitlich (auch unter Windows/Powershell) mit wget:
wget -OutFile fhemcl.ps1 https://raw.githubusercontent.com/heinz-otto/fhemcl/master/fhemcl.ps1
wget -O fhemcl.pl https://raw.githubusercontent.com/heinz-otto/fhemcl/master/fhemcl.pl
wget -O fhemcl.sh https://raw.githubusercontent.com/heinz-otto/fhemcl/master/fhemcl.sh
Anmerkung. -OutFile anstatt -O braucht man auf älteren Windows Systemen.

Allgemeiner Ablauf

  1. Es wird zunächst das erste Argument getestet, dies muss vorhanden sein. Es erfolgt ein Test ob nur die Portnummer angegeben wurde, diese wird um localhost erweitert ansonsten wird die url ohne weitere Prüfung übernommen. 
  2. Bei Powershell wird aus username:password noch ein extra "Credential" String gemacht, da Invoke-Webrequest die url nicht einfach so verarbeitet wie curl.
  3. Der csrfToken wird extrahiert und gespeichert.
  4. Dann wird ein Array mit den FHEM Befehlen gebildet, dazu wird 
    • die Pipeline getestet und gelesen
    • das zweite Argument gelesen und auf Dateiname getestet
    • entweder die Datei oder weitere Argumente eingelesen 
  5. Zum Schluss wird eine Schleife über das Befehlsarray abgearbeitet, die Befehle url-encoded  und an den FHEM Server übergeben und die Antwort ausgewertet.
  6. Wieder was dazu gelernt: Wenn man an den HTTP Aufruf &XHR=1 anhängt wird nur Text und kein HTML ausgeliefert. Von der FHEM Antwort wird der Inhalt zwischen <div id='content' > und </div> ausgefiltert und von überflüssigen <pre>/</pre> Zeilen und HTML Tags befreit. Die Ausgabe entspricht der des fhem.pl Clients bzw. der sichtbaren Ausgabe im Browser Fenster.

Die Scripts

Der folgende Code ist Stand der Veröffentlichung. Bitte unbedingt nach aktuellem Code im GitHub schauen!
Bash Variante
#!/bin/bash
# Heinz-Otto Klas 2019
# send commands to FHEM over HTTP
# if no Argument, show usage

if [ $# -eq 0 ]
then
     echo 'fhemcl Usage'
     echo 'fhemcl [http://<hostName>:]<portNummer> "FHEM command1" "FHEM command2"'
     echo 'fhemcl [http://<hostName>:]<portNummer> filename'
     echo 'echo -e "set Aktor01 toggle" | fhemcl [http://<hostName>:]<portNumber>'
     exit 1
fi

# split the first Argument
IFS=:
arr=($1)

# if only one then use as portNumber
# or use it as url
IFS=
if [ ${#arr[@]} -eq 1 ]
then
    if [[ `echo "$1" | grep -E ^[[:digit:]]+$` ]]
    then
        hosturl=http://localhost:$1
    else
        echo "$1 is not a Portnumber"
        exit 1
    fi
else
    hosturl=$1
fi

# get Token 
token=$(curl -s -D - "$hosturl/fhem?XHR=1" | awk '/X-FHEM-csrfToken/{print $2}')

# reading FHEM command, from Pipe, File or Arguments 
# Check to see if a pipe exists on stdin.
cmdarray=()
if [ -p /dev/stdin ]; then
        echo "Data was piped to this script!"
        # If we want to read the input line by line
        while IFS= read -r line; do
              cmdarray+=("${line}")
        done
else
        # Checking the 2 parameter: filename exist or simple commands
        if [ -f "$2" ]; then
            echo "Reading File: ${2}"
            readarray -t cmdarray < ${2}
        else
        echo "Reading further parameters"
        for ((a=2; a<=${#}; a++)); do
            echo "command specified: ${!a}"
            cmdarray+=("${!a}")
        done
        fi
fi

# loop over all lines stepping up. For stepping down (i=${#cmdarray[*]}; i>0; i--)
for ((i=0; i<${#cmdarray[*]}; i++));do 
    # concat def lines with ending \ to the next line
    cmd=${cmdarray[i]}
    while [ ${cmd:${#cmd}-2:1} = '\' ];do 
          ((i++))
          cmd=${cmd::-2}$'\n'${cmdarray[i]}
    done
    echo "proceeding Line $i : "${cmd}
    # urlencode loop over String
    cmdu=''
    for ((pos=0;pos<${#cmd};pos++)); do
        c=${cmd:$pos:1}
        [[ "$c" =~ [a-zA-Z0-9\.\~\_\-] ]] || printf -v c '%%%02X' "'$c"
        cmdu+="$c"
    done
    cmd=$cmdu
    # send command to FHEM and filter the output (tested with list...).
    # give only lines between, including the two Tags back, then remove all HTML Tags 
    curl -s --data "fwcsrf=$token" $hosturl/fhem?cmd=$cmd | sed -n '/<pre>/,/<\/pre>/p' |sed 's/<[^>]*>//g'
done

Perl Variante
#!/usr/bin/env perl
# Heinz-Otto Klas 2019
# send commands to FHEM over HTTP
# if no Argument, show usage

use strict;
use warnings;
use URI::Escape;
use LWP::UserAgent;

my $token;
my $hosturl;
my $fhemcmd;

 if ( not @ARGV ) {
     print 'fhemcl Usage',"\n";
     print 'fhemcl [http://<hostName>:]<portNummer> "FHEM command1" "FHEM command2"',"\n";
     print 'fhemcl [http://<hostName>:]<portNummer> filename',"\n";
     print 'echo -e "set Aktor01 toggle" | fhemcl [http://<hostName>:]<portNumber>',"\n";
     exit;
 }

if ($ARGV[0] !~ m/:/) {
   if ($ARGV[0] eq ($ARGV[0]+0)) { # isnumber?
       $hosturl = "http://localhost:$ARGV[0]";
   }
   else {
       print "$ARGV[0] is not a Portnumber";
       exit(1);
   }
}
else {
    $hosturl = $ARGV[0];
}

# get token 
my $ua = new LWP::UserAgent;
my $url = "$hosturl/fhem?XHR=1/";
my $resp = $ua->get($url);
   $token = $resp->header('X-FHEM-CsrfToken');

my @cmdarray ;

# test the pipe and read 
if (-p STDIN) {
   while(<STDIN>) {
       chomp($_);
       push(@cmdarray,$_);
   }
}
# second Argument is file or command?
if ($ARGV[1] and -e $ARGV[1]) {
    open(DATA, '<', $ARGV[1]);
    while(<DATA>) {
       s/\r[\n]*/\n/gm;      #remove any \r 
       chomp($_);
       push(@cmdarray,$_);
    }
    close(DATA);
}
else {
    for(my $i=1; $i < int(@ARGV); $i++) {
    push(@cmdarray, $ARGV[$i]);
    }
}
#execute commands and print response from FHEMWEB 
for(my $i = 0; $i < @cmdarray; $i++) {
    # concat def lines with ending \ to the next line
    my $cmd = $cmdarray[$i];
    while ($cmd =~ m/\\$/) {
        $i++;
        $cmd = substr($cmd,0, -1)."\n".$cmdarray[$i];
    };
    # url encode the cmd
    $fhemcmd = uri_escape($cmd);
    print "proceeding line $i : $fhemcmd\n";
    $url = "$hosturl/fhem?cmd=$fhemcmd&fwcsrf=$token";
    $resp = $ua->get($url)->content;
    # only between the lines <pre></pre> and remove any HTML Tag
    #funktioniert noch nicht sauber bei massenimport
    my @resparray = split("\n", $resp);
    foreach my $zeile(@resparray){
        if ($zeile !~ /<[^>]*>/ or $zeile =~ /pre>/ or $zeile =~ /NAME/) {
           $zeile =~ s/<[^>]*>//g;
           print "$zeile\n" ;
        }
    }
}

Powershell Variante
<#
.SYNOPSIS
    This Script is a FHEM Client for HTTP
.DESCRIPTION
    FHEM commands could given over the Pipe, Arguments or File.
.EXAMPLE
    fhemcl [http://<hostName>:]<portNummer> "FHEM command1" "FHEM command2"
    fhemcl [http://<hostName>:]<portNummer> filename
    echo "FHEM command"|fhemcl [http://<hostName>:]<portNummer>
.NOTES
    put every FHEM command line in ""
#>
#region Params
param(
    [Parameter(Mandatory=$true,Position=0,HelpMessage="-first 'Portnumber or URL'")]
    [String]$first,
    [Parameter(ValueFromPipeline=$true,ValueFromRemainingArguments=$true)]
    [String[]]$sec
)
#endregion 

# if only one element the use as portNumber
# or use as hosturl
$arr = $first -split ':'
if ($arr.Length -eq 1){
   if ($first -match '^\d+$') {$hosturl="http://localhost:$first"}
       else {
           write-output "is not a Portnumber"
           exit
            }
 } else {$hosturl=$first}
# url contains usernam@password?
if ($arr.Length -eq 4){
     $username = $($arr[1] -split'//')[1]
     $password = $($arr[2] -split '@')[0]
     $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $username,$password)))
     $headers = @{
     Authorization=("Basic {0}" -f $base64AuthInfo)
     }

     # cut the account from hosturl 
     $hosturl=$arr[0] + "://"+$($arr[2] -split '@')[1] +":" + $arr[3]
}
# get Token
$token = Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri "$hosturl/fhem?XHR=1" | %{$_.Headers["X-FHEM-csrfToken"]}

# reading commands from Pipe, File or Arguments 
# clear cmdarray and save the Pipeline,
# $input contains all lines from pipeline, $sec contains the last line
$cmdarray=@()
foreach ($cmd2 in $input){$cmdarray += $cmd2}
if ($cmdarray.length -eq 0) {
     if((Test-Path $sec) -And ($sec.Length -eq 1)) {$cmdarray = Get-Content $sec} 
     else {foreach ($cmd2 in $sec){$cmdarray += $cmd2}}
}
# send all commands to FHEM
# there is still an error message with Basic Auth and commands like set Aktor01 ..  e.g. list is without any error.

for ($i=0; $i -lt $cmdarray.Length; $i++) {
   # concat def lines with ending \ to the next line
   $cmd = $cmdarray[$i]
   while($cmd.EndsWith('\')) {$cmd=$cmd.Remove($cmd.Length - 1,1) + "`n" + $cmdarray[$i+1];$i++}
   write-output "proceeding line $($i+1) : $cmd"
   # url encode
   $cmd=[System.Uri]::EscapeDataString($cmd)
   $web = Invoke-WebRequest -Uri "$hosturl/fhem?cmd=$cmd&fwcsrf=$token" -Headers $headers
   if ($web.content.IndexOf("<pre>") -ne -1) {$web.content.Substring($web.content.IndexOf("<pre>"),$web.content.IndexOf("</pre>")-$web.content.IndexOf("<pre>")) -replace '<[^>]+>',''}
}

Benchmark/Leistung

Ich habe mal Schleifen mit 10 gleichen Befehlen (set Aktor01 toggle) abgesetzt um zu sehen ob es große Unterschiede bei den realisierten Scripts gibt.

time for ((i=0; i<10; i++));do perl fhemcl.pl http://raspib:8083 "set Aktor01 toggle";done
time for ((i=0; i<10; i++));do bash fhemcl.sh http://raspib:8083 "set Aktor01 toggle";done
Measure-Command {for ($i=1; $i -le 10; $i++) {.\fhemcl.ps1 http://raspib:8083 "set Aktor01 toggle"}}
Die Bash und Powershell Variante läuft etwa gleich schnell ab und braucht für die 10 Durchläufe etwas über 3 sec. Die Perl Variante benötigt je nach Platform (Pi1/Pi2/Pi3) 2 bis 10 mal solange.

Die Telnet Schnittstelle ist wesentlich schneller! Nimmt man die obige Schleife und schickt sie remote an einen Pi3, dann braucht diese Schleife noch 0.5 Sekunden. Lokal auf einem PiB ausgeführt, sind die Unterschiede wieder minimal.
time for ((i=0; i<10; i++));do echo "set Aktor01 toggle"|nc raspib3plus 7072;done
Telnet ist schnell definiert, und wenn man es nicht mehr braucht wieder gelöscht.
define telnetPort telnet 7072 global