/** * 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 };