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>
This commit is contained in:
Kerim (portable/claudecode)
2026-09-04 20:59:56 +02:00
co-authored by Claude Opus 5
parent 70756f3762
commit 5e23ccdcbc
13 changed files with 855 additions and 62 deletions
+63 -34
View File
@@ -5,39 +5,65 @@
* Server. Auf dem Pi laeuft die Bruecke aus deploy/bondrucker/, die fertige
* ESC/POS-Daten per HTTP annimmt. Hier entsteht nur der Aufruf.
*
* Zwei Grundsaetze, die den Rest erklaeren:
* 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. Kein Fremdpaket. Node bringt einen HTTP-Client mit.
* 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');
const HOST = (process.env.PRINTER_HOST || '').trim();
const PORT = parseInt(process.env.PRINTER_PORT) || 9100;
const TOKEN = (process.env.PRINTER_TOKEN || '').trim();
const TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000;
const WIDTH = parseInt(process.env.PRINTER_WIDTH) || 42;
// 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 isConfigured() {
return HOST.length > 0;
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: HOST, port: PORT, width: WIDTH, timeoutMs: TIMEOUT_MS, tokenSet: !!TOKEN };
return { host: cfg.host, port: cfg.port, width: cfg.width, timeoutMs: cfg.timeoutMs, tokenSet: !!cfg.token };
}
function state() {
return {
configured: isConfigured(),
host: HOST || null,
port: HOST ? PORT : null,
host: cfg.host || null,
port: isConfigured() ? cfg.port : null,
width: cfg.width,
lastError,
lastPrintAt
};
@@ -47,8 +73,8 @@ function state() {
* Aufrufer zwingt, ihn zu behandeln - stattdessen {ok, error}. */
function request(pfad, method, body) {
return new Promise((resolve) => {
if (!isConfigured()) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet (PRINTER_HOST fehlt).' });
if (!cfg.host) {
return resolve({ ok: false, error: 'Kein Drucker eingerichtet.' });
}
const headers = {};
@@ -56,34 +82,37 @@ function request(pfad, method, body) {
headers['Content-Type'] = 'application/octet-stream';
headers['Content-Length'] = body.length;
}
if (TOKEN) headers['X-Print-Token'] = TOKEN;
if (cfg.token) headers['X-Print-Token'] = cfg.token;
const req = http.request({ host: HOST, port: 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 */ }
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 });
});
});
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(TIMEOUT_MS, () => {
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${TIMEOUT_MS} ms`));
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 ${HOST}:${PORT} nimmt keine Verbindung an.`
? `Druckdienst auf ${cfg.host}:${cfg.port} nimmt keine Verbindung an.`
: err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH'
? `Drucker-Pi ${HOST} ist im Netz nicht erreichbar.`
? `Drucker-Pi ${cfg.host} ist im Netz nicht erreichbar.`
: (err.message || String(err));
resolve({ ok: false, error: meldung });
});
@@ -116,4 +145,4 @@ async function print(buffer) {
return res;
}
module.exports = { isConfigured, config, state, status, print, WIDTH };
module.exports = { setConfig, isConfigured, width, config, state, status, print };