Compare commits

...
4 Commits
Author SHA1 Message Date
Kerim (portable/claudecode)andClaude Opus 5 b1c7d89a7d Emoji in den Ueberschriften nicht mehr vom Farbverlauf ausfuellen
Deploy auf den Timer-Server / deploy (push) Successful in 18s
Die Ueberschriften von Historie und Einstellungen faerben ihren Text ueber
einen Verlauf ein: background-clip: text mit durchsichtiger Schriftfarbe.
Ein Farb-Emoji hat dabei keine Kontur, die sich einfaerben liesse - der
Browser fuellt seine gesamte Flaeche mit dem Verlauf. Aus dem Balkendiagramm
der Historie wurde so ein weisser Kasten.

Die beiden Symbole stehen jetzt in einem eigenen span, das seine Schriftfarbe
zurueckbekommt und sich damit wieder selbst zeichnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 21:09:14 +02:00
Kerim (portable/claudecode)andClaude Opus 5 f6f2df308c Versatz im Tarif-Raster beheben, Hinweis ohne Drucker richtigstellen
Die Regel ".form-group + .form-group { margin-top: 1.25rem }" aus dem letzten
Umbau traf auch das zweite Feld im Tarif-Raster: "Intervall (Minuten)" stand
dadurch 20 Pixel tiefer als "Betrag". Die Regel war ueberdies ueberfluessig -
.form-group bringt bereits margin-bottom mit -, deshalb ersatzlos raus.

Der Hinweis bei fehlendem Drucker verwies noch auf PRINTER_HOST in der .env.
Seit der Einrichtung aus der Oberflaeche ist das der falsche Weg; jetzt steht
dort, was die Folge ist (keine Bon-Knoepfe) und wo der Knopf sitzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 21:06:34 +02:00
Kerim (portable/claudecode)andClaude Opus 5 5e23ccdcbc Drucker-Pi aus der Oberflaeche heraus einrichten
Die Einrichtung war 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. Jetzt macht das ein Knopf unter "Einstellungen":
Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an,
prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet
install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen
nach. Jeder Schritt steht mit Ergebnis im Protokoll.

Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der
Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf
den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim
allerersten Start, wie RATE_PER_10MIN auch.

Zwei Fehler, die beim Testen der Einrichtung auffielen:

install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei
einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token
weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart.

Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein
Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet
in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1),
und die Einrichtung prueft das mit.

Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token
wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal
geht. Neu dabei: ssh2, reines JavaScript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:59:56 +02:00
Kerim (portable/claudecode)andClaude Opus 5 70756f3762 Bon fuer die Kasse auf einem USB-Bondrucker ausgeben
Der Kunde soll mit einem Zettel zur Kasse gehen koennen, auf dem steht, was
er zahlen muss. Drei Wege fuehren jetzt zu einem Bon: der Knopf "Bon" auf der
Timer-Karte druckt den Zwischenstand und laesst den Timer weiterlaufen,
"Beenden" druckt den Endbeleg, und in der Historie laesst sich jeder Beleg
nachdrucken.

Der Drucker haengt per USB an einem Raspberry Pi und ist damit vom
Timer-Server aus nicht direkt erreichbar. Auf dem Pi laeuft deshalb eine
kleine Bruecke (deploy/bondrucker/, nur Python-Standardbibliothek), die
fertige ESC/POS-Daten per HTTP annimmt. Das Bon-Layout bleibt bewusst hier
im Repo, damit Aenderungen daran nicht auf dem Pi angefasst werden muessen.

Beim Beenden wird erst gespeichert und dann gedruckt. Eine leere Papierrolle
ist damit ein Papierproblem und kein Datenproblem - der Vorgang steht in der
Historie, die Oberflaeche sagt ausdruecklich, dass der Bon nicht gedruckt
wurde, und nennt den Grund.

Ohne PRINTER_HOST in der .env ist die Druckfunktion aus und die App verhaelt
sich unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:37:12 +02:00
16 changed files with 2236 additions and 35 deletions
+105 -8
View File
@@ -12,12 +12,16 @@ Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung,
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).
* **Keine externen Dienste.** Kein Cloud-Backend, keine API-Keys, keine Telemetrie,
keine CDN-Einbindung. Die App macht **keine ausgehenden Netzwerkverbindungen**.
keine CDN-Einbindung. Ausgehend spricht die App nur mit zwei Zielen im
eigenen Netz: Authentik (Anmeldung) und — falls eingerichtet — dem
Bondrucker-Pi (Abschnitt 6). Kein Ziel außerhalb des eigenen Netzes.
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
Vanilla JS im Frontend, Express im Backend.
* **Abhängigkeiten:** 6 npm-Pakete, alle etabliert (express, express-session,
express-rate-limit, sqlite3, bcrypt, dotenv).
* **Abhängigkeiten:** 8 npm-Pakete, alle etabliert (express, express-session,
express-rate-limit, sqlite3, bcrypt, dotenv, openid-client, ssh2). `ssh2` ist
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
beachtet werden. Ohne sie kann es beliebig lange dauern, weil zwei davon **stumm**
@@ -205,6 +209,17 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei |
| `RATE_PER_10MIN` | `10` | **Nur beim allerersten Start.** Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt. |
| `PRINTER_HOST` | leer | IP des Bondrucker-Pi. **Leer = Druckfunktion aus**, die App verhält sich wie vorher |
| `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi |
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen |
| `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm |
| `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
**Die vier `PRINTER_*`-Werte oberhalb von `PRINTER_TIMEOUT_MS` sind nur
Startwerte beim allerersten Start** — genau wie `RATE_PER_10MIN`. Danach lebt
der Druckerzugang in der Datenbank und wird über die Oberfläche gepflegt; eine
spätere Änderung in der `.env` bleibt wirkungslos. Der Grund steht in
Abschnitt 6: ein Druckerwechsel soll kein Server-Zugang sein.
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
@@ -250,6 +265,74 @@ Wichtig in beiden Fällen:
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
* Websockets werden **nicht** gebraucht
### Bondrucker
Vollständige Einrichtung: **`deploy/bondrucker/README.md`**. Hier nur das
Wesentliche.
Der Drucker hängt per USB an einem Raspberry Pi im Laden. Auf dem Pi läuft ein
kleiner Python-Dienst, der fertige ESC/POS-Daten per HTTP annimmt und in
`/dev/usb/lp0` schreibt. Der Bon selbst entsteht in dieser App
(`backend/receipt.js`) — auf dem Pi liegt bewusst keine Layout-Logik.
```
Timer-App ──HTTP POST /print──▶ Pi (bondrucker.service) ──▶ /dev/usb/lp0
receipt.js baut den Bon schiebt Bytes durch Epson TM-T88IV
```
Drei Wege führen zu einem Bon:
| Auslöser | Beleg | Timer danach |
|---|---|---|
| **🖨 Bon** auf der Timer-Karte | Zwischenstand | läuft unverändert weiter |
| **✓ Beenden** | Endbeleg | ist beendet und in der Historie |
| **🖨** in der Historie | Nachdruck desselben Belegs | — |
**Reihenfolge ist Absicht:** Beim Beenden wird erst gespeichert, dann gedruckt.
Ein leerer Papierhalter ist damit ein Papierproblem und kein Datenproblem — der
Vorgang steht in der Historie und lässt sich von dort nachdrucken. Die
Oberfläche sagt in dem Fall ausdrücklich, dass der Bon *nicht* gedruckt wurde.
Kopf- und Fußzeilen des Bons stehen in der Datenbank und werden unter
**Einstellungen** gepflegt, ebenso der Schalter „Bon beim Beenden automatisch
drucken". Der Probedruck dort beantwortet die Frage, ob es am Drucker oder an
der App liegt.
#### Einen Pi einrichten
⚙️ Einstellungen → **🔧 Drucker einrichten** richtet einen frisch aufgesetzten
Raspberry Pi vollständig selbst ein: IP, Benutzer und Passwort eintragen,
Knopf drücken, fertig — rund drei Sekunden.
Die App meldet sich per SSH an, prüft Python/systemd/Druckergerät, überträgt die
vier Dateien aus `deploy/bondrucker/` per SFTP nach `/tmp`, startet `install.sh`
mit einem selbst erzeugten Token, räumt auf und prüft von außen nach. Danach
übernimmt sie den Drucker selbst. Jeder Schritt steht mit Ergebnis im Protokoll.
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
Hand auf dem Server eingetragen werden. Ein Druckerwechsel im Laden soll kein
SSH-Zugang zum Timer-Server sein.
Zum Umgang mit den Zugangsdaten: Benutzername und Passwort stehen nur während
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
HTTP 403 ablehnen — die Gegenprobe nach der Einrichtung prüft deshalb nicht nur
die Erreichbarkeit, sondern auch, ob das neue Token angenommen wird.
**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
Router erspart das.
### Backup
Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
@@ -275,7 +358,11 @@ timer-app/
├── DEPLOY.md ← dieses Dokument
├── README.md ← Kurzanleitung für Anwender
├── backend/
│ ├── server.js ← die gesamte Server-Logik (~470 Zeilen)
│ ├── server.js ← die gesamte Server-Logik
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
│ ├── receipt.js ← Layout des Bons
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
│ ├── provision.js ← richtet einen Drucker-Pi per SSH ein
│ ├── package.json
│ ├── package-lock.json
│ ├── .env.example
@@ -288,7 +375,12 @@ timer-app/
└── deploy/
├── timer-app.service
├── Caddyfile.example
── nginx.conf.example
── nginx.conf.example
└── bondrucker/ ← Druckdienst für den Raspberry Pi
├── README.md ← Einrichtung des Pi
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
├── bondrucker.service
└── install.sh
```
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
@@ -357,12 +449,17 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
| `POST` | `/api/timers/:id/pause` | anhalten |
| `POST` | `/api/timers/:id/resume` | fortsetzen |
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern |
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern; druckt den Bon, sofern eingerichtet. Optional `{print: bool}` überstimmt die Einstellung |
| `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet |
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
| `GET` | `/api/settings` | Tarif lesen |
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` |
| `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen |
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` |
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
| `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 |
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
| `GET` | `/health` | Status, ohne Anmeldung |
+50 -2
View File
@@ -28,7 +28,8 @@ sich jederzeit nachträglich ändern, einfach im Feld überschreiben.
|---|---|
| **❚❚ Pause** | Zeit anhalten, Betrag friert ein |
| **▶ Weiter** | pausierten Timer fortsetzen |
| ** Beenden** | Vorgang abschließen und in der Historie speichern |
| **🖨 Bon** | Zwischenstand ausdrucken — der Timer läuft weiter |
| **✓ Beenden** | Vorgang abschließen, in der Historie speichern und Bon drucken |
| **✕ Verwerfen** | Timer wegwerfen, **nichts** wird gespeichert |
Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
@@ -43,6 +44,28 @@ Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
Wanduhr. Die Karte zeigt, seit wann er läuft. Wer die Zeit korrigieren will,
verwirft den Timer und erfasst ihn neu.
## Bon für die Kasse
Auf dem Bon steht, was der Kunde zahlen muss — mit Beginn, Ende, Dauer und dem
Tarif, damit der Betrag nachvollziehbar ist. Der Betrag selbst steht groß und
allein darunter.
Zwei Wege, je nachdem, wie der Kunde geht:
* **Der Platz wird frei.** **✓ Beenden** drücken. Der Vorgang wandert in die
Historie und der Bon kommt aus dem Drucker — damit geht der Kunde zur Kasse.
* **Der Kunde will zwischendurch zahlen, bleibt aber.** **🖨 Bon** drücken. Der
Zettel zeigt den Stand von jetzt und ist als *Zwischenstand* gekennzeichnet;
der Timer läuft unverändert weiter.
### Wenn nichts aus dem Drucker kommt
Der Vorgang ist trotzdem gespeichert — die App sagt in dem Fall ausdrücklich,
dass der Bon nicht gedruckt wurde, und nennt den Grund. Meist ist die Papierrolle
leer oder der Drucker aus. Nach dem Beheben: in der **Historie** beim Eintrag auf
**🖨** tippen, dann kommt derselbe Beleg noch einmal. Genauso, wenn ein Kunde
seinen Bon verlegt hat.
## Historie
Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name,
@@ -59,11 +82,36 @@ Dauer und Betrag. Oben summieren drei Kacheln den gewählten Zeitraum.
Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall,
zum Beispiel „10 € pro 10 Minuten". Die Vorschau rechnet Beispiele durch.
Darunter steht, was auf dem Bon gedruckt wird:
* **Kopfzeilen** — Ladenname, Adresse, Telefonnummer. Die erste Zeile wird groß
gedruckt.
* **Fußzeilen** — der Hinweis unter dem Betrag, etwa „Bitte an der Kasse
bezahlen."
* **Bon beim Beenden automatisch drucken** — ausgeschaltet kommt ein Bon nur
noch über die 🖨-Knöpfe.
* **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
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:
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
kosten bei einem 10-Minuten-Takt bereits zwei Einheiten.
* Der Tarif gilt für **alle Geräte gemeinsam**.
* Der Tarif und die Bon-Texte gelten für **alle Geräte gemeinsam**.
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
+27
View File
@@ -55,3 +55,30 @@ LOGIN_WINDOW_MIN=15
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
RATE_PER_10MIN=10
# --- Bondrucker ------------------------------------------------------------
# Der Bondrucker haengt per USB an einem Raspberry Pi. Auf dem Pi laeuft die
# Bruecke aus deploy/bondrucker/ und nimmt fertige Druckdaten per HTTP an.
#
# ACHTUNG: Diese vier Werte sind nur die STARTWERTE beim allerersten Start.
# Danach lebt der Druckerzugang in der Datenbank und wird ueber die Oberflaeche
# gepflegt - "Einstellungen" -> "Drucker einrichten" richtet einen Pi sogar
# vollstaendig selbst ein (Uebertragung, Installation, Token). Eine spaetere
# Aenderung hier bleibt also wirkungslos.
#
# Wer gar keinen Drucker hat, laesst alles leer: die App laeuft dann genau wie
# vorher, nur ohne Bon-Knoepfe.
PRINTER_HOST=
PRINTER_PORT=9100
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
# install.sh gibt den Wert am Ende aus.
PRINTER_TOKEN=
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
PRINTER_WIDTH=42
# Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
# darf das Beenden eines Timers nicht blockieren.
PRINTER_TIMEOUT_MS=6000
+152
View File
@@ -0,0 +1,152 @@
/**
* ESC/POS - Bausteine fuer den Bondrucker
*
* Erzeugt die Bytefolge, die ein Thermo-Bondrucker versteht (getestet mit einem
* Epson TM-T88IV). Bewusst ohne Fremdpaket: gebraucht werden ein Dutzend
* Steuersequenzen, und der Drucker ist das Letzte, was wegen eines
* Paket-Updates ploetzlich Buchstabensalat ausspucken soll.
*
* Zeichensatz: Der Drucker kennt kein UTF-8. Umgestellt wird auf Codepage 16
* (WPC1252) - die deckt die deutschen Umlaute ab und ist ab Werk in praktisch
* jedem Epson-Bondrucker vorhanden. Alles, was dort fehlt, wird lesbar
* ersetzt statt als Muell gedruckt.
*/
const ESC = 0x1b;
const GS = 0x1d;
// Codepage 16 = WPC1252. Die Zeichen decken sich dort mit Latin-1, mit
// Ausnahme des Euro-Zeichens (0x80). Deshalb reicht die Latin-1-Kodierung
// von Node plus diese eine Sonderbehandlung.
const CODEPAGE_WPC1252 = 16;
/** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */
const ERSATZ = {
'€': '\x80',
'„': '"', '“': '"', '”': '"', '': "'", '': "'", '': "'",
'': '-', '—': '-', '…': '...', ' ': ' ',
'\t': ' '
};
function encodeText(text) {
let s = String(text == null ? '' : text);
for (const [von, nach] of Object.entries(ERSATZ)) {
s = s.split(von).join(nach);
}
// Was danach ausserhalb von Latin-1 liegt (Emoji, kyrillisch, ...), wuerde
// als zufaelliges Zeichen gedruckt. Lieber ein sichtbares Fragezeichen.
s = s.replace(/[^\x00-\xff]/g, '?');
return Buffer.from(s, 'latin1');
}
class Bon {
/** @param {number} width Zeichen je Zeile (Font A: 42 bei 80 mm, 32 bei 58 mm) */
constructor(width = 42) {
this.width = width;
this.parts = [];
}
raw(...bytes) {
this.parts.push(Buffer.from(bytes));
return this;
}
/** Grundzustand herstellen - der Drucker merkt sich Einstellungen sonst. */
init() {
return this.raw(ESC, 0x40) // ESC @ Zuruecksetzen
.raw(ESC, 0x74, CODEPAGE_WPC1252); // ESC t Zeichensatz
}
/** @param {'left'|'center'|'right'} how */
align(how) {
const n = how === 'center' ? 1 : how === 'right' ? 2 : 0;
return this.raw(ESC, 0x61, n);
}
bold(on) {
return this.raw(ESC, 0x45, on ? 1 : 0);
}
underline(on) {
return this.raw(ESC, 0x2d, on ? 1 : 0);
}
/** Zeichengroesse als Vielfaches, 1..4 je Richtung. */
size(w = 1, h = 1) {
const clamp = v => Math.max(1, Math.min(4, parseInt(v) || 1)) - 1;
return this.raw(GS, 0x21, (clamp(w) << 4) | clamp(h));
}
text(s) {
this.parts.push(encodeText(s));
return this;
}
line(s = '') {
return this.text(s + '\n');
}
/** Zeile mit linkem und rechtem Block - die Trennlinie fuer Betraege. */
columns(left, right, width = this.width) {
const l = String(left == null ? '' : left);
const r = String(right == null ? '' : right);
const luecke = width - l.length - r.length;
if (luecke < 1) {
// Passt nicht nebeneinander: rechts buendig in die naechste Zeile.
return this.line(l).line(r.padStart(width));
}
return this.line(l + ' '.repeat(luecke) + r);
}
/** Text mittig, ohne den Drucker auf zentriert umzustellen. */
center(s) {
const t = String(s == null ? '' : s);
if (t.length >= this.width) return this.line(t);
return this.line(' '.repeat(Math.floor((this.width - t.length) / 2)) + t);
}
hr(zeichen = '-') {
return this.line(zeichen.repeat(this.width));
}
/** Langen Text auf die Zeilenbreite umbrechen. */
wrap(s, width = this.width) {
const worte = String(s == null ? '' : s).split(/\s+/).filter(Boolean);
let zeile = '';
for (const wort of worte) {
if (!zeile) {
zeile = wort;
} else if ((zeile + ' ' + wort).length <= width) {
zeile += ' ' + wort;
} else {
this.line(zeile);
zeile = wort;
}
while (zeile.length > width) {
this.line(zeile.slice(0, width));
zeile = zeile.slice(width);
}
}
if (zeile) this.line(zeile);
return this;
}
feed(n = 1) {
return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n)));
}
/**
* Papier abschneiden. Vorher wird gefuettert: der Schneidekopf sitzt
* einige Millimeter hinter dem Druckkopf, ohne Vorschub landet der Schnitt
* mitten im Text.
*/
cut() {
return this.feed(4).raw(GS, 0x56, 0x42, 0x00);
}
build() {
return Buffer.concat(this.parts);
}
}
module.exports = { Bon, encodeText };
+69 -1
View File
@@ -14,7 +14,8 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"openid-client": "^6.8.4",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"ssh2": "^1.17.0"
},
"engines": {
"node": ">=18"
@@ -183,6 +184,14 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -220,6 +229,14 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -293,6 +310,15 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -424,6 +450,20 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1382,6 +1422,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2092,6 +2138,23 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"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": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@@ -2231,6 +2294,11 @@
"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": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+2 -1
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"openid-client": "^6.8.4",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"ssh2": "^1.17.0"
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Anbindung des Bondruckers
*
* Der Drucker haengt per USB an einem Raspberry Pi im Laden, nicht an diesem
* Server. Auf dem Pi laeuft die Bruecke aus deploy/bondrucker/, die fertige
* ESC/POS-Daten per HTTP annimmt. Hier entsteht nur der Aufruf.
*
* Drei Grundsaetze, die den Rest erklaeren:
*
* 1. Drucken darf die Zeiterfassung nie aufhalten. Ein leerer Papierrollen-
* halter ist ein Papierproblem, kein Datenproblem: der Vorgang ist da
* bereits gespeichert, der Bon laesst sich aus der Historie nachdrucken.
* 2. Die Zugangsdaten stehen in der Datenbank, nicht in der .env. Nur so kann
* die Oberflaeche einen neuen Pi einrichten, ohne dass jemand auf den
* Server muss. Die .env liefert weiterhin die Startwerte (siehe server.js).
* 3. Kein Fremdpaket fuers Drucken. Node bringt einen HTTP-Client mit.
*/
const http = require('http');
// Laufende Konfiguration. Wird beim Start und nach jeder Aenderung in den
// Einstellungen aus der Datenbank gesetzt - siehe refreshPrinterConfig().
let cfg = {
host: '',
port: 9100,
token: '',
width: 42,
timeoutMs: parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000
};
// Zuletzt gesehener Zustand - fuer /health und die Anzeige in den Einstellungen.
let lastError = null;
let lastPrintAt = null;
function setConfig(next = {}) {
const port = parseInt(next.port);
const width = parseInt(next.width);
cfg = {
...cfg,
host: String(next.host == null ? cfg.host : next.host).trim(),
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : cfg.port,
token: next.token == null ? cfg.token : String(next.token).trim(),
width: Number.isInteger(width) && width >= 24 && width <= 96 ? width : cfg.width
};
return cfg;
}
function isConfigured() {
return cfg.host.length > 0;
}
function width() {
return cfg.width;
}
/** Konfiguration OHNE das Token - dieser Wert verlaesst den Server nie. */
function config() {
return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token };
}
function state() {
return {
configured: isConfigured(),
host: cfg.host || null,
port: isConfigured() ? cfg.port : null,
width: cfg.width,
lastError,
lastPrintAt
};
}
/** Ein Aufruf gegen die Bruecke. Loest nie mit einem Fehler auf, der den
* Aufrufer zwingt, ihn zu behandeln - stattdessen {ok, error}. */
function request(pfad, method, body) {
return new Promise((resolve) => {
if (!cfg.host) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet.' });
}
const headers = {};
if (body) {
headers['Content-Type'] = 'application/octet-stream';
headers['Content-Length'] = body.length;
}
if (cfg.token) headers['X-Print-Token'] = cfg.token;
const req = http.request(
{ host: cfg.host, port: cfg.port, path: pfad, method, headers },
(res) => {
const teile = [];
res.on('data', d => teile.push(d));
res.on('end', () => {
const roh = Buffer.concat(teile).toString('utf8');
let daten = null;
try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ }
if (res.statusCode >= 200 && res.statusCode < 300) {
return resolve({ ok: true, data: daten });
}
const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`;
resolve({ ok: false, error: meldung, data: daten });
});
}
);
req.setTimeout(cfg.timeoutMs, () => {
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${cfg.timeoutMs} ms`));
});
req.on('error', (err) => {
// ECONNREFUSED/EHOSTUNREACH sind der Normalfall, wenn der Pi aus ist.
const meldung = err.code === 'ECONNREFUSED'
? `Druckdienst auf ${cfg.host}:${cfg.port} nimmt keine Verbindung an.`
: err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH'
? `Drucker-Pi ${cfg.host} ist im Netz nicht erreichbar.`
: (err.message || String(err));
resolve({ ok: false, error: meldung });
});
if (body) req.write(body);
req.end();
});
}
/** Zustand der Bruecke abfragen (Geraet da? beschreibbar?). */
async function status() {
const res = await request('/status', 'GET');
if (!res.ok) {
lastError = res.error;
return { ok: false, error: res.error, ...state() };
}
return { ok: true, printer: res.data, ...state() };
}
/** Rohe ESC/POS-Daten an den Drucker geben. */
async function print(buffer) {
const res = await request('/print', 'POST', buffer);
if (res.ok) {
lastError = null;
lastPrintAt = Date.now();
} else {
lastError = res.error;
console.warn('[drucker] Druck fehlgeschlagen:', res.error);
}
return res;
}
module.exports = { setConfig, isConfigured, width, config, state, status, print };
+254
View File
@@ -0,0 +1,254 @@
/**
* 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 };
+155
View File
@@ -0,0 +1,155 @@
/**
* Bon-Layout
*
* Baut aus einem abgeschlossenen oder laufenden Vorgang den Zettel, den der
* Kunde mit an die Kasse nimmt. Die eine Zahl, auf die es ankommt - der zu
* zahlende Betrag - steht deshalb gross und allein.
*
* Formatierung bewusst von Hand statt ueber Intl: TZ und Zahlenformat des
* Bons sollen nicht davon abhaengen, mit welcher ICU-Variante das Node auf
* dem Zielhost gebaut wurde.
*/
const { Bon } = require('./escpos');
const p2 = n => String(n).padStart(2, '0');
function fmtDate(ms) {
if (!ms) return '-';
const d = new Date(Number(ms));
return `${p2(d.getDate())}.${p2(d.getMonth() + 1)}.${d.getFullYear()}`;
}
function fmtTime(ms) {
if (!ms) return '-';
const d = new Date(Number(ms));
return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
}
function fmtDuration(ms) {
const s = Math.floor(Math.max(0, ms || 0) / 1000);
return `${p2(Math.floor(s / 3600))}:${p2(Math.floor((s % 3600) / 60))}:${p2(s % 60)}`;
}
/** Deutsches Format mit Komma - "80,00 EUR". */
function fmtEuro(value) {
const n = Number(value || 0);
const [ganz, dez] = n.toFixed(2).split('.');
const mitPunkt = ganz.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${mitPunkt},${dez} EUR`;
}
/** Wie viele angefangene Einheiten berechnet wurden - erklaert den Betrag. */
function unitCount(ms, rateInterval) {
if (!ms || ms <= 0) return 0;
return Math.ceil(Math.ceil(ms / 60000) / (rateInterval || 10));
}
/**
* @param {object} data
* @param {'final'|'interim'} data.kind Endbeleg oder Zwischenstand
* @param {string|number} data.belegNr
* @param {string} data.customerName
* @param {number} data.startedAt Zeitstempel in ms
* @param {number} data.endedAt Zeitstempel in ms (bei 'interim': jetzt)
* @param {number} data.durationMs
* @param {number} data.amount
* @param {number} data.rateAmount
* @param {number} data.rateInterval
* @param {boolean} data.stillRunning
* @param {string} data.header Kopfzeilen, durch \n getrennt
* @param {string} data.footer Fusszeilen, durch \n getrennt
* @param {number} data.width Zeichen je Zeile
* @returns {Buffer}
*/
function buildReceipt(data) {
const width = data.width || 42;
const interim = data.kind === 'interim';
const bon = new Bon(width);
bon.init();
// --- Kopf: Ladenname, gross und fett ------------------------------------
const kopfzeilen = String(data.header || '').split('\n').map(z => z.trim()).filter(Boolean);
if (kopfzeilen.length) {
bon.align('center').bold(true);
bon.size(2, 2).line(kopfzeilen[0].slice(0, Math.floor(width / 2)));
bon.size(1, 1);
for (const zeile of kopfzeilen.slice(1)) bon.wrap(zeile);
bon.bold(false).align('left');
bon.line();
}
if (interim) {
bon.align('center').bold(true).line('--- ZWISCHENSTAND ---').bold(false).align('left');
bon.line();
}
// --- Belegkopf ----------------------------------------------------------
bon.hr();
bon.columns(interim ? `Zwischenbeleg ${data.belegNr}` : `Beleg-Nr. ${data.belegNr}`,
`${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`);
if (data.customerName) {
bon.wrap(`Kunde: ${data.customerName}`);
}
bon.hr();
// --- Zeiten -------------------------------------------------------------
bon.columns('Beginn', `${fmtDate(data.startedAt)} ${fmtTime(data.startedAt)}`);
bon.columns(interim ? 'Stand' : 'Ende', `${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`);
bon.columns('Dauer', fmtDuration(data.durationMs));
bon.line();
// --- Rechenweg: der Betrag soll nachvollziehbar sein ---------------------
const einheiten = unitCount(data.durationMs, data.rateInterval);
bon.columns('Tarif', `${fmtEuro(data.rateAmount)} / ${data.rateInterval} Min`);
bon.columns('Angefangene Einheiten', String(einheiten));
bon.hr();
// --- Der Betrag ---------------------------------------------------------
bon.line();
bon.align('center');
bon.bold(true).line('ZU ZAHLEN').bold(false);
// Doppelte Breite heisst halbe Zeichenzahl je Zeile - deshalb hier keine
// eigene Zentrierung rechnen, sondern die des Druckers nutzen.
bon.size(2, 2).line(fmtEuro(data.amount)).size(1, 1);
bon.align('left');
bon.line();
bon.hr();
// --- Fuss ---------------------------------------------------------------
bon.align('center');
if (interim) {
bon.line();
if (data.stillRunning) {
bon.line('Der Timer laeuft weiter.');
bon.line('Endbetrag erst beim Beenden.');
} else {
bon.line('Timer pausiert.');
}
}
const fusszeilen = String(data.footer || '').split('\n').map(z => z.trim()).filter(Boolean);
if (fusszeilen.length) {
bon.line();
for (const zeile of fusszeilen) bon.wrap(zeile);
}
bon.align('left');
bon.cut();
return bon.build();
}
/** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */
function toPlainText(buffer) {
return buffer.toString('latin1')
.replace(/\x1b@/g, '') // Zuruecksetzen
.replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte
.replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse
.replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt
.replace(/\x80/g, 'EUR')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
module.exports = { buildReceipt, toPlainText, fmtEuro, fmtDuration, unitCount };
+366 -5
View File
@@ -16,6 +16,9 @@ const bcrypt = require('bcrypt');
const path = require('path');
require('dotenv').config();
const oidc = require('./oidc');
const printer = require('./printer');
const { buildReceipt } = require('./receipt');
const { provision } = require('./provision');
const app = express();
@@ -142,6 +145,27 @@ async function initDatabase() {
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
}
// Bon-Texte: Kopf bleibt leer (der Laden traegt ihn selbst ein), der
// Fuss steht auf dem, was der Kunde damit tun soll.
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_header','')`);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_footer',?)`,
['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`);
// Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die
// Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi
// einrichten kann, ohne dass jemand auf den Server muss.
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_host',?)`,
[(process.env.PRINTER_HOST || '').trim()]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_port',?)`,
[String(parseInt(process.env.PRINTER_PORT) || 9100)]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_token',?)`,
[(process.env.PRINTER_TOKEN || '').trim()]);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('printer_width',?)`,
[String(parseInt(process.env.PRINTER_WIDTH) || 42)]);
await refreshPrinterConfig();
console.log('Datenbank bereit');
} catch (err) {
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
@@ -154,6 +178,11 @@ async function initDatabase() {
// die Anzeige mit, der verbindliche Betrag entsteht hier)
// ---------------------------------------------------------------------------
async function setSetting(key, value) {
await dbRun(`INSERT INTO settings (key,value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [key, String(value)]);
}
async function getRate() {
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
@@ -163,6 +192,39 @@ async function getRate() {
};
}
/**
* Druckerzugang aus der Datenbank in das printer-Modul uebernehmen.
* Wird beim Start und nach jeder Aenderung aufgerufen - ein Neustart des
* Dienstes ist fuer einen Druckerwechsel nicht noetig.
*/
async function refreshPrinterConfig() {
const rows = await dbAll(
`SELECT key, value FROM settings WHERE key LIKE 'printer_%'`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
const cfg = printer.setConfig({
host: map.printer_host || '',
port: map.printer_port,
token: map.printer_token || '',
width: map.printer_width
});
console.log(cfg.host
? `Bondrucker: ${cfg.host}:${cfg.port}, ${cfg.width} Zeichen je Zeile`
: 'Bondrucker: nicht eingerichtet');
return cfg;
}
/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */
async function getPrintSettings() {
const rows = await dbAll(
`SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
return {
receiptHeader: map.receipt_header || '',
receiptFooter: map.receipt_footer || '',
printAutoOnStop: map.print_auto_on_stop !== '0'
};
}
/** Jede angefangene Einheit wird voll berechnet. */
function calcAmount(ms, rateAmount, rateInterval) {
if (!ms || ms <= 0) return 0;
@@ -192,6 +254,39 @@ async function decorate(timer, rate) {
};
}
// ---------------------------------------------------------------------------
// Bondruck
// ---------------------------------------------------------------------------
/**
* Baut den Bon und schickt ihn an den Drucker-Pi.
*
* Gibt immer ein Ergebnis zurueck und wirft nie: ein Druckfehler ist ein
* Papierproblem, kein Datenproblem. Der Vorgang ist zu diesem Zeitpunkt schon
* gespeichert, der Bon laesst sich aus der Historie nachdrucken.
*
* @returns {Promise<{printed: boolean, error: string|null}>}
*/
async function printReceipt(vorgang) {
if (!printer.isConfigured()) {
return { printed: false, error: 'Kein Bondrucker eingerichtet.' };
}
try {
const texte = await getPrintSettings();
const daten = buildReceipt({
...vorgang,
header: texte.receiptHeader,
footer: texte.receiptFooter,
width: printer.width()
});
const res = await printer.print(daten);
return { printed: !!res.ok, error: res.ok ? null : res.error };
} catch (err) {
console.error('[drucker] Bon konnte nicht gebaut werden:', err);
return { printed: false, error: 'Bon konnte nicht erzeugt werden.' };
}
}
// ---------------------------------------------------------------------------
// Sitzungsspeicher
//
@@ -283,6 +378,16 @@ app.use(session({
app.use(express.static(path.join(__dirname, '../frontend')));
// Papierbremse: ein hektisch mehrfach getippter Knopf soll nicht die halbe
// Rolle ausspucken. Grosszuegig bemessen, im Alltag unauffaellig.
const printLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Druckauftraege in kurzer Zeit. Bitte kurz warten.' }
});
function requireAuth(req, res, next) {
if (req.session.authenticated) return next();
res.status(401).json({ error: 'Nicht angemeldet' });
@@ -377,7 +482,16 @@ app.get('/api/timers', requireAuth, wrap(async (req, res) => {
const rate = await getRate();
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
res.json({ timers, rate, serverNow: Date.now() });
// Die beiden Druck-Angaben steuern die Bon-Knoepfe und den Hinweistext in
// der Beenden-Rueckfrage. Sie kommen hier mit, damit die Timer-Ansicht
// dafuer keinen zweiten Aufruf braucht.
const druck = await getPrintSettings();
res.json({
timers, rate,
printerConfigured: printer.isConfigured(),
printAutoOnStop: druck.printAutoOnStop,
serverNow: Date.now()
});
}));
app.post('/api/timers', requireAuth, wrap(async (req, res) => {
@@ -425,7 +539,13 @@ app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
res.json({ timer: await decorate(row, await getRate()) });
}));
/** Beenden: schreibt die Session in die Historie und entfernt den Timer. */
/**
* Beenden: schreibt die Session in die Historie, entfernt den Timer und
* druckt - sofern eingerichtet - den Bon fuer die Kasse.
*
* Reihenfolge ist Absicht: erst speichern, dann drucken. Andersherum koennte
* ein Druckfehler einen bereits erfassten Vorgang verlieren.
*/
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
@@ -448,7 +568,67 @@ app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
);
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
res.json({ saved: true, id: result.lastID, duration, amount });
// Drucken, wenn es in den Einstellungen so hinterlegt ist - oder wenn der
// Aufruf es ausdruecklich verlangt bzw. ausdruecklich abwaehlt.
const texte = await getPrintSettings();
const wunsch = req.body && typeof req.body.print === 'boolean' ? req.body.print : texte.printAutoOnStop;
let druck = { printed: false, error: null };
if (wunsch && printer.isConfigured()) {
druck = await printReceipt({
kind: 'final',
belegNr: result.lastID,
customerName: t.customer_name || '',
startedAt: t.created_at,
endedAt: endTime,
durationMs: duration,
amount,
rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval
});
}
res.json({
saved: true, id: result.lastID, duration, amount,
printed: druck.printed,
printError: druck.error,
printerConfigured: printer.isConfigured()
});
}));
/**
* Zwischenbon fuer einen laufenden Timer.
*
* Der eigentliche Anwendungsfall: der Kunde will jetzt zahlen gehen. Er
* bekommt den aktuellen Stand auf Papier, der Timer laeuft davon unberuehrt
* weiter - beendet wird er erst, wenn der Platz wirklich frei ist.
*/
app.post('/api/timers/:id/receipt', requireAuth, printLimiter, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
if (!printer.isConfigured()) {
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
}
const rate = await getRate();
const dauer = elapsedMs(t);
const betrag = calcAmount(dauer, rate.rateAmount, rate.rateInterval);
const druck = await printReceipt({
kind: 'interim',
belegNr: `T-${t.id}`,
customerName: t.customer_name || '',
startedAt: t.created_at,
endedAt: Date.now(),
durationMs: dauer,
amount: betrag,
rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval,
stillRunning: !!t.is_running
});
if (!druck.printed) return res.status(502).json({ error: druck.error });
res.json({ printed: true, duration: dauer, amount: betrag });
}));
/** Verwerfen: Timer weg, nichts in der Historie. */
@@ -462,7 +642,7 @@ app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
// ---------------------------------------------------------------------------
app.get('/api/settings', requireAuth, wrap(async (req, res) => {
res.json(await getRate());
res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() });
}));
app.put('/api/settings', requireAuth, wrap(async (req, res) => {
@@ -481,7 +661,159 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]);
res.json(await getRate());
// Bon-Texte sind optional: aeltere Clients schicken sie nicht mit und
// sollen die vorhandenen Werte dann auch nicht leeren.
const b = req.body || {};
if (typeof b.receiptHeader === 'string') {
await setSetting('receipt_header', b.receiptHeader.slice(0, 240).replace(/\r/g, ''));
}
if (typeof b.receiptFooter === 'string') {
await setSetting('receipt_footer', b.receiptFooter.slice(0, 240).replace(/\r/g, ''));
}
if (typeof b.printAutoOnStop === 'boolean') {
await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0');
}
// Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die
// Einrichtung in dieser Oberflaeche aufgesetzt wurde.
let druckerGeaendert = false;
if (typeof b.printerHost === 'string') {
await setSetting('printer_host', b.printerHost.trim().slice(0, 120));
druckerGeaendert = true;
}
if (b.printerPort !== undefined) {
const p = parseInt(b.printerPort);
if (!Number.isInteger(p) || p < 1 || p > 65535) {
return res.status(400).json({ error: 'Drucker-Port muss zwischen 1 und 65535 liegen.' });
}
await setSetting('printer_port', String(p));
druckerGeaendert = true;
}
if (b.printerWidth !== undefined) {
const w = parseInt(b.printerWidth);
if (!Number.isInteger(w) || w < 24 || w > 96) {
return res.status(400).json({ error: 'Zeichen je Zeile muss zwischen 24 und 96 liegen.' });
}
await setSetting('printer_width', String(w));
druckerGeaendert = true;
}
// Das Token kommt nur herein, nie heraus. Ein leerer Wert wird ignoriert,
// damit ein Speichern ohne Eingabe das vorhandene Token nicht loescht.
if (typeof b.printerToken === 'string' && b.printerToken.trim()) {
await setSetting('printer_token', b.printerToken.trim().slice(0, 200));
druckerGeaendert = true;
}
if (druckerGeaendert) await refreshPrinterConfig();
res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() });
}));
// ---------------------------------------------------------------------------
// Drucker
// ---------------------------------------------------------------------------
/** Erreichbarkeit und Zustand der Bruecke auf dem Drucker-Pi. */
app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
if (!printer.isConfigured()) {
return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet.' });
}
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.
const provisionLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Einrichtungsversuche. Bitte 15 Minuten warten.' }
});
/**
* Drucker-Pi per SSH einrichten und anschliessend uebernehmen.
*
* Die Zugangsdaten kommen aus dem Formular, werden einmal benutzt und danach
* vergessen: gespeichert wird ausschliesslich das erzeugte Drucker-Token.
*/
app.post('/api/printer/provision', requireAuth, provisionLimiter, wrap(async (req, res) => {
const b = req.body || {};
const host = String(b.host || '').trim();
const username = String(b.username || '').trim();
const password = String(b.password || '');
const port = b.port === undefined || b.port === '' ? 22 : parseInt(b.port);
if (!host) return res.status(400).json({ error: 'Adresse des Pi fehlt.' });
if (!username) return res.status(400).json({ error: 'Benutzername fehlt.' });
if (!password) return res.status(400).json({ error: 'Passwort fehlt.' });
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return res.status(400).json({ error: 'SSH-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 ergebnis = await provision({ host, port, username, password });
if (!ergebnis.ok) {
console.warn('[einrichtung] fehlgeschlagen:', ergebnis.error);
return res.status(502).json({ error: ergebnis.error, steps: ergebnis.steps });
}
// 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();
// Gegenprobe von aussen. Erreichbarkeit allein genuegt nicht: die Bruecke
// meldet zusaetzlich, ob sie das Token annimmt. Ein Dienst, der noch mit
// einem alten Token laeuft, wuerde sonst als "bereit" durchgehen und beim
// ersten Bon mit 403 abbrechen.
const probe = await printer.status();
const tokenOk = !probe.printer || probe.printer.tokenAccepted !== false;
const bereit = probe.ok && tokenOk;
let detail;
if (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?". */
app.post('/api/printer/test', requireAuth, printLimiter, wrap(async (req, res) => {
if (!printer.isConfigured()) {
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
}
const rate = await getRate();
const jetzt = Date.now();
const druck = await printReceipt({
kind: 'interim',
belegNr: 'PROBE',
customerName: 'Probedruck',
startedAt: jetzt - 25 * 60000,
endedAt: jetzt,
durationMs: 25 * 60000,
amount: calcAmount(25 * 60000, rate.rateAmount, rate.rateInterval),
rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval,
stillRunning: false
});
if (!druck.printed) return res.status(502).json({ error: druck.error });
res.json({ printed: true });
}));
// ---------------------------------------------------------------------------
@@ -532,6 +864,34 @@ app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
res.json({ success: true });
}));
/** Bon nachdrucken - fuer den Fall "Papier war leer" oder "Kunde hat ihn verlegt". */
app.post('/api/sessions/:id/receipt', requireAuth, printLimiter, wrap(async (req, res) => {
const s = await dbGet(`SELECT * FROM sessions WHERE id = ?`, [req.params.id]);
if (!s) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
if (!printer.isConfigured()) {
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
}
// Altbestand aus der Zeit vor der Tarif-Spalte: dann den heutigen Tarif
// ausweisen - der Betrag selbst stammt weiter aus dem Datensatz.
const rate = await getRate();
const druck = await printReceipt({
kind: 'final',
belegNr: s.id,
customerName: s.customer_name || '',
startedAt: s.start_time,
endedAt: s.end_time,
durationMs: s.duration,
amount: s.amount,
rateAmount: s.rate_amount != null ? s.rate_amount : rate.rateAmount,
rateInterval: s.rate_interval != null ? s.rate_interval : rate.rateInterval
});
if (!druck.printed) return res.status(502).json({ error: druck.error });
res.json({ printed: true });
}));
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
const { where, params } = dateFilter(req.query);
@@ -588,6 +948,7 @@ app.get('/health', wrap(async (req, res) => {
rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval,
activeTimers: active.c,
printer: printer.state(),
uptimeSeconds: Math.floor(process.uptime())
});
}));
+134
View File
@@ -0,0 +1,134 @@
# Bondrucker-Bruecke
Kleiner Dienst für den Raspberry Pi, an dem der Kassendrucker per USB hängt.
## Warum es ihn gibt
Der Bondrucker ist ein USB-Gerät (`/dev/usb/lp0`) und damit nur für den Rechner
sichtbar, an dem er steckt. Der Timer-Server läuft woanders. Diese Brücke nimmt
fertige ESC/POS-Daten per HTTP entgegen und schiebt sie in das Gerät — mehr
nicht. Der Bon selbst wird in der Timer-App gebaut (`backend/receipt.js`), damit
Layout-Änderungen nicht auf dem Pi angefasst werden müssen.
Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
## Installation — der übliche Weg
**Aus der Oberfläche:** ⚙️ Einstellungen → **🔧 Drucker einrichten**. Dort IP,
Benutzer und Passwort des Pi eintragen und auf **🚀 Jetzt einrichten** klicken.
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
# Dateien auf den Pi kopieren
scp print-server.py bondrucker.service install.sh README.md pi@<PI-IP>:/tmp/bondrucker/
# Dort einrichten
ssh pi@<PI-IP>
cd /tmp/bondrucker && sudo bash install.sh
```
`install.sh` legt an:
| Was | Wo |
|---|---|
| Dienstbenutzer `bondrucker` (Gruppe `lp`) | — |
| Programm | `/opt/bondrucker/print-server.py` |
| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
| udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` |
| Dienst | `bondrucker.service`, aktiviert und gestartet |
Am Ende gibt das Skript den Wert für das Token aus.
`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 weiter und würde jeden Druckauftrag mit HTTP 403 ablehnen.
## Timer-Server konfigurieren
Nach der Einrichtung aus der Oberfläche: nichts zu tun.
Nach der Einrichtung von Hand: in ⚙️ Einstellungen unter „Drucker einrichten"
lassen sich Adresse, Port und Token auch direkt eintragen. Der Druckerzugang
liegt in der Datenbank, nicht in der `.env` — ein Neustart des Dienstes ist für
einen Druckerwechsel nicht nötig.
Die Werte `PRINTER_HOST`, `PRINTER_PORT`, `PRINTER_TOKEN` und `PRINTER_WIDTH`
in der `.env` sind nur die **Startwerte beim allerersten Start**. Sind sie leer,
ist die Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe,
bis ein Drucker eingerichtet wird.
## Prüfen
```bash
# Zustand des Druckers
curl -s http://<PI-IP>:9100/status
# Aus der App heraus: Einstellungen -> "Probedruck"
```
Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar.
## Schnittstelle
| Aufruf | Wirkung |
|---|---|
| `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist |
| `POST /print` | Rumpf = rohe ESC/POS-Bytes, Kopfzeile `X-Print-Token`. Antwortet `{"ok":true,"bytes":N}` |
`/status` verlangt **kein** Token — der Zustand des Druckers ist nicht geheim
und soll für Health-Checks abfragbar bleiben. Wird trotzdem eines mitgeschickt,
meldet die Antwort in `tokenAccepted`, ob es passt. Die App nutzt das nach der
Einrichtung als Gegenprobe: erreichbar allein genügt nicht, das Token muss auch
angenommen werden.
Fehler kommen als HTTP 4xx/5xx mit `{"error": "..."}` zurück — die Timer-App
zeigt diesen Text unverändert an. „Papier leer" und „Deckel offen" landen als
`OSError` beim Schreiben und damit als HTTP 502 in der Anzeige.
## Betriebshinweise
* **Feste IP vergeben.** Der Pi hängt per WLAN und DHCP im Netz. Wechselt die
Adresse, druckt die App nicht mehr, bis `PRINTER_HOST` angepasst ist. Eine
DHCP-Reservierung im Router ist der einfachste Weg.
* **USB-Kabel neu gesteckt?** Aus `lp0` kann `lp1` werden. Der Dienst sucht
deshalb selbst nach `/dev/usb/lp*` — es ist nichts zu tun.
* **Ein Auftrag nach dem anderen.** Der Dienst serialisiert Druckaufträge, zwei
gleichzeitige Bons können sich nicht ineinanderschieben.
* **Netzgrenze.** Port 9100 sollte nur aus dem eigenen Netz erreichbar sein.
Das Token schützt gegen versehentliche, nicht gegen entschlossene Zugriffe.
## Fehlersuche
```bash
systemctl status bondrucker
journalctl -u bondrucker -f # jeder Auftrag wird protokolliert
# Am Pi direkt drucken, ohne App und ohne Dienst:
printf 'Test\n\n\n\n\x1dVB\x00' | sudo tee /dev/usb/lp0 > /dev/null
```
| Symptom | Ursache |
|---|---|
| `/status` sagt `deviceExists: false` | Drucker aus oder USB-Kabel ab |
| `/status` sagt `writable: false` | udev-Regel nicht gegriffen — `sudo udevadm trigger`, Dienst neu starten |
| HTTP 403 | `PRINTER_TOKEN` in der `.env` weicht von `/etc/bondrucker/env` ab |
| App meldet „nimmt keine Verbindung an" | Dienst läuft nicht, oder falsche IP/Port |
| Einrichtung bricht bei „Dienst eingerichtet" ab | sudo-Passwort falsch, oder der Benutzer darf kein sudo |
| Einrichtung meldet „lehnt das neue Token ab" | auf dem Pi läuft noch eine Brücke vor 1.1 — Einrichtung wiederholen |
| Umlaute als Kringel | Drucker beherrscht Codepage 16 (WPC1252) nicht — siehe `backend/escpos.js` |
+49
View File
@@ -0,0 +1,49 @@
[Unit]
Description=Bondrucker-Bruecke (ESC/POS ueber HTTP an den USB-Kassendrucker)
Documentation=file:///opt/bondrucker/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/bondrucker/print-server.py
# Laeuft NICHT als root. Der Dienstbenutzer ist nur deshalb in der Gruppe "lp",
# weil /dev/usb/lp0 dem Besitzer root und der Gruppe lp gehoert (Rechte 660).
User=bondrucker
Group=bondrucker
SupplementaryGroups=lp
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
# Versionsverwaltung landet - die Datei legt install.sh an.
Environment=PRINT_DEVICE=/dev/usb/lp0
Environment=PRINT_HOST=0.0.0.0
Environment=PRINT_PORT=9100
EnvironmentFile=-/etc/bondrucker/env
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# --- Haertung ---------------------------------------------------------------
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
# Der Dienst braucht nirgends Schreibrecht - er schreibt ausschliesslich in die
# Geraetedatei, und die ist von ProtectSystem nicht betroffen.
PrivateDevices=false
[Install]
WantedBy=multi-user.target
+71
View File
@@ -0,0 +1,71 @@
#!/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)"
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Bondrucker-Bruecke — laeuft auf dem Raspberry Pi am Kassendrucker.
Warum es diesen Dienst ueberhaupt gibt: der Bondrucker haengt per USB am Pi
(/dev/usb/lp0) und ist damit vom Timer-Server aus nicht erreichbar. Dieser
Dienst nimmt fertige ESC/POS-Daten per HTTP entgegen und schiebt sie in das
Geraet. Die Timer-App baut den Bon, der Pi druckt ihn nur noch.
Bewusst nur Python-Standardbibliothek: auf dem Pi muss dafuer nichts
nachinstalliert werden (kein pip, kein CUPS, kein node).
Endpunkte
GET /status Zustand des Druckers als JSON (fuer Health-Checks)
POST /print Rumpf = rohe ESC/POS-Bytes, wird 1:1 an den Drucker gereicht
Konfiguration ueber Umgebungsvariablen (siehe bondrucker.service):
PRINT_DEVICE Geraetedatei, Standard /dev/usb/lp0
PRINT_HOST Lausch-Adresse, Standard 0.0.0.0
PRINT_PORT Lausch-Port, Standard 9100
PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!)
PRINT_MAX_BYTES Obergrenze je Auftrag, Standard 262144
"""
import glob
import hmac
import json
import os
import socketserver
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.1"
DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0")
HOST = os.environ.get("PRINT_HOST", "0.0.0.0")
PORT = int(os.environ.get("PRINT_PORT", "9100"))
TOKEN = os.environ.get("PRINT_TOKEN", "").strip()
MAX_BYTES = int(os.environ.get("PRINT_MAX_BYTES", str(256 * 1024)))
# Zwei Auftraege duerfen sich nicht ineinander schieben - sonst kommt Buchstaben-
# salat aus dem Drucker. Ein Prozess, ein Schloss.
_print_lock = threading.Lock()
_last_error = None
_last_ok = None
def log(msg):
print(msg, flush=True)
def find_device():
"""Konfiguriertes Geraet, sonst das erste vorhandene /dev/usb/lp*.
Nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0 ein lp1
werden. Ohne diesen Fallback druckt danach nichts mehr, bis jemand den
Dienst anfasst.
"""
if os.path.exists(DEVICE):
return DEVICE
found = sorted(glob.glob("/dev/usb/lp*"))
return found[0] if found else None
def device_status(token_accepted=None):
dev = find_device()
status = {
"ok": False,
"version": VERSION,
# Ob das mitgeschickte Token passt. Der Zustand des Druckers selbst ist
# nicht geheim und bleibt fuer Health-Checks ohne Token abfragbar - aber
# wer einrichtet, muss pruefen koennen, ob auch das DRUCKEN klappen
# wird. Ohne diese Angabe meldet ein Dienst, der noch mit dem alten
# Token laeuft, faelschlich "alles gut".
"tokenRequired": bool(TOKEN),
"tokenAccepted": token_accepted,
"device": dev or DEVICE,
"deviceConfigured": DEVICE,
"deviceExists": dev is not None,
"writable": False,
"lastError": _last_error,
"lastPrintAt": _last_ok,
}
if dev:
status["writable"] = os.access(dev, os.W_OK)
status["ok"] = status["writable"]
return status
def write_to_printer(data):
"""Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError."""
global _last_error, _last_ok
with _print_lock:
dev = find_device()
if dev is None:
raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?")
# os.open statt open(): kein Puffer dazwischen, und Fehler des Druckers
# (Papier leer, Deckel offen) kommen direkt als OSError zurueck.
fd = os.open(dev, os.O_WRONLY)
try:
written = 0
while written < len(data):
written += os.write(fd, data[written:])
finally:
os.close(fd)
_last_error = None
_last_ok = time.strftime("%Y-%m-%d %H:%M:%S")
return written
def token_ok(supplied):
if not TOKEN:
return True
return hmac.compare_digest(TOKEN, (supplied or "").strip())
class Handler(BaseHTTPRequestHandler):
server_version = "bondrucker/" + VERSION
protocol_version = "HTTP/1.1"
def _send(self, code, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path.rstrip("/") in ("/status", "/health", ""):
st = device_status(token_ok(self.headers.get("X-Print-Token")))
self._send(200 if st["ok"] else 503, st)
else:
self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
def do_POST(self):
global _last_error
if self.path.rstrip("/") != "/print":
return self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
if not token_ok(self.headers.get("X-Print-Token")):
log(f"[abgelehnt] falsches Token von {self.client_address[0]}")
return self._send(403, {"ok": False, "error": "Token ungueltig"})
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
return self._send(400, {"ok": False, "error": "Content-Length fehlt"})
if length <= 0:
return self._send(400, {"ok": False, "error": "Leerer Druckauftrag"})
if length > MAX_BYTES:
return self._send(413, {"ok": False, "error": f"Auftrag groesser als {MAX_BYTES} Bytes"})
data = self.rfile.read(length)
try:
written = write_to_printer(data)
except OSError as err:
_last_error = str(err)
log(f"[fehler] {self.client_address[0]}: {err}")
return self._send(502, {"ok": False, "error": f"Drucker meldet: {err}"})
log(f"[gedruckt] {written} Bytes von {self.client_address[0]}")
self._send(200, {"ok": True, "bytes": written})
# Zugriffszeilen selbst formatieren; das Standardformat von
# BaseHTTPRequestHandler geht auf stderr und verrauscht das Journal.
def log_message(self, fmt, *args):
pass
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def main():
st = device_status()
log(f"Bondrucker-Bruecke {VERSION}")
log(f" Geraet: {st['device']} (vorhanden: {st['deviceExists']}, beschreibbar: {st['writable']})")
log(f" Lauscht: {HOST}:{PORT}")
log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}")
if not st["deviceExists"]:
log(" Hinweis: Drucker gerade nicht da. Der Dienst laeuft trotzdem und")
log(" findet das Geraet, sobald es wieder angesteckt ist.")
try:
Server((HOST, PORT), Handler).serve_forever()
except KeyboardInterrupt:
log("Beendet.")
sys.exit(0)
if __name__ == "__main__":
main()
+227 -12
View File
@@ -10,6 +10,8 @@
const state = {
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
rate: { rateAmount: 10, rateInterval: 10 },
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
tickHandle: null,
syncHandle: null,
history: { offset: 0, limit: 50, reachedEnd: false }
@@ -63,7 +65,9 @@ function toast(message, kind = 'info') {
el.textContent = message;
el.className = 'toast show ' + kind;
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => { el.className = 'toast'; }, 4000);
// Fehler laenger stehen lassen: die Meldung "Bon nicht gedruckt" enthaelt
// den Grund und den Hinweis auf den Nachdruck - das will gelesen werden.
toastTimeout = setTimeout(() => { el.className = 'toast'; }, kind === 'error' ? 8000 : 4000);
}
async function api(url, options = {}) {
@@ -78,8 +82,13 @@ async function api(url, options = {}) {
}
if (!response.ok) {
let msg = 'Serverfehler';
try { msg = (await response.json()).error || msg; } catch (e) { /* egal */ }
throw new Error(msg);
let daten = null;
try { daten = await response.json(); msg = daten.error || msg; } catch (e) { /* egal */ }
const fehler = new Error(msg);
// Manche Endpunkte liefern im Fehlerfall Zusatzinfos mit (etwa das
// Protokoll der Druckereinrichtung). Die waeren sonst verloren.
if (daten && daten.steps) fehler.steps = daten.steps;
throw fehler;
}
return response.status === 204 ? null : response.json();
}
@@ -178,6 +187,8 @@ async function logout() {
async function syncTimers() {
const data = await api('/api/timers');
state.rate = data.rate;
state.printerConfigured = !!data.printerConfigured;
state.printAutoOnStop = data.printAutoOnStop !== false;
const now = Date.now();
state.timers = data.timers.map(t => ({
@@ -261,6 +272,10 @@ function renderTimers() {
${timer.isRunning
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
${state.printerConfigured
? `<button class="btn btn-secondary btn-sm" onclick="printInterim(${timer.id})"
title="Zwischenstand drucken - der Timer läuft weiter">🖨 Bon</button>`
: ''}
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
</div>
@@ -316,21 +331,48 @@ async function resumeTimer(id) {
}
}
/**
* Zwischenbon: der Kunde geht jetzt zur Kasse, der Platz bleibt aber belegt.
* Der Timer wird dabei bewusst NICHT angefasst.
*/
async function printInterim(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
try {
toast('Bon wird gedruckt …', 'info');
const res = await api(`/api/timers/${id}/receipt`, { method: 'POST' });
toast(`Bon gedruckt ${formatEuro(res.amount)} (Timer läuft weiter)`, 'success');
} catch (e) {
toast('Bon konnte nicht gedruckt werden: ' + e.message, 'error');
}
}
async function stopTimer(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
const ms = currentMs(timer);
const label = timer.customerName ? `${timer.customerName}"` : 'Timer';
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.`)) return;
const bonHinweis = state.printerConfigured && state.printAutoOnStop
? '\n\nDer Bon für die Kasse wird gedruckt.' : '';
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.${bonHinweis}`)) return;
try {
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
await syncTimers();
if (result.saved) {
toast(`Gespeichert: ${formatTime(result.duration)} ${formatEuro(result.amount)}`, 'success');
if (!result.saved) {
return toast(result.reason || 'Nichts gespeichert.', 'info');
}
const kern = `Gespeichert: ${formatTime(result.duration)} ${formatEuro(result.amount)}`;
if (result.printed) {
toast(kern + ' · Bon gedruckt', 'success');
} else if (result.printError) {
// Bewusst als Fehler und mit Nennung des Nachdrucks: der Vorgang ist
// gespeichert, es fehlt nur das Papier.
toast(`${kern}\nBon NICHT gedruckt (${result.printError}) Nachdruck über die Historie.`, 'error');
} else {
toast(result.reason || 'Nichts gespeichert.', 'info');
toast(kern, 'success');
}
} catch (e) {
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
@@ -426,6 +468,10 @@ function renderHistory(data, reset) {
</div>
<div class="history-item-right">
<div class="history-item-amount">${formatEuro(s.amount)}</div>
${state.printerConfigured
? `<button class="btn btn-secondary btn-sm" onclick="reprintSession(${s.id})"
title="Bon noch einmal drucken">🖨</button>`
: ''}
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
</div>
</div>`;
@@ -448,6 +494,17 @@ async function loadMoreHistory() {
await loadHistory(false);
}
/** Nachdruck aus der Historie - Papier war leer, Bon verlegt, Kunde will eine Kopie. */
async function reprintSession(id) {
try {
toast('Bon wird gedruckt …', 'info');
await api(`/api/sessions/${id}/receipt`, { method: 'POST' });
toast('Bon nachgedruckt', 'success');
} catch (e) {
toast('Nachdruck fehlgeschlagen: ' + e.message, 'error');
}
}
async function deleteSession(id) {
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
try {
@@ -502,11 +559,20 @@ document.getElementById('filterName').addEventListener('input', (() => {
async function loadSettings() {
try {
const rate = await api('/api/settings');
state.rate = rate;
document.getElementById('rateAmount').value = rate.rateAmount;
document.getElementById('rateInterval').value = rate.rateInterval;
const settings = await api('/api/settings');
state.rate = settings;
state.printerConfigured = !!(settings.printer && settings.printer.configured);
state.printAutoOnStop = settings.printAutoOnStop !== false;
document.getElementById('rateAmount').value = settings.rateAmount;
document.getElementById('rateInterval').value = settings.rateInterval;
document.getElementById('receiptHeader').value = settings.receiptHeader || '';
document.getElementById('receiptFooter').value = settings.receiptFooter || '';
document.getElementById('printAutoOnStop').checked = state.printAutoOnStop;
updateSettingsPreview();
renderPrinterState(settings.printer);
refreshPrinterStatus();
} catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
}
@@ -515,13 +581,17 @@ async function loadSettings() {
async function saveSettings() {
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
const rateInterval = parseInt(document.getElementById('rateInterval').value);
const receiptHeader = document.getElementById('receiptHeader').value;
const receiptFooter = document.getElementById('receiptFooter').value;
const printAutoOnStop = document.getElementById('printAutoOnStop').checked;
try {
const rate = await api('/api/settings', {
method: 'PUT',
body: JSON.stringify({ rateAmount, rateInterval })
body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop })
});
state.rate = rate;
state.printAutoOnStop = rate.printAutoOnStop !== false;
renderTimers();
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
showTimer();
@@ -537,6 +607,151 @@ async function resetSettings() {
await saveSettings();
}
// ---------------------------------------------------------------------------
// Bondrucker
// ---------------------------------------------------------------------------
/** Zeigt in den Einstellungen, ob und wo ein Drucker haengt. */
function renderPrinterState(info, live) {
const el = document.getElementById('printerState');
const testBtn = document.getElementById('printerTestBtn');
if (!el) return;
if (!info || !info.configured) {
el.className = 'printer-state off';
el.textContent = 'Kein Bondrucker eingerichtet es wird nichts gedruckt, '
+ 'und die Bon-Knöpfe an den Timern und in der Historie sind ausgeblendet. '
+ 'Über „🔧 Drucker einrichten" lässt sich einer hinzufügen.';
if (testBtn) testBtn.disabled = true;
return;
}
if (testBtn) testBtn.disabled = false;
// Ohne bekannte Adresse (Statusabfrage selbst fehlgeschlagen) nicht
// "undefined:undefined" anzeigen.
const ziel = info.host ? `${info.host}:${info.port}` : 'Drucker';
if (live === undefined) {
el.className = 'printer-state';
el.textContent = `Drucker eingerichtet: ${ziel} Zustand wird geprüft …`;
} else if (live.ok) {
el.className = 'printer-state ok';
const geraet = live.printer && live.printer.device ? ` (${live.printer.device})` : '';
el.textContent = `Drucker bereit: ${ziel}${geraet}`;
} else {
el.className = 'printer-state error';
el.textContent = `Drucker nicht erreichbar: ${ziel} ${live.error || 'unbekannter Fehler'}`;
}
}
async function refreshPrinterStatus() {
try {
const status = await api('/api/printer/status');
renderPrinterState({ configured: status.configured, host: status.host, port: status.port }, status);
} catch (e) {
renderPrinterState({ configured: state.printerConfigured }, { ok: false, error: e.message });
}
}
async function testPrint() {
const btn = document.getElementById('printerTestBtn');
if (btn) btn.disabled = true;
try {
await api('/api/printer/test', { method: 'POST' });
toast('Probebon gedruckt', 'success');
} catch (e) {
toast('Probedruck fehlgeschlagen: ' + e.message, 'error');
} finally {
if (btn) btn.disabled = false;
refreshPrinterStatus();
}
}
// ---------------------------------------------------------------------------
// Einrichtung eines Drucker-Pi
// ---------------------------------------------------------------------------
function toggleSetup() {
const box = document.getElementById('setupBox');
const offen = box.style.display !== 'none';
box.style.display = offen ? 'none' : 'block';
document.getElementById('setupToggleBtn').textContent =
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
if (!offen) document.getElementById('setupHost').focus();
}
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
function renderSetupLog(steps, laufend) {
const el = document.getElementById('setupLog');
el.classList.add('show');
const zeilen = (steps || []).map(s => `
<div class="setup-step ${s.ok ? '' : 'fail'}">
<span class="setup-step-icon">${s.ok ? '✅' : '❌'}</span>
<div>
<div class="setup-step-name">${escapeHtml(s.name)}</div>
${s.detail ? `<div class="setup-step-detail">${escapeHtml(s.detail)}</div>` : ''}
</div>
</div>`).join('');
el.innerHTML = zeilen + (laufend
? `<div class="setup-step"><span class="setup-step-icon">⏳</span>
<div><div class="setup-step-name">läuft …</div></div></div>`
: '');
}
async function startProvision() {
const host = document.getElementById('setupHost').value.trim();
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');
btn.disabled = true;
btn.textContent = '⏳ Wird eingerichtet …';
renderSetupLog([], true);
try {
const res = await api('/api/printer/provision', {
method: 'POST',
body: JSON.stringify({ host, port, username, password })
});
// Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts
// mehr verloren.
passwordEl.value = '';
renderSetupLog(res.steps, false);
state.printerConfigured = true;
if (res.ready) {
toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success');
} else {
toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht siehe Protokoll.', 'error');
}
await refreshPrinterStatus();
await syncTimers().catch(() => {});
} catch (e) {
// Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind
// das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen.
renderSetupLog(e.steps, false);
toast('Einrichtung fehlgeschlagen: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🚀 Jetzt einrichten';
}
}
function updateSettingsPreview() {
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
+223 -6
View File
@@ -219,7 +219,8 @@
input[type="text"],
input[type="password"],
input[type="number"],
input[type="date"] {
input[type="date"],
textarea {
width: 100%;
padding: 0.875rem 1.125rem;
background: rgba(255, 255, 255, 0.05);
@@ -231,14 +232,16 @@
transition: all 0.3s ease;
}
input:focus {
input:focus,
textarea:focus {
outline: none;
border-color: #4d35a9;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35);
}
input::placeholder { color: rgba(255, 255, 255, 0.35); }
input::placeholder,
textarea::placeholder { color: rgba(255, 255, 255, 0.35); }
/* Datumsfelder im Dark Theme lesbar halten */
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
@@ -390,6 +393,17 @@
background-clip: text;
}
/* Der Farbverlauf oben faerbt den Text ueber eine durchsichtige
Schriftfarbe ein. Ein Farb-Emoji hat dabei keine Kontur, die sich
einfaerben liesse - der Browser fuellt seine gesamte Flaeche mit dem
Verlauf, und aus dem Balkendiagramm wird ein weisser Kasten.
Deshalb bekommen die Symbole ihre Schriftfarbe zurueck und malen
sich wieder selbst. */
.heading-icon {
-webkit-text-fill-color: #ffffff;
color: #ffffff;
}
.filter-bar {
display: flex;
gap: 1rem;
@@ -551,6 +565,123 @@
margin-top: 1rem !important;
}
/* ---------- Bondrucker ---------- */
.settings-section {
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 2rem 0;
padding-top: 1.5rem;
}
.settings-section h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.settings-section > p {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.settings-section textarea {
box-sizing: border-box;
resize: vertical;
min-height: 4.5rem;
line-height: 1.5;
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-top: 1.25rem;
}
.checkbox-row input[type="checkbox"] {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
flex-shrink: 0;
accent-color: #6d4aff;
}
.checkbox-row label { font-weight: 500; cursor: pointer; }
.checkbox-row small { display: block; color: rgba(255, 255, 255, 0.5); font-size: 0.8rem; margin-top: 0.25rem; }
.printer-state {
font-size: 0.9rem;
padding: 0.75rem 1rem;
border-radius: 10px;
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid rgba(255, 255, 255, 0.25);
}
.setup-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.25rem;
margin-top: 1rem;
}
.setup-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.setup-grid { grid-template-columns: 1fr; }
}
.setup-hint {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
margin-top: 1rem;
line-height: 1.5;
}
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
sichtbar ist, wie weit es gekommen ist. */
.setup-log {
margin-top: 1rem;
font-size: 0.85rem;
display: none;
}
.setup-log.show { display: block; }
.setup-step {
display: flex;
gap: 0.65rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.setup-step:last-child { border-bottom: none; }
.setup-step-icon { flex-shrink: 0; }
.setup-step-name { font-weight: 600; }
.setup-step-detail {
color: rgba(255, 255, 255, 0.6);
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.78rem;
margin-top: 0.3rem;
max-height: 11rem;
overflow-y: auto;
}
.setup-step.fail .setup-step-name { color: #f87171; }
.printer-state.ok { border-left-color: #22c55e; }
.printer-state.error { border-left-color: #ef4444; }
.printer-state.off { border-left-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.6); }
.settings-actions { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.settings-actions .btn { flex: 1; padding: 1rem; }
@@ -582,6 +713,8 @@
max-width: 90vw;
font-size: 0.95rem;
text-align: center;
/* Meldungen zum Bondruck sind zweizeilig - Umbrueche sollen stehen bleiben. */
white-space: pre-line;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
@@ -658,7 +791,7 @@
<!-- Historie -->
<div id="historyView" class="view">
<div class="history-container">
<h2>📊 Historie</h2>
<h2><span class="heading-icon">📊</span> Historie</h2>
<div class="filter-bar">
<div class="filter-field">
@@ -693,7 +826,7 @@
<!-- Einstellungen -->
<div id="settingsView" class="view">
<div class="settings-container">
<h2>⚙️ Einstellungen</h2>
<h2><span class="heading-icon">⚙️</span> Einstellungen</h2>
<p class="settings-description">Der Tarif gilt für alle Geräte gemeinsam.</p>
<form id="settingsForm">
@@ -716,6 +849,90 @@
<p class="preview-examples" id="previewExamples"></p>
</div>
<div class="settings-section">
<h3>🖨️ Bon für die Kasse</h3>
<p>Was auf dem Zettel steht, den der Kunde mit zur Kasse nimmt.
Betrag, Zeiten und Tarif kommen automatisch dazu.</p>
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
<div class="form-group">
<label for="receiptHeader">Kopfzeilen</label>
<textarea id="receiptHeader" maxlength="240" rows="3"
placeholder="Handy-Insel&#10;Hauptstraße 12, 12345 Musterstadt&#10;Tel. 01234 / 56789"></textarea>
<small>Erste Zeile wird groß gedruckt. Eine Zeile je Zeilenumbruch, maximal 240 Zeichen.</small>
</div>
<div class="form-group">
<label for="receiptFooter">Fußzeilen</label>
<textarea id="receiptFooter" maxlength="240" rows="2"
placeholder="Bitte an der Kasse bezahlen."></textarea>
<small>Steht unter dem Betrag, maximal 240 Zeichen.</small>
</div>
<div class="checkbox-row">
<input type="checkbox" id="printAutoOnStop" checked>
<label for="printAutoOnStop">Bon beim Beenden automatisch drucken
<small>Aus: der Bon wird nur über die Knöpfe 🖨 gedruckt.</small>
</label>
</div>
<div class="settings-actions" style="margin-top: 1.25rem;">
<button type="button" class="btn btn-secondary" id="printerTestBtn"
onclick="testPrint()">🖨️ Probedruck</button>
<button type="button" class="btn btn-secondary"
onclick="refreshPrinterStatus()">🔄 Drucker prüfen</button>
<button type="button" class="btn btn-secondary"
onclick="toggleSetup()" id="setupToggleBtn">🔧 Drucker einrichten</button>
</div>
<div class="setup-box" id="setupBox" style="display: none;">
<h3 style="margin-bottom: 0.35rem;">Neuen Drucker-Pi einrichten</h3>
<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
vollständig ein und übernimmt ihn anschließend. Der Pi braucht nur
SSH, Python 3 und den Drucker am USB-Anschluss.
</p>
<div class="setup-grid">
<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"
onclick="startProvision()">🚀 Jetzt einrichten</button>
</div>
<p class="setup-hint">
Das Passwort wird einmal verwendet und danach verworfen — gespeichert
wird nur ein neu erzeugtes Drucker-Token. Ein vorhandener Druckdienst
auf demselben Pi wird dabei überschrieben.
</p>
<div class="setup-log" id="setupLog"></div>
</div>
</div>
<div class="settings-actions">
<button type="submit" class="btn">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
@@ -733,6 +950,6 @@
<div class="toast" id="toast"></div>
<script src="/app.js?v=20260803-sso"></script>
<script src="/app.js?v=20260904-emoji"></script>
</body>
</html>