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
+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 };