CC2 ↔ Spoolman bridge with NFC tag generator
CI / test (push) Successful in 1m4s
CI / image (push) Successful in 1m1s

- Reads /opt/usr/canvas_record_info.json over SSH (pinned host key,
  restricted key), decodes Spoolman spool ids from the tag weight
- Mirrors spools into CC2 Control's spool library and assigns slots,
  keeps its own slot mapping, sets Spoolman locations
- Books the drop of CC2 Control's measured remaining weight to Spoolman
  after each print, idempotent across restarts and interrupted bookings
- Dry-run by default; web UI for settings, SSH key, status and log
- Tag generator for NFC Tools command chains (NTAG213/215/216)
- Tests, mock environment, Dockerfile, Gitea Actions CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:01:17 +01:00
commit bf32667f77
32 files changed
+4216

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
{% block content %}
<h2>Einstellungen</h2>
<form id="settings" class="panel" onsubmit="saveSettings(event)">
<div class="row">
<div class="field"><label for="printer_host">Drucker-IP</label><input id="printer_host" required></div>
<div class="field"><label for="ssh_port">SSH-Port</label><input id="ssh_port" type="number" min="1" max="65535"></div>
<div class="field"><label for="ssh_user">SSH-Benutzer</label><input id="ssh_user"></div>
</div>
<div class="row">
<div class="field"><label for="cc2_url">CC2-Control-URL</label><input id="cc2_url" required></div>
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div>
</div>
<div class="row">
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
<div class="field"><label for="poll_interval">Abfrage-Intervall (s)</label><input id="poll_interval" type="number" step="0.5" min="1" max="300"></div>
<div class="field"><label for="empty_grace">Slot leer nach (s)</label><input id="empty_grace" type="number" min="0"></div>
<div class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
</div>
<div class="row">
<div class="field"><label for="location_slot">Ort im Slot (<code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
<div class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
</div>
<div class="field">
<label><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
</div>
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
</form>
<h2>SSH-Schlüssel für den Drucker</h2>
<div class="panel">
<div id="keyinfo" class="field muted">Lade …</div>
<div class="field">
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen</label>
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
</div>
<div class="row">
<input type="file" id="keyfile" style="flex:1 1 220px">
<button class="primary" onclick="saveKey()">Schlüssel speichern</button>
<button class="danger" onclick="deleteKey()">Schlüssel löschen</button>
</div>
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
</div>
<h2>Verbindung testen</h2>
<div class="panel">
<div id="hostkey" class="field muted"></div>
<div class="row"><button class="primary" onclick="testSSH()">Verbindung testen</button>
<button class="danger" onclick="forgetHost()">Host-Key vergessen</button></div>
<div id="testresult" style="margin-top:12px"></div>
</div>
{% endblock %}
{% block script %}
<script>
const FIELDS = ['printer_host', 'ssh_port', 'ssh_user', 'cc2_url', 'spoolman_url', 'id_offset', 'poll_interval',
'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
let current = {};
async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('dry_run').checked = current.dry_run;
setMode(current.dry_run);
}
async function saveSettings(ev) {
ev.preventDefault();
const body = {};
for (const f of FIELDS) body[f] = $(f).value;
body.dry_run = $('dry_run').checked;
if (current.dry_run && !body.dry_run && !confirm(
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, setzt Orte in Spoolman und bucht Verbrauch.')) return;
try {
current = await api('POST', '/api/settings', body);
setMode(current.dry_run);
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
} catch (e) { toast(e.message); }
}
function renderKey(info) {
const k = info.key;
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; }
if (k.error) { $('keyinfo').innerHTML = `<span class="tag err">${esc(k.error)}</span>`; return; }
const host = $('printer_host').value || '10.10.2.202';
$('keyinfo').innerHTML = `<dl class="kv"><dt>Typ</dt><dd>${esc(k.type)}</dd><dt>Fingerprint</dt><dd><code>${esc(k.fingerprint)}</code></dd></dl>
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf dem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
<pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre>
<button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
}
function renderHost(hk) {
$('hostkey').innerHTML = hk
? `<dl class="kv"><dt>Bestätigter Host-Key</dt><dd><code>${esc(hk.fingerprint)}</code> <span class="muted small">(${esc(hk.type)}, ${esc(hk.host)})</span></dd></dl>`
: '<span class="tag warn">Host-Key des Druckers noch nicht bestätigt</span>';
}
async function loadSSH() { const info = await api('GET', '/api/ssh'); renderKey(info); renderHost(info.host_key); }
$('keyfile').addEventListener('change', async (ev) => {
const f = ev.target.files[0];
if (f) $('keytext').value = await f.text();
});
async function saveKey() {
const key = $('keytext').value.trim();
if (!key) { toast('Kein Schlüssel eingegeben'); return; }
try {
const r = await api('POST', '/api/ssh/key', { key });
$('keytext').value = ''; $('keyfile').value = '';
renderKey(r); toast('Schlüssel gespeichert');
} catch (e) { toast(e.message); }
}
async function deleteKey() {
if (!confirm('SSH-Schlüssel löschen?')) return;
await api('DELETE', '/api/ssh/key'); loadSSH();
}
async function forgetHost() {
if (!confirm('Gespeicherten Host-Key vergessen? Danach muss er beim nächsten Test neu bestätigt werden.')) return;
await api('DELETE', '/api/ssh/hostkey'); loadSSH();
}
async function testSSH() {
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>';
let r;
try { r = await api('POST', '/api/ssh/test'); } catch (e) { $('testresult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
if (!r.ok) {
$('testresult').innerHTML = `<div class="banner err">${esc(r.error)}</div>`;
return;
}
const rows = r.slots.map((s) => {
const ch = s.channel || {};
const c = (ch.color || '').replace(/^0x/i, '');
return `<tr><td>Slot ${s.slot + 1}</td><td>${s.occupied ? `${/^[0-9a-f]{6}$/i.test(c) ? `<span class="swatch" style="background:#${c}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">leer</span>'}</td>
<td class="mono">${esc(ch.weight)}</td><td>${s.spool_id ?? '<span class="muted">–</span>'}</td></tr>`;
}).join('');
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`;
if (!r.host_key_pinned) {
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}).
Mit <code>ssh-keygen -lf /etc/dropbear/…</code> bzw. <code>ssh-keyscan</code> vergleichen und bestätigen.
<button class="primary" onclick="acceptHost('${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
}
$('testresult').innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}
async function acceptHost(fp) {
if (!confirm('Host-Key ' + fp + ' als vertrauenswürdig speichern?')) return;
try { const r = await api('POST', '/api/ssh/hostkey', { fingerprint: fp }); renderHost(r.host_key); testSSH(); }
catch (e) { toast(e.message); }
}
loadSettings().then(loadSSH);
</script>
{% endblock %}