Files
timer-app/backend/printer.js
T
Kerim (portable/claudecode)andClaude Opus 5 5e23ccdcbc Drucker-Pi aus der Oberflaeche heraus einrichten
Die Einrichtung war Handarbeit: Dateien per scp auf den Pi, dort install.sh
mit sudo starten, Token abschreiben, in die .env eintragen, Dienst neu
starten. Das ist genau die Sorte Arbeit, die beim naechsten Geraetetausch
niemand mehr weiss. Jetzt macht das ein Knopf unter "Einstellungen":
Adresse, Benutzer und Passwort eintragen, und die App meldet sich per SSH an,
prueft Python/systemd/Druckergeraet, uebertraegt deploy/bondrucker/, startet
install.sh mit einem selbst erzeugten Token, raeumt auf und prueft von aussen
nach. Jeder Schritt steht mit Ergebnis im Protokoll.

Damit das ueberhaupt Sinn ergibt, liegt der Druckerzugang jetzt in der
Datenbank statt in der .env - sonst muesste hinterher doch wieder jemand auf
den Server. Die PRINTER_*-Werte in der .env sind nur noch Startwerte beim
allerersten Start, wie RATE_PER_10MIN auch.

Zwei Fehler, die beim Testen der Einrichtung auffielen:

install.sh startete den Dienst nicht neu, sondern nur "enable --now". Bei
einer erneuten Einrichtung lief die Bruecke deshalb mit dem ALTEN Token
weiter und lehnte jeden Bon mit HTTP 403 ab. Jetzt: restart.

Die Gegenprobe fragte nur /status ab - und /status prueft kein Token. Ein
Dienst mit altem Token galt damit faelschlich als bereit. Die Bruecke meldet
in /status jetzt zusaetzlich, ob das mitgeschickte Token passt (Version 1.1),
und die Einrichtung prueft das mit.

Zugangsdaten werden einmal benutzt und danach vergessen; das erzeugte Token
wird aus dem Protokoll entfernt, bevor es in den Browser oder ins Journal
geht. Neu dabei: ssh2, reines JavaScript.

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

149 lines
5.1 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,
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
};
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, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token };
}
function state() {
return {
configured: isConfigured(),
host: cfg.host || null,
port: isConfigured() ? cfg.port : null,
width: cfg.width,
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;
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');
if (!res.ok) {
lastError = res.error;
return { ok: false, error: res.error, ...state() };
}
return { ok: true, printer: res.data, ...state() };
}
/** 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, print };