Compare commits
4
Commits
5e83999950
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c7d89a7d | ||
|
|
f6f2df308c | ||
|
|
5e23ccdcbc | ||
|
|
70756f3762 |
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
Generated
+69
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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())
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
Executable
+71
@@ -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)"
|
||||
@@ -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
@@ -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
@@ -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 Hauptstraße 12, 12345 Musterstadt 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>
|
||||
|
||||
Reference in New Issue
Block a user