Sonntag, 15. Oktober 2017

FHEM Anwesenheitserkennung mit FritzBox-WLAN

Für die Anwesenheitserkennung meiner HomeAutomation setze ich schon lange auf die WLAN Assoziierungen im WLAN Router (OpenWRT: 'iw dev wlan1 station dump' bzw. Fritz!Box: 'ctlmgr_ctl r wlan status/wlanlist'). Nachdem in ab Fritz!OS 6.25 Telnet auf die Fritz!Box aus Sicherheitsgründen nicht mehr unterstützt wird, kommt das moderner TR-064 mit SOAP zum Einsatz.

Da ich in einer Wohnung mit vielen Heizkörpern günstige MAX! Heizkörperventile einsetze, bin ich auch wieder zum Nutzer der FHEM Homeautomatisierungssoftware geworden.
FHEM Anwesenheitsstatus

Vieles, was ich sonst per BASH Script und Crontabs gelöst hatte, lässt sich so sauber per Webfrontend umsetzen. Es gibt für alle erdenklichen Anforderungen ein Modul - so z.B. auch für die Fritz!Box.
Allerdings scheint das Modul 72_FRITZBOX.pm noch nicht so richtig in der "neuen" SOAP Zeit angekommen zu sein. So wird TR-064 zwar für Nachrichten unterstützt, aber vieles scheint noch über die Fernsteuerung der Web-Oberfläche zu laufen.
Für die meisten Leute funktioniert das ja auch und bringt darüber hinaus eine Unmenge weiterer Funktionen mit sich.

Für mich disqualifizierte sich das Modul dadurch, dass ich das Polling-Interval auf minimal 60 Sekunden stellen konnte (und auch ständig Events generierte - jede Signalstärkenänderung eines Geräts = neuer Event). Also habe ich eine eigene kleine Anwesenheitserkennung per Perl geschrieben, die per HTTP Aufruf mit FHEM kommuniziert.

In FHEM habe ich zunaächst ein RESIDENTS "Gerät" angelegt und zwei ROOMMATEs.
define Familie RESIDENTS
define Jennifer ROOMMATE Familie
attr Jennifer rr_autoGoneAfter 1
define Hansi ROOMMATE Familie
attr Hansi rr_autoGoneAfter 1

Das schöne an diesen Modulen sind verschiedene Eigenschaften und Aktionen, die sich ohne Notifys auf das Resident Gerät auswirken - z.B. die automatische vollständige Abwesenheit nach einer Stunde (in meiner Konfiguration). Man könnte auch auf die Roomate Geräte verzichten, aber dann hat man z.B. keine Auto-Abwesenheit.

Nun kann ich die Anwesenheit einfach per HTTP GET ändern:
curl -s http://localhost:8083/fhem?cmd=set%20Hansi%20home
curl -s http://localhost:8083/fhem?cmd=set%20Hansi%20absent

Fehlt noch der Code, um den WLAN Router abzufragen. Auf einem OpenWRT Router könnte man z.B. folgenden Code einsetzen:
#!/bin/sh

STATION="a1:b1:c1:d1:e1:f1|a2:b2:c2:d2:e2:f2|a3:b3:c3:d3:e3:f3|a4:b4:c4:d4:e4:f4"
STATE=""
COUNT=0
MAXCOUNT=3

while [ true ] ; do
 PRESENCE=`iw dev wlan1 station dump | grep -E -i "$STATION"`
 echo $PRESENCE
 if [ -n "$PRESENCE" ] ; then
  if [ -z "$STATE" ] ; then
   wget -q -O - http://fhemhost:8083/fhem?cmd=set%20Familie%20home > /dev/null 2>&1
   STATE="here"
  fi
  COUNT=0
 else
  if [ -n "$STATE" ] ; then
   if [ $COUNT -ge $MAXCOUNT ] ; then
    wget -q -O - http://fhemhost:8083/fhem?cmd=set%20Familie%20absent > /dev/null 2>&1
    STATE=""
   else
    COUNT=$((COUNT+1))
   fi
  fi
 fi
 sleep 10
done

Um eine Fritz!Box per TR064 abzufragen, benutze ich SOAP::Lite in Perl (siehe auch mein letzter Fritz!Box TR064 Beitrag).
Ursprünglich hatte ich versucht, tatsächlich nur die Assoziierten WLAN Geräte abzufragen, allerdings ist die Liste, die man mit GetTotalAssociations() erhält nicht immer vollständig.
Selbst das direkte Abfragen von GetGenericAssociatedDeviceInfo(NewAssociatedDeviceMACAddress) ergab bei offensichtlich verbundenen und aktiven Geräten einen NoSuchEntryInArray Fehler.
Daher gehe ich einen ähnlichen Weg wie in der Unbound Host Extrahierung und benutze die Host Informationen der Box. Das hat auch den Vorteil, aktuell (FRITZ!OS: 06.90) ohne Authentifizierung an der Fritz!Box auzukommen.
#!/usr/bin/perl -wT

use strict;
use SOAP::Lite; # +trace => 'debug', readable => 1;
use LWP::Simple;

# MACs in lower case!!!
my %station = (
                'a1:b1:c1:d1:e1:f1' => 'Jennifer',
                'a2:b2:c2:d2:e2:f2' => 'Hansi',
);

# wait for interval before a device becomes absent
my $maxinterval = 3;

my %presence = (
                'Jennifer' => 0,
                'Hansi'    => 0,
);

my $fritzboxIP = 'fritz.box';
my $fritzboxPort = '49000';
my $login = 'smarthome';
my $password = 'geheim';
my $sleep = 10;

my $client = SOAP::Lite
     -> uri( 'urn:dslforum-org:service:Hosts:1' )
     -> proxy( 'http://'.$fritzboxIP.':'.$fritzboxPort.'/upnp/control/hosts' );

while( 1 )
{
 eval
 {
  foreach my $mac (keys %station)
  {
   my $host  = $client->GetSpecificHostEntry(SOAP::Data->type('string')->name('NewMACAddress')->value($mac))->valueof('//GetSpecificHostEntryResponse');

   if( $host->{'NewActive'} == 1)
   {
#   print( $mac ."\n" );

    if( ! $presence{$station{$mac}} )
    {
     print( 'Anwesenheit erkannt fuer: '. $station{$mac} ."\n" );
     get('http://localhost:8088/fhem?cmd=set%20'. $station{$mac} .'%20home');
    }
    $presence{$station{$mac}} = $maxinterval;
   }
  }

  foreach my $p ( keys %presence )
  {
   if( $presence{$p} )
   {
    $presence{$p}--;
    if( ! $presence{$p} )
    {
     print( 'Abwesenheit erkannt fuer: '. $p ."\n" );
     get('http://localhost:8088/fhem?cmd=set%20'. $p .'%20absent');
    }
   }
  }
 } or do {
   if( $@ ){ print( "Error: $@\n" ); }
 };
 sleep( $sleep );
}

# in case authentication is needed
sub SOAP::Transport::HTTP::Client::get_basic_credentials {
     return $login => $password;
}

Update (2017-11-07): Auf einer Fritz!Box mit FREETZ

Sollte die Fritz!Box unter Freetz laufen, kann das Script natürlich auch wieder direkt auf der Fritzbox den WLAN Status überwachen:
#!/bin/sh

NAME="Jennifer Hansi"
STATION="A1:B1:C1:D1:E1:F1 A2:B2:C2:D2:E2:F2"

SLEEP=10
MAXINTERVAL=3

######################################################

IDX=0
for N in $NAME ; do
 IDX=$((IDX+1))
 eval NAME_${IDX}=$N
 eval STATE_${IDX}=0
 eval COUNT_${IDX}=0
done

IDX=0
for S in $STATION ; do
 IDX=$((IDX+1))
 eval STATION_${IDX}=$S
done


while [ true ] ; do
 C=0
 FOUND=0
 MAX=$(ctlmgr_ctl r wlan status/wlanlist/count)
 while [ $C -lt $MAX ] ; do
  D=$(ctlmgr_ctl r wlan status/wlanlist${C}/state)
  if [ $D -gt 0 ] ; then
   MAC=$(ctlmgr_ctl r wlan status/wlanlist${C}/mac)
   for I in $(seq 1 $IDX) ; do
    if [ "$MAC" = "$(eval echo \$STATION_${I})" ] ; then
     if [ $(eval echo \$STATE_${I}) -eq 0 ] ; then
#      echo "$(eval echo \$NAME_${I}) home"
      wget -q -O - "http://fhemhost:8083/fhem?cmd=set%20$(eval echo \$NAME_${I})%20home" > /dev/null
      eval STATE_${I}=1
     fi
     eval COUNT_${I}=0
     FOUND=$((FOUND+1))
     if [ $FOUND -ge $IDX ] ; then
      C=$MAX
     fi
    fi
   done
  fi
  C=$((C+1))
 done

 for I in $(seq 1 $IDX) ; do
  if [ $(eval echo \$STATE_${I}) -gt 0 ] ; then
   if [ $(eval echo \$COUNT_${I}) -gt $MAXINTERVAL ] ; then
#    echo "$(eval echo \$NAME_${I}) absent"
    wget -q -O - "http://fhemhost:8083/fhem?cmd=set%20$(eval echo \$NAME_${I})%20absent" > /dev/null
    eval STATE_${I}=0
   else
    eval COUNT_${I}=$(($(eval echo \$COUNT_${I})+1))
   fi
  fi
 done

 sleep $SLEEP
done