Files
timer-app/backend/escpos.js
T
Kerim (portable/claudecode)andClaude Opus 5 70756f3762 Bon fuer die Kasse auf einem USB-Bondrucker ausgeben
Der Kunde soll mit einem Zettel zur Kasse gehen koennen, auf dem steht, was
er zahlen muss. Drei Wege fuehren jetzt zu einem Bon: der Knopf "Bon" auf der
Timer-Karte druckt den Zwischenstand und laesst den Timer weiterlaufen,
"Beenden" druckt den Endbeleg, und in der Historie laesst sich jeder Beleg
nachdrucken.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 20:37:12 +02:00

153 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };