diff --git a/DEPLOY.md b/DEPLOY.md index 01ae6d9..2ac78c2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -211,6 +211,7 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen. | `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi | | `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen | | `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm | +| `PRINTER_DEVICE` | leer | Gerät am Pi, z. B. `/dev/usb/lp0`. Leer = erstes gefundenes. Wird über die Oberfläche gepflegt | | `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet | | `PUBLIC_URL` | leer | Adresse, unter der der Pi die App erreicht. Leer = aus der Anfrage abgeleitet (siehe Abschnitt 6) | @@ -328,11 +329,33 @@ curl -fsSL "http:///setup/bondrucker.sh?code=…" -o bondrucker.sh sudo bash bondrucker.sh ``` -Das Skript prüft Python/systemd, **fragt, welcher Drucker verwendet werden -soll** (mit Klartextnamen aus dem sysfs, also „EPSON TM-T88IV" statt „lp0"), -legt Dienstbenutzer, Programm, udev-Regel und systemd-Unit an, erzeugt ein -Token und meldet sich damit selbst bei der App an. Die Oberfläche pollt -`/api/printer/setup-status` und springt um, sobald der Pi sich gemeldet hat. +Das Skript prüft Python/systemd, legt Dienstbenutzer, Programm, udev-Regel und +systemd-Unit an, erzeugt ein Token und meldet sich damit selbst bei der App an. +Es läuft ohne Rückfrage durch. Die Oberfläche pollt `/api/printer/setup-status` +und springt um, sobald der Pi sich gemeldet hat. + +#### Welcher Drucker bedient wird + +Das entscheidet die **App**, nicht der Pi. `GET /api/printer/devices` reicht die +Frage an die Brücke weiter, die alle `/dev/usb/lp*` mit Klartextnamen aus dem +sysfs zurückgibt („EPSON TM-T88IV" statt „lp0" — bei zwei Druckern der +Unterschied zwischen Wissen und Raten). Die Auswahl landet in der Einstellung +`printer_device` und geht bei **jedem** Auftrag im Kopf `X-Print-Device` mit. + +Die Brücke hält dazu keinen Zustand: es gibt genau eine Stelle, die weiß, +welcher Drucker gemeint ist, und das ist die, an der es bedient wird. Ein +`PRINT_DEVICE` in `/etc/bondrucker/env` schreibt das Einrichtungsskript +deshalb nicht mehr. + +Leer heißt „automatisch": die Brücke nimmt das erste vorhandene `/dev/usb/lp*`. +Das ist auch der Rückfall, wenn nach dem Aus- und Einstecken aus `lp0` ein +`lp1` geworden ist. + +Der Pfad kommt damit von außen und wird an **beiden** Enden gegen +`^/dev/(usb/)?lp[0-9]{1,3}$` geprüft — in `PUT /api/settings` und noch einmal in +der Brücke. Eine Schranke, die nur an einem Ende steht, ist keine: ohne die +Prüfung auf dem Pi ließe sich der Dienst dazu bringen, in eine beliebige Datei +zu schreiben. **Warum der Pi sich meldet und nicht umgekehrt.** Vorher richtete die App den Pi per SSH ein. Das verlangte ein SSH-Passwort in einem Webformular, eine @@ -505,8 +528,9 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und | `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet | | `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern | | `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen | -| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` | +| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle, printerDevice}` | | `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers | +| `GET` | `/api/printer/devices` | welche Drucker am Pi hängen, mit Klartextnamen | | `POST` | `/api/printer/test` | Probedruck | | `POST` | `/api/printer/setup-code` | erzeugt einen Einrichtungscode, liefert Abhol-Link und fertigen Befehl | | `GET` | `/api/printer/setup-status` | hat sich schon ein Pi gemeldet? | diff --git a/README.md b/README.md index d5072a2..ee2e17e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ Darunter steht, was auf dem Bon gedruckt wird: * **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der Bon genau wie vorher. * **Artikel in der Kasse** — der Name, der im Strichcode steckt. +* **Drucker am Pi** — an welchem Gerät gedruckt wird. **🔄 Suchen** fragt den + Pi, was gerade angeschlossen ist, und zeigt die Drucker mit Namen an + („EPSON TM-T88IV"). „Automatisch" nimmt den ersten gefundenen — richtig, + solange nur ein Drucker am Pi hängt. * **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand. @@ -133,10 +137,12 @@ eingegeben — über **📋 Kopieren** landet er in der Zwischenablage: curl -fsSL "…" -o bondrucker.sh && sudo bash bondrucker.sh ``` -Das Skript zeigt dann alle angeschlossenen Drucker mit Namen an und fragt, -welcher verwendet werden soll. Danach richtet es sich selbst ein und meldet -sich bei der Timer-App. Die Anzeige hier springt von „wartet auf den Pi" auf -den fertigen Drucker um — es ist nichts weiter zu tun. +Das Skript installiert die Software und meldet den Pi anschließend bei der +Timer-App an. Die Anzeige hier springt von „wartet auf den Pi" auf den fertigen +Drucker um. + +Danach oben unter **Drucker am Pi** auf **🔄 Suchen** tippen und den Drucker +auswählen. Hängt nur einer am Pi, kann „Automatisch" stehen bleiben. Der Link **gilt nur einmal und eine halbe Stunde lang**. Ist er abgelaufen, einfach einen neuen erzeugen. Weitergeben sollte man ihn nicht: wer ihn hat, diff --git a/backend/.env.example b/backend/.env.example index a8111a1..8ad9f06 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -80,6 +80,12 @@ PRINTER_TOKEN= # Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier. PRINTER_WIDTH=42 +# Welcher Drucker am Pi bedient wird, z. B. /dev/usb/lp0. LEER lassen: dann +# nimmt die Bruecke das erste gefundene Geraet. Ausgewaehlt wird das ohnehin in +# den Einstellungen - dort zeigt "Drucker am Pi" alles an, was angeschlossen +# ist, mit Klartextnamen. +PRINTER_DEVICE= + # Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker # darf das Beenden eines Timers nicht blockieren. PRINTER_TIMEOUT_MS=6000 diff --git a/backend/printer.js b/backend/printer.js index 1b2940b..a872a0d 100644 --- a/backend/printer.js +++ b/backend/printer.js @@ -25,6 +25,10 @@ let cfg = { port: 9100, token: '', width: 42, + // Welcher Drucker am Pi bedient wird. Leer = die Bruecke sucht sich selbst + // einen. Die Wahl lebt hier und nicht auf dem Pi, damit sie dort bedient + // werden kann, wo auch alles andere eingestellt wird. + device: '', timeoutMs: parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000 }; @@ -40,7 +44,8 @@ function setConfig(next = {}) { host: String(next.host == null ? cfg.host : next.host).trim(), port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port, token: next.token == null ? cfg.token : String(next.token).trim(), - width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width + width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width, + device: next.device == null ? cfg.device : String(next.device).trim() }; return cfg; } @@ -55,7 +60,10 @@ function width() { /** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */ function config() { - return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token }; + return { + host: cfg.host, port: cfg.port, width: cfg.width, device: cfg.device, + timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token + }; } function state() { @@ -64,6 +72,7 @@ function state() { host: cfg.host || null, port: isConfigured() ? cfg.port : null, width: cfg.width, + device: cfg.device || null, lastError, lastPrintAt }; @@ -83,6 +92,9 @@ function request(pfad, method, body) { headers['Content-Length'] = body.length; } if (cfg.token) headers['X-Print-Token'] = cfg.token; + // Ohne diesen Kopf sucht sich die Bruecke selbst ein Geraet. Mit ihm + // gilt genau das, was in den Einstellungen steht. + if (cfg.device) headers['X-Print-Device'] = cfg.device; const req = http.request( { host: cfg.host, port: cfg.port, path: pfad, method, headers }, @@ -125,6 +137,22 @@ function request(pfad, method, body) { /** Zustand der Bruecke abfragen (Geraet da? beschreibbar?). */ async function status() { const res = await request('/status', 'GET'); + + // Die Bruecke antwortet mit 503, wenn sie zwar laeuft, der Drucker aber + // nicht bereit ist: Kabel ab, falsches Geraet gewaehlt, kein Schreibrecht. + // Das ist kein Protokollfehler - und der Rumpf sagt, was genau fehlt. + // Ohne diese Auswertung stuende in der Oberflaeche "HTTP 503", was niemandem + // verraet, dass nur ein USB-Stecker lose ist. + const p = res.data; + if (!res.ok && p && p.version) { + lastError = !p.deviceExists + ? `Gerät ${p.device} ist am Pi nicht vorhanden. Kabel prüfen oder einen anderen Drucker wählen.` + : !p.writable + ? `Auf ${p.device} darf der Druckdienst nicht schreiben.` + : p.lastError || 'Drucker nicht bereit.'; + return { ok: false, error: lastError, printer: p, ...state() }; + } + if (!res.ok) { lastError = res.error; return { ok: false, error: res.error, ...state() }; @@ -132,6 +160,16 @@ async function status() { return { ok: true, printer: res.data, ...state() }; } +/** Welche Drucker am Pi haengen - die Auswahl fuer die Einstellungen. */ +async function devices() { + const res = await request('/devices', 'GET'); + if (!res.ok) { + lastError = res.error; + return { ok: false, error: res.error }; + } + return { ok: true, devices: (res.data && res.data.devices) || [], fallback: res.data && res.data.fallback }; +} + /** Rohe ESC/POS-Daten an den Drucker geben. */ async function print(buffer) { const res = await request('/print', 'POST', buffer); @@ -145,4 +183,4 @@ async function print(buffer) { return res; } -module.exports = { setConfig, isConfigured, width, config, state, status, print }; +module.exports = { setConfig, isConfigured, width, config, state, status, devices, print }; diff --git a/backend/server.js b/backend/server.js index cc274aa..4edb9d8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -170,6 +170,10 @@ async function initDatabase() { [(process.env.PRINTER_TOKEN || '').trim()]); await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`, [String(parseInt(process.env.PRINTER_WIDTH) || 42)]); + // Leer heisst: die Bruecke auf dem Pi sucht sich selbst ein Geraet. + // Sobald in den Einstellungen einer gewaehlt wurde, gilt der. + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_device',?)`, + [(process.env.PRINTER_DEVICE || '').trim()]); await refreshPrinterConfig(); @@ -212,10 +216,12 @@ async function refreshPrinterConfig() { host: map.printer_host || '', port: map.printer_port, token: map.printer_token || '', - width: map.printer_width + width: map.printer_width, + device: map.printer_device || '' }); console.log(cfg.host - ? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile` + ? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile, ` + + `Geraet ${cfg.device || 'automatisch'}` : 'Bondrucker: nicht eingerichtet'); return cfg; } @@ -732,6 +738,16 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => { await setSetting('printer_width', String(w)); druckerGeaendert = true; } + if (typeof b.printerDevice === 'string') { + const geraet = b.printerDevice.trim(); + // Derselbe Rahmen wie auf dem Pi. Der Wert wird dort noch einmal + // geprueft - eine Schranke, die nur an einem Ende steht, ist keine. + if (geraet && !/^\/dev\/(usb\/)?lp[0-9]{1,3}$/.test(geraet)) { + return res.status(400).json({ error: 'Das ist kein Druckergerät (erwartet: /dev/usb/lp0).' }); + } + await setSetting('printer_device', geraet); + druckerGeaendert = true; + } // Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert, // damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht. if (typeof b.printerToken === 'string' && b.printerToken.trim()) { @@ -755,6 +771,21 @@ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => { res.json({ configured: true, ...await printer.status(), config: printer.config() }); })); +/** + * Welche Drucker am Pi haengen. + * + * Die Liste kommt frisch vom Pi und wird nicht zwischengespeichert: wer sie + * aufruft, hat gerade ein Kabel umgesteckt und will genau das sehen. + */ +app.get('/api/printer/devices', requireAuth, wrap(async (req, res) => { + if (!printer.isConfigured()) { + return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' }); + } + const liste = await printer.devices(); + if (!liste.ok) return res.status(502).json({ error: liste.error }); + res.json({ devices: liste.devices, fallback: liste.fallback, selected: printer.config().device }); +})); + // Einrichtung beruehrt ein fremdes Geraet und ist von aussen erreichbar - // hier deutlich enger begrenzt als beim Drucken. const setupLimiter = rateLimit({ diff --git a/deploy/bondrucker/README.md b/deploy/bondrucker/README.md index 65006eb..ad79d2a 100644 --- a/deploy/bondrucker/README.md +++ b/deploy/bondrucker/README.md @@ -23,10 +23,16 @@ curl -fsSL "" -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. +Das Skript richtet alles ein und meldet den Pi anschließend selbst bei der +Timer-App an — ohne Rückfrage. In der Oberfläche springt die Anzeige von +„wartet auf den Pi" auf den fertigen Drucker um. In der `.env` ist **nichts** +einzutragen. + +**Welcher Drucker bedient wird, wird danach in der Timer-App ausgewählt** +(Einstellungen → *Drucker am Pi* → **🔄 Suchen**). Die App fragt den Pi über +`/devices`, was angeschlossen ist, und schickt den gewählten Pfad bei jedem +Auftrag im Kopf `X-Print-Device` mit. Dieser Dienst hält dazu keinen Zustand: +es gibt genau eine Stelle, die weiß, welcher Drucker gemeint ist. 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. @@ -49,7 +55,7 @@ deshalb nicht in einen Chatverlauf. |---|---| | Dienstbenutzer `bondrucker` (Gruppe `lp`) | — | | Programm | `/opt/bondrucker/print-server.py` | -| Token und Gerät | `/etc/bondrucker/env` (`0640`, root:bondrucker) | +| Token und Port | `/etc/bondrucker/env` (`0640`, root:bondrucker) | | udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` | | Dienst | `bondrucker.service`, aktiviert und gestartet | @@ -57,10 +63,10 @@ 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: +Abweichender Port der Brücke: ```bash -sudo bash bondrucker.sh --device /dev/usb/lp1 --port 9100 +sudo bash bondrucker.sh --port 9100 ``` ## Installation ohne die Timer-App @@ -71,8 +77,8 @@ 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. +`/etc/bondrucker/env` mit `PRINT_TOKEN=`. Adresse, Port und Token kommen +anschließend von Hand in die Einstellungen der Timer-App. ## Timer-Server konfigurieren @@ -104,8 +110,18 @@ Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar. | Aufruf | Wirkung | |---|---| | `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist | +| `GET /devices` | alle `/dev/usb/lp*` mit Klartextnamen. Token nötig | | `POST /print` | Rumpf = rohe ESC/POS-Bytes, Kopfzeile `X-Print-Token`. Antwortet `{"ok":true,"bytes":N}` | +`/status` und `/print` beachten den Kopf **`X-Print-Device`**. Fehlt er, nimmt +die Brücke das eingestellte Gerät und sonst das erste gefundene. Der Pfad wird +gegen `^/dev/(usb/)?lp[0-9]{1,3}$` geprüft — er kommt von außen, und ohne diese +Schranke ließe sich der Dienst dazu bringen, in eine beliebige Datei zu +schreiben. Ein unerlaubter Pfad ergibt HTTP 400. + +`/devices` verlangt ein Token: welche Geräte am Pi hängen, ist zwar kein +Geheimnis, aber auch keine Auskunft für jeden im Netz. + `/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 diff --git a/deploy/bondrucker/einrichten.sh b/deploy/bondrucker/einrichten.sh index a48d7c3..37aa07b 100755 --- a/deploy/bondrucker/einrichten.sh +++ b/deploy/bondrucker/einrichten.sh @@ -13,11 +13,14 @@ # # Der Link steht in der Timer-App unter Einstellungen -> Drucker einrichten. # +# Dieses Skript installiert nur die Software. WELCHER Drucker bedient wird, +# wird anschliessend in der Timer-App ausgewaehlt - sie fragt den Pi, was +# angeschlossen ist. Hier ist dazu nichts einzustellen. +# # Ohne Netz zur Timer-App laeuft das Skript trotzdem durch; es zeigt am Ende # Adresse und Token an, die dann von Hand in die Einstellungen gehoeren. # # Schalter: -# --device /dev/usb/lp0 Drucker vorgeben, statt danach zu fragen # --port 9100 Lausch-Port der Bruecke # --------------------------------------------------------------------------- set -euo pipefail @@ -27,16 +30,14 @@ APP_URL="@@APP_URL@@" SETUP_CODE="@@SETUP_CODE@@" ZIEL=/opt/bondrucker -GERAET="" PORT=9100 # --- Schalter -------------------------------------------------------------- while [ $# -gt 0 ]; do case "$1" in - --device) GERAET="${2:-}"; shift 2 ;; --port) PORT="${2:-9100}"; shift 2 ;; - -h|--help) sed -n '2,22p' "$0"; exit 0 ;; + -h|--help) sed -n '2,26p' "$0"; exit 0 ;; *) echo "Unbekannter Schalter: $1" >&2; exit 1 ;; esac done @@ -55,24 +56,8 @@ punkt() { printf ' %s\n' "$1"; } warnung() { printf ' \033[33m%s\033[0m\n' "$1"; } fehler() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; } -# Fragt den Benutzer und gibt die Antwort auf stdout zurueck. -# -# Die Umleitung auf /dev/tty ist der Grund, warum auch -# "curl ... | sudo bash" noch bedienbar bleibt: dort ist die Standardeingabe -# die Pipe und nicht die Tastatur. Gibt es gar kein Terminal, wird die Vorgabe -# genommen, statt in einer Endlosschleife zu haengen. -frage() { - local text="$1" vorgabe="${2:-}" antwort="" - if [ -t 0 ]; then - read -r -p "$text" antwort || true - elif [ -e /dev/tty ]; then - read -r -p "$text" antwort < /dev/tty || true - fi - printf '%s' "${antwort:-$vorgabe}" -} - -# Klartextname eines USB-Druckers aus dem sysfs, damit in der Auswahl nicht -# nur "lp0" steht, sondern "EPSON TM-T88IV". +# Klartextname eines USB-Druckers aus dem sysfs. Nur zur Anzeige - ausgewaehlt +# wird spaeter in der Timer-App, die dieselbe Liste ueber /devices abfragt. usb_name() { local pfad pfad=$(readlink -f "/sys/class/usbmisc/$(basename "$1")/device" 2>/dev/null) || return 1 @@ -99,41 +84,27 @@ punkt "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s) ($(un punkt "$(python3 --version)" # --------------------------------------------------------------------------- -# 2. Drucker auswaehlen +# 2. Angeschlossene Drucker anzeigen # --------------------------------------------------------------------------- -titel "2. Drucker" +titel "2. Angeschlossene Drucker" -if [ -z "$GERAET" ]; then - GEFUNDEN=() - for d in /dev/usb/lp* /dev/lp*; do - [ -e "$d" ] && GEFUNDEN+=("$d") - done +gefunden=0 +for d in /dev/usb/lp* /dev/lp*; do + [ -e "$d" ] || continue + gefunden=$((gefunden + 1)) + printf ' %-16s %s\n' "$d" "$(usb_name "$d" || echo '')" +done - if [ "${#GEFUNDEN[@]}" -eq 0 ]; then - warnung "Gerade haengt kein Drucker unter /dev/usb/lp* am Pi." - warnung "Das ist kein Hindernis: der Dienst findet ihn, sobald er angesteckt wird." - GERAET=$(frage " Geraetepfad [/dev/usb/lp0]: " "/dev/usb/lp0") - else - echo - for i in "${!GEFUNDEN[@]}"; do - printf ' %d) %-16s %s\n' "$((i + 1))" "${GEFUNDEN[$i]}" "$(usb_name "${GEFUNDEN[$i]}" || echo '')" - done - printf ' a) anderer Pfad\n\n' - - WAHL=$(frage " Welcher Drucker? [1]: " "1") - if [ "$WAHL" = "a" ] || [ "$WAHL" = "A" ]; then - GERAET=$(frage " Geraetepfad: " "/dev/usb/lp0") - elif [ "$WAHL" -ge 1 ] 2>/dev/null && [ "$WAHL" -le "${#GEFUNDEN[@]}" ]; then - GERAET="${GEFUNDEN[$((WAHL - 1))]}" - else - fehler "Ungueltige Auswahl: $WAHL" - fi - fi +if [ "$gefunden" -eq 0 ]; then + warnung "Gerade haengt kein Drucker unter /dev/usb/lp* am Pi." + warnung "Das ist kein Hindernis: der Dienst findet ihn, sobald er angesteckt" + warnung "wird. Danach in der Timer-App die Liste neu abrufen." +else + echo + punkt "Welcher davon genommen wird, wird in der Timer-App ausgewaehlt." fi -punkt "gewaehlt: $GERAET" - # --------------------------------------------------------------------------- # 3. Dienstbenutzer # --------------------------------------------------------------------------- @@ -177,9 +148,11 @@ titel "5. Konfiguration" TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)" install -d -m 750 /etc/bondrucker +# Bewusst OHNE PRINT_DEVICE: welcher Drucker bedient wird, schickt die +# Timer-App bei jedem Auftrag mit. Stuende die Wahl auch hier, gaebe es zwei +# Stellen, die es zu wissen glauben - und eine davon waere irgendwann falsch. { printf 'PRINT_TOKEN=%s\n' "$TOKEN" - printf 'PRINT_DEVICE=%s\n' "$GERAET" printf 'PRINT_PORT=%s\n' "$PORT" } > /etc/bondrucker/env chmod 640 /etc/bondrucker/env @@ -226,11 +199,6 @@ case "$ANTWORT" in *) warnung "Die Bruecke antwortet, lehnt das Token aber ab: $ANTWORT" ;; esac -case "$ANTWORT" in - *'"deviceExists": false'*|*'"deviceExists":false'*) - warnung "Das Geraet $GERAET ist gerade nicht da - Kabel pruefen." ;; -esac - # --------------------------------------------------------------------------- # 7. Bei der Timer-App anmelden # --------------------------------------------------------------------------- @@ -249,13 +217,13 @@ case "$APP_URL" in if ! command -v curl >/dev/null; then warnung "curl fehlt - die Anmeldung muss von Hand erfolgen." else - RUMPF="$(printf '{"code":"%s","port":%s,"token":"%s","device":"%s","hostHint":"%s"}' \ - "$SETUP_CODE" "$PORT" "$TOKEN" "$GERAET" "$IP")" + RUMPF="$(printf '{"code":"%s","port":%s,"token":"%s","hostHint":"%s"}' \ + "$SETUP_CODE" "$PORT" "$TOKEN" "$IP")" if ERGEBNIS="$(curl -fsS -m 15 -X POST \ -H 'Content-Type: application/json' \ -d "$RUMPF" "$APP_URL/api/setup/register" 2>&1)"; then punkt "Bei $APP_URL angemeldet." - punkt "Die Timer-App druckt ab jetzt auf diesen Pi." + punkt "Jetzt dort unter Einstellungen den Drucker auswaehlen." angemeldet=1 else warnung "Anmeldung fehlgeschlagen: $ERGEBNIS" @@ -270,7 +238,6 @@ esac # --------------------------------------------------------------------------- titel "Fertig" -punkt "Drucker: $GERAET" punkt "Adresse: ${IP:-}" punkt "Port: $PORT" diff --git a/deploy/bondrucker/print-server.py b/deploy/bondrucker/print-server.py index ac0013f..a54fa41 100644 --- a/deploy/bondrucker/print-server.py +++ b/deploy/bondrucker/print-server.py @@ -10,12 +10,21 @@ Geraet. Die Timer-App baut den Bon, der Pi druckt ihn nur noch. Bewusst nur Python-Standardbibliothek: auf dem Pi muss dafuer nichts nachinstalliert werden (kein pip, kein CUPS, kein node). +Welcher Drucker genommen wird, entscheidet die Timer-App: sie fragt ueber +/devices ab, was angeschlossen ist, und schickt den gewaehlten Pfad bei jedem +Auftrag im Kopf X-Print-Device mit. Dieser Dienst haelt dazu keinen Zustand - +die Einstellung lebt an einer Stelle, naemlich dort, wo sie bedient wird. + Endpunkte GET /status Zustand des Druckers als JSON (fuer Health-Checks) + GET /devices angeschlossene Drucker mit Klartextnamen (Token noetig) POST /print Rumpf = rohe ESC/POS-Bytes, wird 1:1 an den Drucker gereicht +/status und /print beachten den Kopf X-Print-Device. Fehlt er, wird das +eingestellte Geraet genommen und sonst das erste gefundene. + Konfiguration ueber Umgebungsvariablen (siehe bondrucker.service): - PRINT_DEVICE Geraetedatei, Standard /dev/usb/lp0 + PRINT_DEVICE Rueckfall, wenn die App nichts vorgibt; Standard /dev/usb/lp0 PRINT_HOST Lausch-Adresse, Standard 0.0.0.0 PRINT_PORT Lausch-Port, Standard 9100 PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!) @@ -26,15 +35,21 @@ import glob import hmac import json import os +import re import socketserver import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -VERSION = "1.1" +VERSION = "1.2" DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0") + +# Welcher Drucker genommen wird, entscheidet die Timer-App und schickt es bei +# jedem Auftrag mit. Der Pfad kommt damit von aussen - ohne diese Schranke +# liesse sich der Dienst dazu bringen, in eine beliebige Datei zu schreiben. +DEVICE_MUSTER = re.compile(r"^/dev/(usb/)?lp[0-9]{1,3}$") HOST = os.environ.get("PRINT_HOST", "0.0.0.0") PORT = int(os.environ.get("PRINT_PORT", "9100")) TOKEN = os.environ.get("PRINT_TOKEN", "").strip() @@ -52,21 +67,87 @@ def log(msg): print(msg, flush=True) -def find_device(): - """Konfiguriertes Geraet, sonst das erste vorhandene /dev/usb/lp*. +def _lies(pfad): + try: + with open(pfad) as f: + return f.read().strip() + except OSError: + return "" - Nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0 ein lp1 - werden. Ohne diesen Fallback druckt danach nichts mehr, bis jemand den - Dienst anfasst. + +def usb_name(geraet): + """Klartextname eines USB-Druckers, etwa "EPSON TM-T88IV". + + Dahinter steckt ein Aufstieg im sysfs: die Geraetedatei zeigt auf die + USB-Schnittstelle, die Bezeichnungen haengen aber am Geraet darueber. + Ohne diesen Namen steht in der Auswahl nur "lp0" und "lp1" - bei zwei + Druckern ist das Raten. """ + basis = os.path.basename(geraet) + start = "/sys/class/usbmisc/%s/device" % basis + if not os.path.exists(start): + start = "/sys/class/printer/%s/device" % basis + if not os.path.exists(start): + return "" + + pfad = os.path.realpath(start) + while pfad and pfad != "/": + if os.path.isfile(os.path.join(pfad, "product")): + name = _lies(os.path.join(pfad, "manufacturer")) + " " + \ + _lies(os.path.join(pfad, "product")) + return " ".join(name.split()) + pfad = os.path.dirname(pfad) + return "" + + +def list_devices(): + """Alle angeschlossenen Druckergeraete - die Auswahl fuer die Timer-App.""" + pfade = sorted(set(glob.glob("/dev/usb/lp*")) | set(glob.glob("/dev/lp*"))) + return [ + { + "path": p, + "name": usb_name(p), + "writable": os.access(p, os.W_OK), + } + for p in pfade + if DEVICE_MUSTER.match(p) + ] + + +def find_device(requested=None): + """Welches Geraet bedient werden soll. + + Mit Angabe: genau dieses, sofern der Pfad ueberhaupt ein Druckergeraet sein + kann. Ohne Angabe das eingestellte, und wenn es das nicht gibt, das erste + vorhandene - nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0 + naemlich ein lp1 werden, und ohne diesen Rueckfall druckt danach nichts + mehr, bis jemand den Dienst anfasst. + """ + if requested: + if not DEVICE_MUSTER.match(requested): + raise ValueError("Unerlaubter Geraetepfad: %s" % requested) + return requested if os.path.exists(DEVICE): return DEVICE found = sorted(glob.glob("/dev/usb/lp*")) return found[0] if found else None -def device_status(token_accepted=None): - dev = find_device() +def device_status(token_accepted=None, requested=None): + try: + dev = find_device(requested) + except ValueError as err: + return { + "ok": False, + "version": VERSION, + "tokenRequired": bool(TOKEN), + "tokenAccepted": token_accepted, + "device": requested, + "deviceExists": False, + "writable": False, + "lastError": str(err), + "lastPrintAt": _last_ok, + } status = { "ok": False, "version": VERSION, @@ -79,23 +160,24 @@ def device_status(token_accepted=None): "tokenAccepted": token_accepted, "device": dev or DEVICE, "deviceConfigured": DEVICE, - "deviceExists": dev is not None, + "deviceRequested": requested or None, + "deviceExists": dev is not None and os.path.exists(dev), "writable": False, "lastError": _last_error, "lastPrintAt": _last_ok, } - if dev: + if dev and os.path.exists(dev): status["writable"] = os.access(dev, os.W_OK) status["ok"] = status["writable"] return status -def write_to_printer(data): +def write_to_printer(data, requested=None): """Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError.""" global _last_error, _last_ok with _print_lock: - dev = find_device() + dev = find_device(requested) if dev is None: raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?") @@ -133,16 +215,29 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self): - if self.path.rstrip("/") in ("/status", "/health", ""): - st = device_status(token_ok(self.headers.get("X-Print-Token"))) + pfad = self.path.split("?")[0].rstrip("/") + + if pfad in ("/status", "/health", ""): + st = device_status( + token_ok(self.headers.get("X-Print-Token")), + self.headers.get("X-Print-Device"), + ) self._send(200 if st["ok"] else 503, st) + + elif pfad == "/devices": + # Anders als /status mit Token: welche Geraete am Pi haengen, ist + # zwar kein Geheimnis, aber auch keine Auskunft fuer jeden im Netz. + if not token_ok(self.headers.get("X-Print-Token")): + return self._send(403, {"ok": False, "error": "Token ungueltig"}) + self._send(200, {"ok": True, "devices": list_devices(), "fallback": find_device()}) + else: self._send(404, {"ok": False, "error": "Unbekannter Pfad"}) def do_POST(self): global _last_error - if self.path.rstrip("/") != "/print": + if self.path.split("?")[0].rstrip("/") != "/print": return self._send(404, {"ok": False, "error": "Unbekannter Pfad"}) if not token_ok(self.headers.get("X-Print-Token")): @@ -162,7 +257,10 @@ class Handler(BaseHTTPRequestHandler): data = self.rfile.read(length) try: - written = write_to_printer(data) + written = write_to_printer(data, self.headers.get("X-Print-Device")) + except ValueError as err: + log("[abgelehnt] %s" % err) + return self._send(400, {"ok": False, "error": str(err)}) except OSError as err: _last_error = str(err) log(f"[fehler] {self.client_address[0]}: {err}") @@ -186,6 +284,8 @@ def main(): st = device_status() log(f"Bondrucker-Bruecke {VERSION}") log(f" Geraet: {st['device']} (vorhanden: {st['deviceExists']}, beschreibbar: {st['writable']})") + for g in list_devices(): + log(f" gefunden: {g['path']} {g['name']}") log(f" Lauscht: {HOST}:{PORT}") log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}") diff --git a/frontend/app.js b/frontend/app.js index 9da36e6..3bf3954 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -11,6 +11,7 @@ const state = { timers: [], // {id, customerName, isRunning, baseMs, syncedAt} rate: { rateAmount: 10, rateInterval: 10 }, printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen + printerDevice: '', // gewaehltes Geraet am Pi, leer = automatisch printAutoOnStop: true, // Bon beim Beenden automatisch drucken tickHandle: null, syncHandle: null, @@ -564,6 +565,7 @@ async function loadSettings() { const settings = await api('/api/settings'); state.rate = settings; state.printerConfigured = !!(settings.printer && settings.printer.configured); + state.printerDevice = (settings.printer && settings.printer.device) || ''; state.printAutoOnStop = settings.printAutoOnStop !== false; document.getElementById('rateAmount').value = settings.rateAmount; @@ -578,6 +580,7 @@ async function loadSettings() { updateSettingsPreview(); renderPrinterState(settings.printer); refreshPrinterStatus(); + loadDevices(); } catch (e) { toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error'); } @@ -670,6 +673,94 @@ async function refreshPrinterStatus() { } } +/** + * Die Druckerliste vom Pi holen. + * + * Sie kommt bewusst live und nicht aus dem Zwischenspeicher: wer hier auf + * "Suchen" tippt, hat gerade ein Kabel umgesteckt und will genau das sehen. + * + * @param {boolean} lautstark Rueckmeldung geben - beim Knopfdruck ja, beim + * stillen Laden der Einstellungen nein. + */ +async function loadDevices(lautstark = false) { + const feld = document.getElementById('printerDevice'); + const gruppe = document.getElementById('printerDeviceGroup'); + const hinweis = document.getElementById('printerDeviceHint'); + if (!feld) return; + + // Ohne eingerichteten Drucker gibt es nichts auszuwaehlen. + gruppe.style.display = state.printerConfigured ? '' : 'none'; + if (!state.printerConfigured) return; + + const gewaehlt = state.printerDevice || ''; + + try { + const res = await api('/api/printer/devices'); + state.printerDevice = res.selected || ''; + + const automatisch = res.fallback + ? `automatisch (zurzeit ${res.fallback})` + : 'automatisch'; + + feld.innerHTML = `` + + res.devices.map(g => { + const beschriftung = g.name ? `${g.path} — ${g.name}` : g.path; + return ``; + }).join(''); + + // Der eingestellte Drucker haengt gerade nicht dran. Er darf trotzdem + // nicht aus der Liste fallen, sonst stellt ein Speichern ihn unbemerkt + // auf "automatisch" um. + const eingestellt = res.selected || ''; + if (eingestellt && !res.devices.some(g => g.path === eingestellt)) { + feld.innerHTML += ``; + } + + feld.value = eingestellt; + feld.disabled = false; + hinweis.textContent = res.devices.length + ? 'Welches Gerät am Raspberry Pi bedruckt wird.' + : 'Am Pi hängt gerade kein Drucker. Kabel prüfen, dann erneut suchen.'; + + if (lautstark) { + toast(res.devices.length + ? `${res.devices.length} Drucker gefunden` + : 'Am Pi hängt gerade kein Drucker', res.devices.length ? 'success' : 'error'); + } + } catch (e) { + feld.innerHTML = ``; + feld.disabled = true; + hinweis.textContent = 'Liste nicht abrufbar: ' + e.message; + if (lautstark) toast('Drucker konnten nicht abgerufen werden: ' + e.message, 'error'); + } +} + +/** Die Auswahl sofort speichern - ein eigener Speichern-Knopf waere hier + * eine Falle, weil das Feld weit ueber dem Knopf steht. */ +async function saveDevice() { + const feld = document.getElementById('printerDevice'); + const vorher = state.printerDevice || ''; + const neu = feld.value; + + try { + const antwort = await api('/api/settings', { + method: 'PUT', + body: JSON.stringify({ + rateAmount: state.rate.rateAmount, + rateInterval: state.rate.rateInterval, + printerDevice: neu + }) + }); + state.printerDevice = (antwort.printer && antwort.printer.device) || ''; + toast(neu ? 'Drucker gewählt: ' + neu : 'Drucker wird automatisch gewählt', 'success'); + await refreshPrinterStatus(); + } catch (e) { + feld.value = vorher; + toast('Auswahl nicht gespeichert: ' + e.message, 'error'); + } +} + async function testPrint() { const btn = document.getElementById('printerTestBtn'); if (btn) btn.disabled = true; @@ -801,8 +892,9 @@ function startSetupWatch() { { name: 'Übernommen', ok: true, detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' } ], false); - toast('Drucker eingerichtet: ' + z.host, 'success'); + toast('Drucker eingerichtet: ' + z.host + ' — jetzt oben den Drucker auswählen.', 'success'); await refreshPrinterStatus(); + await loadDevices(); await syncTimers().catch(() => {}); } else if (z.state === 'expired') { stopSetupWatch(); diff --git a/frontend/index.html b/frontend/index.html index b792438..d48c3da 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -220,6 +220,7 @@ input[type="password"], input[type="number"], input[type="date"], + select, textarea { width: 100%; padding: 0.875rem 1.125rem; @@ -233,6 +234,7 @@ } input:focus, + select:focus, textarea:focus { outline: none; border-color: #4d35a9; @@ -644,6 +646,19 @@ line-height: 1.5; } + /* Das Auswahlfeld erbt die Eingabefeld-Optik; die Liste selbst malt der + Browser und braucht deshalb eine eigene, dunkle Fuellfarbe. */ + select option { background: #2d2d44; color: #ffffff; } + + .device-row { + display: flex; + gap: 0.75rem; + align-items: center; + } + + .device-row select { flex: 1; } + .device-row .btn { flex-shrink: 0; } + .setup-link { margin-top: 1.25rem; } /* Der Befehl muss vollstaendig lesbar sein - er wird abgetippt, wenn @@ -887,6 +902,19 @@
Druckerzustand wird geprüft …
+
+ +
+ + +
+ Welches Gerät am Raspberry Pi bedruckt wird. + „Automatisch" nimmt den ersten gefundenen. +
+