Bon fuer die Kasse auf einem USB-Bondrucker ausgeben

Der Kunde soll mit einem Zettel zur Kasse gehen koennen, auf dem steht, was
er zahlen muss. Drei Wege fuehren jetzt zu einem Bon: der Knopf "Bon" auf der
Timer-Karte druckt den Zwischenstand und laesst den Timer weiterlaufen,
"Beenden" druckt den Endbeleg, und in der Historie laesst sich jeder Beleg
nachdrucken.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kerim (portable/claudecode)
2026-09-04 20:37:12 +02:00
co-authored by Claude Opus 5
parent 5e83999950
commit 70756f3762
13 changed files with 1424 additions and 27 deletions
+62 -6
View File
@@ -12,7 +12,9 @@ Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung,
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel). 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 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 Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build. * **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
Vanilla JS im Frontend, Express im Backend. Vanilla JS im Frontend, Express im Backend.
@@ -205,6 +207,11 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten | | `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei | | `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. | | `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 |
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion `SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
@@ -250,6 +257,43 @@ Wichtig in beiden Fällen:
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung) * `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
* Websockets werden **nicht** gebraucht * 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.
**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 ### Backup
Alles Wichtige steckt in **einer Datei**: `data/timer.db`. Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
@@ -275,7 +319,10 @@ timer-app/
├── DEPLOY.md ← dieses Dokument ├── DEPLOY.md ← dieses Dokument
├── README.md ← Kurzanleitung für Anwender ├── README.md ← Kurzanleitung für Anwender
├── backend/ ├── 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
│ ├── package.json │ ├── package.json
│ ├── package-lock.json │ ├── package-lock.json
│ ├── .env.example │ ├── .env.example
@@ -288,7 +335,12 @@ timer-app/
└── deploy/ └── deploy/
├── timer-app.service ├── timer-app.service
├── Caddyfile.example ├── 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, `server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
@@ -357,12 +409,16 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen | | `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
| `POST` | `/api/timers/:id/pause` | anhalten | | `POST` | `/api/timers/:id/pause` | anhalten |
| `POST` | `/api/timers/:id/resume` | fortsetzen | | `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 | | `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
| `GET` | `/api/settings` | Tarif lesen | | `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen |
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` | | `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` |
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
| `POST` | `/api/printer/test` | Probedruck |
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` | | `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
| `DELETE` | `/api/sessions/:id` | Eintrag löschen | | `DELETE` | `/api/sessions/:id` | Eintrag löschen |
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter | | `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
| `GET` | `/health` | Status, ohne Anmeldung | | `GET` | `/health` | Status, ohne Anmeldung |
+36 -2
View File
@@ -28,7 +28,8 @@ sich jederzeit nachträglich ändern, einfach im Feld überschreiben.
|---|---| |---|---|
| **❚❚ Pause** | Zeit anhalten, Betrag friert ein | | **❚❚ Pause** | Zeit anhalten, Betrag friert ein |
| **▶ Weiter** | pausierten Timer fortsetzen | | **▶ 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 | | **✕ Verwerfen** | Timer wegwerfen, **nichts** wird gespeichert |
Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag. 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, Wanduhr. Die Karte zeigt, seit wann er läuft. Wer die Zeit korrigieren will,
verwirft den Timer und erfasst ihn neu. 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 ## Historie
Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name, Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name,
@@ -59,11 +82,22 @@ Dauer und Betrag. Oben summieren drei Kacheln den gewählten Zeitraum.
Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall, Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall,
zum Beispiel „10 € pro 10 Minuten". Die Vorschau rechnet Beispiele durch. 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.
Wichtig zu wissen: Wichtig zu wissen:
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde * **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
kosten bei einem 10-Minuten-Takt bereits zwei Einheiten. 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 * Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
Einträge in der Historie behalten den Tarif, der beim Beenden galt. Einträge in der Historie behalten den Tarif, der beim Beenden galt.
+22
View File
@@ -55,3 +55,25 @@ LOGIN_WINDOW_MIN=15
# Nur beim allerersten Start relevant: setzt den Anfangstarif. # Nur beim allerersten Start relevant: setzt den Anfangstarif.
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen"). # Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
RATE_PER_10MIN=10 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.
# Einrichtung des Pi: siehe deploy/bondrucker/README.md
#
# Bleibt PRINTER_HOST leer, ist die Druckfunktion aus - die App laeuft dann
# genau wie vorher, nur ohne Bon-Knoepfe.
PRINTER_HOST=
PRINTER_PORT=9100
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
# install.sh gibt den Wert am Ende aus.
PRINTER_TOKEN=
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
PRINTER_WIDTH=42
# Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
# darf das Beenden eines Timers nicht blockieren.
PRINTER_TIMEOUT_MS=6000
+152
View File
@@ -0,0 +1,152 @@
/**
* ESC/POS - Bausteine fuer den Bondrucker
*
* Erzeugt die Bytefolge, die ein Thermo-Bondrucker versteht (getestet mit einem
* Epson TM-T88IV). Bewusst ohne Fremdpaket: gebraucht werden ein Dutzend
* Steuersequenzen, und der Drucker ist das Letzte, was wegen eines
* Paket-Updates ploetzlich Buchstabensalat ausspucken soll.
*
* Zeichensatz: Der Drucker kennt kein UTF-8. Umgestellt wird auf Codepage 16
* (WPC1252) - die deckt die deutschen Umlaute ab und ist ab Werk in praktisch
* jedem Epson-Bondrucker vorhanden. Alles, was dort fehlt, wird lesbar
* ersetzt statt als Muell gedruckt.
*/
const ESC = 0x1b;
const GS = 0x1d;
// Codepage 16 = WPC1252. Die Zeichen decken sich dort mit Latin-1, mit
// Ausnahme des Euro-Zeichens (0x80). Deshalb reicht die Latin-1-Kodierung
// von Node plus diese eine Sonderbehandlung.
const CODEPAGE_WPC1252 = 16;
/** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */
const ERSATZ = {
'€': '\x80',
'„': '"', '“': '"', '”': '"', '': "'", '': "'", '': "'",
'': '-', '—': '-', '…': '...', ' ': ' ',
'\t': ' '
};
function encodeText(text) {
let s = String(text == null ? '' : text);
for (const [von, nach] of Object.entries(ERSATZ)) {
s = s.split(von).join(nach);
}
// Was danach ausserhalb von Latin-1 liegt (Emoji, kyrillisch, ...), wuerde
// als zufaelliges Zeichen gedruckt. Lieber ein sichtbares Fragezeichen.
s = s.replace(/[^\x00-\xff]/g, '?');
return Buffer.from(s, 'latin1');
}
class Bon {
/** @param {number} width Zeichen je Zeile (Font A: 42 bei 80 mm, 32 bei 58 mm) */
constructor(width = 42) {
this.width = width;
this.parts = [];
}
raw(...bytes) {
this.parts.push(Buffer.from(bytes));
return this;
}
/** Grundzustand herstellen - der Drucker merkt sich Einstellungen sonst. */
init() {
return this.raw(ESC, 0x40) // ESC @ Zuruecksetzen
.raw(ESC, 0x74, CODEPAGE_WPC1252); // ESC t Zeichensatz
}
/** @param {'left'|'center'|'right'} how */
align(how) {
const n = how === 'center' ? 1 : how === 'right' ? 2 : 0;
return this.raw(ESC, 0x61, n);
}
bold(on) {
return this.raw(ESC, 0x45, on ? 1 : 0);
}
underline(on) {
return this.raw(ESC, 0x2d, on ? 1 : 0);
}
/** Zeichengroesse als Vielfaches, 1..4 je Richtung. */
size(w = 1, h = 1) {
const clamp = v => Math.max(1, Math.min(4, parseInt(v) || 1)) - 1;
return this.raw(GS, 0x21, (clamp(w) << 4) | clamp(h));
}
text(s) {
this.parts.push(encodeText(s));
return this;
}
line(s = '') {
return this.text(s + '\n');
}
/** Zeile mit linkem und rechtem Block - die Trennlinie fuer Betraege. */
columns(left, right, width = this.width) {
const l = String(left == null ? '' : left);
const r = String(right == null ? '' : right);
const luecke = width - l.length - r.length;
if (luecke < 1) {
// Passt nicht nebeneinander: rechts buendig in die naechste Zeile.
return this.line(l).line(r.padStart(width));
}
return this.line(l + ' '.repeat(luecke) + r);
}
/** Text mittig, ohne den Drucker auf zentriert umzustellen. */
center(s) {
const t = String(s == null ? '' : s);
if (t.length >= this.width) return this.line(t);
return this.line(' '.repeat(Math.floor((this.width - t.length) / 2)) + t);
}
hr(zeichen = '-') {
return this.line(zeichen.repeat(this.width));
}
/** Langen Text auf die Zeilenbreite umbrechen. */
wrap(s, width = this.width) {
const worte = String(s == null ? '' : s).split(/\s+/).filter(Boolean);
let zeile = '';
for (const wort of worte) {
if (!zeile) {
zeile = wort;
} else if ((zeile + ' ' + wort).length <= width) {
zeile += ' ' + wort;
} else {
this.line(zeile);
zeile = wort;
}
while (zeile.length > width) {
this.line(zeile.slice(0, width));
zeile = zeile.slice(width);
}
}
if (zeile) this.line(zeile);
return this;
}
feed(n = 1) {
return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n)));
}
/**
* Papier abschneiden. Vorher wird gefuettert: der Schneidekopf sitzt
* einige Millimeter hinter dem Druckkopf, ohne Vorschub landet der Schnitt
* mitten im Text.
*/
cut() {
return this.feed(4).raw(GS, 0x56, 0x42, 0x00);
}
build() {
return Buffer.concat(this.parts);
}
}
module.exports = { Bon, encodeText };
+119
View File
@@ -0,0 +1,119 @@
/**
* 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.
*
* Zwei 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. Kein Fremdpaket. Node bringt einen HTTP-Client mit.
*/
const http = require('http');
const HOST = (process.env.PRINTER_HOST || '').trim();
const PORT = parseInt(process.env.PRINTER_PORT) || 9100;
const TOKEN = (process.env.PRINTER_TOKEN || '').trim();
const TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000;
const WIDTH = parseInt(process.env.PRINTER_WIDTH) || 42;
// Zuletzt gesehener Zustand - fuer /health und die Anzeige in den Einstellungen.
let lastError = null;
let lastPrintAt = null;
function isConfigured() {
return HOST.length > 0;
}
function config() {
return { host: HOST, port: PORT, width: WIDTH, timeoutMs: TIMEOUT_MS, tokenSet: !!TOKEN };
}
function state() {
return {
configured: isConfigured(),
host: HOST || null,
port: HOST ? PORT : null,
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 (!isConfigured()) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet (PRINTER_HOST fehlt).' });
}
const headers = {};
if (body) {
headers['Content-Type'] = 'application/octet-stream';
headers['Content-Length'] = body.length;
}
if (TOKEN) headers['X-Print-Token'] = TOKEN;
const req = http.request({ host: HOST, port: 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(TIMEOUT_MS, () => {
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${TIMEOUT_MS} ms`));
});
req.on('error', (err) => {
// ECONNREFUSED/EHOSTUNREACH sind der Normalfall, wenn der Pi aus ist.
const meldung = err.code === 'ECONNREFUSED'
? `Druckdienst auf ${HOST}:${PORT} nimmt keine Verbindung an.`
: err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH'
? `Drucker-Pi ${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 = { isConfigured, config, state, status, print, WIDTH };
+155
View File
@@ -0,0 +1,155 @@
/**
* Bon-Layout
*
* Baut aus einem abgeschlossenen oder laufenden Vorgang den Zettel, den der
* Kunde mit an die Kasse nimmt. Die eine Zahl, auf die es ankommt - der zu
* zahlende Betrag - steht deshalb gross und allein.
*
* Formatierung bewusst von Hand statt ueber Intl: TZ und Zahlenformat des
* Bons sollen nicht davon abhaengen, mit welcher ICU-Variante das Node auf
* dem Zielhost gebaut wurde.
*/
const { Bon } = require('./escpos');
const p2 = n => String(n).padStart(2, '0');
function fmtDate(ms) {
if (!ms) return '-';
const d = new Date(Number(ms));
return `${p2(d.getDate())}.${p2(d.getMonth() + 1)}.${d.getFullYear()}`;
}
function fmtTime(ms) {
if (!ms) return '-';
const d = new Date(Number(ms));
return `${p2(d.getHours())}:${p2(d.getMinutes())}`;
}
function fmtDuration(ms) {
const s = Math.floor(Math.max(0, ms || 0) / 1000);
return `${p2(Math.floor(s / 3600))}:${p2(Math.floor((s % 3600) / 60))}:${p2(s % 60)}`;
}
/** Deutsches Format mit Komma - "80,00 EUR". */
function fmtEuro(value) {
const n = Number(value || 0);
const [ganz, dez] = n.toFixed(2).split('.');
const mitPunkt = ganz.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${mitPunkt},${dez} EUR`;
}
/** Wie viele angefangene Einheiten berechnet wurden - erklaert den Betrag. */
function unitCount(ms, rateInterval) {
if (!ms || ms <= 0) return 0;
return Math.ceil(Math.ceil(ms / 60000) / (rateInterval || 10));
}
/**
* @param {object} data
* @param {'final'|'interim'} data.kind Endbeleg oder Zwischenstand
* @param {string|number} data.belegNr
* @param {string} data.customerName
* @param {number} data.startedAt Zeitstempel in ms
* @param {number} data.endedAt Zeitstempel in ms (bei 'interim': jetzt)
* @param {number} data.durationMs
* @param {number} data.amount
* @param {number} data.rateAmount
* @param {number} data.rateInterval
* @param {boolean} data.stillRunning
* @param {string} data.header Kopfzeilen, durch \n getrennt
* @param {string} data.footer Fusszeilen, durch \n getrennt
* @param {number} data.width Zeichen je Zeile
* @returns {Buffer}
*/
function buildReceipt(data) {
const width = data.width || 42;
const interim = data.kind === 'interim';
const bon = new Bon(width);
bon.init();
// --- Kopf: Ladenname, gross und fett ------------------------------------
const kopfzeilen = String(data.header || '').split('\n').map(z => z.trim()).filter(Boolean);
if (kopfzeilen.length) {
bon.align('center').bold(true);
bon.size(2, 2).line(kopfzeilen[0].slice(0, Math.floor(width / 2)));
bon.size(1, 1);
for (const zeile of kopfzeilen.slice(1)) bon.wrap(zeile);
bon.bold(false).align('left');
bon.line();
}
if (interim) {
bon.align('center').bold(true).line('--- ZWISCHENSTAND ---').bold(false).align('left');
bon.line();
}
// --- Belegkopf ----------------------------------------------------------
bon.hr();
bon.columns(interim ? `Zwischenbeleg ${data.belegNr}` : `Beleg-Nr. ${data.belegNr}`,
`${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`);
if (data.customerName) {
bon.wrap(`Kunde: ${data.customerName}`);
}
bon.hr();
// --- Zeiten -------------------------------------------------------------
bon.columns('Beginn', `${fmtDate(data.startedAt)} ${fmtTime(data.startedAt)}`);
bon.columns(interim ? 'Stand' : 'Ende', `${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`);
bon.columns('Dauer', fmtDuration(data.durationMs));
bon.line();
// --- Rechenweg: der Betrag soll nachvollziehbar sein ---------------------
const einheiten = unitCount(data.durationMs, data.rateInterval);
bon.columns('Tarif', `${fmtEuro(data.rateAmount)} / ${data.rateInterval} Min`);
bon.columns('Angefangene Einheiten', String(einheiten));
bon.hr();
// --- Der Betrag ---------------------------------------------------------
bon.line();
bon.align('center');
bon.bold(true).line('ZU ZAHLEN').bold(false);
// Doppelte Breite heisst halbe Zeichenzahl je Zeile - deshalb hier keine
// eigene Zentrierung rechnen, sondern die des Druckers nutzen.
bon.size(2, 2).line(fmtEuro(data.amount)).size(1, 1);
bon.align('left');
bon.line();
bon.hr();
// --- Fuss ---------------------------------------------------------------
bon.align('center');
if (interim) {
bon.line();
if (data.stillRunning) {
bon.line('Der Timer laeuft weiter.');
bon.line('Endbetrag erst beim Beenden.');
} else {
bon.line('Timer pausiert.');
}
}
const fusszeilen = String(data.footer || '').split('\n').map(z => z.trim()).filter(Boolean);
if (fusszeilen.length) {
bon.line();
for (const zeile of fusszeilen) bon.wrap(zeile);
}
bon.align('left');
bon.cut();
return bon.build();
}
/** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */
function toPlainText(buffer) {
return buffer.toString('latin1')
.replace(/\x1b@/g, '') // Zuruecksetzen
.replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte
.replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse
.replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt
.replace(/\x80/g, 'EUR')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
module.exports = { buildReceipt, toPlainText, fmtEuro, fmtDuration, unitCount };
+226 -5
View File
@@ -16,6 +16,8 @@ const bcrypt = require('bcrypt');
const path = require('path'); const path = require('path');
require('dotenv').config(); require('dotenv').config();
const oidc = require('./oidc'); const oidc = require('./oidc');
const printer = require('./printer');
const { buildReceipt } = require('./receipt');
const app = express(); const app = express();
@@ -142,6 +144,13 @@ async function initDatabase() {
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`); 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')`);
console.log('Datenbank bereit'); console.log('Datenbank bereit');
} catch (err) { } catch (err) {
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message); console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
@@ -154,6 +163,11 @@ async function initDatabase() {
// die Anzeige mit, der verbindliche Betrag entsteht hier) // 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() { async function getRate() {
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`); 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])); const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
@@ -163,6 +177,18 @@ async function getRate() {
}; };
} }
/** 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. */ /** Jede angefangene Einheit wird voll berechnet. */
function calcAmount(ms, rateAmount, rateInterval) { function calcAmount(ms, rateAmount, rateInterval) {
if (!ms || ms <= 0) return 0; if (!ms || ms <= 0) return 0;
@@ -192,6 +218,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 // Sitzungsspeicher
// //
@@ -283,6 +342,16 @@ app.use(session({
app.use(express.static(path.join(__dirname, '../frontend'))); 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) { function requireAuth(req, res, next) {
if (req.session.authenticated) return next(); if (req.session.authenticated) return next();
res.status(401).json({ error: 'Nicht angemeldet' }); res.status(401).json({ error: 'Nicht angemeldet' });
@@ -377,7 +446,16 @@ app.get('/api/timers', requireAuth, wrap(async (req, res) => {
const rate = await getRate(); const rate = await getRate();
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`); const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
const timers = await Promise.all(rows.map(t => decorate(t, rate))); 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) => { app.post('/api/timers', requireAuth, wrap(async (req, res) => {
@@ -425,7 +503,13 @@ app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
res.json({ timer: await decorate(row, await getRate()) }); 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) => { app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]); 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 (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
@@ -448,7 +532,67 @@ app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
); );
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]); 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. */ /** Verwerfen: Timer weg, nichts in der Historie. */
@@ -462,7 +606,7 @@ app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
app.get('/api/settings', 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) => { app.put('/api/settings', requireAuth, wrap(async (req, res) => {
@@ -481,7 +625,55 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?) await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(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');
}
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 (PRINTER_HOST).' });
}
res.json({ configured: true, ...await printer.status(), config: printer.config() });
}));
/** 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 +724,34 @@ app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
res.json({ success: true }); 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. */ /** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => { app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
const { where, params } = dateFilter(req.query); const { where, params } = dateFilter(req.query);
@@ -588,6 +808,7 @@ app.get('/health', wrap(async (req, res) => {
rateAmount: rate.rateAmount, rateAmount: rate.rateAmount,
rateInterval: rate.rateInterval, rateInterval: rate.rateInterval,
activeTimers: active.c, activeTimers: active.c,
printer: printer.state(),
uptimeSeconds: Math.floor(process.uptime()) uptimeSeconds: Math.floor(process.uptime())
}); });
})); }));
+103
View File
@@ -0,0 +1,103 @@
# 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 auf dem Pi
```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 `PRINTER_TOKEN` aus. Der gehört in die
`.env` des Timer-Servers.
## Timer-Server konfigurieren
In `backend/.env`:
```
PRINTER_HOST=192.168.8.157
PRINTER_PORT=9100
PRINTER_TOKEN=<Ausgabe von install.sh>
PRINTER_WIDTH=42
```
Danach `systemctl restart timer-app`. Ist `PRINTER_HOST` leer, ist die
Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe.
## 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}` |
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 |
| Umlaute als Kringel | Drucker beherrscht Codepage 16 (WPC1252) nicht — siehe `backend/escpos.js` |
+49
View File
@@ -0,0 +1,49 @@
[Unit]
Description=Bondrucker-Bruecke (ESC/POS ueber HTTP an den USB-Kassendrucker)
Documentation=file:///opt/bondrucker/README.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/bondrucker/print-server.py
# Laeuft NICHT als root. Der Dienstbenutzer ist nur deshalb in der Gruppe "lp",
# weil /dev/usb/lp0 dem Besitzer root und der Gruppe lp gehoert (Rechte 660).
User=bondrucker
Group=bondrucker
SupplementaryGroups=lp
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
# Versionsverwaltung landet - die Datei legt install.sh an.
Environment=PRINT_DEVICE=/dev/usb/lp0
Environment=PRINT_HOST=0.0.0.0
Environment=PRINT_PORT=9100
EnvironmentFile=-/etc/bondrucker/env
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# --- Haertung ---------------------------------------------------------------
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
# Der Dienst braucht nirgends Schreibrecht - er schreibt ausschliesslich in die
# Geraetedatei, und die ist von ProtectSystem nicht betroffen.
PrivateDevices=false
[Install]
WantedBy=multi-user.target
+67
View File
@@ -0,0 +1,67 @@
#!/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 --now 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)"
+197
View File
@@ -0,0 +1,197 @@
#!/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.0"
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():
dev = find_device()
status = {
"ok": False,
"version": VERSION,
"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()
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()
+131 -10
View File
@@ -10,6 +10,8 @@
const state = { const state = {
timers: [], // {id, customerName, isRunning, baseMs, syncedAt} timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
rate: { rateAmount: 10, rateInterval: 10 }, rate: { rateAmount: 10, rateInterval: 10 },
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
tickHandle: null, tickHandle: null,
syncHandle: null, syncHandle: null,
history: { offset: 0, limit: 50, reachedEnd: false } history: { offset: 0, limit: 50, reachedEnd: false }
@@ -63,7 +65,9 @@ function toast(message, kind = 'info') {
el.textContent = message; el.textContent = message;
el.className = 'toast show ' + kind; el.className = 'toast show ' + kind;
clearTimeout(toastTimeout); 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 = {}) { async function api(url, options = {}) {
@@ -178,6 +182,8 @@ async function logout() {
async function syncTimers() { async function syncTimers() {
const data = await api('/api/timers'); const data = await api('/api/timers');
state.rate = data.rate; state.rate = data.rate;
state.printerConfigured = !!data.printerConfigured;
state.printAutoOnStop = data.printAutoOnStop !== false;
const now = Date.now(); const now = Date.now();
state.timers = data.timers.map(t => ({ state.timers = data.timers.map(t => ({
@@ -261,6 +267,10 @@ function renderTimers() {
${timer.isRunning ${timer.isRunning
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>` ? `<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>`} : `<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-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button> <button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
</div> </div>
@@ -316,21 +326,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) { async function stopTimer(id) {
const timer = state.timers.find(t => t.id === id); const timer = state.timers.find(t => t.id === id);
if (!timer) return; if (!timer) return;
const ms = currentMs(timer); const ms = currentMs(timer);
const label = timer.customerName ? `${timer.customerName}"` : '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 { try {
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' }); const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
await syncTimers(); await syncTimers();
if (result.saved) { if (!result.saved) {
toast(`Gespeichert: ${formatTime(result.duration)} ${formatEuro(result.amount)}`, 'success'); 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 { } else {
toast(result.reason || 'Nichts gespeichert.', 'info'); toast(kern, 'success');
} }
} catch (e) { } catch (e) {
toast('Beenden fehlgeschlagen: ' + e.message, 'error'); toast('Beenden fehlgeschlagen: ' + e.message, 'error');
@@ -426,6 +463,10 @@ function renderHistory(data, reset) {
</div> </div>
<div class="history-item-right"> <div class="history-item-right">
<div class="history-item-amount">${formatEuro(s.amount)}</div> <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> <button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
</div> </div>
</div>`; </div>`;
@@ -448,6 +489,17 @@ async function loadMoreHistory() {
await loadHistory(false); 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) { async function deleteSession(id) {
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return; if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
try { try {
@@ -502,11 +554,20 @@ document.getElementById('filterName').addEventListener('input', (() => {
async function loadSettings() { async function loadSettings() {
try { try {
const rate = await api('/api/settings'); const settings = await api('/api/settings');
state.rate = rate; state.rate = settings;
document.getElementById('rateAmount').value = rate.rateAmount; state.printerConfigured = !!(settings.printer && settings.printer.configured);
document.getElementById('rateInterval').value = rate.rateInterval; 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(); updateSettingsPreview();
renderPrinterState(settings.printer);
refreshPrinterStatus();
} catch (e) { } catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error'); toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
} }
@@ -515,13 +576,17 @@ async function loadSettings() {
async function saveSettings() { async function saveSettings() {
const rateAmount = parseFloat(document.getElementById('rateAmount').value); const rateAmount = parseFloat(document.getElementById('rateAmount').value);
const rateInterval = parseInt(document.getElementById('rateInterval').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 { try {
const rate = await api('/api/settings', { const rate = await api('/api/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ rateAmount, rateInterval }) body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop })
}); });
state.rate = rate; state.rate = rate;
state.printAutoOnStop = rate.printAutoOnStop !== false;
renderTimers(); renderTimers();
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success'); toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
showTimer(); showTimer();
@@ -537,6 +602,62 @@ async function resetSettings() {
await saveSettings(); 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. Einzurichten in der .env des Servers (PRINTER_HOST) siehe deploy/bondrucker/README.md.';
if (testBtn) testBtn.disabled = true;
return;
}
if (testBtn) testBtn.disabled = false;
const ziel = `${info.host}:${info.port}`;
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();
}
}
function updateSettingsPreview() { function updateSettingsPreview() {
const amount = parseFloat(document.getElementById('rateAmount').value) || 0; const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
const interval = parseInt(document.getElementById('rateInterval').value) || 1; const interval = parseInt(document.getElementById('rateInterval').value) || 1;
+105 -4
View File
@@ -219,7 +219,8 @@
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
input[type="number"], input[type="number"],
input[type="date"] { input[type="date"],
textarea {
width: 100%; width: 100%;
padding: 0.875rem 1.125rem; padding: 0.875rem 1.125rem;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -231,14 +232,16 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
input:focus { input:focus,
textarea:focus {
outline: none; outline: none;
border-color: #4d35a9; border-color: #4d35a9;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35); 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 */ /* Datumsfelder im Dark Theme lesbar halten */
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; } input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
@@ -551,6 +554,66 @@
margin-top: 1rem !important; 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;
}
.form-group + .form-group { margin-top: 1.25rem; }
.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);
}
.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 { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.settings-actions .btn { flex: 1; padding: 1rem; } .settings-actions .btn { flex: 1; padding: 1rem; }
@@ -582,6 +645,8 @@
max-width: 90vw; max-width: 90vw;
font-size: 0.95rem; font-size: 0.95rem;
text-align: center; 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; } .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
@@ -716,6 +781,42 @@
<p class="preview-examples" id="previewExamples"></p> <p class="preview-examples" id="previewExamples"></p>
</div> </div>
<div class="settings-section">
<h3>🖨️ Bon für die Kasse</h3>
<p>Was auf dem Zettel steht, den der Kunde mit zur Kasse nimmt.
Betrag, Zeiten und Tarif kommen automatisch dazu.</p>
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
<div class="form-group">
<label for="receiptHeader">Kopfzeilen</label>
<textarea id="receiptHeader" maxlength="240" rows="3"
placeholder="Handy-Insel&#10;Hauptstraße 12, 12345 Musterstadt&#10;Tel. 01234 / 56789"></textarea>
<small>Erste Zeile wird groß gedruckt. Eine Zeile je Zeilenumbruch, maximal 240 Zeichen.</small>
</div>
<div class="form-group">
<label for="receiptFooter">Fußzeilen</label>
<textarea id="receiptFooter" maxlength="240" rows="2"
placeholder="Bitte an der Kasse bezahlen."></textarea>
<small>Steht unter dem Betrag, maximal 240 Zeichen.</small>
</div>
<div class="checkbox-row">
<input type="checkbox" id="printAutoOnStop" checked>
<label for="printAutoOnStop">Bon beim Beenden automatisch drucken
<small>Aus: der Bon wird nur über die Knöpfe 🖨 gedruckt.</small>
</label>
</div>
<div class="settings-actions" style="margin-top: 1.25rem;">
<button type="button" class="btn btn-secondary" id="printerTestBtn"
onclick="testPrint()">🖨️ Probedruck</button>
<button type="button" class="btn btn-secondary"
onclick="refreshPrinterStatus()">🔄 Drucker prüfen</button>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<button type="submit" class="btn">💾 Speichern</button> <button type="submit" class="btn">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button> <button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
@@ -733,6 +834,6 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script src="/app.js?v=20260803-sso"></script> <script src="/app.js?v=20260904-bon"></script>
</body> </body>
</html> </html>