![]() |
Raspberry-Pi-Projekte: Das LangweilometerProf. Jürgen Plate |
Im Projektpraktikum des Wintersemesters 2014/2015 war die Studierendengruppe relativ klein, so dass mir Zeit blieb, neben der Betreuung der Projekte auch ein kleines "Extra-Projekt" durchzuziehen: das Langweilometer.
Die Idee des "Langweilometers" ist recht schnell erklärt: Per Webinterface kann man den aktuellen Eindruck der gerade statfindenden Lehrveranstaltung bewerten. Dies geschieht durch Eingabe eines prozentualen Langweilepegels zwischen (0 = "super spannend und unterhaltsam" und 100 = "Schlaftablette"). Der aktuelle Stand wird auf der Webseite nach der Eingabe zurückgemeldet. Damit nun nicht jede Eingabe zur totalen Schwankung der Anzeige führt, wird der Medianwert über die letzten 11 Eingaben gebildet und angezeigt. Sollte es sich im Verlauf der Anwendung ergeben, kann diese "Dämpfung" auch auf mehr als 11 Eingaben erweitert werden. Als Webserver wurde ein Raspberry Pi gewählt, das klein und kostengünstig. Er steuert auch die analoge Anzeige und alles andere.
Da das Webinterface nur der jeweilige Studi bei der aktiven Eingabe sieht, muss logischerweise noch eine externe Anzeige hinzukommen. Die Wahl fiel hier nicht auf das übliche Display oder einen Siebensegment-Ziffernanzeige, sondern es sollte etwas Analoges sein. Glücklicherweise hatte ich ein altes Voltmeter aufgehoben, das im Messtechniklabor ausgemustert worden war. Es handelt sich um ein Voltmeter im ca. 40 cm hohen Glas- und Holzgehäuse, wie es früher auch im Physikunterricht verwendet wurde. Ein riesiges Drehspulinstrument bewegt den Zeiger, der auch noch aus der Entfernung erkennbar ist. Das Gehäuse bot nach dem Ausbau der Vorwiderstände und des Messbereichs-Wahlschalters auch noch Platz für den Raspberry Pi. Als erstes wurde statt der Volt-Skala eine Prozent-Skala erstellt, dann die Bohrungen abgedeckt und das Typenschild umgedreht (dahinter war leider ein Loch für die Sicherungen). Die große Skala des Drehschalters für die Messbereiche wurde entfernt und durch ein gähnendes Smiley ersetzt.
Der Raspberry Pi und eine kleine Zusatzplatine (Lochrasterplatte) für den Digital-Analog-Wandler finden wunderbar im Gehäuse Platz. Für das Netzteil-Kabel und das Netzwerk-Kabel wurde in den Boden ein Loch gebohrt. Für die Netzwerkverbindung habe ich dann ein besonders dünnes und flaches Kabel genommen, das auch sehr flexibel ist. Solche Kabel lassen sich sogar unter dem Teppich verlegen. Der Blick durch die hintere Scheibe zeigt unten den Pi und das Messwerk. Der blaue Knubbel links am Messgerät ist ein Vorwiderstand (Mehrgangpoti), mit dem der Vollausschlag des Messinstruments auf die maximal ca. 3,3 V des D/A-Wandlers eingestellt werden kann. Das blaue Kabel führt zu einer (ebenfalls blauen) LED, die in die Öffnung des ehemaligen Umschalters eingebaut wurde. Sie zeigt nicht nur an, ob der Raspberry noch "lebt", sondern auch, wann ein neuer Datenwert eintrifft (s. u.).
Als Digital-Analog-Wandler wurde der Baustein LTC1453 von Linear Technology im DIL-Gehäuse genommen. Der Chip ist für 3,3-V-Betrieb ausgelegt und hat 12 Bit Auflösung, was für die geplante Anwendung mehr als ausreichend ist. Auf den ersten Blick schien der LTC1453 aus ein ganz normaler SPI-Chip zu sein - also null Probleme bei der Ansteuerung. Leider stellte sich später heraus, dass es sich nur um einen nur fast SPI-kompatiblen Chip handelt. Gut versteckt im Datenblatt stand der kleine Satz: "Note: CLK must be low before CS/LD is pulled low to avoid an extra internal clock pulse." Übersetzt heisst das:
Das C-Programm zum Setzen eines Analogwertes über den Baustein LTC1453 verwendet die an anderer Stelle beschriebenen Funktionen für den Zugrff auf die GPIO-Ports. Diese Funktionen werden im Listing hier unten nicht aufgeführt, sind aber in der Quelldatei enthalten. Die Eingabe auf der Kommandozeile ist ein Prozentwert (int) zwischen 0 und 100. Damit kann das Programm nicht nur für das Langweilometer, sondern auch für andere Anwendungen verwendet werden. Auch ein Aufruf per Shell-Script ist möglich.
Der Aufruf erfolgt zum Beispiel als ./lwm_setdac 45 für eine Anzeige von 45%. Das Compilieren erfolgt mit dem Kommando:
gcc -Wall -o lwm_setdac lwm_setdac.cwobei die Meldung "'GPIO_Read' defined but not used" ignoriert werden kann. Die Funktion ist in der Bibliothek enthalten, wird aber nicht verwendet.
Damit unprivilegierte User (z. B. pi, www-data usw.) das Programm aufrufen können, muss es SUID root gesetzt werden. Es läuft dann mit Root-Rechten:
sudo chown root.root setdac sudo chmod 4711 setdac
Das Programm macht noch eine kleine Pause von 0,2 Sekunden, damit auch ein amok laufendes Script nicht zu wild mit dem Gerätezeiger wedeln kann. Der folgende Ausschnitt zeigt den Programmanfang und die Funktionen für die Ansteuerung des LTC1453, die Bibliothek mit den GPIO-Routinen habe ich weggelassen. Kern des Ganzen ist die Funktion void SetData(), welche den Analogwert als 12-Bit-Zahl bitweise in den Baustein schiebt, wobei das MSB als erstens kommt. Da sehen Sie auch den Workaround beim letzten Bit.
Die Funktion delay() rechnet die Millisekunden in Sekunden und Nanosekunden um und ruft dann die Libraryfunktion nanosleep() auf.
Im Hauptprogramm werden nun alle Funktionen vereint. Nach der Definition der einzubindenden Headerdateien und der Konstanten (auch die Pins des GPIO erhalten symbolische Namen) wird zunächst geprüft, ob das Programm mit root-Rechten aufgerufen wird. Danach initialisiert es die benötigten GPIO-Ports. Anschließend wird der Kommandozeilenparameter bearbeitet und überprüft. Die eigentliche Arbeit beschränkt sich auf die Befehle value = (unsigned int)(40.95 * value); und SetData(value);. Da der D/A-Wandler 12 Bit verarbeitet, muss der an ihn gesendete Wert zwischen 0 und 4095 liegen. Da über die Kommandozeile ein Wert zwischen 0 und 100 übergeben wird, muss dieser mit 40,95 multipliziert werden, damit der D/A-Wandler den richtigen Ganzzahlwert bekommt.
#include <sys/stat.h> #include <sys/types.h> #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <ctype.h> #include <time.h> /* Symbolische Namen fuer die Datenrichtung und die Daten */ #define IN 0 #define OUT 1 #define LOW 0 #define HIGH 1 /* Datenpuffer fuer die GPIO-Funktionen */ #define MAXBUFFER 100 /* LTC 1453 Pins */ #define CLK 22 /* Clock Input */ #define DIN 23 /* Data Input */ #define CS 24 /* Enable */ /* Funktionsprototypen */ static int GPIO_Export(int pin); static int GPIO_Unexport(int pin); static int GPIO_Direction(int pin, int dir); static int GPIO_Read(int pin); static int GPIO_Write(int pin, int value); int delay(unsigned long millis); void SetData(unsigned int value); int main(int argc, char **argv) { unsigned int value; int err; /************* Beginn Initialisierung *************/ /* bin ich root? */ if (geteuid() != 0) { fprintf(stderr, "You must be root!\n"); return 1; } /* Init GPIO-Pins */ err = GPIO_Export(CLK); err += GPIO_Direction(CLK, OUT); err += GPIO_Export(DIN); err += GPIO_Direction(DIN, OUT); err += GPIO_Export(CS); err += GPIO_Direction(CS, OUT); err += GPIO_Write(CLK, 0); err += GPIO_Write(CS, 0); err += GPIO_Write(DIN, 0); if (err > 0) { fprintf(stderr, "Can not init GPIO!\n"); return 2; } /************** Ende Initialisierung **************/ if (argc == 2) { /* String lesen, nach int umwandeln */ value = atoi(argv[1]); /* Wertebereich 0 .. 100 */ if ((value >= 0) || (value <= 100)) { /* Prozent nach 12-Bit-int wandeln */ value = (unsigned int)(40.95 * value); SetData(value); } else { fprintf(stderr, "Argument must be between 0 ans 100!\n"); return 3; } delay(200); } GPIO_Unexport(CLK); GPIO_Unexport(CS); GPIO_Unexport(DIN); return 0; } /* * 12 Bit Daten an den LTC 1453 senden * * The data on the DIN input is loaded into the shift register * on the rising edge of the clock. The MSB is loaded first. The * DAC register loads the data from the shift register when * CS/LD is pulled high. The CLK is disabled internally when * CS/LD is high. Note: CLK must be low before CS/LD is * pulled low to avoid an extra internal clock pulse. */ void SetData(unsigned int value) { int i; /* CS muss LOW sein */ GPIO_Write(CS, LOW); /* shift MSB ... LSB (MSB zuerst) */ for(i = 11; i >= 0; i--) { /* set data bit, check MSB */ if (value & 0x800) GPIO_Write(DIN, HIGH); else GPIO_Write(DIN, LOW); value = value << 1; /* CLK-Impuls senden und bei Bit 0 CS/LD-Impuls auf HIGH. (siehe Datenblatt) */ GPIO_Write(CLK, HIGH); if (i == 0) GPIO_Write(CS, HIGH); GPIO_Write(CLK, LOW); } GPIO_Write(CS, LOW); } /* * Delay (warten) - Zeitangabe in Millisekunden */ int delay(unsigned long millis) { struct timespec ts; int err; ts.tv_sec = millis / 1000; ts.tv_nsec = (millis % 1000) * 1000000L; err = nanosleep(&ts, (struct timespec *)NULL); return (err); } ...
Das Compilieren erfolgt mit dem folgenden Kommando, wobei die Meldung "'GPIO_Read' defined but not used" wie schon oben, ignoriert werden kann.
gcc -Wall -o lwm_frontled lwm_frontled.cDas Programm muss natürlich ständig laufen. Damit es bei Bootvorgang aktiviert wird, wird es sinnvollerweise in die Datei /etc/rc.local eingetragen. Damit der Zeiger des Messinstruments nach dem Booten nicht bei 0 stehen bleibt, wird auch ein Aufruf von lwm_setdac in diese Datei geschrieben. Somit müssen zwei Zeilen vor dem exit in /etc/rc.local ergänzt werden.
/home/pi/lwm_setdac 50 & /home/pi/lwm_frontled &Wichtig ist dabei, die Programme zur Ausführung in den Hintergrund zu legen, da sonst der Ablauf der Startscripte unterbrochen wird (die Ausführung bleibt dann bei lwm_frontled hängen). Dies geschieht durch Anhängen eines & an das jeweilige Kommando.
Das Blinken ist sicher einfach zu realisieren: delay(), LED an, delay(), LED aus usw. Wie erfährt aber das LED-Programm von einer Neueingabe über das Webinterface. Da mir die Realisierung über eine der zahlreichen Möglichkeiten der Prozesskommunikation zu aufwendig erschien, habe ich einen einfache Trick angewendet. Jeder neue Wert, der vom Webinterface kommt, wird ja in die Datei /home/pi/boring eingetragen. Dadurch ändert sich aber auch das Modifikationsdatum der Datei. So merkt sich das Programm einfach das Dateidatum und fragt es nach jedem Herzschlag-Zyklus ab. Ist das neue Dateidatum größer, wurde die Datei geändert → Ligtshow und neues Datum speichern. Günstig ist es hier, nicht Datum und Uhrzeit zu speichern, sondern die sogenannte UNIX-Epoche, also die Anzahl der Sekunden, die seit dem 1.1.1970, 0 Uhr vergangen sind. Das ist ein monoton steigender 32-Bit-Wert.
#include <sys/stat.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <ctype.h> #include <time.h> /* Symbolische Namen fuer die Datenrichtung und die Daten */ #define IN 0 #define OUT 1 #define LOW 0 #define HIGH 1 /* Datenpuffer fuer die GPIO-Funktionen */ #define MAXBUFFER 100 /* LED Pin, Stiftleiste Pin 13 */ #define LED 27 /* Datei fuer die Werteuebergabe */ #define FILENAME "/home/pi/boring" /* Funktionsprototypen */ static int GPIO_Export(int pin); static int GPIO_Unexport(int pin); static int GPIO_Direction(int pin, int dir); static int GPIO_Read(int pin); static int GPIO_Write(int pin, int value); int delay(unsigned long millis); void beenden(int dummy); int main(int argc, char **argv) { int err; /* Fehlervariable */ time_t epoc, oldepoc; /* 32-Bit-Zahl */ struct stat attr; /* Filestatus */ int i; /* Schleifenzaehler */ /************* Beginn Initialisierung *************/ /* Signalhandler setzen */ signal(SIGINT,beenden); /* bin ich root? */ if (geteuid() != 0) { fprintf(stderr, "You must be root!\n"); return 1; } /* Init GPIO-Pins */ err = GPIO_Export(LED); err += GPIO_Direction(LED, OUT); err += GPIO_Write(LED, 0); if (err > 0) { fprintf(stderr, "Can not init GPIO!\n"); return 2; } /************** Ende Initialisierung **************/ /* get file modification date */ stat(FILENAME, &attr); oldepoc = attr.st_mtime; for(;;) { /* get file modification date */ stat(FILENAME, &attr); epoc = attr.st_mtime; if (epoc > oldepoc) { /* file has been changed, lightshow :-) */ for (i = 0; i < 50; i++) { GPIO_Write(LED, 1); delay(50); GPIO_Write(LED, 0); delay(50); } oldepoc = epoc; } else { /* normal heartbeat */ GPIO_Write(LED, 1); delay(50); GPIO_Write(LED, 0); delay(100); GPIO_Write(LED, 1); delay(50); GPIO_Write(LED, 0); delay(1000); } } return 0; /* never reached */ } /* * STRG-C behandeln, aufraeumen */ void beenden(int dummy) { GPIO_Write(LED, 0); GPIO_Unexport(LED); delay(100); printf("\nHasta la vista, baby!\n"); exit(0); } /* * Delay (warten) - Zeitangabe in Millisekunden */ int delay(unsigned long millis) { struct timespec ts; int err; ts.tv_sec = millis / 1000; ts.tv_nsec = (millis % 1000) * 1000000L; err = nanosleep(&ts, (struct timespec *)NULL); return (err); } ...
// Pfad zum Binary (SIUD root) define("SETDAC", "/home/pi/lwm_setdac"); // Pfad zur Datendatei, diese muss fuer den // User www-data beschreibbar sein define("DATAFILE", "/home/pi/boring"); // Anzahl Daten in der Datendatei define("MAXDATA", "11");Bei der Datenspeicherung soll die Datendatei einerseits nicht zu groß werden und andererseits auch die Aktualität wiederspiegeln, denn wen interessiert, wie hoch der Langeweilepegel in der letzten Woche war. Daher werden die Daten rollierend gespeichert: immer wenn einen neues Datum hinzukommt, wird das älteste gelöscht.
Die Einfügeroutine liest die Datei in ein Array, schiebt dann alle Datenwerte um eine Indexposition weiter ( → letzter und damit ältester Datenpunkt fällt weg) und trägt den neuen Wert als Ersten ein. Dabei wird auch noch eine kleine Korrektur durchgeführt: Da am Anfang ja noch keine Werte vorhanden sind, füllt das Programm das Array ggf. mit 50%-Werten auf. Dann werden die Daten zurück in die Datei geschrieben, wobei auch Leerzeichen, Tabs und Newlines entfernt werden.
// Neuen Datenwert in die Datei einfuegen // Die Datei enthaelt immer die letzten MAXDATA Werte function insert($newvalue, $datafile) { // Datei einlesen $lines = array(); if (is_readable($datafile)) { $lines = file($datafile); } // sind es (anfangs) noch wenige Elemente, mit 50%-Werten ergaenzen while (count($lines) < MAXDATA) { $lines[] = 50; } // neuen Wert hinzufuegen, letzten Wert entfernen for ($i = MAXDATA - 1; $i > 0; $i--) { $lines[$i] = $lines[$i-1]; } $lines[0] = $newvalue; // Datei aktualisieren if (is_writable($datafile)) { $handle = @fopen($datafile, "w"); for ($i = 0; $i < count($lines); $i++) { fwrite($handle, trim($lines[$i]) . "\n"); } fclose($handle); } }Wie lange das Ganze gut geht, hängt übrigens davon ab, wie viele Schreibzyklen die SD-Karte verkraftet. Der Wert hängt auch von der Qualität der SD-Karte ab, aber diese Chip-Speicher sind halt doch kein vollwertiger Ersatz für eine Festplatte. Deshalb der Tipp: Wenn alles läuft, eine 1:1-Kopie der SD-Karte in eine Image-Datei auf dem PC oder Laptop machen. Dann beschränkt sich die Wartungsarbeit beim Versagen der SD-Karte auf das Kopieren des Images auf eine neue SD-Karte.
Bei der "Glättung" der Werte habe ich mit zwei Verfahren experimentiert. Zum einen bot sich wegen der sowieso schon rollierenden Datei der "gleitende Mittelwert" an. Bei diesem werden immer alle Werte addiert und die Summe durch die Anzahl der Werte dividiert. Da beim Eintreffen eines neuen Werts jeweils der älteste wegfällt, ergibt sich immer ein Mittel über die letzten N Werte. Bei ersten Test lief das Verfahren zwar zufriedenstellen, zeigt aber manchmal den Einfluss von Ausreissern. Dazu ein Beispiel: Wenn in einer kleinen Firma die fünf Mitarbeiter 1200 Euro im Monat verdienen und der Chef 5000 Euro, ergibt sich ein Durchschnittsgehalt von über 1800 Euro. Das Chef-Gehalt "verzerrt" also das Ergebnis. Trotzdem habe ich die Funktion im Quellcode gelassen, falls ich doch später nochmal damit experimentieren will.
Wesentlich "aussreisserfest" ist eine andere statistische Masszahl, der Median. Er stellt den Wert dar, der eine Wertemenge/Werteverteilung in zwei gleich große Teile teilt. Der Median ist besonders interessant für schiefe Verteilungen, also solche, die von der Normalverteilung abweichen und er eignet sich als Lokalisationsmass für ordinalskalierte Beobachtungen, nur wenige Messwerte, asymetrische Verteilungen, für Verteilungen mit offenen Endklassen und bei Verdacht auf Ausreißer. Der Median kann auf folgende Weise bestimmt werden:
Die PHP-Funktion ist entsprechend der oben angegebenen Vorschrift programmiert. Durch die Sortierung des Arrays ergeben sich quasi gratis noch zwei Werte: Das erste Arrayelement enthält nach der Sortierung das Minimum, das letzte Element das Maximum der Werte. Die Funktion hat als Parameter den Namen der Datendatei und sie gibt eine Liste von vier Werten zurück: den aktuellen Wert, das Minimum, das Maximum und den Median. Auch hier wird ggf. die Datei auf MAXDATA 50%-Elemente ergänzt.
// Datenmittelung mit Median function calculate_median($datafile) { // Datei einlesen $lines = array(); if (is_readable($datafile)) { $lines = file($datafile); } // sind es (anfangs) noch wenige Elemente, mit 50%-Werten ergaenzen while (count($lines) < MAXDATA) { $lines[] = 50; } $aktual = $lines[0]; // Leerzeichen weg for ($i = 0; $i < MAXDATA; $i++) { $lines[$i] = trim($lines[$i]); } // aufsteigend sortieren sort($lines); // Maxima berechnen $min = $lines[0]; $max = $lines[MAXDATA-1]; if(MAXDATA % 2 == 0) { //gerade Anzahl => der Median ist das arithmetische Mittel der beiden mittleren Zahlen $mean = ($lines[(MAXDATA/2) - 1] + $lines[MAXDATA/2])/2; } else { //ungerade Anzahl => der mittlere Wert ist der Median $mean = $lines[MAXDATA/2]; } // aktueller Wert, Mittelwert, Minimum, Maximum return array ($aktual, $mean, $min, $max); }Der Rest der PHP-Datei ist eigentlich unspektakulär. Es gibt einige Funktionen, die versuchen, unsinnige Daten bei der Eingabe auszufiltern und natürlich eine Rückmeldung, welcher Pegel der Langeweile erreicht wurde (nicht jeder kann ja das Instrument sehen). Die Anzeige ist als waagrechter Balken realisiert, dessen Breite mittels CSS an den aktuellen Prozentwert angepasst wird und der je nach Bereich seine Farbe von grün über gelb, orange und rot nach violett ändert.
Damit nun nicht Hinz und Kunz in aller Welt, sondern nur die aktuell Betroffenen einen Wert eingeben können, wird die IP-Adresse des Webclient untersucht und es dürfen nur diejenigen eine Wertung abgeben, die aus dem internen Netz der Fakultät kommen, deren IP-Adresse also mit "10.27." beginnt:
// Woher kommt die Anfrage $Client = $_SERVER['REMOTE_ADDR']; ... // Passwort nur von ausserhalb der Fakulteat noetig if (preg_match('/^10\.27\./', $Client)) { $Passwort = $PW; }Alle anderen, die über WLAN etc. im Netz herumgeistern, müssen das geheime Passwort kennen. Das Setzen der Instrumentenanzeige erfolgt per System-Call:
... $cmd = SETDAC . ' ' . $Mittel; system($cmd, $retval); ...Der folgende Link verweist aus Vorsicht auf den Dateinamen lwm_index.php.txt, damit sich beim Download nicht jemand was zerschiesst. Auf der Webseite des Langweilometers heisst die Datei natürlich index.php.