Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser, ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi. Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll. Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0". Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten. Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist eine Vorlage, in die setup.js Adresse, Code, print-server.py und die systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben. Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32 Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant verglichen, hinter einem eigenen Zaehler. Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript. Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen, Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die uebrige API weiterhin 401 liefert. Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bondrucker-Bruecke
Kleiner Dienst für den Raspberry Pi, an dem der Kassendrucker per USB hängt.
Warum es ihn gibt
Der Bondrucker ist ein USB-Gerät (/dev/usb/lp0) und damit nur für den Rechner
sichtbar, an dem er steckt. Der Timer-Server läuft woanders. Diese Brücke nimmt
fertige ESC/POS-Daten per HTTP entgegen und schiebt sie in das Gerät — mehr
nicht. Der Bon selbst wird in der Timer-App gebaut (backend/receipt.js), damit
Layout-Änderungen nicht auf dem Pi angefasst werden müssen.
Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
Installation — der übliche Weg
In der Timer-App: ⚙️ Einstellungen → 🔧 Drucker einrichten → 🔗 Einrichtungslink erzeugen. Die App zeigt einen Befehl an. Der wird auf dem Pi in ein Terminal getippt:
curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
sudo bash bondrucker.sh
Das Skript fragt, welcher Drucker verwendet werden soll, richtet alles ein und
meldet den Pi anschließend selbst bei der Timer-App an. In der Oberfläche
springt die Anzeige von „wartet auf den Pi" auf den fertigen Drucker um. In der
.env ist nichts einzutragen.
Der Pi braucht dafür nur Python 3, systemd, curl und den Drucker am
USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt das ab Werk.
Warum der Pi sich meldet und nicht umgekehrt
Früher hat die App den Pi per SSH eingerichtet. Das verlangte ein SSH-Passwort in einem Webformular und eine Verbindung vom Server zum Pi. Beides ist jetzt weg: kein Passwort verlässt den Laden, und die Verbindung muss nur in eine Richtung funktionieren. Wer einen Drucker anschließt, steht ohnehin am Pi.
Der Link enthält einen Einrichtungscode. Er gilt einmal und 30 Minuten, liegt nur im Arbeitsspeicher der App und ist nach einem Neustart der App ungültig. Wer den Code hat, kann den Drucker der App umbiegen — er gehört deshalb nicht in einen Chatverlauf.
Was das Skript anlegt
| Was | Wo |
|---|---|
Dienstbenutzer bondrucker (Gruppe lp) |
— |
| Programm | /opt/bondrucker/print-server.py |
| Token und Gerät | /etc/bondrucker/env (0640, root:bondrucker) |
| udev-Regel | /etc/udev/rules.d/99-bondrucker.rules |
| Dienst | bondrucker.service, aktiviert und gestartet |
Das Skript ist wiederholbar: ein erneuter Aufruf überschreibt Programm und Token und startet den Dienst neu. Ohne den Neustart liefe er mit dem alten Token weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen.
Schalter für den Fall, dass nicht gefragt werden soll:
sudo bash bondrucker.sh --device /dev/usb/lp1 --port 9100
Installation ohne die Timer-App
einrichten.sh im Quelltext ist eine Vorlage: die Stellen @@APP_URL@@,
@@SETUP_CODE@@, @@PRINT_SERVER@@ und @@SERVICE@@ füllt die App beim
Ausliefern. Direkt ausführbar ist sie deshalb nicht.
Ohne die App werden die Dateien von Hand abgelegt — print-server.py nach
/opt/bondrucker/, bondrucker.service nach /etc/systemd/system/, dazu
/etc/bondrucker/env mit PRINT_TOKEN= und PRINT_DEVICE=. Adresse, Port und
Token kommen anschließend von Hand in die Einstellungen der Timer-App.
Timer-Server konfigurieren
Nach der Einrichtung über den Link: nichts zu tun.
Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten"
lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang
liegt in der Datenbank, nicht in der .env — ein Neustart des Dienstes ist für
einen Druckerwechsel nicht nötig.
Die Werte PRINTER_HOST, PRINTER_PORT, PRINTER_TOKEN und PRINTER_WIDTH
in der .env sind nur die Startwerte beim allerersten Start. Sind sie leer,
ist die Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe,
bis ein Drucker eingerichtet wird.
Prüfen
# Zustand des Druckers
curl -s http://<PI-IP>:9100/status
# Aus der App heraus: Einstellungen -> "Probedruck"
Antwortet /status mit "ok": true, ist das Gerät da und beschreibbar.
Schnittstelle
| Aufruf | Wirkung |
|---|---|
GET /status |
JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist |
POST /print |
Rumpf = rohe ESC/POS-Bytes, Kopfzeile X-Print-Token. Antwortet {"ok":true,"bytes":N} |
/status verlangt kein Token — der Zustand des Druckers ist nicht geheim
und soll für Health-Checks abfragbar bleiben. Wird trotzdem eines mitgeschickt,
meldet die Antwort in tokenAccepted, ob es passt. Die App nutzt das nach der
Einrichtung als Gegenprobe: erreichbar allein genügt nicht, das Token muss auch
angenommen werden.
Fehler kommen als HTTP 4xx/5xx mit {"error": "..."} zurück — die Timer-App
zeigt diesen Text unverändert an. „Papier leer" und „Deckel offen" landen als
OSError beim Schreiben und damit als HTTP 502 in der Anzeige.
Betriebshinweise
- Feste IP vergeben. Der Pi hängt per WLAN und DHCP im Netz. Wechselt die
Adresse, druckt die App nicht mehr, bis
PRINTER_HOSTangepasst ist. Eine DHCP-Reservierung im Router ist der einfachste Weg. - USB-Kabel neu gesteckt? Aus
lp0kannlp1werden. Der Dienst sucht deshalb selbst nach/dev/usb/lp*— es ist nichts zu tun. - Ein Auftrag nach dem anderen. Der Dienst serialisiert Druckaufträge, zwei gleichzeitige Bons können sich nicht ineinanderschieben.
- Netzgrenze. Port 9100 sollte nur aus dem eigenen Netz erreichbar sein. Das Token schützt gegen versehentliche, nicht gegen entschlossene Zugriffe.
Fehlersuche
systemctl status bondrucker
journalctl -u bondrucker -f # jeder Auftrag wird protokolliert
# Am Pi direkt drucken, ohne App und ohne Dienst:
printf 'Test\n\n\n\n\x1dVB\x00' | sudo tee /dev/usb/lp0 > /dev/null
| Symptom | Ursache |
|---|---|
/status sagt deviceExists: false |
Drucker aus oder USB-Kabel ab |
/status sagt writable: false |
udev-Regel nicht gegriffen — sudo udevadm trigger, Dienst neu starten |
| HTTP 403 | PRINTER_TOKEN in der .env weicht von /etc/bondrucker/env ab |
| App meldet „nimmt keine Verbindung an" | Dienst läuft nicht, oder falsche IP/Port |
| Einrichtung bricht bei „Dienst eingerichtet" ab | sudo-Passwort falsch, oder der Benutzer darf kein sudo |
| Einrichtung meldet „lehnt das neue Token ab" | auf dem Pi läuft noch eine Brücke vor 1.1 — Einrichtung wiederholen |
| Umlaute als Kringel | Drucker beherrscht Codepage 16 (WPC1252) nicht — siehe backend/escpos.js |