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:
co-authored by
Claude Opus 5
parent
5e83999950
commit
70756f3762
@@ -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 };
|
||||
Reference in New Issue
Block a user