Compare commits

...
2 Commits
Author SHA1 Message Date
Kerim (portable/claudecode)andClaude Opus 5 0e8d826bce Drucker wird in der App gewaehlt, nicht auf dem Pi
Deploy auf den Timer-Server / deploy (push) Successful in 20s
Das Einrichtungsskript fragte bisher am Pi, welcher Drucker bedient werden
soll. Damit stand die Einstellung an einem Ort, an den man nur mit einem
Terminal kommt - und ein zweiter Drucker am Pi bedeutete: Skript nochmal.

Jetzt fragt die App den Pi, was angeschlossen ist, und der Bediener waehlt in
den Einstellungen. Die Bruecke bekam dafuer /devices; sie liest die
Klartextnamen aus dem sysfs, damit dort "EPSON TM-T88IV" steht und nicht
"lp0" - bei zwei Druckern der Unterschied zwischen Wissen und Raten.

Der gewaehlte Pfad geht bei jedem Auftrag im Kopf X-Print-Device mit. Die
Bruecke haelt dazu bewusst keinen Zustand und das Skript schreibt kein
PRINT_DEVICE mehr: es soll genau eine Stelle geben, die weiss, welcher Drucker
gemeint ist, naemlich die, an der es bedient wird. Leer bleibt "automatisch" -
das erste gefundene Geraet, zugleich der Rueckfall, wenn aus lp0 nach dem
Umstecken ein lp1 geworden ist.

Der Pfad kommt damit von aussen und wird an beiden Enden geprueft, in
PUT /api/settings und noch einmal in der Bruecke. Eine Schranke, die nur an
einem Ende steht, ist keine: ohne die Pruefung auf dem Pi liesse sich der
Dienst dazu bringen, in eine beliebige Datei zu schreiben.

Nebenbei: ein nicht bereiter Drucker meldete sich in der Oberflaeche als
"HTTP 503". Das verriet niemandem, dass nur ein Stecker lose ist. Die 503 der
Bruecke ist kein Protokollfehler, und ihr Rumpf sagt, was fehlt - der wird
jetzt ausgewertet.

Geprueft am echten Pi (Debian 13, TM-T88IV): Einrichtung ohne Rueckfrage,
Geraeteliste mit Namen, ausdrueckliche Wahl, Druck darauf, Rueckkehr auf
automatisch, Druck darauf. Dazu die Abweise: /etc/passwd und ein Pfad mit
angehaengtem Befehl werden zurueckgewiesen, ein erlaubtes aber fehlendes
/dev/usb/lp7 ergibt eine Meldung im Klartext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:50 +02:00
Kerim (portable/claudecode)andClaude Opus 5 e68daee457 Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen
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>
2026-09-12 13:37:26 +02:00
16 changed files with 1165 additions and 628 deletions

No files matched your search

+90 -32
View File
@@ -18,10 +18,8 @@ gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze paral
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei. * **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build. * **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
Vanilla JS im Frontend, Express im Backend. Vanilla JS im Frontend, Express im Backend.
* **Abhängigkeiten:** 8 npm-Pakete, alle etabliert (express, express-session, * **Abhängigkeiten:** 7 npm-Pakete, alle etabliert (express, express-session,
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client, ssh2). `ssh2` ist express-rate-limit, sqlite3, bcrypt, dotenv, openid-client).
reines JavaScript und wird nur für die Einrichtung eines Drucker-Pi gebraucht
(Abschnitt 6).
Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3 Realistisch: In 15 Minuten deployt, wenn die drei Stolpersteine aus Abschnitt 3
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm** beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
@@ -213,7 +211,9 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
| `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi | | `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi |
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen | | `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_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 | | `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) |
**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur **Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur
Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt
@@ -321,38 +321,92 @@ der App liegt.
#### Einen Pi einrichten #### Einen Pi einrichten
⚙️ Einstellungen → **🔧 Drucker einrichten** richtet einen frisch aufgesetzten ⚙️ Einstellungen → **🔧 Drucker einrichten** → **🔗 Einrichtungslink erzeugen**.
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen, Die App zeigt einen fertigen Befehl an, der auf dem Pi ausgeführt wird:
Knopf drücken, fertig — rund drei Sekunden.
Die App meldet sich per SSH an, prüft Python/systemd/Druckergerät, überträgt die ```bash
vier Dateien aus `deploy/bondrucker/` per SFTP nach `/tmp`, startet `install.sh` curl -fsSL "http://<timer-app>/setup/bondrucker.sh?code=…" -o bondrucker.sh
mit einem selbst erzeugten Token, räumt auf und prüft von außen nach. Danach sudo bash bondrucker.sh
übernimmt sie den Drucker selbst. Jeder Schritt steht mit Ergebnis im Protokoll. ```
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
ssh2-Abhängigkeit und eine Verbindung vom Server zum Pi. Alle drei sind weg.
Wer einen Drucker ansteckt, steht ohnehin am Pi — und genau dort gehört auch
die Frage hin, welcher Drucker es sein soll.
**Das Skript ist zusammengesetzt, nicht statisch.** `deploy/bondrucker/einrichten.sh`
ist eine Vorlage mit vier Marken: `@@APP_URL@@`, `@@SETUP_CODE@@`,
`@@PRINT_SERVER@@` und `@@SERVICE@@`. `backend/setup.js` füllt sie beim Abholen
und bettet `print-server.py` und `bondrucker.service` als Here-Dokumente ein.
Heraus kommt **eine** Datei — auf dem Pi ist nichts nachzuladen. Enthielte eine
eingebettete Datei zufällig eine Zeile mit dem Schlusswort des Here-Dokuments,
bräche das Skript mittendrin ab; `einsetzen()` prüft das und verweigert lieber
die Auslieferung.
**Der Einrichtungscode.** 32 Zeichen, gilt **einmal** und **30 Minuten**, liegt
nur im Arbeitsspeicher — ein Neustart der App macht ihn ungültig. Verglichen
wird zeitkonstant. Es gibt immer nur einen; ein neuer entwertet den alten. Wer
ihn hat, kann den Drucker der App umbiegen, deshalb: nicht weiterreichen.
**Zwei Endpunkte ohne Anmeldung.** `GET /setup/bondrucker.sh` und
`POST /api/setup/register` müssen ohne Session auskommen — der Pi kann sich bei
Authentik nicht anmelden. Der Code ist dort der einzige Schlüssel, beide sind
über `setupLimiter` begrenzt (30 Versuche je 15 Minuten und IP).
**Die Adresse des Pi kommt aus der Verbindung**, nicht aus dem Rumpf der
Anmeldung: was der Pi über sich behauptet, könnte auch jemand anders behaupten.
Nur wenn die Anmeldung über die Rückschleife kam (Tunnel, Testaufbau), zählt
die Selbstauskunft `hostHint`.
**Die eigene Adresse leitet die App aus dem Host-Kopf ab** (`appBasisUrl`) —
hinter einem nginx sieht sie von sich aus nur `127.0.0.1`. Wer die Einrichtung
startet, hat die App gerade offen; unter genau dieser Adresse findet der Pi sie
auch. Passt das nicht, setzt `PUBLIC_URL` in der `.env` den Wert fest. Der Wert
landet in einem Shell-Skript und wird deshalb gegen
`^https?://[A-Za-z0-9.\-]+(:\d{1,5})?$` geprüft, nicht maskiert.
Warum der Druckerzugang in der Datenbank liegt und nicht in der `.env`: Sonst Warum der Druckerzugang in der Datenbank liegt und nicht in der `.env`: Sonst
wäre genau dieser Knopf sinnlos — die Werte müssten hinterher doch wieder von wäre die Anmeldung des Pi sinnlos — die Werte müssten hinterher doch von Hand
Hand auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
SSH-Zugang zum Timer-Server sein. SSH-Zugang zum Timer-Server sein.
Zum Umgang mit den Zugangsdaten: Benutzername und Passwort stehen nur während Das Skript ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
des Vorgangs im Arbeitsspeicher. Sie werden nicht gespeichert, nicht ins Journal
geschrieben und nicht an den Browser zurückgegeben; das erzeugte Drucker-Token
wird vor der Ausgabe aus dem Protokoll entfernt. Dauerhaft bleibt allein dieses
Token in der Datenbank.
Der Hostschlüssel des Pi wird beim Erstkontakt angenommen und im Protokoll
ausgewiesen (`SHA256:…`) — im eigenen Netz vertretbar, aber es ist eben *kein*
Schutz gegen einen Angreifer, der schon im Netz sitzt.
`install.sh` ist wiederholbar und **startet den Dienst dabei neu**. Ohne den
Neustart liefe die Brücke mit dem alten Token weiter und würde jeden Bon mit Neustart liefe die Brücke mit dem alten Token weiter und würde jeden Bon mit
HTTP 403 ablehnen — die Gegenprobe nach der Einrichtung prüft deshalb nicht nur HTTP 403 ablehnen — es prüft deshalb nach dem Start selbst, ob das neue Token
die Erreichbarkeit, sondern auch, ob das neue Token angenommen wird. angenommen wird, und die App prüft danach noch einmal von außen.
**Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die **Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die
App nicht mehr, bis `PRINTER_HOST` angepasst ist — eine DHCP-Reservierung im App nicht mehr, bis der Drucker neu eingerichtet oder die Adresse in den
Router erspart das. Einstellungen angepasst ist — eine DHCP-Reservierung im Router erspart das.
### Backup ### Backup
@@ -383,7 +437,7 @@ timer-app/
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker │ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
│ ├── receipt.js ← Layout des Bons │ ├── receipt.js ← Layout des Bons
│ ├── printer.js ← Aufruf der Brücke auf dem Pi │ ├── printer.js ← Aufruf der Brücke auf dem Pi
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein │ ├── setup.js ← Einrichtungscode und Skript für den Pi
│ ├── package.json │ ├── package.json
│ ├── package-lock.json │ ├── package-lock.json
│ ├── .env.example │ ├── .env.example
@@ -401,7 +455,7 @@ timer-app/
├── README.md ← Einrichtung des Pi ├── README.md ← Einrichtung des Pi
├── print-server.py ← der Dienst (nur Python-Standardbibliothek) ├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
├── bondrucker.service ├── bondrucker.service
└── install.sh └── einrichten.sh ← Vorlage, die die App beim Abholen füllt
``` ```
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank, `server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
@@ -474,10 +528,14 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
| `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet | | `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet |
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern | | `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
| `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen | | `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/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/test` | Probedruck |
| `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten | | `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? |
| `GET` | `/setup/bondrucker.sh` | **ohne Anmeldung**, Code als Parameter → das fertige Einrichtungsskript |
| `POST` | `/api/setup/register` | **ohne Anmeldung**, `{code, port, token, device}` → der Pi meldet sich selbst an |
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` | | `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
| `DELETE` | `/api/sessions/:id` | Eintrag löschen | | `DELETE` | `/api/sessions/:id` | Eintrag löschen |
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken | | `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
+30 -14
View File
@@ -110,23 +110,13 @@ Darunter steht, was auf dem Bon gedruckt wird:
* **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der * **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der
Bon genau wie vorher. Bon genau wie vorher.
* **Artikel in der Kasse** — der Name, der im Strichcode steckt. * **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 * **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand. Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand.
### Einen neuen Drucker anschließen
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
**🔧 Drucker einrichten** anklicken, Adresse, Benutzer und Passwort des
Raspberry Pi eintragen, **🚀 Jetzt einrichten** drücken. Nach wenigen Sekunden
ist der Drucker eingerichtet und übernommen — es ist nichts weiter zu tun.
Das Protokoll darunter zeigt jeden Schritt. Bleibt einer rot stehen, steht dort
auch, woran es lag. Das Passwort wird nur für diesen Vorgang verwendet und
danach verworfen.
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian), der
eingetragene Benutzer darf `sudo`, und der Bondrucker hängt am USB-Anschluss.
Wichtig zu wissen: Wichtig zu wissen:
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde * **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
@@ -135,6 +125,32 @@ Wichtig zu wissen:
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte * Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
Einträge in der Historie behalten den Tarif, der beim Beenden galt. Einträge in der Historie behalten den Tarif, der beim Beenden galt.
### Einen neuen Drucker anschließen
Wenn der Drucker-Rechner getauscht wird oder ein zweiter Laden dazukommt:
**🔧 Drucker einrichten** anklicken, dann **🔗 Einrichtungslink erzeugen**.
Es erscheint ein Befehl. Der wird auf dem Raspberry Pi in ein Terminal
eingegeben — über **📋 Kopieren** landet er in der Zwischenablage:
```
curl -fsSL "…" -o bondrucker.sh && sudo bash bondrucker.sh
```
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,
kann einstellen, auf welchem Gerät hier gedruckt wird.
Voraussetzung: Auf dem Pi läuft ein frisches Raspberry Pi OS (oder Debian) und
der Bondrucker hängt am USB-Anschluss.
## Abmelden ## Abmelden
**🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon **🚪 Abmelden** meldet nur dieses Gerät ab. Laufende Timer bleiben davon
+18 -4
View File
@@ -62,9 +62,9 @@ RATE_PER_10MIN=10
# #
# ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start. # ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start.
# Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche # Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche
# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar # gepflegt - "Einstellungen" -> "Drucker einrichten" legt einen Link bereit,
# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere # ueber den sich ein Pi selbst einrichtet und anschliessend hier anmeldet.
# Aenderung hier bleibt also wirkungslos. # Eine spaetere Aenderung hier bleibt also wirkungslos.
# #
# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie # Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie
# vorher, nur ohne Bon-Knoepfe. # vorher, nur ohne Bon-Knoepfe.
@@ -73,12 +73,26 @@ PRINTER_HOST=
PRINTER_PORT=9100 PRINTER_PORT=9100
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen. # Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
# install.sh gibt den Wert am Ende aus. # Das Einrichtungsskript gibt den Wert am Ende aus, falls es sich nicht selbst
# bei der App anmelden konnte.
PRINTER_TOKEN= PRINTER_TOKEN=
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier. # Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
PRINTER_WIDTH=42 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 # Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
# darf das Beenden eines Timers nicht blockieren. # darf das Beenden eines Timers nicht blockieren.
PRINTER_TIMEOUT_MS=6000 PRINTER_TIMEOUT_MS=6000
# Adresse, unter der der Drucker-Pi diese App erreicht. Normalerweise LEER
# lassen: die App leitet sie aus der Anfrage ab, also aus der Adresse, unter der
# gerade jemand die Einstellungen offen hat. Nur setzen, wenn der Pi die App
# unter einem anderen Namen erreichen muss als der Browser.
# Beispiel: PUBLIC_URL=http://192.168.1.50:3003
PUBLIC_URL=
+1 -69
View File
@@ -14,8 +14,7 @@
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"openid-client": "^6.8.4", "openid-client": "^6.8.4",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6"
"ssh2": "^1.17.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -184,14 +183,6 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -229,14 +220,6 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bindings": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -310,15 +293,6 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -450,20 +424,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
}, },
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1422,12 +1382,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}, },
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"optional": true
},
"node_modules/napi-build-utils": { "node_modules/napi-build-utils": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2138,23 +2092,6 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
}, },
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@@ -2294,11 +2231,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+1 -2
View File
@@ -19,7 +19,6 @@
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"openid-client": "^6.8.4", "openid-client": "^6.8.4",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6"
"ssh2": "^1.17.0"
} }
} }
+41 -3
View File
@@ -25,6 +25,10 @@ let cfg = {
port: 9100, port: 9100,
token: '', token: '',
width: 42, 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 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(), host: String(next.host == null ? cfg.host : next.host).trim(),
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port,
token: next.token == null ? cfg.token : String(next.token).trim(), 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; return cfg;
} }
@@ -55,7 +60,10 @@ function width() {
/** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */ /** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */
function config() { 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() { function state() {
@@ -64,6 +72,7 @@ function state() {
host: cfg.host || null, host: cfg.host || null,
port: isConfigured() ? cfg.port : null, port: isConfigured() ? cfg.port : null,
width: cfg.width, width: cfg.width,
device: cfg.device || null,
lastError, lastError,
lastPrintAt lastPrintAt
}; };
@@ -83,6 +92,9 @@ function request(pfad, method, body) {
headers['Content-Length'] = body.length; headers['Content-Length'] = body.length;
} }
if (cfg.token) headers['X-Print-Token'] = cfg.token; 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( const req = http.request(
{ host: cfg.host, port: cfg.port, path: pfad, method, headers }, { 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?). */ /** Zustand der Bruecke abfragen (Geraet da? beschreibbar?). */
async function status() { async function status() {
const res = await request('/status', 'GET'); 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) { if (!res.ok) {
lastError = res.error; lastError = res.error;
return { ok: false, error: res.error, ...state() }; return { ok: false, error: res.error, ...state() };
@@ -132,6 +160,16 @@ async function status() {
return { ok: true, printer: res.data, ...state() }; 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. */ /** Rohe ESC/POS-Daten an den Drucker geben. */
async function print(buffer) { async function print(buffer) {
const res = await request('/print', 'POST', buffer); const res = await request('/print', 'POST', buffer);
@@ -145,4 +183,4 @@ async function print(buffer) {
return res; return res;
} }
module.exports = { setConfig, isConfigured, width, config, state, status, print }; module.exports = { setConfig, isConfigured, width, config, state, status, devices, print };
-254
View File
@@ -1,254 +0,0 @@
/**
* Einrichtung eines Drucker-Pi aus der Oberflaeche heraus
*
* Bis hierher war das Handarbeit: Dateien per scp auf den Pi, dort install.sh
* mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
* starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
* niemand mehr weiss. Dieses Modul macht daraus einen Knopf.
*
* Was hier passiert, ist bewusst dasselbe wie von Hand - es laeuft nur
* ferngesteuert ab:
*
* 1. per SSH anmelden
* 2. pruefen, ob der Pi ueberhaupt taugt (Python, Druckergeraet, sudo)
* 3. die drei Dateien aus deploy/bondrucker/ uebertragen
* 4. install.sh mit einem hier erzeugten Token starten
* 5. Spuren im /tmp beseitigen
* 6. von aussen nachsehen, ob der Dienst antwortet
*
* Zum Umgang mit den Zugangsdaten: Passwort und Benutzername stehen nur
* waehrend des Vorgangs im Arbeitsspeicher. Nichts davon wird gespeichert,
* protokolliert oder an den Browser zurueckgegeben. Dauerhaft bleibt allein
* das erzeugte Drucker-Token.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { Client } = require('ssh2');
const QUELLE = path.join(__dirname, '../deploy/bondrucker');
const DATEIEN = ['print-server.py', 'bondrucker.service', 'install.sh', 'README.md'];
const VERBINDUNGS_TIMEOUT_MS = 15000;
const BEFEHL_TIMEOUT_MS = 180000;
/**
* Token fuer die Bruecke. Bewusst nur Buchstaben und Ziffern: der Wert wird
* auf dem Pi als Argument an install.sh weitergereicht, und was keine
* Sonderzeichen enthaelt, kann in keiner Shell etwas anderes bedeuten.
*/
function tokenErzeugen() {
return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
}
/** Fingerabdruck des Hostschluessels, im selben Format wie ssh es zeigt. */
function fingerabdruck(key) {
return 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
}
/**
* Einen Befehl auf dem Pi ausfuehren.
*
* Wichtig: Nutzereingaben werden NIE in die Befehlszeile geschrieben. Das
* Skript kommt fest aus dieser Datei, veraenderliche Werte gehen ueber stdin
* oder ueber Umgebungsvariablen, die ssh2 selbst quotet.
*/
function exec(conn, command, stdin = null) {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
stream.close();
reject(new Error('Zeitueberschreitung auf dem Pi'));
}, BEFEHL_TIMEOUT_MS);
stream.on('close', (code) => {
clearTimeout(timeout);
resolve({ code: code === null ? -1 : code, stdout, stderr });
});
stream.on('data', d => { stdout += d.toString('utf8'); });
stream.stderr.on('data', d => { stderr += d.toString('utf8'); });
if (stdin !== null) stream.write(stdin);
stream.end();
});
});
}
function verbinden({ host, port, username, password }) {
return new Promise((resolve, reject) => {
const conn = new Client();
let hostkey = null;
const abbruch = setTimeout(() => {
conn.end();
reject(new Error(`Keine Antwort von ${host}:${port} innerhalb von ${VERBINDUNGS_TIMEOUT_MS / 1000} s`));
}, VERBINDUNGS_TIMEOUT_MS);
conn.on('ready', () => {
clearTimeout(abbruch);
resolve({ conn, hostkey });
});
conn.on('error', (err) => {
clearTimeout(abbruch);
// Die Meldungen von ssh2 sind fuer eine Ladenoberflaeche zu technisch.
const roh = String(err.message || err);
if (/All configured authentication methods failed/i.test(roh)) {
return reject(new Error('Anmeldung abgelehnt - Benutzername oder Passwort stimmt nicht.'));
}
if (err.code === 'ECONNREFUSED') {
return reject(new Error(`${host}:${port} nimmt keine SSH-Verbindung an. Laeuft dort sshd?`));
}
if (err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH' || err.code === 'ETIMEDOUT') {
return reject(new Error(`${host} ist im Netz nicht erreichbar.`));
}
reject(new Error(roh));
});
conn.connect({
host, port, username, password,
readyTimeout: VERBINDUNGS_TIMEOUT_MS,
// Erstkontakt im eigenen Netz: der Schluessel wird angenommen und
// im Protokoll ausgewiesen, damit er nachvollziehbar bleibt.
hostVerifier: (key) => { hostkey = fingerabdruck(key); return true; }
});
});
}
/** Die vier Dateien per SFTP in ein frisches Verzeichnis unter /tmp legen. */
function uebertragen(conn, zielVerzeichnis) {
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) return reject(err);
const naechste = (i) => {
if (i >= DATEIEN.length) return resolve(DATEIEN.length);
const name = DATEIEN[i];
const inhalt = fs.readFileSync(path.join(QUELLE, name));
const strom = sftp.createWriteStream(`${zielVerzeichnis}/${name}`);
strom.on('close', () => naechste(i + 1));
strom.on('error', (e) => reject(new Error(`${name}: ${e.message}`)));
strom.end(inhalt);
};
naechste(0);
});
});
}
/**
* Einen Pi einrichten.
*
* @param {object} o
* @param {string} o.host IP oder Name des Pi
* @param {number} o.port SSH-Port
* @param {string} o.username SSH-Benutzer (muss sudo duerfen)
* @param {string} o.password SSH-Passwort, zugleich sudo-Passwort
* @returns {Promise<{ok, token, host, port, steps, fingerprint}>}
* steps = [{name, ok, detail}] - taugt unveraendert als Protokoll
* fuer die Oberflaeche.
*/
async function provision({ host, port = 22, username, password }) {
const steps = [];
const schritt = (name, ok, detail) => { steps.push({ name, ok, detail: detail || '' }); };
const token = tokenErzeugen();
const tmp = `/tmp/bondrucker-${crypto.randomBytes(6).toString('hex')}`;
let conn = null;
let fingerprint = null;
try {
// --- 1. Anmelden ----------------------------------------------------
const verbindung = await verbinden({ host, port, username, password });
conn = verbindung.conn;
fingerprint = verbindung.hostkey;
schritt('Verbindung', true, `als ${username}@${host}:${port} angemeldet` +
(fingerprint ? ` · Hostschlüssel ${fingerprint}` : ''));
// --- 2. Pi pruefen ---------------------------------------------------
// Ein Skript statt vieler Einzelbefehle: eine Runde ueber die Leitung,
// und die Ausgabe ist auch fuer Menschen lesbar.
const pruefung = await exec(conn, 'bash -s', `
printf 'SYSTEM=%s\\n' "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
printf 'ARCH=%s\\n' "$(uname -m)"
printf 'PYTHON=%s\\n' "$(command -v python3 || echo FEHLT)"
printf 'DRUCKER=%s\\n' "$(ls /dev/usb/lp* 2>/dev/null | head -1 || echo KEINER)"
printf 'SYSTEMD=%s\\n' "$(command -v systemctl || echo FEHLT)"
`);
const info = Object.fromEntries(
pruefung.stdout.split('\n').filter(z => z.includes('=')).map(z => {
const i = z.indexOf('=');
return [z.slice(0, i), z.slice(i + 1).trim()];
})
);
if (info.PYTHON === 'FEHLT') {
schritt('System geprüft', false, 'Auf dem Pi fehlt python3 - der Druckdienst braucht es.');
throw new Error('python3 fehlt auf dem Zielsystem.');
}
if (info.SYSTEMD === 'FEHLT') {
schritt('System geprüft', false, 'Kein systemd gefunden - der Dienst kann nicht eingerichtet werden.');
throw new Error('systemd fehlt auf dem Zielsystem.');
}
// Ein fehlender Drucker ist kein Abbruchgrund: der Dienst laeuft auch
// ohne und findet das Geraet, sobald es angesteckt wird.
const druckerHinweis = info.DRUCKER === 'KEINER'
? ' · ACHTUNG: gerade kein Drucker unter /dev/usb/lp* - Kabel prüfen'
: ` · Drucker ${info.DRUCKER}`;
schritt('System geprüft', true, `${info.SYSTEM || 'unbekannt'} (${info.ARCH})${druckerHinweis}`);
// --- 3. Dateien uebertragen ------------------------------------------
await exec(conn, `mkdir -p ${tmp} && chmod 700 ${tmp}`);
const anzahl = await uebertragen(conn, tmp);
schritt('Dateien übertragen', true, `${anzahl} Dateien nach ${tmp}`);
// --- 4. Einrichten ----------------------------------------------------
// Das Passwort geht ueber stdin an sudo, nie ueber die Befehlszeile -
// dort waere es fuer jeden sichtbar, der auf dem Pi "ps" tippt.
const install = await exec(
conn,
`cd ${tmp} && sudo -S -p '' bash install.sh ${token} 2>&1`,
password + '\n'
);
// install.sh gibt das Token am Ende aus - fuer den Menschen, der es von
// Hand aufsetzt, genau richtig. Hier ist es unnoetig: die App kennt es
// ohnehin. Und das Protokoll geht in den Browser und ins Journal.
const ausgabe = (install.stdout + install.stderr)
.split(token).join('(hier nicht angezeigt)')
.trim();
if (install.code !== 0) {
const grund = /incorrect password|Sorry, try again/i.test(ausgabe)
? 'sudo hat das Passwort abgelehnt.'
: `install.sh endete mit Rückgabewert ${install.code}.`;
schritt('Dienst eingerichtet', false, `${grund}\n${ausgabe}`);
throw new Error(grund);
}
schritt('Dienst eingerichtet', true, ausgabe);
// --- 5. Aufraeumen ----------------------------------------------------
await exec(conn, `rm -rf ${tmp}`);
schritt('Aufgeräumt', true, `${tmp} entfernt`);
return { ok: true, token, host, port: 9100, steps, fingerprint };
} catch (err) {
if (!steps.length || steps[steps.length - 1].ok) {
schritt('Abbruch', false, String(err.message || err));
}
return { ok: false, error: String(err.message || err), steps, fingerprint };
} finally {
if (conn) {
// Der Versuch aufzuraeumen darf das Ergebnis nicht mehr kippen.
try { await exec(conn, `rm -rf ${tmp}`); } catch (e) { /* egal */ }
conn.end();
}
}
}
module.exports = { provision };
+149 -53
View File
@@ -19,7 +19,7 @@ const oidc = require('./oidc');
const printer = require('./printer'); const printer = require('./printer');
const { buildReceipt } = require('./receipt'); const { buildReceipt } = require('./receipt');
const { barcodeMaxLength } = require('./escpos'); const { barcodeMaxLength } = require('./escpos');
const { provision } = require('./provision'); const setup = require('./setup');
const app = express(); const app = express();
@@ -170,6 +170,10 @@ async function initDatabase() {
[(process.env.PRINTER_TOKEN || '').trim()]); [(process.env.PRINTER_TOKEN || '').trim()]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`, await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`,
[String(parseInt(process.env.PRINTER_WIDTH) || 42)]); [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(); await refreshPrinterConfig();
@@ -212,10 +216,12 @@ async function refreshPrinterConfig() {
host: map.printer_host || '', host: map.printer_host || '',
port: map.printer_port, port: map.printer_port,
token: map.printer_token || '', token: map.printer_token || '',
width: map.printer_width width: map.printer_width,
device: map.printer_device || ''
}); });
console.log(cfg.host 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'); : 'Bondrucker: nicht eingerichtet');
return cfg; return cfg;
} }
@@ -732,6 +738,16 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
await setSetting('printer_width', String(w)); await setSetting('printer_width', String(w));
druckerGeaendert = true; 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, // Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert,
// damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht. // damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht.
if (typeof b.printerToken === 'string' && b.printerToken.trim()) { if (typeof b.printerToken === 'string' && b.printerToken.trim()) {
@@ -755,77 +771,157 @@ app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
res.json({ configured: true, ...await printer.status(), config: printer.config() }); res.json({ configured: true, ...await printer.status(), config: printer.config() });
})); }));
// Einrichtung ist teuer und beruehrt ein fremdes Geraet - hier deutlich /**
// enger begrenzt als beim Drucken. * Welche Drucker am Pi haengen.
const provisionLimiter = rateLimit({ *
* 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({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 10, max: 30,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' } message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
}); });
/** /**
* Drucker-Pi per SSH einrichten und anschliessend uebernehmen. * Adresse, unter der der Pi diese App erreicht.
* *
* Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach * Abgeleitet aus der Anfrage, denn die App weiss von sich aus nicht, unter
* vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token. * welchem Namen sie erreichbar ist - hinter einem nginx sieht sie nur
* 127.0.0.1. Wer die Einrichtung startet, hat die App gerade offen; unter
* genau dieser Adresse findet sie auch der Pi im selben Netz. PUBLIC_URL
* ueberstimmt das, falls die Adresse fuer den Pi eine andere sein muss.
*/ */
app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => { function appBasisUrl(req) {
const fest = (process.env.PUBLIC_URL || '').trim();
if (fest) return fest.replace(/\/+$/, '');
return `${req.protocol}://${req.get('host')}`;
}
/**
* Adresse des Pi - aus der Verbindung, nicht aus dem Rumpf.
*
* Was der Pi ueber sich selbst behauptet, koennte auch jemand anders
* behaupten. Nur wenn die Anmeldung ueber die Rueckschleife kam (Testaufbau
* oder Tunnel), hilft die Selbstauskunft ueberhaupt weiter.
*/
function quelleAdresse(req, hinweis) {
const ip = String(req.ip || '').replace(/^::ffff:/, '');
if (ip && ip !== '::1' && ip !== '127.0.0.1') return ip;
const h = String(hinweis || '').trim();
return /^[A-Za-z0-9.\-]{1,120}$/.test(h) ? h : ip;
}
/** Einen Einrichtungscode erzeugen und den Abhol-Link dazu liefern. */
app.post('/api/printer/setup-code', requireAuth, setupLimiter, wrap(async (req, res) => {
const basis = appBasisUrl(req);
const { code, expiresAt } = setup.codeAnlegen();
// Das Skript gleich hier bauen: ein unbrauchbarer Hostname soll jetzt
// auffallen und nicht erst, wenn jemand mit dem Laptop am Pi steht.
try {
setup.skriptBauen({ appUrl: basis, code });
} catch (err) {
console.error('[einrichtung] Skript nicht baubar:', err.message);
return res.status(500).json({ error: err.message });
}
const url = `${basis}/setup/bondrucker.sh?code=${code}`;
console.log(`[einrichtung] Code erzeugt, gueltig bis ${new Date(expiresAt).toISOString()}`);
res.json({
url,
expiresAt,
command: `curl -fsSL "${url}" -o bondrucker.sh && sudo bash bondrucker.sh`
});
}));
/** Wartet noch jemand auf den Pi, oder hat er sich gemeldet? */
app.get('/api/printer/setup-status', requireAuth, wrap(async (req, res) => {
res.json({ ...setup.zustand(), printer: printer.state() });
}));
/**
* Das Einrichtungsskript ausliefern.
*
* OHNE Anmeldung: der Pi holt es mit curl ab und kann sich bei Authentik nicht
* anmelden. Der Einrichtungscode ist hier der Schluessel - er gilt einmal und
* eine halbe Stunde lang.
*/
app.get('/setup/bondrucker.sh', setupLimiter, wrap(async (req, res) => {
const eintrag = setup.codePruefen(req.query.code);
if (!eintrag) {
return res.status(403).type('text/plain; charset=utf-8').send(
'# Einrichtungscode ungueltig, verbraucht oder abgelaufen.\n' +
'# In der Timer-App unter Einstellungen einen neuen erzeugen.\n');
}
let skript;
try {
skript = setup.skriptBauen({ appUrl: appBasisUrl(req), code: eintrag.code });
} catch (err) {
console.error('[einrichtung] Skript nicht baubar:', err.message);
return res.status(500).type('text/plain; charset=utf-8').send(`# ${err.message}\n`);
}
res.type('text/x-shellscript; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="bondrucker.sh"');
res.setHeader('Cache-Control', 'no-store');
res.send(skript);
}));
/**
* Der Pi meldet sich nach der Einrichtung selbst an.
*
* OHNE Anmeldung, aus demselben Grund wie oben. Der Code wird hier verbraucht:
* ein zweiter Pi kann sich mit demselben Code nicht dazwischenschieben.
*/
app.post('/api/setup/register', setupLimiter, wrap(async (req, res) => {
const b = req.body || {}; const b = req.body || {};
const host = String(b.host || '').trim(); const eintrag = setup.codePruefen(b.code);
const username = String(b.username || '').trim(); if (!eintrag) {
const password = String(b.password || ''); console.warn('[einrichtung] Anmeldung mit ungueltigem Code abgewiesen');
const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port); return res.status(403).json({ error: 'Einrichtungscode ungueltig, verbraucht oder abgelaufen.' });
}
if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' }); const token = String(b.token || '').trim();
if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' }); const port = b.port === undefined ? 9100 : parseInt(b.port);
if (!password) return res.status(400).json({ error: 'Passwort fehlt.' }); const device = String(b.device || '').slice(0, 120);
if (!token) return res.status(400).json({ error: 'Drucker-Token fehlt.' });
if (!Number.isInteger(port) || port < 1 || port > 65535) { if (!Number.isInteger(port) || port < 1 || port > 65535) {
return res.status(400).json({ error: 'SSH-Port muss zwischen 1 und 65535 liegen.' }); return res.status(400).json({ error: 'Port muss zwischen 1 und 65535 liegen.' });
}
// Kein Doppelpunkt, kein Leerzeichen: eine Adresse, kein Befehlsfragment.
if (!/^[A-Za-z0-9.\-_]+$/.test(host)) {
return res.status(400).json({ error: 'Adresse enthält unerlaubte Zeichen.' });
} }
console.log(`[einrichtung] Pi ${username}@${host}:${port} wird eingerichtet`); const host = quelleAdresse(req, b.hostHint);
const ergebnis = await provision({ host, port, username, password }); if (!host) return res.status(400).json({ error: 'Adresse des Pi nicht ermittelbar.' });
if (!ergebnis.ok) { await setSetting('printer_host', host);
console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error); await setSetting('printer_port', String(port));
return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps }); await setSetting('printer_token', token);
}
// Uebernehmen: ab hier druckt die App auf diesen Pi.
await setSetting('printer_host', ergebnis.host);
await setSetting('printer_port', String(ergebnis.port));
await setSetting('printer_token', ergebnis.token);
await refreshPrinterConfig(); await refreshPrinterConfig();
setup.codeEinloesen(host, device);
// Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke // Gegenprobe von aussen. Der Pi hat sich eben selbst geprueft, aber nur von
// meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit // innen - ob die App wirklich durchkommt, zeigt erst dieser Griff.
// einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim
// ersten Bon mit 403 abbrechen.
const probe = await printer.status(); const probe = await printer.status();
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false; const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
const bereit = probe.ok && tokenOk; const bereit = probe.ok && tokenOk;
let detail; console.log(`[einrichtung] ${host}:${port} angemeldet, Drucker ${bereit ? 'bereit' : 'NICHT erreichbar'}`);
if (bereit) { res.json({ ok: true, host, port, ready: bereit });
detail = `${ergebnis.host}:${ergebnis.port} antwortet und nimmt das neue Token an` +
(probe.printer && probe.printer.device ? ` · Gerät ${probe.printer.device}` : '');
} else if (probe.ok && !tokenOk) {
detail = 'Der Dienst antwortet, lehnt das neue Token aber ab. Läuft dort noch eine ' +
'alte Fassung der Brücke? Einrichtung bitte wiederholen.';
} else {
detail = `${probe.error} — der Dienst wurde eingerichtet, antwortet aber noch nicht. ` +
'Meist steht eine Firewall zwischen Server und Pi.';
}
ergebnis.steps.push({ name: 'Gegenprobe', ok: bereit, detail });
console.log(`[einrichtung] fertig, Drucker ${bereit ? 'bereit' : 'NICHT bereit'}`);
res.json({ ok: true, ready: bereit, steps: ergebnis.steps, printer: printer.state() });
})); }));
/** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */ /** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */
+139
View File
@@ -0,0 +1,139 @@
/**
* Einrichtung eines Drucker-Pi - die Holschuld-Variante
*
* Frueher hat die App den Pi selbst eingerichtet: per SSH anmelden, Dateien
* schieben, Skript starten. Das verlangte, dass jemand ein SSH-Passwort in ein
* Webformular tippt und dass der Server den Pi ueberhaupt erreicht.
*
* Jetzt andersherum: die App legt ein Skript zum Abholen bereit und wartet.
* Wer den Drucker ansteckt, steht ohnehin am Pi - dort wird das Skript
* gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi
* von selbst bei der App an. Kein Passwort verlaesst den Laden, und die
* Verbindung muss nur in eine Richtung funktionieren.
*
* Das Skript wird beim Abholen zusammengesetzt: die Vorlage aus
* deploy/bondrucker/einrichten.sh bekommt die Adresse dieser App, den
* einmaligen Code sowie den Druckdienst und die systemd-Unit eingesetzt.
* Heraus kommt eine einzige Datei - herunterladen, starten, fertig.
*
* Der Code lebt nur im Arbeitsspeicher. Ein Neustart der App macht ihn
* ungueltig; das ist gewollt, denn ein Einrichtungscode auf der Platte waere
* ein Dauerschluessel zum Umbiegen des Druckers.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const QUELLE = path.join(__dirname, '../deploy/bondrucker');
const VORLAGE = path.join(QUELLE, 'einrichten.sh');
// Halbe Stunde: lang genug, um vom Schreibtisch zum Pi zu gehen und ein Kabel
// zu suchen; kurz genug, dass ein vergessener Code nicht wochenlang gilt.
const GUELTIG_MS = 30 * 60 * 1000;
// Bewusst nur ein Code gleichzeitig. Zwei offene Einrichtungen waeren ein
// Rennen darum, welcher Pi am Ende der Drucker ist.
let aktuell = null;
/** Nur Buchstaben und Ziffern - der Wert landet in einer URL und in einem
* Shell-Skript, und was keine Sonderzeichen hat, kann dort nichts anrichten. */
function codeErzeugen() {
return crypto.randomBytes(32).toString('base64').replace(/[^A-Za-z0-9]/g, '').slice(0, 32);
}
/**
* Neuen Einrichtungscode anlegen. Ein vorhandener wird dabei ungueltig.
* @returns {{code: string, expiresAt: number}}
*/
function codeAnlegen() {
aktuell = {
code: codeErzeugen(),
expiresAt: Date.now() + GUELTIG_MS,
erzeugtAm: Date.now(),
eingeloestAm: null,
host: null,
device: null
};
return { code: aktuell.code, expiresAt: aktuell.expiresAt };
}
/** Gueltiger, noch nicht eingeloester Code? Sonst null. */
function codePruefen(code) {
if (!aktuell || !code) return null;
if (aktuell.eingeloestAm) return null;
if (Date.now() > aktuell.expiresAt) return null;
// Zeitkonstanter Vergleich: sonst verraet die Laufzeit, wie viele Zeichen
// schon stimmen, und der Code liesse sich Stueck fuer Stueck erraten.
const a = Buffer.from(String(code));
const b = Buffer.from(aktuell.code);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
return aktuell;
}
/** Code als verbraucht markieren - er gilt genau einmal. */
function codeEinloesen(host, device) {
if (!aktuell) return;
aktuell.eingeloestAm = Date.now();
aktuell.host = host;
aktuell.device = device;
}
/** Zustand fuer die Oberflaeche: wartet noch jemand, oder ist es durch? */
function zustand() {
if (!aktuell) return { state: 'none' };
if (aktuell.eingeloestAm) {
return { state: 'done', host: aktuell.host, device: aktuell.device, at: aktuell.eingeloestAm };
}
if (Date.now() > aktuell.expiresAt) return { state: 'expired', expiresAt: aktuell.expiresAt };
return { state: 'waiting', expiresAt: aktuell.expiresAt };
}
/**
* Eine Datei als Inhalt eines Here-Dokuments einsetzen.
*
* Der Abschluss eines Here-Dokuments ist eine Zeile, die genau aus dem
* Schlusswort besteht. Steht dieselbe Zeile zufaellig in der eingesetzten
* Datei, endet das Dokument zu frueh und der Rest wird als Shell-Befehl
* gelesen. Das darf nicht stillschweigend passieren.
*/
function einsetzen(skript, marke, dateiname, schlusswort) {
const inhalt = fs.readFileSync(path.join(QUELLE, dateiname), 'utf8').replace(/\n$/, '');
if (inhalt.split('\n').some(z => z.trim() === schlusswort)) {
throw new Error(`${dateiname} enthaelt die Zeile "${schlusswort}" und kann so nicht eingebettet werden.`);
}
if (!skript.includes(marke)) {
throw new Error(`In einrichten.sh fehlt die Marke ${marke}.`);
}
return skript.replace(marke, inhalt);
}
/**
* Das fertige Einrichtungsskript bauen.
*
* @param {object} o
* @param {string} o.appUrl Adresse dieser App, wie der Pi sie erreicht
* @param {string} o.code Einrichtungscode
* @returns {string}
*/
function skriptBauen({ appUrl, code }) {
// appUrl stammt aus dem Host-Kopf der Anfrage und ist damit von aussen
// bestimmbar. Der Wert landet in einem Shell-Skript - hier wird deshalb
// streng geprueft statt maskiert.
if (!/^https?:\/\/[A-Za-z0-9.\-]+(:\d{1,5})?$/.test(appUrl)) {
throw new Error(`Unbrauchbare Adresse fuer die Rueckmeldung: ${appUrl}`);
}
if (!/^[A-Za-z0-9]{1,64}$/.test(code)) {
throw new Error('Unbrauchbarer Einrichtungscode.');
}
let skript = fs.readFileSync(VORLAGE, 'utf8');
skript = skript.split('@@APP_URL@@').join(appUrl);
skript = skript.split('@@SETUP_CODE@@').join(code);
skript = einsetzen(skript, '@@PRINT_SERVER@@', 'print-server.py', 'PRINT_SERVER_ENDE');
skript = einsetzen(skript, '@@SERVICE@@', 'bondrucker.service', 'SERVICE_ENDE');
return skript;
}
module.exports = { codeAnlegen, codePruefen, codeEinloesen, zustand, skriptBauen, GUELTIG_MS };
+62 -31
View File
@@ -14,54 +14,75 @@ Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
## Installation — der übliche Weg ## Installation — der übliche Weg
**Aus der Oberfläche:** ⚙️ Einstellungen → **🔧 Drucker einrichten**. Dort IP, **In der Timer-App:** ⚙️ Einstellungen → **🔧 Drucker einrichten** →
Benutzer und Passwort des Pi eintragen und auf **🚀 Jetzt einrichten** klicken. **🔗 Einrichtungslink erzeugen**. Die App zeigt einen Befehl an. Der wird auf
dem Pi in ein Terminal getippt:
Die App macht dann per SSH genau das, was unten von Hand steht: Dateien
übertragen, `install.sh` mit einem frisch erzeugten Token starten, aufräumen
und nachsehen, ob der Dienst antwortet. Anschließend übernimmt sie den Drucker
selbst — es ist **nichts** mehr in der `.env` einzutragen. Das Protokoll zeigt
Schritt für Schritt, was passiert ist.
Der Pi braucht dafür nur: SSH, Python 3, systemd, einen Benutzer mit sudo — und
den Drucker am USB-Anschluss. Ein frisch aufgesetztes Raspberry Pi OS erfüllt
das ab Werk.
Die Zugangsdaten werden einmal benutzt und danach verworfen. Gespeichert wird
allein das erzeugte Drucker-Token.
## Installation von Hand
Falls die App den Pi nicht erreicht (anderes Netz, kein sudo per Passwort):
```bash ```bash
# Dateien auf den Pi kopieren curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
scp print-server.py bondrucker.service install.sh README.md pi@<PI-IP>:/tmp/bondrucker/ sudo bash bondrucker.sh
# Dort einrichten
ssh pi@<PI-IP>
cd /tmp/bondrucker && sudo bash install.sh
``` ```
`install.sh` legt an: 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.
### 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 | | Was | Wo |
|---|---| |---|---|
| Dienstbenutzer `bondrucker` (Gruppe `lp`) | — | | Dienstbenutzer `bondrucker` (Gruppe `lp`) | — |
| Programm | `/opt/bondrucker/print-server.py` | | Programm | `/opt/bondrucker/print-server.py` |
| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) | | Token und Port | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
| udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` | | udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` |
| Dienst | `bondrucker.service`, aktiviert und gestartet | | Dienst | `bondrucker.service`, aktiviert und gestartet |
Am Ende gibt das Skript den Wert für das Token aus. Das Skript ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
`install.sh` ist wiederholbar: ein erneuter Aufruf überschreibt Programm und
Token und **startet den Dienst neu**. Ohne den Neustart liefe er mit dem alten 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. Token weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen.
Abweichender Port der Brücke:
```bash
sudo bash bondrucker.sh --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=`. Adresse, Port und Token kommen
anschließend von Hand in die Einstellungen der Timer-App.
## Timer-Server konfigurieren ## Timer-Server konfigurieren
Nach der Einrichtung aus der Oberfläche: nichts zu tun. Nach der Einrichtung über den Link: nichts zu tun.
Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten" Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten"
lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang
@@ -89,8 +110,18 @@ Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar.
| Aufruf | Wirkung | | Aufruf | Wirkung |
|---|---| |---|---|
| `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist | | `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}` | | `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 `/status` verlangt **kein** Token — der Zustand des Druckers ist nicht geheim
und soll für Health-Checks abfragbar bleiben. Wird trotzdem eines mitgeschickt, 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 meldet die Antwort in `tokenAccepted`, ob es passt. Die App nutzt das nach der
+1 -1
View File
@@ -15,7 +15,7 @@ Group=bondrucker
SupplementaryGroups=lp SupplementaryGroups=lp
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der # Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
# Versionsverwaltung landet - die Datei legt install.sh an. # Versionsverwaltung landet - die Datei legt einrichten.sh an.
Environment=PRINT_DEVICE=/dev/usb/lp0 Environment=PRINT_DEVICE=/dev/usb/lp0
Environment=PRINT_HOST=0.0.0.0 Environment=PRINT_HOST=0.0.0.0
Environment=PRINT_PORT=9100 Environment=PRINT_PORT=9100
+253
View File
@@ -0,0 +1,253 @@
#!/bin/bash
# ---------------------------------------------------------------------------
# Bondrucker-Bruecke einrichten - wird AUF DEM PI ausgefuehrt.
#
# Dieses Skript wird nicht von Hand gepflegt heruntergeladen, sondern von der
# Timer-App ausgeliefert: sie setzt ihre eigene Adresse und einen einmaligen
# Einrichtungscode ein und haengt den Druckdienst sowie die systemd-Unit
# gleich mit an. Heraus kommt eine einzige Datei, die alles enthaelt.
#
# Aufruf auf dem Pi:
# curl -fsSL "<Link aus der Timer-App>" -o bondrucker.sh
# sudo bash bondrucker.sh
#
# 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:
# --port 9100 Lausch-Port der Bruecke
# ---------------------------------------------------------------------------
set -euo pipefail
# Diese beiden Werte ersetzt die Timer-App beim Ausliefern.
APP_URL="@@APP_URL@@"
SETUP_CODE="@@SETUP_CODE@@"
ZIEL=/opt/bondrucker
PORT=9100
# --- Schalter --------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="${2:-9100}"; shift 2 ;;
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
*) echo "Unbekannter Schalter: $1" >&2; exit 1 ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte mit sudo starten: sudo bash $0" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Hilfsmittel
# ---------------------------------------------------------------------------
titel() { printf '\n\033[1m%s\033[0m\n' "$1"; }
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; }
# 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
while [ -n "$pfad" ] && [ "$pfad" != "/" ]; do
if [ -r "$pfad/product" ]; then
printf '%s %s' "$(cat "$pfad/manufacturer" 2>/dev/null || true)" "$(cat "$pfad/product")"
return 0
fi
pfad=$(dirname "$pfad")
done
return 1
}
# ---------------------------------------------------------------------------
# 1. System pruefen
# ---------------------------------------------------------------------------
titel "1. System"
command -v python3 >/dev/null || fehler "python3 fehlt. Bitte nachinstallieren: apt install python3"
command -v systemctl >/dev/null || fehler "systemd fehlt - dieses Skript richtet einen systemd-Dienst ein."
punkt "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s) ($(uname -m))"
punkt "$(python3 --version)"
# ---------------------------------------------------------------------------
# 2. Angeschlossene Drucker anzeigen
# ---------------------------------------------------------------------------
titel "2. Angeschlossene Drucker"
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"
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
# ---------------------------------------------------------------------------
# 3. Dienstbenutzer
# ---------------------------------------------------------------------------
titel "3. Dienstbenutzer"
if ! id bondrucker >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker
punkt "Benutzer 'bondrucker' angelegt (Gruppe lp)."
else
usermod -aG lp bondrucker
punkt "Benutzer 'bondrucker' war schon da."
fi
# ---------------------------------------------------------------------------
# 4. Programmdateien
# ---------------------------------------------------------------------------
titel "4. Programmdateien"
install -d -m 755 "$ZIEL"
cat > "$ZIEL/print-server.py" <<'PRINT_SERVER_ENDE'
@@PRINT_SERVER@@
PRINT_SERVER_ENDE
chmod 755 "$ZIEL/print-server.py"
punkt "$ZIEL/print-server.py"
cat > /etc/systemd/system/bondrucker.service <<'SERVICE_ENDE'
@@SERVICE@@
SERVICE_ENDE
chmod 644 /etc/systemd/system/bondrucker.service
punkt "/etc/systemd/system/bondrucker.service"
# ---------------------------------------------------------------------------
# 5. Konfiguration
# ---------------------------------------------------------------------------
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_PORT=%s\n' "$PORT"
} > /etc/bondrucker/env
chmod 640 /etc/bondrucker/env
chown root:bondrucker /etc/bondrucker/env
punkt "/etc/bondrucker/env geschrieben, neues Token erzeugt."
# Ohne diese Regel gehoert /dev/usb/lp0 nach jedem Einstecken wieder root
# allein - der Dienst laeuft aber nicht als root und koennte nicht drucken.
cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE'
# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker'
# ohne root drucken kann. Gilt fuer alle usblp-Geraete.
SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660"
SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660"
RULE
udevadm control --reload-rules >/dev/null 2>&1 || true
udevadm trigger --subsystem-match=usbmisc >/dev/null 2>&1 || true
punkt "udev-Regel gesetzt (Geraet ueberlebt das Aus- und Einstecken)."
# ---------------------------------------------------------------------------
# 6. Dienst starten
# ---------------------------------------------------------------------------
titel "6. Dienst"
systemctl daemon-reload
systemctl enable bondrucker.service >/dev/null 2>&1
# Bewusst "restart" und nicht "enable --now": bei einer erneuten Einrichtung
# laeuft der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er
# liefe dann mit dem ALTEN Token weiter und wuerde jeden Bon ablehnen.
systemctl restart bondrucker.service
sleep 1
if ! systemctl is-active --quiet bondrucker.service; then
systemctl --no-pager --lines=20 status bondrucker.service || true
fehler "Der Dienst laeuft nicht. Die Ausgabe oben sagt, woran es liegt."
fi
punkt "bondrucker.service laeuft."
# Gegenprobe von innen: antwortet die Bruecke, und nimmt sie das neue Token?
ANTWORT="$(curl -fsS -m 5 -H "X-Print-Token: $TOKEN" "http://127.0.0.1:$PORT/status" 2>/dev/null || true)"
case "$ANTWORT" in
*'"tokenAccepted": true'*|*'"tokenAccepted":true'*) punkt "Bruecke antwortet und nimmt das Token an." ;;
"") warnung "Die Bruecke antwortet auf 127.0.0.1:$PORT noch nicht." ;;
*) warnung "Die Bruecke antwortet, lehnt das Token aber ab: $ANTWORT" ;;
esac
# ---------------------------------------------------------------------------
# 7. Bei der Timer-App anmelden
# ---------------------------------------------------------------------------
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
titel "7. Timer-App"
angemeldet=0
case "$APP_URL" in
@@*|"")
punkt "Kein Einrichtungscode enthalten - dieses Skript wurde nicht ueber"
punkt "die Timer-App geladen. Anmeldung wird uebersprungen."
;;
*)
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","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 "Jetzt dort unter Einstellungen den Drucker auswaehlen."
angemeldet=1
else
warnung "Anmeldung fehlgeschlagen: $ERGEBNIS"
warnung "Meist ist der Code abgelaufen - in der Timer-App einen neuen erzeugen."
fi
fi
;;
esac
# ---------------------------------------------------------------------------
# Fertig
# ---------------------------------------------------------------------------
titel "Fertig"
punkt "Adresse: ${IP:-<IP des Pi>}"
punkt "Port: $PORT"
if [ "$angemeldet" -eq 0 ]; then
echo
punkt "Von Hand eintragen in der Timer-App unter Einstellungen:"
printf ' Adresse: %s\n Port: %s\n Token: %s\n' "${IP:-?}" "$PORT" "$TOKEN"
fi
echo
punkt "Zustand abfragen: curl -s http://${IP:-127.0.0.1}:$PORT/status"
punkt "Protokoll: journalctl -u bondrucker -f"
echo
-71
View File
@@ -1,71 +0,0 @@
#!/bin/bash
# ---------------------------------------------------------------------------
# Bondrucker-Bruecke auf dem Raspberry Pi einrichten.
#
# Aufruf auf dem Pi: sudo bash install.sh [TOKEN]
# Ohne TOKEN wird eins erzeugt und am Ende ausgegeben - dieser Wert gehoert
# anschliessend in die .env des Timer-Servers (PRINTER_TOKEN).
# ---------------------------------------------------------------------------
set -euo pipefail
QUELLE="$(cd "$(dirname "$0")" && pwd)"
ZIEL=/opt/bondrucker
TOKEN="${1:-}"
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte mit sudo starten." >&2
exit 1
fi
echo "--- Dienstbenutzer"
if ! id bondrucker >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker
echo " Benutzer 'bondrucker' angelegt (Gruppe lp)."
else
usermod -aG lp bondrucker
echo " Benutzer 'bondrucker' war schon da."
fi
echo "--- Programmdateien nach $ZIEL"
install -d -m 755 "$ZIEL"
install -m 755 "$QUELLE/print-server.py" "$ZIEL/print-server.py"
[ -f "$QUELLE/README.md" ] && install -m 644 "$QUELLE/README.md" "$ZIEL/README.md"
echo "--- Konfiguration"
install -d -m 750 /etc/bondrucker
if [ -f /etc/bondrucker/env ] && [ -z "$TOKEN" ]; then
echo " /etc/bondrucker/env besteht bereits - Token bleibt unveraendert."
else
[ -n "$TOKEN" ] || TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)"
printf 'PRINT_TOKEN=%s\n' "$TOKEN" > /etc/bondrucker/env
chmod 640 /etc/bondrucker/env
chown root:bondrucker /etc/bondrucker/env
echo " Token geschrieben."
fi
echo "--- udev-Regel (Geraet ueberlebt das Aus- und Einstecken)"
cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE'
# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker'
# ohne root drucken kann. Gilt fuer alle usblp-Geraete.
SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660"
SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660"
RULE
udevadm control --reload-rules || true
udevadm trigger --subsystem-match=usbmisc || true
echo "--- systemd"
install -m 644 "$QUELLE/bondrucker.service" /etc/systemd/system/bondrucker.service
systemctl daemon-reload
systemctl enable bondrucker.service
# Bewusst "restart" statt "enable --now": bei einer erneuten Einrichtung laeuft
# der Dienst schon, und "enable --now" wuerde ihn in Ruhe lassen. Er liefe dann
# mit dem ALTEN Token weiter und wuerde jeden Druckauftrag ablehnen.
systemctl restart bondrucker.service
sleep 1
systemctl --no-pager --lines=5 status bondrucker.service || true
echo
echo "--- Fertig"
echo "Status abfragen: curl -s http://$(hostname -I | awk '{print $1}'):9100/status"
echo "Token fuer die .env des Timer-Servers:"
echo " PRINTER_TOKEN=$(sed -n 's/^PRINT_TOKEN=//p' /etc/bondrucker/env)"
+117 -17
View File
@@ -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 Bewusst nur Python-Standardbibliothek: auf dem Pi muss dafuer nichts
nachinstalliert werden (kein pip, kein CUPS, kein node). 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 Endpunkte
GET /status Zustand des Druckers als JSON (fuer Health-Checks) 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 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): 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_HOST Lausch-Adresse, Standard 0.0.0.0
PRINT_PORT Lausch-Port, Standard 9100 PRINT_PORT Lausch-Port, Standard 9100
PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!) PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!)
@@ -26,15 +35,21 @@ import glob
import hmac import hmac
import json import json
import os import os
import re
import socketserver import socketserver
import sys import sys
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.1" VERSION = "1.2"
DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0") 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") HOST = os.environ.get("PRINT_HOST", "0.0.0.0")
PORT = int(os.environ.get("PRINT_PORT", "9100")) PORT = int(os.environ.get("PRINT_PORT", "9100"))
TOKEN = os.environ.get("PRINT_TOKEN", "").strip() TOKEN = os.environ.get("PRINT_TOKEN", "").strip()
@@ -52,21 +67,87 @@ def log(msg):
print(msg, flush=True) print(msg, flush=True)
def find_device(): def _lies(pfad):
"""Konfiguriertes Geraet, sonst das erste vorhandene /dev/usb/lp*. 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 def usb_name(geraet):
Dienst anfasst. """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): if os.path.exists(DEVICE):
return DEVICE return DEVICE
found = sorted(glob.glob("/dev/usb/lp*")) found = sorted(glob.glob("/dev/usb/lp*"))
return found[0] if found else None return found[0] if found else None
def device_status(token_accepted=None): def device_status(token_accepted=None, requested=None):
dev = find_device() 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 = { status = {
"ok": False, "ok": False,
"version": VERSION, "version": VERSION,
@@ -79,23 +160,24 @@ def device_status(token_accepted=None):
"tokenAccepted": token_accepted, "tokenAccepted": token_accepted,
"device": dev or DEVICE, "device": dev or DEVICE,
"deviceConfigured": DEVICE, "deviceConfigured": DEVICE,
"deviceExists": dev is not None, "deviceRequested": requested or None,
"deviceExists": dev is not None and os.path.exists(dev),
"writable": False, "writable": False,
"lastError": _last_error, "lastError": _last_error,
"lastPrintAt": _last_ok, "lastPrintAt": _last_ok,
} }
if dev: if dev and os.path.exists(dev):
status["writable"] = os.access(dev, os.W_OK) status["writable"] = os.access(dev, os.W_OK)
status["ok"] = status["writable"] status["ok"] = status["writable"]
return status return status
def write_to_printer(data): def write_to_printer(data, requested=None):
"""Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError.""" """Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError."""
global _last_error, _last_ok global _last_error, _last_ok
with _print_lock: with _print_lock:
dev = find_device() dev = find_device(requested)
if dev is None: if dev is None:
raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?") raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?")
@@ -133,16 +215,29 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self): def do_GET(self):
if self.path.rstrip("/") in ("/status", "/health", ""): pfad = self.path.split("?")[0].rstrip("/")
st = device_status(token_ok(self.headers.get("X-Print-Token")))
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) 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: else:
self._send(404, {"ok": False, "error": "Unbekannter Pfad"}) self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
def do_POST(self): def do_POST(self):
global _last_error global _last_error
if self.path.rstrip("/") != "/print": if self.path.split("?")[0].rstrip("/") != "/print":
return self._send(404, {"ok": False, "error": "Unbekannter Pfad"}) return self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
if not token_ok(self.headers.get("X-Print-Token")): if not token_ok(self.headers.get("X-Print-Token")):
@@ -162,7 +257,10 @@ class Handler(BaseHTTPRequestHandler):
data = self.rfile.read(length) data = self.rfile.read(length)
try: 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: except OSError as err:
_last_error = str(err) _last_error = str(err)
log(f"[fehler] {self.client_address[0]}: {err}") log(f"[fehler] {self.client_address[0]}: {err}")
@@ -186,6 +284,8 @@ def main():
st = device_status() st = device_status()
log(f"Bondrucker-Bruecke {VERSION}") log(f"Bondrucker-Bruecke {VERSION}")
log(f" Geraet: {st['device']} (vorhanden: {st['deviceExists']}, beschreibbar: {st['writable']})") 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" Lauscht: {HOST}:{PORT}")
log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}") log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}")
+185 -41
View File
@@ -11,9 +11,12 @@ const state = {
timers: [], // {id, customerName, isRunning, baseMs, syncedAt} timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
rate: { rateAmount: 10, rateInterval: 10 }, rate: { rateAmount: 10, rateInterval: 10 },
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
printerDevice: '', // gewaehltes Geraet am Pi, leer = automatisch
printAutoOnStop: true, // Bon beim Beenden automatisch drucken printAutoOnStop: true, // Bon beim Beenden automatisch drucken
tickHandle: null, tickHandle: null,
syncHandle: null, syncHandle: null,
setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi
setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird
history: { offset: 0, limit: 50, reachedEnd: false } history: { offset: 0, limit: 50, reachedEnd: false }
}; };
@@ -562,6 +565,7 @@ async function loadSettings() {
const settings = await api('/api/settings'); const settings = await api('/api/settings');
state.rate = settings; state.rate = settings;
state.printerConfigured = !!(settings.printer && settings.printer.configured); state.printerConfigured = !!(settings.printer && settings.printer.configured);
state.printerDevice = (settings.printer && settings.printer.device) || '';
state.printAutoOnStop = settings.printAutoOnStop !== false; state.printAutoOnStop = settings.printAutoOnStop !== false;
document.getElementById('rateAmount').value = settings.rateAmount; document.getElementById('rateAmount').value = settings.rateAmount;
@@ -576,6 +580,7 @@ async function loadSettings() {
updateSettingsPreview(); updateSettingsPreview();
renderPrinterState(settings.printer); renderPrinterState(settings.printer);
refreshPrinterStatus(); refreshPrinterStatus();
loadDevices();
} catch (e) { } catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error'); toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
} }
@@ -668,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 = `<option value="">${escapeHtml(automatisch)}</option>` +
res.devices.map(g => {
const beschriftung = g.name ? `${g.path} — ${g.name}` : g.path;
return `<option value="${escapeHtml(g.path)}">${escapeHtml(beschriftung)}</option>`;
}).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 += `<option value="${escapeHtml(eingestellt)}">` +
`${escapeHtml(eingestellt)} — zurzeit nicht angeschlossen</option>`;
}
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 = `<option value="">${escapeHtml(gewaehlt || 'automatisch')}</option>`;
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() { async function testPrint() {
const btn = document.getElementById('printerTestBtn'); const btn = document.getElementById('printerTestBtn');
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
@@ -692,7 +785,7 @@ function toggleSetup() {
box.style.display = offen ? 'none' : 'block'; box.style.display = offen ? 'none' : 'block';
document.getElementById('setupToggleBtn').textContent = document.getElementById('setupToggleBtn').textContent =
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen'; offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
if (!offen) document.getElementById('setupHost').focus(); if (offen) stopSetupWatch();
} }
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */ /** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
@@ -711,59 +804,110 @@ function renderSetupLog(steps, laufend) {
el.innerHTML = zeilen + (laufend el.innerHTML = zeilen + (laufend
? `<div class="setup-step"><span class="setup-step-icon">⏳</span> ? `<div class="setup-step"><span class="setup-step-icon">⏳</span>
<div><div class="setup-step-name">läuft …</div></div></div>` <div><div class="setup-step-name">wartet auf den Pi …</div></div></div>`
: ''); : '');
} }
async function startProvision() { /** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */
const host = document.getElementById('setupHost').value.trim(); async function startSetup() {
const port = document.getElementById('setupPort').value;
const username = document.getElementById('setupUser').value.trim();
const passwordEl = document.getElementById('setupPassword');
const password = passwordEl.value;
if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error');
if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error');
if (!password) return toast('Bitte das Passwort eintragen.', 'error');
if (state.printerConfigured && !confirm(
'Es ist bereits ein Drucker eingerichtet.\n\n' +
`Nach der Einrichtung druckt die App auf ${host}. Fortfahren?`
)) return;
const btn = document.getElementById('setupStartBtn'); const btn = document.getElementById('setupStartBtn');
btn.disabled = true; btn.disabled = true;
btn.textContent = '⏳ Wird eingerichtet …';
renderSetupLog([], true);
try { try {
const res = await api('/api/printer/provision', { const res = await api('/api/printer/setup-code', { method: 'POST' });
method: 'POST',
body: JSON.stringify({ host, port, username, password })
});
// Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts state.setupCommand = res.command;
// mehr verloren. document.getElementById('setupCommand').textContent = res.command;
passwordEl.value = ''; document.getElementById('setupLink').style.display = 'block';
renderSetupLog(res.steps, false); const bis = new Date(res.expiresAt);
state.printerConfigured = true; document.getElementById('setupExpiry').textContent =
`Der Link gilt einmal und läuft um ${bis.getHours()}:` +
`${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` +
'Danach hier einfach einen neuen erzeugen.';
if (res.ready) { renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true);
toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success'); startSetupWatch();
} else { toast('Link erzeugt. Jetzt am Pi ausführen.', 'success');
toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error');
}
await refreshPrinterStatus();
await syncTimers().catch(() => {});
} catch (e) { } catch (e) {
// Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind toast('Link konnte nicht erzeugt werden: ' + e.message, 'error');
// das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen.
renderSetupLog(e.steps, false);
toast('Einrichtung fehlgeschlagen: ' + e.message, 'error');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '🚀 Jetzt einrichten'; btn.textContent = '🔗 Neuen Link erzeugen';
}
}
function copySetupCommand() {
const text = state.setupCommand || '';
if (!text) return;
// navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft
// das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten
// Weg zurueck, statt wortlos nichts zu tun.
const alterWeg = () => {
const feld = document.createElement('textarea');
feld.value = text;
feld.style.position = 'fixed';
feld.style.opacity = '0';
document.body.appendChild(feld);
feld.select();
try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ }
document.body.removeChild(feld);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(
() => toast('Befehl kopiert', 'success'),
() => { alterWeg(); toast('Befehl kopiert', 'success'); }
);
} else {
alterWeg();
toast('Befehl kopiert', 'success');
}
}
/**
* Auf die Anmeldung des Pi warten.
*
* Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur
* nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung
* anzufuehlen, und selten genug, um niemandem wehzutun.
*/
function startSetupWatch() {
stopSetupWatch();
state.setupWatch = setInterval(async () => {
let z;
try {
z = await api('/api/printer/setup-status');
} catch (e) {
return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder
}
if (z.state === 'done') {
stopSetupWatch();
state.printerConfigured = true;
renderSetupLog([
{ name: 'Pi angemeldet', ok: true,
detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` },
{ 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 + ' — jetzt oben den Drucker auswählen.', 'success');
await refreshPrinterStatus();
await loadDevices();
await syncTimers().catch(() => {});
} else if (z.state === 'expired') {
stopSetupWatch();
renderSetupLog([{ name: 'Link abgelaufen', ok: false,
detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false);
}
}, 3000);
}
function stopSetupWatch() {
if (state.setupWatch) {
clearInterval(state.setupWatch);
state.setupWatch = null;
} }
} }
+78 -36
View File
@@ -220,6 +220,7 @@
input[type="password"], input[type="password"],
input[type="number"], input[type="number"],
input[type="date"], input[type="date"],
select,
textarea { textarea {
width: 100%; width: 100%;
padding: 0.875rem 1.125rem; padding: 0.875rem 1.125rem;
@@ -233,6 +234,7 @@
} }
input:focus, input:focus,
select:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: #4d35a9; border-color: #4d35a9;
@@ -644,6 +646,50 @@
line-height: 1.5; 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
das Kopieren ins Leere geht (kein HTTPS, fremdes Tablet). Deshalb
umbrechen statt abschneiden. */
.setup-command {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.setup-command code {
flex: 1;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.6;
color: #d7d7ff;
word-break: break-all;
user-select: all;
}
.setup-command .btn { flex-shrink: 0; }
@media (max-width: 600px) {
.setup-command { flex-direction: column; }
}
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall /* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
sichtbar ist, wie weit es gekommen ist. */ sichtbar ist, wie weit es gekommen ist. */
.setup-log { .setup-log {
@@ -856,6 +902,19 @@
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div> <div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
<div class="form-group" id="printerDeviceGroup">
<label for="printerDevice">Drucker am Pi</label>
<div class="device-row">
<select id="printerDevice" onchange="saveDevice()">
<option value="">automatisch</option>
</select>
<button type="button" class="btn btn-secondary btn-sm"
onclick="loadDevices(true)">🔄 Suchen</button>
</div>
<small id="printerDeviceHint">Welches Gerät am Raspberry Pi bedruckt wird.
„Automatisch" nimmt den ersten gefundenen.</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="receiptHeader">Kopfzeilen</label> <label for="receiptHeader">Kopfzeilen</label>
<textarea id="receiptHeader" maxlength="240" rows="3" <textarea id="receiptHeader" maxlength="240" rows="3"
@@ -903,47 +962,30 @@
</div> </div>
<div class="setup-box" id="setupBox" style="display: none;"> <div class="setup-box" id="setupBox" style="display: none;">
<h3 style="margin-bottom: 0.35rem;">Neuen Drucker-Pi einrichten</h3> <h3 style="margin-bottom: 0.35rem;">Drucker-Pi einrichten</h3>
<p style="color: rgba(255,255,255,0.7); font-size: 0.9rem; margin-bottom: 1.25rem;"> <p style="color: rgba(255,255,255,0.7); font-size: 0.9rem; margin-bottom: 1.25rem;">
Richtet den Druckdienst auf einem frisch aufgesetzten Raspberry Pi Die App legt ein Einrichtungsskript bereit. Das wird auf dem Pi
vollständig ein und übernimmt ihn anschließend. Der Pi braucht nur heruntergeladen und gestartet — dort wird auch ausgewählt, welcher
SSH, Python 3 und den Drucker am USB-Anschluss. Drucker verwendet werden soll. Danach meldet sich der Pi von selbst
hier an. Der Pi braucht nur Python 3 und den Drucker am USB-Anschluss.
</p> </p>
<div class="setup-grid"> <div class="settings-actions" style="margin-bottom: 0;">
<div class="form-group">
<label for="setupHost">Adresse des Pi</label>
<input type="text" id="setupHost" placeholder="192.168.8.157" autocomplete="off">
</div>
<div class="form-group">
<label for="setupPort">SSH-Port</label>
<input type="number" id="setupPort" value="22" min="1" max="65535">
</div>
</div>
<div class="setup-grid" style="margin-top: 1rem;">
<div class="form-group">
<label for="setupUser">Benutzer</label>
<input type="text" id="setupUser" value="pi" autocomplete="off">
<small>Muss auf dem Pi sudo dürfen</small>
</div>
<div class="form-group">
<label for="setupPassword">Passwort</label>
<input type="password" id="setupPassword" autocomplete="new-password">
<small>Wird auch für sudo verwendet</small>
</div>
</div>
<div class="settings-actions" style="margin-top: 1.25rem; margin-bottom: 0;">
<button type="button" class="btn" id="setupStartBtn" <button type="button" class="btn" id="setupStartBtn"
onclick="startProvision()">🚀 Jetzt einrichten</button> onclick="startSetup()">🔗 Einrichtungslink erzeugen</button>
</div> </div>
<p class="setup-hint"> <div id="setupLink" class="setup-link" style="display: none;">
Das Passwort wird einmal verwendet und danach verworfen — gespeichert <p class="setup-step-name" style="margin-bottom: 0.5rem;">
wird nur ein neu erzeugtes Drucker-Token. Ein vorhandener Druckdienst Auf dem Pi im Terminal ausführen:
auf demselben Pi wird dabei überschrieben. </p>
</p> <div class="setup-command">
<code id="setupCommand"></code>
<button type="button" class="btn btn-secondary btn-sm"
onclick="copySetupCommand()">📋 Kopieren</button>
</div>
<p class="setup-hint" id="setupExpiry"></p>
</div>
<div class="setup-log" id="setupLog"></div> <div class="setup-log" id="setupLog"></div>
</div> </div>
@@ -966,6 +1008,6 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/app.js?v=20260912-barcode"></script> <script src="/app.js?v=20260912-devices"></script>
</body> </body>
</html> </html>