Mach's wie Aldi (Linux-Magazin, Oktober 2008)

Nicht nur Kassierer im Supermarkt scannen Produkte anhand der aufgedruckten UPC-Barcodes. Mit einem Lesegerät für 25 Euro lässt sich auch die hauseigene Bibliothek, CD- oder DVD-Sammlung erfassen.

Die in Hongkong ansässige Firma dealextreme.com bietet allerhand Artikel aus chinesischer Billigproduktion zu absoluten Schlagerpreisen an. Sie verschickt sie außerdem auch noch weltweit kostenlos, nachdem man mit Paypal bezahlt hat. Soll's ein Laserpointer für $1.50 sein oder eine SATA/IDE-Adapter für nur 8 Dollar? Wenn man es nicht eilig hat (das Verschicken dauert bis zu zwei Wochen), ist dealextreme unschlagbar. Auf den CCD-basierten Barcodeleser für $41.89 (etwa 25 Euro, eines der teuersten Produkte dort [2]) hatte ich schon geraume Zeit ein Auge geworfen und eines Tages drückte ich gut gelaunt den ``Buy''-Button.

Abbildung 1: Der Barcode-Scanner erfasst den UPC-Code eines Buchs.

Post aus Hongkong

Als der Postbote dann endlich das Paket brachte, gab es kein Halten mehr: Was lag näher, als eine Applikation zu schreiben, die die UPC-Codes aller Ausgaben meiner umfangreichen Fachbuchsammlung erfasst und in einer Datenbank ablegt? Amazon.com bietet einen kostenlosen Webservice an, der detaillierte Daten zu den meisten per UPC erfassten Produkten angibt. So kann ein Perlskript einfach den Autor und den Titel eines Buches bestimmen oder den Interpreten einer gerade eingescannten CD. Auch Bilddateien der CD- und Buchcover gehören zum Lieferumfang. Läuft die Applikation als graphische Oberfläche, kann sie die Buchdeckel und die Hüllen eingescannter CDs sogleich farbig auf dem Bildschirm anzeigen.

Abbildung 2: Der Scanner arbeitet mit einem CCD-Sensor und schaltet auf Knopfdruck rote Leuchtdioden an.

Das Lesegerät verfügt über einen USB-Stecker, den Linux sofort als zweite Tastatur erkennt. Hält man den Lesekopf wie ein Kassierer über den UPC-Barcode auf dem Rücken eines Fachbuchs, einer CD oder DVD, und drückt den Knopf, schaltet der Leser das rote Licht ein, aktiviert den eingebauten CCD-Sensor und der eingebaute Kleinstcomputer versucht, anhand der verschieden dicken Striche den dargestellten UPC-Code zu erkennen.

Der Leser ermittelt den Barcode sehr zuverlässig, piept wenn er fertig ist und sendet die Ziffernfolge per USB an den Rechner, ganz so, als hätte der User jede einzelne Ziffer über die Tastatur eingegeben und anschließend 'Enter' gedrückt. Ich konnte zwar keinen solchen Fall finden, aber sollte der Leser einen Barcode mal wirklich nicht erkennen, kann der User die Nummern in das Eingabefeld der GUI eingeben und anschließend 'Return' drücken -- der Effekt ist der gleiche.

Farbbild inklusive

Das heute vorgestellte Skript upcscan baut eine auf dem Toolkit Tk basierende graphische Oberfläche auf, deren Texteingabefeld sich sofort nach dem Start den Tastaturfokus schnappt. Falls der Barcodereader einen Code erkennt, landen dessen Ziffern im Eingabefeld. Auf das vom Leser abschließend gesandte Return-Zeichen reagiert die GUI mit dem Aufruf einer Callback-Funktion scan_done(). Diese schickt den UPC-Code an den Webservice von amazon.com und bekommt nach etwa einer Sekunde nicht nur Titel und Autor/Interpret des Buches/CD/DVD, sondern auch einen URL, hinter dem sich ein JPG-Bild des Buchdeckels oder der CD-Hülle zum Herunterladen versteckt.

Abbildung 3: Das Skript hat den eingelesenen UPC-Code an Amazon.com geschickt und die zugehörigen Produktdaten eingeholt.

Abbildung 3 zeigt die Applikation kurz nach dem Einscannen des Barcodes auf dem Rücken eines JavaScript-Buches. Die Datenfelder sind korrekt ausgefüllt und die Abbildung zeigt den richtigen Buchdeckel. In Abbildung 4 ist das Ergebnis einer eingescannten CD des Beach-Boys-Sängers Brian Wilson zu sehen. In beiden Fällen hinterlegt das Skript die von Amazon eingeholten Daten in einer lokalen SQLite-Datenbank, in der man dann anschließend nach Herzenslust programmatisch herumstöbern kann (Abbildung 5).

Abbildung 4: Auch CDs und DVDs erkennt der Scanner anhand des UPC-Codes und holt Produktdaten und Coverbild von Amazon.com.

Abbildung 5: Die SQLite-Datenbank enthält anschließend alle eingescannten Artikel.

Immer weiterticken

Mit dem Tk-Paket vom CPAN zaubern Perl-Skripts leicht ansehnliche GUIs, allerdings stellt sich wie immer die Frage nach langlaufenden Operationen wie Web-Requests, die die Oberfläche einfrieren lassen. Denn eine Anfrage an Amazon mit einem UPC-Code kann schon mal einige Sekunden dauern, und in der Zwischenzeit wäre die Oberfläche tot.

Das ebenfalls auf dem CPAN erhältliche POE-Modul lässt die GUI aus diesem Grund in einem Event-basierten Kernel ticken und stellt Mechanismen für ein kooperatives Multitasking zur Verfügung. Webrequests arbeitet ein Skript in dieser Umgebung nicht mehr syncron ab. Statt dessen setzt es zunächst einen Request an den Webserver ab und gibt die Kontrolle sofort an den POE-Kernel weiter. Liegt die Antwort aus dem Internet dann endlich vor, weckt der Kernel die wartende Task und übermittelt die vorliegenden Daten.

Die Kommunikation mit Amazon übernimmt das CPAN-Modul Net::Amazon, das eine Vielzahl von Anfragen an den Webservice des Warengiganten unterstützt. Es verwendet allerdings intern nicht das asyncrone POE für die Anfragen an die Amazon-Datenbank, sondern das syncrone LWP::UserAgent. Doch es lässt sich mit dem Parameter ua dazu überreden, einen hereingereichten Useragenten zu verwenden. Auf dem CPAN steht LWP::UserAgent::POE bereit, ein Agent mit der LWP-Schnittstelle, aber mit besonderer Berücksichtigung der asyncronen Bedürfnisse des POE-Kernels. Während das Modul scheinbar Web-Requests absetzt und syncron auf das Ergebnis wartet, ist in seinem Innern schwarze Magie am Werke, die den POE-Kernel immer wieder ein paar Ticks weiterlaufen lässt, damit auch andere Tasks drankommen.

Listing 1: upcscan

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Tk;
    004 use Tk::JPEG;
    005 use POE;
    006 use LWP::UserAgent::POE;
    007 use Net::Amazon;
    008 use Net::Amazon::Request::UPC;
    009 use MIME::Base64;
    010 use Rose::DB::Object::Loader;
    011 use Log::Log4perl qw(:easy);
    012 
    013 my @MODES = qw(books music dvd);
    014 
    015 my $UA = LWP::UserAgent::POE->new();
    016 
    017 my $loader = Rose::DB::Object::Loader->new(
    018   db_dsn => 
    019     "dbi:SQLite:dbname=articles.dat",
    020   db_options   => {
    021     AutoCommit => 1, RaiseError => 1 },
    022 );
    023 $loader->make_classes();
    024 
    025 my $top = $poe_main_window;
    026 $top->configure(-title     => "UPC Reader", 
    027                 -background=> "#a2b2a3");
    028 $top->geometry("200x300");
    029 
    030 my $FOOTER = $top->Label();
    031 $FOOTER->configure(-text => 
    032                    "Scan next item");
    033 
    034 my $BYWHO = $top->Label();
    035 my $UPC   = $top->Label();
    036 my $PHOTO = $top->Photo(-format => 'jpeg');
    037 my $photolabel = 
    038              $top->Label(-image => $PHOTO);
    039 my $entry = $top->Entry(
    040             -textvariable => \my $UPC_VAR);
    041 
    042 my $PRODUCT = $top->Label();
    043 
    044 $entry->focus();
    045 
    046 for my $w ($entry, $photolabel, $PRODUCT, 
    047            $BYWHO, $UPC, $FOOTER) {
    048   $w->pack(-side => 'top', -expand => 1, 
    049            -fill => "x" );
    050 }
    051 
    052 $entry->bind("<Return>", \&scan_done);
    053 
    054 my $session = POE::Session->create(
    055   inline_states => { 
    056     _start => sub{
    057       $poe_kernel->delay("_start", 60);
    058   } 
    059 });
    060 
    061 POE::Kernel->run();
    062 
    063 ###########################################
    064 sub scan_done {
    065 ###########################################
    066   $PHOTO->blank();
    067   $PRODUCT->configure(-text => "");
    068   $FOOTER->configure(-text => 
    069                      "Processing ...");
    070   $BYWHO->configure(-text => "");
    071   $UPC->configure(-text => $UPC_VAR);
    072   resp_process(
    073           amzn_fetch( $UPC_VAR ) );
    074   $UPC_VAR = "";
    075 }
    076 
    077 ###########################################
    078 sub amzn_fetch {
    079 ###########################################
    080   my($upc) = @_;
    081 
    082   my $resp;
    083 
    084   my $amzn = Net::Amazon->new(
    085       token => 'XXXXXXXXXXXXXXXXXXXX',
    086       ua    => $UA,
    087   );
    088 
    089   for my $mode (@MODES) {
    090 
    091     my $req = 
    092       Net::Amazon::Request::UPC->new(
    093           upc  => $upc,
    094           mode => $mode,
    095       );
    096 
    097      $resp = $amzn->request($req);
    098 
    099      if($resp->is_success()) {
    100          return($resp, $mode, $upc);
    101          last;
    102      }
    103 
    104      WARN "Nothing found in mode '$mode'";
    105   }
    106   return $resp;
    107 }
    108 
    109 ###########################################
    110 sub resp_process {
    111 ###########################################
    112   my($resp, $mode, $upc) = @_;
    113 
    114   if($resp->is_error()) {
    115     $PRODUCT->configure(
    116                  -text => "NOT FOUND");
    117     return 0;
    118   }
    119 
    120   my ($property) = $resp->properties();
    121   my $imgurl = $property->ImageUrlMedium();
    122   img_display( $imgurl );
    123 
    124   my $a = Article->new();
    125   $a->upc($upc);
    126   $a->type($mode);
    127   $a->title( $property->Title() );
    128 
    129   if($mode eq "books") {
    130     $a->bywho( $property->author() );
    131   } elsif( $mode eq "music") {
    132     $a->bywho( $property->artist() );
    133   } else {
    134     $a->bywho( "" );
    135   }
    136 
    137   $BYWHO->configure(-text => $a->bywho() );
    138   $PRODUCT->configure( 
    139                     -text => $a->title() );
    140 
    141   if($a->load( speculative => 1 )) {
    142       $PRODUCT->configure(
    143                 -text => "ALREADY EXISTS");
    144   } else {
    145     $a->save();
    146   }
    147 
    148   $FOOTER->configure(
    149                 -text => "Scan next item");
    150   return 1;
    151 }
    152 
    153 ###########################################
    154 sub img_display {
    155 ###########################################
    156   my($imgurl) = @_;
    157   
    158   my $imgresp = $UA->get( $imgurl );
    159 
    160   if($imgresp->is_success()) {
    161     $PHOTO->configure( -data => 
    162      encode_base64( $imgresp->content() ));
    163   }
    164 }

Ab in die Bank

upcscan nutzt den Datenbankwrapper Rose::DB vom CPAN, um das Schema der Datenbank zu ermitteln und neue Records in deren Tabelle articles einzufügen (Abbildung 6). Zeile 19 setzt die Datei articles.dat im aktuellen Verzeichnis als SQLite-Datenbank und die nachfolgenden Optionen Autocommit und RaiseError stellen sicher, dass neue Einträge ohne extra Commit-Befehl in die Datenbank wandern und eventuell auftretende Fehler sofort eine Exception werfen.

Die Methode make_classes() in Zeile 23 importiert dann die Datenbankobjekte in den Scriptcode, sodass später ein einfaches Article->new() genügt, um einen neuen Eintrag in die Datenbanktabelle articles vorzubereiten.

Widgets im POE-Reigen

Die graphische Oberfläche ruht im Hauptfenster $top, das Zeile 25 von $poe_main_window übernimmt, da ja Tk im Skript nicht allein vor sich hin orgelt, sondern den POE-Tango tanzt. Wenn 'use Tk' im Code vor 'use POE' steht, weiß POE, dass es eine Eventschleife für das Tk-GUI bereitstellen muss, initialisiert bereits das Hauptfenster MainWindow, und legt eine Referenz darauf in $poe_main_window ab.

In der Kopfzeile des Fensters legt der configure()-Befehl den String ``UPC Reader'' ab und setzt und die Hintergrundfarbe der GUI auf ``#a2b2a3'', also helles Olivgrün.

Ganz oben im Hauptfenster liegt das Entry-Widget $ENTRY, das die Zahlenkolonnen des Scanners entgegennimmt und diese in der globalen Variablen $UPC_VAR ablegt. Weiter unten befindet sich ein Photo-Widget für die Buch- und CD-Cover, welches wiederum aus organisatorischen Gründen in einem Label-Widget ruht. Weiter folgen vier Widgets vom Typ Label, die den Titel ($PRODUCT), Autor/Interpret ($BYWHO), den gelesenen UPC-Code ($UPC) und eine Statusanzeige ($FOOTER, ganz unten) aufnehmen.

Die for-Schleife ab Zeile 46 packt die Widgets von oben nach unten in das Hauptfenster und stellt mit -fill => 'x' und -expand => 1 sicher, dass sich die Labels horizontal bis zum Rand ausdehnen und auch beim Aufziehen des Hauptfensters automatisch mitziehen.

Eine kritische Rolle kommt dem bind()-Befehl in Zeile 52 zu. Das Entry-Widget ignoriert das Return-Zeichen des Scanners, denn es handelt sich um ein einzeiliges Eingabefeld. bind bindet den Tastaturcode aber an die Funktion scan_done(), die ab Zeile 64 definiert ist und die Verarbeitung des vom Scanner eingelesenen Codes veranlasst.

Zunächst löscht sie mit der Methode blank() des Photo-Objektes die Anzeige des alten Covers und auch die Anzeige des Titels und des Autoren/Interpreten setzt sie auf den Leerstring. Im $FOOTER erscheint der Text ``Processing ...'' und der Request an Amazon wird mit amzn_fetch() abgesetzt.

Zeile 74 löscht den vom Scanner gelesenen UPC-Code dann sofort wieder aus dem Entry-Widget, um es auf den nächsten Lesevorgang vorzubereiten. Die UPC des aktuellen Artikels ist ja im $UPC-Widget gesichert.

Nach diesen Vorbereitungen definiert Zeile 54 eine POE-Session und Zeile 61 startet den POE-Kernel, der ab diesem Zeitpunkt das Programm bis zu dessen Abbruch steuert. Er nimmt Benutzereingaben wie Mausklicks oder Tastatur-/Scannereingaben engegeben und sorgt dafür, dass jede anstehende Task ihr Zeitscheibchen bekommt.

POE regiert seine Sessions mit eiserner Hand. Sobald diese nichts mehr zu tun haben, eliminiert es sie rücksichtslos. Dass eine Tk-Applikation einfach nur auf Usereingaben wartet, begreift es nicht, deshalb definieren die Zeilen 54 bis 59 eine Session, die im 60-Sekunden-Takt wieder in den anfänglich durchlaufenen _start-Event anspringt.

Frag nach bei Amazon

Liefert der Scanner einen UPC-Code an, erzeugt die Funktion amzn_fetch ab Zeile 78 eine Instanz eines Net::Amazon-Objektes und übergibt ihm nicht nur den Developer-Token, den der Skriptanwender von Amazon holen muss (siehe Abschnitt Installation), sondern auch den vorher global erzeugten Spezial-Agenten LWP::UserAgent::POE, der nicht nur Web-Requests einholt, sondern auch den POE-Tango tanzt.

Ein Request-Objekt vom Typ Net::Amazon::Request::UPC spricht mit dem Webservice bei Amazon, der den UPC-Lookup bereitstellt. Die zurückgelieferte Antwort bietet die Methode is_success() an, die angibt, ob ein entsprechender UPC-Code gefunden wurde. Der Request muss vorher angeben, ob der UPC-Code im Bereich ``books'', ``music'' (CDs) oder ``dvd'' zu suchen ist. Da der Scanner nicht weiß, ob er gerade ein Buch oder eine CD scannt, probiert die for-Schleife ab Zeile 89 einfach alle drei unterstützten Bereiche durch und bricht ab, sobald Amazon Erfolg meldet. amzn_fetch liefert drei Parameter zurück: Das Antwort-Objekt $resp, den Bereich (books/music/dvd), in dem es fündig geworden ist und den eingescannten UPC-Code.

Die ab Zeile 110 definierte Funktion resp_process() schnappt sich das Ergebnis und frischt die Felder der GUI damit auf. Die Methode ImageUrlMedium() des gefundenen Artikels $property gibt eine URL zu einem JPEG-Bild mittlerer Größe an, das das Produkt als Buchdeckel oder Albumcover darstellt.

JPEGs lesen

Damit das Photo-Widget aus dem Tk-Toolkit auch JPG-Bilder lesen und darstellen kann, holt Zeile 4 das Modul Tk::JPEG aus der Tk-Distribution herein. Die ab Zeile 154 definierte Funktion img_display nimmt den URL eines Bildes auf amazon.com entgegen und holt selbiges mit dem POE-freundlichen Useragenten vom Netz.

Da das Photo-Widget (zumindest für JPEGs) stur auf Base64-kodierten Daten besteht, wandelt die Funktion encode_base64 aus dem Modul MIME::Base64 die mittels der content()-Methode extrahierten JPEG-Daten um, bis sie dem Photo-Widget schmecken. Anschließend setzt die configure()-Methode des Photo-Widgets die Option -data auf die kodierten Daten, was wiederum das Widget dazu veranlasst, die Daten zu lesen, in das interne Tk-Format umzuwandeln und auf dem GUI anzuzeigen.

Datenbank mit Wrapper

Zurück zu resp_process(): Es zeigt nicht nur die Produktdaten an, sondern legt sie auch noch in der Datenbank ab. Hierzu legt es in Zeile 124 mit Hilfe des Rose::DB-Wrappers ein neues Object vom Typ Article an und setzt dessen Felder upc, type, title und bywho, die sich allesamt auf die Spalten der Datenbanktabelle beziehen.

Die Methode load() mit dem Attribut speculative versucht anschließend, einen entsprechenden Eintrag in der Datenbank zu finden. Führt dies zum Erfolg, schreibt das Skript "ALREADY EXISTS" in die GUI-Anzeige, damit der Operator weiß, dass er den Artikel schon einmal erfolgreich gescannt hat. Schlägt load() fehl, sichert save() den neu erfassten Artikel in Zeile 145 in der Datenbank.

Installation

Bevor das Skript den Webservice von Amazon.com nutzen kann, muss der User einen Entwicklertoken von Amazon holen. Dies geht problemlos und schnell unter [3] wenn man sich mit einer gültigen Email registriert. Ohne gültigen Token wird das Skript immer nur NOT FOUND melden.

Abbildung 6: Das Schema der SQLite-Datenbank

Die Datenbank richtet der SQLite-Client sqlite3 ein, wenn er die Schema-Datei (Abbildung 5) reingeschoben bekommt:

    sqlite3 articles.dat <schema.sql

Dies richtet die File-basierte Datenbank articles.dat ein und erzeugt darin ein leere Tabelle articles mit den Spalten id, upc (dem UPC-Code), type (``books'', ``music'' oder ``dvd''), title (Titel des Buchs oder der CD/DVD) und bywho (Autor oder Interpret). Das UNIQUE-Kommando im SQL der Datebanktabelle macht aus dem Artikeltyp und der UPC-Nummer einen eindeutigen Schlüssel, sodass der Datenbankwrapper Rose bei einem neu eingescannten Artikel schnell mit load() nachsehen kann, ob das Produkt schon einmal gescannt wurde oder nicht.

Die verwendeten Module sind allesamt vom CPAN erhältlich und werden mit einer CPAN-Shell installiert. Die mit dem Scanner erstellte Artikeldatenbank lässt sich anschließend auf vielerlei Weise nutzen: Als Einkaufshilfe (``Hab ich dieses Buch schon?''), als Online-Bibliotheksindex, als CD-Archiv, oder, falls man auch noch eine Ortsbeschreibung einfügt (z.B. ``Zimmer 1, Regal 4, Fach 3''), als Orientierungshilfe für zerstreute Bibliothekare.

Infos

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

[2]
http://www.dealextreme.com/details.dx/sku.12559

[3]
Amazon Web Service (Entwicklertoken abholen) http://amazon.com/soap

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.