Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen

Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins
Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser,
ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi.

Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird
das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am
Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht
ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll.

Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0".
Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten.

Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist
eine Vorlage, in die setup.js Adresse, Code, print-server.py und die
systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts
nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben.

Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik
nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32
Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant
verglichen, hinter einem eigenen Zaehler.

Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der
Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders
behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft
sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript.

Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen,
Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die
Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die
uebrige API weiterhin 401 liefert.

Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kerim (portable/claudecode)andClaude Opus 5 committed 2026-09-12 13:37:26 +02:00
1 parent 8c5cdf16c4
commit e68daee457
14 files changed
+834 -605

No files matched your search

+93 -41
View File
@@ -14,6 +14,8 @@ const state = {
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
tickHandle: null,
syncHandle: null,
setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi
setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird
history: { offset: 0, limit: 50, reachedEnd: false }
};
@@ -692,7 +694,7 @@ function toggleSetup() {
box.style.display = offen ? 'none' : 'block';
document.getElementById('setupToggleBtn').textContent =
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
if (!offen) document.getElementById('setupHost').focus();
if (offen) stopSetupWatch();
}
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
@@ -711,59 +713,109 @@ function renderSetupLog(steps, laufend) {
el.innerHTML = zeilen + (laufend
? `<div class="setup-step"><span class="setup-step-icon">⏳</span>
<div><div class="setup-step-name">läuft …</div></div></div>`
<div><div class="setup-step-name">wartet auf den Pi …</div></div></div>`
: '');
}
async function startProvision() {
const host = document.getElementById('setupHost').value.trim();
const port = document.getElementById('setupPort').value;
const username = document.getElementById('setupUser').value.trim();
const passwordEl = document.getElementById('setupPassword');
const password = passwordEl.value;
if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error');
if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error');
if (!password) return toast('Bitte das Passwort eintragen.', 'error');
if (state.printerConfigured && !confirm(
'Es ist bereits ein Drucker eingerichtet.\n\n' +
`Nach der Einrichtung druckt die App auf ${host}. Fortfahren?`
)) return;
/** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */
async function startSetup() {
const btn = document.getElementById('setupStartBtn');
btn.disabled = true;
btn.textContent = '⏳ Wird eingerichtet …';
renderSetupLog([], true);
try {
const res = await api('/api/printer/provision', {
method: 'POST',
body: JSON.stringify({ host, port, username, password })
});
const res = await api('/api/printer/setup-code', { method: 'POST' });
// Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts
// mehr verloren.
passwordEl.value = '';
state.setupCommand = res.command;
document.getElementById('setupCommand').textContent = res.command;
document.getElementById('setupLink').style.display = 'block';
renderSetupLog(res.steps, false);
state.printerConfigured = true;
const bis = new Date(res.expiresAt);
document.getElementById('setupExpiry').textContent =
`Der Link gilt einmal und läuft um ${bis.getHours()}:` +
`${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` +
'Danach hier einfach einen neuen erzeugen.';
if (res.ready) {
toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success');
} else {
toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error');
}
await refreshPrinterStatus();
await syncTimers().catch(() => {});
renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true);
startSetupWatch();
toast('Link erzeugt. Jetzt am Pi ausführen.', 'success');
} catch (e) {
// Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind
// das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen.
renderSetupLog(e.steps, false);
toast('Einrichtung fehlgeschlagen: ' + e.message, 'error');
toast('Link konnte nicht erzeugt werden: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🚀 Jetzt einrichten';
btn.textContent = '🔗 Neuen Link erzeugen';
}
}
function copySetupCommand() {
const text = state.setupCommand || '';
if (!text) return;
// navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft
// das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten
// Weg zurueck, statt wortlos nichts zu tun.
const alterWeg = () => {
const feld = document.createElement('textarea');
feld.value = text;
feld.style.position = 'fixed';
feld.style.opacity = '0';
document.body.appendChild(feld);
feld.select();
try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ }
document.body.removeChild(feld);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(
() => toast('Befehl kopiert', 'success'),
() => { alterWeg(); toast('Befehl kopiert', 'success'); }
);
} else {
alterWeg();
toast('Befehl kopiert', 'success');
}
}
/**
* Auf die Anmeldung des Pi warten.
*
* Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur
* nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung
* anzufuehlen, und selten genug, um niemandem wehzutun.
*/
function startSetupWatch() {
stopSetupWatch();
state.setupWatch = setInterval(async () => {
let z;
try {
z = await api('/api/printer/setup-status');
} catch (e) {
return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder
}
if (z.state === 'done') {
stopSetupWatch();
state.printerConfigured = true;
renderSetupLog([
{ name: 'Pi angemeldet', ok: true,
detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` },
{ name: 'Übernommen', ok: true,
detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' }
], false);
toast('Drucker eingerichtet: ' + z.host, 'success');
await refreshPrinterStatus();
await syncTimers().catch(() => {});
} else if (z.state === 'expired') {
stopSetupWatch();
renderSetupLog([{ name: 'Link abgelaufen', ok: false,
detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false);
}
}, 3000);
}
function stopSetupWatch() {
if (state.setupWatch) {
clearInterval(state.setupWatch);
state.setupWatch = null;
}
}