Ist das noch normal? (Linux-Magazin, Oktober 2010)

Beim Überwachen der Zimmertemperatur mit einem preiswerten USB-Temperaturfühler bietet das Holt-Winters-Verfahren aus dem rrdtool-Werkzeugkasten einen Ansatz, normale Schwankungen von Ausreißern zu unterscheiden.

Per USB eingestöpselte Zusatzgeräte verlangten von experimentierfreudigen Linuxlern noch vor einigen Jahren heftige Klimmzüge. Der Bastler musste passende Treiber aufstöbern oder selbst schreiben, und diese dann in den Kernel einbinden. Heutzutage funktioniert es mit aktuellen Distributionen erstaunlich oft völlig automatisch. Ein neulich für 7 Dollar (inklusive Versand) auf Ebay gekauftes TEMPer USB Thermometer [2] funktionierte sofort und ohne dass ich auch nur die Bedienungsanleitung durchlesen musste.

Abbildung 1: Der preisgünstige Temperaturfühler "TEMPer USB Thermometer"

Sofort erkannt

Beim Einstöpseln des Fühlers in einen USB-Port des Rechners erkennt der Kernel das Device als generisches HID (Human Interface Device) und weist ihm den "rohen" Treiber hidraw zu. Dieser Allerweltstreiber kommuniziert mit unterschiedlichen Geräten, kennt aber deren Eigenheiten nicht. Die Bit-Pfrümelei, die zur Verständigung zwischen Hardwarekomponenten notwendig ist, läuft in diesem Fall über einen Treiber im Userspace ab, den das CPAN-Modul Device::USB::PCSensor::HidTEMPer implementiert.

Abbildung 2: Beim Einstöpseln des Temperaturfühlers erkennt Ubuntu das neue USB-Device korrekt.

Um die vom Fühler gemessene Temperatur auszulesen, sucht Listing 1 zunächst mit der Methode device() das zuständige Device im USB-Baum. Da nur ein Fühler eingestöpselt ist, kommt auch nur ein Eintrag zurück, bei mehreren Geräten gäbe die Methode list_devices() eine Liste aller verfügbaren Fühler zurück. Den internen Sensor des Geräts spricht die Methode internal() an und celsius() liefert die von ihm gemessene Temperatur als Fließkommazahl mit einer Auflösung von .5 Grad zurück.

Listing 1: celsius

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use local::lib;
    04 use Device::USB::PCSensor::HidTEMPer;
    05 
    06 my $temper = 
    07   Device::USB::PCSensor::HidTEMPer->new();
    08 
    09 my $sensor = $temper->device();
    10 
    11 if( defined $sensor->internal() ) {
    12   print "Temperature: ", 
    13     $sensor->internal()->celsius(),
    14     " C\n";
    15 }

Bitpfrümeln hinterm Vorhang

Ein Blick in den Source-Code des CPAN-Moduls offenbart, dass hinter der schnieken objektorientierten API, die einfach die ausgelesene Temperatur in Grad Celsius liefert, Bitwerte hin- und hersausen, Datenpuffer zusammengestellt und zerlegt, und Prüfsummen ermittelt werden. Damit Linux den Fühler beim Einstöpseln als solchen erkennt, liest es dessen Vendor-ID (1130) und Product-ID (660C) aus. So ist es egal, in welchem USB-Port oder an welchem USB-Hub der User das Kabel einsteckt. Das Perl-Modul Device::USB (beziehungsweise die dahintersteckende C-Library libusb) durchstöbert dazu den gesamten USB-Baum, bis es ein Gerät mit der gesuchten Kombination aus Hersteller- und Produkt-ID findet.

Minutiös Buchführen

Mit dem CPAN-Modul App::Daemon entsteht nun in Listing 2 ein Dämon-Prozess, den der Admin mit logtemp start und logtemp stop hoch- und wieder herunterfährt. Während er im Hintergrund läuft, legt er in der Logdatei /var/log/temper.log nach Abbildung 3 minütlich den aktuell ausgelesenen Temperaturwert ab. Das Log4perl-Framework schreibt noch einen Zeitstempel davor.

Der Befehl sudo_me() in Zeile 10 stammt aus dem CPAN-Modul Sysadm::Install und stellt sicher, dass das Skript als Superuser läuft, und startet sich selbst mit einem sudo-Aufruf, falls dies noch nicht der Fall ist. Root-Rechte sind notwendig, damit der Dämon die Logdatei in /var/log anlegen und seine Prozess-ID in /var/run/temper.pid speichern kann. Gleich darauf gibt App::Daemon die Privilegien aus Sicherheitsgründen ab und brummt anschließend unter der ID des in der Variablen $App::Daemon::as_user abgelegten Users weiter. Diesen User zieht das Skript aus der Environment-Variablen SUDO_USER, die das sudo-Kommando auf die ID des aufrufenden Users setzt.

Listing 2: logtemp

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use local::lib;
    04 use Device::USB::PCSensor::HidTEMPer;
    05 use App::Daemon 0.10 qw(daemonize);
    06 use Log::Log4perl qw(:easy);
    07 use Sysadm::Install qw(:all);
    08 use File::Basename;
    09 
    10 sudo_me();
    11 
    12 $ENV{SUDO_USER} ||= "mschilli";
    13 
    14 $App::Daemon::logfile =
    15   "/var/log/temper.log";
    16 $App::Daemon::pidfile =
    17   "/var/run/temper.pid";
    18 $App::Daemon::as_user = $ENV{SUDO_USER};
    19 
    20 daemonize();
    21 
    22 while(1) {
    23   my $temper = 
    24    Device::USB::PCSensor::HidTEMPer->new();
    25 
    26   my $sensor = $temper->device();
    27 
    28   if( defined $sensor->internal() ) {
    29     INFO "READ ", 
    30          $sensor->internal()->celsius();
    31   } else {
    32     ERROR "No reading available";
    33   }
    34 
    35   sleep 60;
    36 }

Der von App::Daemon bereitgestellte Befehl daemonize() schickt den Dämon in den Hintergrund, so dass der aufrufende User sich wieder dem Kommandozeilenprompt der Shell gegenüber sieht. Mit dem Befehl tail -f /var/log/temper.log kann er, wie in Abbildung 3 gezeigt, das Treiben des Dämons verfolgen. Zu Testzwecken lässt sich logtemp auch mit logtemp -X im Vordergrund hochfahren, dann erscheinen die Logmeldungen auf der Standardausgabe.

Abbildung 3: In der Logdatei legt der Thermo-Dämon nach dem Hochfahren minütlich einen Messwert ab.

Graphen statt Zahlenreihen

Messreihen in Logdateien eignen sich aber leider nur selten dazu, Euphorie oder Gehaltserhöhungen auszulösen, und so nimmt es nicht wunder, dass als nächster Schritt gleich die Darstellung in einem Graph folgt. Das Werkzeug rrdtool eignet sich hierfür hervorragend und wem die etwas altertümliche Syntax des Old-Timers missfällt, nutzt das Perl-Modul RRDTool::OO, das eine moderne objektorientierte Syntax bietet.

Um die Logmeldungen im menschenlesbaren Datumsformat "Jahr/Monat/Tag Stunde:Minute:Sekunde" in das von RRDTool geforderte Sekundenformat umzuformen, nutzt das Listing 3 das CPAN-Modul DateTime::Format::Strptime und definiert in Zeile 13 ein entsprechendes Erkennungs-Pattern. Zeile 14 stellt die Zeitzone der erfassten Einträge auf die des lokalen Rechners ein. Die While-Schleife ab Zeile 20 iteriert durch die Logzeilen und das Regex-Pattern in Zeile 21 erfasst Zeilen mit Temperatureinträgen und lässt andere, wie zum Beispiel Start- und Stopp-Nachrichten, außen vor.

Alle so gefundenen Messwerte speichert Listing 3 in einem Array @data_points, zusammen mit den jeweiligen Zeitstempeln. Ab Zeile 32 geht dann RRDTool zu Werke und definiert dann zunächst eine neue Round-Robin-Datenbank ([8]) mit genügend Einträgen für 5 Monate. Zur Glättung von Ausrutschern fasst es jeweils 5 Minutenwerte zu einem Datenbankwert zusammen, was sich über den step-Wert in Zeile 41 manifestiert.

Als Datentyp definiert es GAUGE, den Allerweltstyp für numerische Werte in rrdtool. Die for-Schleife ab Zeile 63 füttert dann die in @data_points zwischengespeicherten Werte mit ihren Zeitstempeln mittels der Methode update in die RRD-Datenbank. Der Aufruf der Methode graph() ab Zeile 71 zeichnet schließlich ein Diagramm nach Abbildung 4. Es beschriftet auch gleich die Achsen und sie skaliert sie entsprechend der Meßwerte und Datumsangaben. Bequemer geht es kaum!

Spitz, pass auf!

Das Auf und Ab im Graphen spiegelt die täglichen Schwankungen der Zimmertemperatur wider. Um aber festzustellen, ob ein Ausreißer wegen unvorhergesehener Ereignisse vorliegt (wenn sich zum Beispiel die Katze auf den Sensor legt oder das Gebäude in Flammen steht), genügt es nicht, absolute Werte zu vergleichen, da diese nicht konstant bleiben. RRDTool bietet deswegen die sogenannte "Aberrant Behavior Detection" an, die mittels 4 Parametern "normales" Verhalten vorhersagt und dann eintreffende Werte mit der Prognose vergleicht. Es lernt aus vergangenen Ereignissen und prognostiziert mit ihrer Hilfe die Zukunft. Stimmen in einem vordefinierten Zeitfenster eine definierbare Zahl von Vorhersagen nicht mit der Wirklichkeit überein, löst das System Fehler aus, die im Diagramm in Abbildung 4 rot eingezeichnet sind.

Abbildung 4 zeichnet die Messwerte schwarz, die Prognose grün und die erlaubte Bandbreite um die Prognose, in denen ein Messwert noch als "normal" gilt, blau. Alarme erscheinen als rote Linien am Fuß des Graphs.

Leider löst das Verfahren auch Fehlalarme aus (wie zum Beispiel am Mittag des dritten Tages) und auch richtige Fehler erkennt es nicht immer zuverlässig. Der Admin spielt dann solange an den 4 Knöpfen herum, bis sich ein zufriedenstellendes Ergebnis zeigt. Dies ist natürlich keine Garantie dafür, dass nicht schon am nächsten Tag wieder ein Fehlalarm ausgelöst wird, und das Drehen an den Knöpfen gleicht eher Zauberkunst als einer Ingenieurswissenschaft.

An Knöpfen drehen

Drehen darf der Admin an den Parametern alpha, beta und gamma (jeweils zwischen 0 und 1, ausschließlich), sowie an der Länge der"seasonal period", also dem Zeitraum, in dem sich Ereignisse wiederholen, wie zum Beispiel einem Tagesturnus für Temperaturen.

Kleine Werte (also nahe Null) für alpha, beta und gamma richten das Augenmerk auf Ereignisse, die schon etwas zurückliegen, während bei Werten nahe 1 die Vorhersage nahe an kürzlich gesichteten Werten liegt. Während alpha die Prognose des Basiswerts des Graphen kontrolliert, arbeitet beta mit der Steigung des Graphen. Gamma bestimmt die Progrose bei Wiederholungen in vordefinierten Zeitfenstern der Länge seasonal_period.

Listing 4 setzt alpha=0.1, beta=0.0035, gamma=0.5 und die Länge des säsonalen Zeitrahmens, seasonal_period, auf die Gesamtzahl der von RRD im Laufe eines Tages erfassten Messwerte.

Einen Fehler meldet das System, falls während eines window_length langen Zeitfensters eine Anzahl threshold oder mehr Messpunkte außerhalb des "confidence bands", also des blauen Bandes um die Prognose, liegen. Wie breit dieses Akzeptanzband genau ist, ermittelt RRDTool automatisch und lässt sich dabei weder in die Karten sehen noch beieinflussen.

Abbildung 4: Der Graph mit Holt-Winters-Forecasting, dem erlaubten Wertebereich innerhalb der Prognose, und ausgelösten Alarmen.

Einige Stellen im Graph stechen ins Auge: Während der ersten zwei Tage erstellt rrdtool keine Vorhersage, denn es benötigt einige Zyklen, bis die "seasonal component" und deren Einfluss auf die Prognose feststeht.

Lustigerweise erwartet das System nach dem Ausreißer kurz vor Mittag des vierten Tages auch an den darauffolgenden Tagen zur gleichen Zeit einen Höhepunkt, der aber ausbleibt. An den darauffolgenden Tagen geht deshalb die Erwartung schrittweise zurück, bis das System sich einige Zeit später wieder fängt.

Listing 3: rrdtemp

    001 #!/usr/bin/perl -w
    002 use strict;
    003 use local::lib;
    004 use RRDTool::OO;
    005 use DateTime::Format::Strptime;
    006 
    007 my $logfile     = "temper.log";
    008 my @data_points = ();
    009 my $rrd_file    = "data.rrd";
    010 
    011 my $date_fmt =
    012   DateTime::Format::Strptime->new(
    013     pattern   => "%Y/%m/%d %H:%M:%S",
    014     time_zone => "local",
    015 );
    016 
    017   # Read logged temperature data
    018 open FILE, "$logfile" or
    019     die "Cannot open $logfile ($!)";
    020 while( <FILE> ) {
    021   if( /(.*) READ (.*)/ ) {
    022     my($datestr, $temp) = ($1, $2);
    023 
    024     my $dt = 
    025       $date_fmt->parse_datetime($datestr);
    026     push @data_points, 
    027          [$dt->epoch(), $temp];
    028   }
    029 }
    030 close FILE;
    031 
    032   # Create RRD
    033 my $rrd = RRDTool::OO->new(
    034   file        => $rrd_file,
    035   raise_error => 1,
    036 );
    037 
    038 my $rows = 60*24*30;
    039 
    040 $rrd->create(
    041   step => 60*5,
    042   data_source => {
    043     name    => "temp",
    044     type    => "GAUGE" 
    045   },
    046   archive => { 
    047       rows => $rows,
    048       cpoints => 1,
    049       cfunc   => 'AVERAGE',
    050   },
    051   start => $data_points[0]->[0] - 60,
    052   hwpredict   => { 
    053       rows            => $rows,
    054       alpha           => 0.1,
    055       beta            => 0.0035,
    056       gamma           => 0.5,
    057       seasonal_period => 24*60/5,
    058       threshold       => 14,
    059       window_length   => 18,
    060   },
    061 );
    062 
    063 for my $data_point (@data_points) {
    064   $rrd->update(
    065       time  => $data_point->[0],
    066       value => $data_point->[1],
    067   );
    068 }
    069 
    070   # Draw Graph
    071 $rrd->graph(
    072   image => "bounds.png",
    073   width  => 1600,
    074   height => 800,
    075   start => $data_points[0]->[0],
    076   end   => $data_points[-1]->[0],
    077   draw => {
    078     type   => "line",
    079     color  => '000000',
    080     legend => "Temperature over Time",
    081     thickness  => 2,
    082     cfunc      => 'AVERAGE',
    083   },
    084   draw           => {
    085     type   => "line",
    086     color  => '00FF00',
    087     cfunc  => 'HWPREDICT',
    088     name   => 'predict',
    089     legend => 'hwpredict',
    090   },
    091   draw           => {
    092     type   => "hidden",
    093     cfunc  => 'DEVPREDICT',
    094     name   => 'dev',
    095   },
    096   draw           => {
    097     type   => "hidden",
    098     name   => "failures",
    099     cfunc  => 'FAILURES',
    100   },
    101   tick => {
    102     draw => "failures",
    103     color  => '#FF0000',
    104      legend => "Failures",
    105   },
    106   draw => {
    107     type   => "line",
    108     color  => '0000FF',
    109     legend => "Upper Bound",
    110     cdef   => "predict,dev,2,*,+",
    111   },
    112   draw => {
    113     type   => "line",
    114     color  => '0000FF',
    115     legend => "Lower Bound",
    116     cdef   => "predict,dev,2,*,-",
    117   },
    118 );

Was rrdtool unter der Haube so treibt, lässt sich durch Einschalten des Log4perl-Frameworks herausfinden, denn RRDTool::OO unterstützt das Verfahren und wartet nur darauf, bis der User es aktiviert. Abbildung 5 zeigt einen Blick in den Maschinenraum von RRDTool::OO, erst das Kommando, um die Datenbank anzulegen, dann eine Auswahl der abgesetzten update-Befehle für geloggte Temperaturwerte, und schließlich das graph-Kommando, das das Diagramm zeichnet.

Abbildung 5: RRDTool-Kommandos für den Graphen in Abbildung 4, den das Skript mit RRDTool::OO erzeugt.

Die Holt-Winters-Vorhersage nennt rrdtool HWPREDICT und die erwartete statistische Abweichung DEVPREDICT. Zeile 92 in Listing 3 definiert auf letztere einen Alias dev, den die Zeilen 110 und 116 jeweils aufgreifen, um das erlaubte Streuband zu zeichnen. In RRDTool-typischer RPM-Notation steht "predict,dev,2,*,+" für "predict - 2 * dev" in algebraischer Notation, denn RRDTool erlaubt Abweichungen nach oben und unten, jeweils im Wert des doppelten DEVPREDICT-Wertes.

Installation

Da das benötigte CPAN-Modul nicht als Ubuntu-Paket verfügbar ist, installiert es der auf Sauberkeit bedachte Systemadministrator nicht unter /usr, sondern nutzt local::lib, um es unter dem Home-Verzeichnis einzupflanzen. Mit

    sudo apt-get install liblocal-lib-perl

installiert er dazu unter Ubuntu Lucid Lynx das Modul local::lib unter /usr und ruft anschließend die CPAN-Shell mit

    perl -Mlocal::lib -MCPAN -eshell

auf. Darin startet dann der Befehl "install Device::USB::PCSensor::HidTEMPer" den Download und die Installation des Modules unter dem Verzeichnis "perl5" im Home-Verzeichnis. Das Skript in Listing 1 sucht wegen der Anweisung "use local::lib" auch dort nach dem Modul.

Abbildung 6: Eine neue Datei in /etc/udev/rules.d weist das Udev-System an, neu erscheinende Temperaturfühler im Modus 666 bereitzustellen.

Ohne zusätzliche Tricks darf nur root den Sensor auslesen, wer aber unter /etc/udev/rules.d eine Datei 99-tempsensor.rules anlegt und die in Abbildung 6 gezeigten Variablen einträgt, stellt sicher, dass auch unpriviligierte User die Temperaturwerte abholen können. Nach dem Editieren der Rules-Datei ist ein Neustart des udev-Subsystems erforderlich (sudo service restart udev) und die Messungen können beginnen.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2010/10/Perl

[2]

TEMPer USB Thermometer, http://www.amazon.com/dp/B002VA813U

[3]

Kyle Rankin, "Cool Projects edition", Linux Journal August 2010, page 32-34.

[4]

"Aberrant Behavior Detection in Time Series for Network Service Monitoring", Jake D. Brutlagg, http://www.usenix.org/events/lisa00/brutlag.html

[5]

"A Signal Analysis of Network Traffic Anomalies", Paul Barford, Jeffery Kline, David Plonka und Amos Ron, http://pages.cs.wisc.edu/~pb/paper_imw_02.pdf

[6]

"Traffic Anomaly Detection at Fine Timescales with Bayes Nets", Jeff Kline, Sangnam Nam, Paul Barford, David Plonka, und Amos Ron, http://pages.cs.wisc.edu/~pb/icimp08_final.pdf

[7]

"libudev and Sysfs Tutorial", Alan Ott, http://www.signal11.us/oss/udev/

[8]

"Daten ausgesiebt", Michael Schilli, Linux-Magazin 06/2004, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2004/06/Daten-ausgesiebt

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.