Files
timer-app/backend/printer.js
T
Kerim (portable/claudecode)andClaude Opus 5 0e8d826bce
Deploy auf den Timer-Server / deploy (push) Successful in 20s
Drucker wird in der App gewaehlt, nicht auf dem Pi
Das Einrichtungsskript fragte bisher am Pi, welcher Drucker bedient werden
soll. Damit stand die Einstellung an einem Ort, an den man nur mit einem
Terminal kommt - und ein zweiter Drucker am Pi bedeutete: Skript nochmal.

Jetzt fragt die App den Pi, was angeschlossen ist, und der Bediener waehlt in
den Einstellungen. Die Bruecke bekam dafuer /devices; sie liest die
Klartextnamen aus dem sysfs, damit dort "EPSON TM-T88IV" steht und nicht
"lp0" - bei zwei Druckern der Unterschied zwischen Wissen und Raten.

Der gewaehlte Pfad geht bei jedem Auftrag im Kopf X-Print-Device mit. Die
Bruecke haelt dazu bewusst keinen Zustand und das Skript schreibt kein
PRINT_DEVICE mehr: es soll genau eine Stelle geben, die weiss, welcher Drucker
gemeint ist, naemlich die, an der es bedient wird. Leer bleibt "automatisch" -
das erste gefundene Geraet, zugleich der Rueckfall, wenn aus lp0 nach dem
Umstecken ein lp1 geworden ist.

Der Pfad kommt damit von aussen und wird an beiden Enden geprueft, in
PUT /api/settings und noch einmal in der Bruecke. Eine Schranke, die nur an
einem Ende steht, ist keine: ohne die Pruefung auf dem Pi liesse sich der
Dienst dazu bringen, in eine beliebige Datei zu schreiben.

Nebenbei: ein nicht bereiter Drucker meldete sich in der Oberflaeche als
"HTTP 503". Das verriet niemandem, dass nur ein Stecker lose ist. Die 503 der
Bruecke ist kein Protokollfehler, und ihr Rumpf sagt, was fehlt - der wird
jetzt ausgewertet.

Geprueft am echten Pi (Debian 13, TM-T88IV): Einrichtung ohne Rueckfrage,
Geraeteliste mit Namen, ausdrueckliche Wahl, Druck darauf, Rueckkehr auf
automatisch, Druck darauf. Dazu die Abweise: /etc/passwd und ein Pfad mit
angehaengtem Befehl werden zurueckgewiesen, ein erlaubtes aber fehlendes
/dev/usb/lp7 ergibt eine Meldung im Klartext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:50 +02:00

187 lines
6.9 KiB
JavaScript

/**
* 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,
// Welcher Drucker am Pi bedient wird. Leer = die Bruecke sucht sich selbst
// einen. Die Wahl lebt hier und nicht auf dem Pi, damit sie dort bedient
// werden kann, wo auch alles andere eingestellt wird.
device: '',
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,
device: next.device == null ? cfg.device : String(next.device).trim()
};
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, device: cfg.device,
timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token
};
}
function state() {
return {
configured: isConfigured(),
host: cfg.host || null,
port: isConfigured() ? cfg.port : null,
width: cfg.width,
device: cfg.device || 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 (!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;
// Ohne diesen Kopf sucht sich die Bruecke selbst ein Geraet. Mit ihm
// gilt genau das, was in den Einstellungen steht.
if (cfg.device) headers['X-Print-Device'] = cfg.device;
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');
// Die Bruecke antwortet mit 503, wenn sie zwar laeuft, der Drucker aber
// nicht bereit ist: Kabel ab, falsches Geraet gewaehlt, kein Schreibrecht.
// Das ist kein Protokollfehler - und der Rumpf sagt, was genau fehlt.
// Ohne diese Auswertung stuende in der Oberflaeche "HTTP 503", was niemandem
// verraet, dass nur ein USB-Stecker lose ist.
const p = res.data;
if (!res.ok && p && p.version) {
lastError = !p.deviceExists
? `Gerät ${p.device} ist am Pi nicht vorhanden. Kabel prüfen oder einen anderen Drucker wählen.`
: !p.writable
? `Auf ${p.device} darf der Druckdienst nicht schreiben.`
: p.lastError || 'Drucker nicht bereit.';
return { ok: false, error: lastError, printer: p, ...state() };
}
if (!res.ok) {
lastError = res.error;
return { ok: false, error: res.error, ...state() };
}
return { ok: true, printer: res.data, ...state() };
}
/** Welche Drucker am Pi haengen - die Auswahl fuer die Einstellungen. */
async function devices() {
const res = await request('/devices', 'GET');
if (!res.ok) {
lastError = res.error;
return { ok: false, error: res.error };
}
return { ok: true, devices: (res.data && res.data.devices) || [], fallback: res.data && res.data.fallback };
}
/** 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, devices, print };