Auf Kommando: Saft! (Linux-Magazin, April 2007)

Hat sich ein Billig-Router aufgehängt, ist kein Reset mehr über das Ethernet möglich. Hängt seine Stromversorgung jedoch an einem X10-Modul, kann man ihm mit dem heute vorgestellten Ajax-Web-GUI den Saft ab- und wieder andrehen.

Abbildung 1: Das Netzteil des DSL-Modems hängt an einem X10-Empfänger, um es notfalls per Fernsteuerung aus- und wieder einzuschalten.

Abbildung 2: Die interaktive Browserapplikation steuert verschiedene X10-Geräte auf Knopfdruck.

Schon im letzten Snapshot kam X10-Technologie zum Einsatz, heute werden gleich drei weitere Geräte mit X10-Empfängern versehen: Das DSL- Modem (Abbildung 1), der DSL-Router und mein digitaler Videorekorder TiVo. Und die Lampen im Schlaf- und im Wohnzimmer hängen sowieso schon an X10- Kästen. Abbildung 2 zeigt die heute gezeigten Skripte im Browser in Aktion. Die Geräte werden mit leserlichen Namen angezeigt und in der rechten Spalte der Tabelle befindet sich in jeder Reihe ein Button, der entweder grün oder rot eingefärbt ist, je nach dem, ob der am jeweiligen Gerät hängende X10-Empfänger ein- oder ausgeschaltet ist. Ein Mausklick auf den Button, und ein ausgeschaltetes Gerät wird eingeschaltet und umgekehrt. Dabei kommt modernste Ajax-Technologie zum Einsatz, die Seite bleibt im Browser, nur veränderte Felder werden aufgefrischt.

Mit X10 auf Du und Du

Jedes X10-Gerät in einem Haushalt ist auf einen bestimmten House- und Unit-Code eingestellt, damit es eindeutig im Stromnetz addressierbar ist. Damit der User sich nicht diese Buchstaben und Nummern zu merken muss, definiert die Datei /etc/x10.conf (Listing 1) einfach alle erreichbaren X10-Geräte im YAML-Format.

Listing 1: x10.conf

    01 # x10.conf Configuration File
    02 
    03 - device: dslmodem
    04   code:   K4
    05   name:   DSL Modem
    06 
    07 - device: bedroom
    08   code:   K9
    09   name:   Bedroom Lights
    10 
    11 - device: office
    12   code:   K10
    13   name:   Office Back Light
    14 
    15 - device: dslrouter
    16   code:   K14
    17   name:   DSL Router
    18 
    19 - device: tivo
    20   code:   K13
    21   name:   TiVo
    22 
    23 - device: livingroom
    24   code:   K1
    25   name:   Living Room Lights

Ein voranstehender Bindestrich heißt in YAML soviel wie ``Array-Element'', während die Doppelpunktnotation die Key/Value-Paare eines Hashs trennt. Die in /etc/x10.conf angegebene Konfiguration gibt also einen Array von Geräten an. Jedes wird durch einen Hash repräsentiert, der unter den Schlüsseln device, code und name Werte für das Gerätekürzel, die House/Unit-Code-Kombination und einen leserlichen Gerätenamen enthält.

Listing 2: myx10

    1 #!/usr/bin/perl -w
    2 use strict;
    3 use MyX10;
    4 my($device, $command) = @ARGV;
    5 my $x10 = MyX10->new();
    6 $x10->send($device, $command);

Das Skript myx10 erlaubt es dann, von der Kommandozeile aus bestimmte Geräte über ihr Kürzel anzusprechen, sie ein- (on) oder auszuschalten (off) und ihren Status abzufragen:

  # myx10 dslmodem on
  # myx10 dslmodem status
  on

Billig-Trick

Mit billigen X10-Modulen ist aber leider nur Kommunikation in einer Richtung möglich: Man kann sie ansteuern, aber ihr Zustand lässt sich nicht abfragen. Wird ein Empfänger aber ausschließlich über das heute gezeigte Skript bedient, merkt sich einfach das Skript in einer kleinen persistenten dbm-Datei, ob der Empfänger gerade ein- oder ausgeschaltet ist. Das führt zwar zu Verwirrungen, falls man Geräte von Hand oder mittels anderer Fernbedienungen bedient, doch ein kleiner Zustandsfehler lässt sich leicht beheben, indem man einen Wechsel über die Web-UI herbeiführt. Anschließend ist wieder alles im Lot.

myx10 nutzt dafür die Dienste des Perlmoduls MyX10.pm in Listing 3, das zunächst, wie schon im letzten Snapshot vorgestellt, die Baudrate und das serielle Interface für die Kommunikation mit dem X10-Transceiver einstellt. Unter /var/local/myx10.db legt es mit dbmopen eine persistente DBM-Datei vom Typ DB_File an, um unter den Geräteschlüsseln den vermuteten Zustand (on|off) des zugehörigen Gerätes abzuspeichern. Die DESTROY-Methode ab Zeile 48 schließt die dbm-Datei wieder, falls das MyX10-Objekt zerstört wird.

Listing 3: MyX10.pm

    001 package MyX10;
    002 ###########################################
    003 use strict;
    004 use warnings;
    005 use Device::SerialPort;
    006 use ControlX10::CM11;
    007 use YAML qw(LoadFile);
    008 use Log::Log4perl qw(:easy);
    009 use DB_File;
    010 
    011 ###########################################
    012 sub new {
    013 ###########################################
    014   my($class, %options) = @_;
    015 
    016   LOGDIE "You must be root" if $> != 0;
    017 
    018   my $self = {
    019     serial   => "/dev/ttyS0",
    020     baudrate => 4800,
    021     devices  => LoadFile("/etc/x10.conf"),
    022     commands => {
    023         on     => "J",
    024         off    => "K",
    025         status => undef,
    026     },
    027     dbm => {},
    028     dbmfile => "/var/local/myx10.db",
    029     %options,
    030   };
    031 
    032   $self->{devhash} = { 
    033       map { $_->{device} => $_ } 
    034       @{$self->{devices}} };
    035 
    036   dbmopen(%{$self->{dbm}}, 
    037           $self->{dbmfile}, 0644) or 
    038       LOGDIE "Cannot open $self->{dbmfile}";
    039 
    040   for (keys %{$self->{devhash}}) {
    041     $self->{dbm}->{$_} ||= "off";
    042   }
    043 
    044   bless $self, $class;
    045 }
    046 
    047 ###########################################
    048 sub DESTROY {
    049 ###########################################
    050     my($self) = @_;
    051     dbmclose(%{$self->{dbm}});
    052 }
    053 
    054 ###########################################
    055 sub send {
    056 ###########################################
    057   my($self, $device, $cmd) = @_;
    058 
    059   LOGDIE("No device specified") if 
    060       !defined $device;
    061 
    062   LOGDIE("Unknown device") if 
    063       !exists $self->{devhash}->{$device};
    064 
    065   LOGDIE("No command specified") if 
    066       !defined $cmd;
    067 
    068   LOGDIE("Unknown command") if
    069       !exists $self->{commands}->{$cmd};
    070 
    071   if($cmd eq "status") {
    072     print $self->status($device), "\n";
    073     return 1;
    074   }
    075 
    076   my $serial = Device::SerialPort->new(
    077     $self->{serial}, undef);
    078 
    079   $serial->baudrate($self->{baudrate});
    080 
    081   my($house_code, $unit_code) = split //,
    082     $self->{devhash}->{$device}->{code}, 2;
    083 
    084   sleep(1);
    085 
    086     # Address unit
    087   DEBUG "Addressing HC=$house_code ",
    088         "UC=$unit_code";
    089   ControlX10::CM11::send($serial,
    090                $house_code . $unit_code);
    091     
    092   DEBUG "Sending command $cmd ",
    093         "$self->{commands}->{$cmd}";
    094   ControlX10::CM11::send($serial, 
    095     $house_code . 
    096     $self->{commands}->{$cmd});
    097 
    098   $self->{dbm}->{$device} = $cmd;
    099 }
    100 
    101 ###########################################
    102 sub status {
    103 ###########################################
    104     my($self, $device) = @_;
    105     return $self->{dbm}->{$device};
    106 }
    107 
    108 1;

Hashes ohne Reihenfolge

Für schnelle Tests, ob ein angegebenes Device existiert oder um vom Device-Kürzel zu dessen House/Unit-Code zu gelangen, wäre es sinnvoll, /etc/x10.conf in Hashform zu speichern. Doch leider geht in einem Hash die ursprünglich definierte Reihenfolge verloren, und die ist für die im Browser geplante Anzeige wichtig. Wer will schon bunt durcheinander gewürfelte Bedienelemente?

So machen sich die Zeilen 32-34 in MyX10.pm daran, den Array mit Hashelementen in einen Hash umzuwandeln, dessen Schlüssel die Gerätekürzel sind, und der als Werte die vorher definierten jeweiligen Geräte-Hashes führt. In der Instanzvariablen devhash wird eine Referenz auf diesen Schnellzugreifer für später abgelegt. Die Zeilen 40-42 iterieren über alle Einträge und setzen den Zustände bislang unbekannter Geräte auf off. Das muss nicht unbedingt stimmen, aber falls nicht, renkt der nächste Zustandswechsel den X10-Empfänger wieder ein.

Die Methode send() schickt über den am Linux-Rechner angeschlossenen X10-Transceiver ein Kommando an einen per Gerätekürzel angegebenen X10-Empfänger. Ist das Kommando nicht ``on|off'', sondern ``status'', verzweigt Zeile 71 stattdessen zu der weiter unten definierten Methode status(), die den vermuteten Status des X10-Empfängers aus der Konserve holt.

Zwischen der Initialisierung der seriellen Schnittstelle und dem Aufruf des X10 schläft MyX10.pm eine Sekunde lang mit sleep(1). Dies wäre eigentlich unnötig, doch beim Weglassen stellten sich unerklärliche Timing-Probleme mit der X10-Ansteuerung ein.

Sudo ohne Passwort

Nur root darf X10-Signale über die serielle Schnittstelle senden. Deswegen muss myx10 als root laufen. Will man die Geräte über ein Web-GUI steuern, ergibt sich ein Problem: Der Webserver läuft aber sicherheitshalber als nobody, ihn als root laufen zu lassen, wäre grob fahrlässig. Aber folgender Eintrag in /etc/sudoers öffnet ein kleines Loch, das den Webserver über sudo das Skript myx10 als root ausführen lässt, ohne dass die Eingabe eines Passworts erforderlich wäre:

    # /etc/sudoers
    nobody ALL= NOPASSWD:/usr/bin/myx10

Das Schlüsselwort ALL links vom Gleichheitsszeichen legt fest, dass keine Beschränkung über den Hostnamen erfolgt. Das nach dem Doppelpunkt folgende Kommando beschränkt mögliche Aktivitäten allerdings auf das angegebene Skript. So kann ein Einbrecher nach feindlicher Übernahme des Webservers höchstens die X10-Geräte ein- und ausschalten, nicht aber den root-Account des Linux-Rechners übernehmen. Alternativ könnte man auch mit chmod a+rw /dev/ttyS0 die serielle Schnittstelle für jedermann beschreibbar machen, dann könnte man sich den sudo-Trick ganz sparen.

Schwung mit CGI

Das CGI-Skript myx10.cgi macht dann auch nicht viel mehr, als das Kommandozeilenskript myx10 aufzurufen und dessen Ausgabe zurück zum Webclient zu senden. Es nutzt hierzu die Funktion tap des CPAN-Moduls Sysadm::Install, die einfach die Ausgaben eines Kommandos komfortabel abfängt.

Wird myx10.cgi vom Browser allerdings ohne device-Parameter aufgerufen, möchte der Webclient die in Abbildung 2 gezeigte Übersicht sehen. Hierzu lädt myx10.cgi die X10-Konfigurationsdatei und ruft anschließend den Prozessor des Template-Toolkits auf, um das Template myx10.tmpl zu rendern (Abbildung 3). Dort sorgt eine FOREACH-Schleife dafür, dass für jedes konfigurierte Gerät eine Tabellenspalte mit Druckknopf entsteht. Die onClick-Aktion eines jeden Buttons ruft die später in myx10.js definierte Funktion toggle() auf, die nicht nur die Kommunikation mit dem Server abwickelt, sondern auch die Farbe des Buttons entsprechend dem Ergebnis anpasst. Die id jedes Buttons wird auf das Gerätekürzel gesetzt, die Klasse class auf den zufällig gewählten Namen "clicker", damit eine JavaScript-Funktion später leicht über alle so ausgezeichneten Elemente iterieren kann.

Listing 4: myx10.cgi

    01 #!/usr/bin/perl -w
    02 use strict;
    03 use CGI qw(:all);
    04 use Log::Log4perl qw(:easy);
    05 use YAML qw(LoadFile);
    06 use Template;
    07 
    08 print header();
    09 
    10 my $action = param("action");
    11 my $device = param("device");
    12 
    13 if(!defined $device) {
    14   my $devices = LoadFile("/etc/x10.conf");
    15 
    16   my $tpl = Template->new();
    17   $tpl->process("myx10.tmpl", { 
    18     devices => $devices, 
    19   } ) or die $tpl->error();
    20   exit 0;
    21 }
    22 
    23 if(!defined $action or
    24    $action !~ /^(on|off|status)$/) {
    25   print "Error: No/Invalid action\n";
    26   exit 0;
    27 }
    28 
    29 if(!defined $device or $device =~ /\W/) {
    30   print "Error: use a proper 'device'\n";
    31   exit 0;
    32 }
    33 
    34 system "sudo", "/usr/bin/myx10", 
    35        $device, param("action");

Abbildung 3: Das HTML-Template der Webapplikation

YUI

Moderne Web-Applikationen laden nicht mehr die ganze Seite nach, wenn nur ein Knöpferl gedrückt wurde. Die Kommunikation mit dem Webserver findet auf asynchronem Weg über Ajax statt und nur tatsächlich veränderte Elemente werden neu gezeichnet ([3]). Da Ajax aber recht umständlich zu programmieren ist und exzessiver JavaScript-Gebrauch bekanntlich Haarausfall verursacht, gibt es eine Reihe von JavaScript-Bibliotheken, die die Handhabung vereinfachen und Browerkompatibilität gewährleisten. Ein Beispiel ist die YUI-Library meines Arbeitgebers Yahoo, die kostenlos und ohne Registrierungspflicht verfügbar ist. Auf [2] liegt eine zip-Datei, die im Verzeichnis 'build' alle notwendigen JavaScript-Dateien enthält. Nach dem Download entpackt man einfach das zip-Archiv und kopiert das 'build'-Verzeichnis zum Beispiel unter htdocs/yui auf den lokalen Webserver. Ab dann können JavaScript-Applikationen mit die .js-Dateien zum Beispiel als src=/yui/yahoo/yahoo.js einbinden.

Dynamisch gepatchtes HTML

Die am Ende von myx10.tmpl eingebundene JavaScript-Datei myx10.js (Abbildung 4) definiert die Funktion update_buttons(), die der Browser aufruft, gleich nachdem das Dokument geladen wurde.

Für jedes Gerät wird so nicht nur ein Eintrag in der in Abbildung 2 dargestellten HTML-Tabelle erzeugt, sondern auch der Aufruf

   x10remote(device, 'status');

durchgeführt. Der JavaScript-Code nutzt hierfür die Methode YAHOO.util.Dom.getElementsByClassName() der YUI, die einfach alle gefundenen DOM-Knoten liefert, die vorher mit dem Attribut class="clicker" gekennzeichnet wurden.

Um den Status eines in /etc/x10.conf konfigurierten X10- Empfängers zu erhalten, ruft der Browser für jeden definierten Button asynchron das CGI-Skript mit den Parametern device=kuerzel und action=status auf. Daraufhin sieht myx10.cgi auf dem Server in seiner dbm-Datei nach und gibt den letzten dort abgelegten Zustand des gewünschten X10-Gerätes als ``on'' oder ``off'' zurück.

Dynamisch einfärben

Abbildung 4: Der JavaScript-Code in myx10.js

Die JavaScript-Datei myx10.js zeigt die Knöpfe der eingeschalteten X10-Empfänger grün und die deaktivierten rot an. Dies erledigt die Methode setStyle der Klasse Yahoo.dom, die den Namen eines Objektes der Browser-DOM entgegennimmt, es heraussucht und das BackgroundColor-Attribut des CSS-Stylesheets modifiziert.

Beim ersten Laden der vom CGI-Skript generierten HTML-Seite sind die Knöpfe zunächst alle farblos, aber update_buttons() setzt für jeden Knopf einen Ajax-Request an den Server ab, der den im dbm-File gespeicherten Zustand des zugehörigen Gerätes vom Server holt. Trifft die Antwort auf einen dieser asynchronen Requests ein, wird sie auf "on" oder "off" überprüft und der entsprechende Knopf eingefärbt.

Damit der JavaScript-Code auch bei dutzenden von gleichzeitig abgefeuerten Requests übersichtlich bleibt, kommt der ConnectionManager der YUI zum Einsatz.

Auf Kommando: Saft!

Drückt der Benutzer mit der Maus auf einen der dargestellen Knöpfe, springt der Browser dessen OnClick()-Routine an. Diese frischt zunächst die Statuszeile mit einer Nachricht wie ``Request: device on'' auf, um dann mittels des ConnectionManagers einen Ajax-Request an den Server abzufeuern.

Der Request, um das DSL-Modem einzuschalten, heißt dann zum Beispiel

    /cgi-bin/myx10.cgi?device=dslmodem&action=on

und an der später asynchron eintreffenden Antwort interessiert eigentlich nur der HTTP-Statuscode. Ist er 200 (OK), springt der Browser die Routine handleSuccess() im JavaScript-Code an. Dort wird zunächst die Statuszeile gelöscht und anschließend mittels der Funktion update_button() dem Button die entsprechende Farbe zugewiesen, denn die die Zustandsänderung wurde offenbar ordnungsgemäß durchgeführt.

Wurde mit action=status eine Statusabfrage durchgeführt, antwortet der Server auf der zurückgeschickten Seite entweder mit "on" oder "off". Da der Antwort auch ein Newline-Zeichen anhängt, entfernt der JavaScript-Code dieses, bevor update_button() den Auftrag zur Button-Aktualisierung erhält.

Fehler passieren

Tritt beim asynchronen Request hingegen ein Fehler auf, wird die Funktion handleFailure angesprungen. Dort stehen Status-Code und eine lesbare Fehlermeldung bereit und der Fehler wird in der Statuszeile angezeigt.

Diese Logik wird über das in myx10.js definierte Callback-Objekt erzielt. Außer den beiden Ansprungpunkten im Fehler- und im Erfolgsfall lassen sich auch Argumente definieren, die diesen Funktionen am Ende eines Requests übergeben werden. Die Zeilen

   callback.argument.device = device;
   callback.argument.cmd    = action;

setzen so das Kürzel des gerade modifizierten Geräts (bequemerweise auch die ID des zugehörigen Buttons) und das zu sendende Kommando. So weiss handleSuccess() später genau, zu welchem der vielen asynchron abgeschickten Requests die gerade eingetrudelte Antwort eigentlich gehört.

Beim ersten Laden der Seite sind nämlich schnell ein halbes Dutzend Ajax-Zugriffe gleichzeitig unterwegs, bis sämtliche Knöpfe nach und nach an den auf dem Server vermuteten Gerätezustand angepasst sind. Und auch der Benutzer kann durch schnelles Klicken mehrere Requests quasi gleichzeitig auslösen. Der ConnectionManager macht es einfach, Ordnung zu halten und eine eintrudelnde Antwort nach der anderen abzuarbeiten, ohne die Requests miteinander zu verquirlen.

Farblos, aber bedienbar

Da das Serverseitige X10-Kommando einige Sekunden zum Ablaufen benötigt, bleibt ein Button nach dem Anklicken typischerweise eine zeitlang farblos. Das Schöne an asynchronen Requests ist dabei freilich, dass die Oberfläche bedienbar bleibt und der Benutzer zum Beispiel andere Knöpfe drücken kann. Der Connection-Manager bearbeitet so beliebig viele Verbindungen gleichzeitig.

Installation

Das Skript myx10 kommt ausführbar nach /usr/bin und das Perl-Modul MyX10.pm in den Perl-Pfad, zum Beispiel nach /usr/lib/perl5/site_perl. Die Konfigurationsdatei /etc/x10.conf sollte mit den Namen und Daten der lokal verwendeten Elektrogeräte bestückt werden, einschließlich der House- und Unitcodes der daranhängenden X10-Empfänger. Das CGI-Skript myx10.cgi kommt ausführbar in das cgi-bin-Verzeichnis des Webservers und das Template myx10.tmpl sollte auch dorthin, damit myx10.cgi es findet. Die JavaScript-Datei myx10.js sollte ins htdocs-Verzeichnis des Webservers, denn der Browser sucht sie (letzte Zeile in myx10.tmpl) dort. Dann kann sich der Administrator zurücklehnen, auf den Knöpfen der Weboberfläche herumdrücken und die entsprechenden Elektrogeräte ein- und ausschalten. Die Relays der angesteuerten X10-Appliance-Module klicken jeweils zur Bestätigung. Das ist Bedienkomfort!

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2007/04/Perl

[2]
Yahoo YUI Library, http://developer.yahoo.com/yui

[3]
``Browser-Turbo'', Michael Schilli, http://www.linux-magazin.de/Artikel/ausgabe/2005/12/perl/perl.html

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.