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

+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CC2 Bridge{% endblock %}</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --text: #1d2330; --muted: #5d6677; --line: #dde1e8;
--accent: #2856d9; --accent-text: #fff; --ok: #1b7f3b; --warn: #a15c00; --err: #b3261e;
--ok-bg: #e7f5ec; --warn-bg: #fff4e0; --err-bg: #fde8e7; --dry-bg: #eef1ff; --code: #f0f2f5;
--radius: 8px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c; --panel: #1c2027; --text: #e6e9ef; --muted: #9aa3b2; --line: #2e343e;
--accent: #6d8dff; --accent-text: #0d1020; --ok: #5cc982; --warn: #f0b55c; --err: #ff8a80;
--ok-bg: #16301f; --warn-bg: #33270f; --err-bg: #3a1a18; --dry-bg: #1e2340; --code: #232832;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
header { background: var(--panel); border-bottom: 1px solid var(--line); }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; }
header .wrap { display: flex; align-items: center; gap: 24px; min-height: 52px; flex-wrap: wrap; }
header h1 { font-size: 16px; margin: 0; font-weight: 650; }
nav { display: flex; gap: 4px; flex-wrap: wrap; }
nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; }
nav a.on, nav a:hover { color: var(--text); background: var(--code); }
.mode { margin-left: auto; font-size: 13px; padding: 3px 10px; border-radius: 99px; font-weight: 600; }
.mode.dry { background: var(--dry-bg); color: var(--accent); }
.mode.live { background: var(--err-bg); color: var(--err); }
main { padding: 20px 0 48px; }
h2 { font-size: 18px; margin: 28px 0 12px; }
h2:first-child { margin-top: 0; }
h3 { font-size: 15px; margin: 0 0 8px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.banner { border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; display: flex; gap: 12px;
align-items: center; flex-wrap: wrap; }
.banner.err { background: var(--err-bg); color: var(--err); }
.banner.warn { background: var(--warn-bg); color: var(--warn); }
.banner.ok { background: var(--ok-bg); color: var(--ok); }
.banner.dry { background: var(--dry-bg); color: var(--accent); }
button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text);
padding: 6px 12px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
button.danger { color: var(--err); }
button:disabled { opacity: .5; cursor: default; }
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
border-radius: 6px; padding: 6px 8px; width: 100%; }
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.field { margin-bottom: 12px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.row > .field { flex: 1 1 160px; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
pre { background: var(--code); padding: 10px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap;
word-break: break-all; margin: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-size: 13px; color: var(--muted); font-weight: 600; }
.table-wrap { overflow-x: auto; }
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line);
vertical-align: -2px; }
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 99px; background: var(--code); }
.tag.ok { background: var(--ok-bg); color: var(--ok); }
.tag.warn { background: var(--warn-bg); color: var(--warn); }
.tag.err { background: var(--err-bg); color: var(--err); }
.tag.dry { background: var(--dry-bg); color: var(--accent); }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 12px; margin: 0; font-size: 14px; }
dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
.toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text);
color: var(--bg); padding: 8px 14px; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; }
.toast.show { opacity: 1; }
</style>
</head>
<body>
<header><div class="wrap">
<h1>CC2 ↔ Spoolman</h1>
<nav>
<a href="/" class="{{ 'on' if active == 'status.html' }}">Status</a>
<a href="/tags" class="{{ 'on' if active == 'tags.html' }}">Tag-Generator</a>
<a href="/settings" class="{{ 'on' if active == 'settings.html' }}">Einstellungen</a>
</nav>
<span id="mode" class="mode"></span>
</div></header>
<main><div class="wrap">
{% block content %}{% endblock %}
</div></main>
<div id="toast" class="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(method, url, body) {
const opt = { method, headers: { 'X-Bridge-Request': '1' } };
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
let data = null;
try { data = await r.json(); } catch (e) {}
if (!r.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + r.status));
return data;
}
function toast(msg) {
const t = $('toast'); t.textContent = msg; t.classList.add('show');
clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('show'), 2200);
}
async function copyText(text) {
try { await navigator.clipboard.writeText(text); }
catch (e) {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta);
ta.select(); document.execCommand('copy'); ta.remove();
}
toast('Kopiert');
}
function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; }
function setMode(dry) {
const m = $('mode');
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
m.className = 'mode ' + (dry ? 'dry' : 'live');
}
api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {});
</script>
{% block script %}{% endblock %}
</body>
</html>
+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 %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Status{% endblock %}
{% block content %}
<div id="banners"></div>
<h2>Canvas-Slots</h2>
<div class="grid" id="slots"><div class="panel muted">Lade …</div></div>
<p class="small muted" id="lastpoll"></p>
<h2>Protokoll</h2>
<div class="panel table-wrap">
<table><thead><tr><th style="width:170px">Zeit</th><th style="width:80px"></th><th>Aktion</th></tr></thead>
<tbody id="log"></tbody></table>
</div>
{% endblock %}
{% block script %}
<script>
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] };
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] };
function colorOf(ch) {
const c = (ch.color || '').replace(/^0x/i, '');
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
}
function slotCard(s) {
const ch = s.record.channel || {};
const tray = TRAY[s.tray_status];
const col = colorOf(ch);
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool;
let tag = '<span class="muted">kein Tag</span>';
if (s.record.occupied) {
tag = `${col ? `<span class="swatch" style="background:#${col}"></span> ` : ''}${esc(ch.detailed_type || ch.type)}` +
` <span class="muted small">${esc(ch.manufacturer)}, weight ${esc(ch.weight)}</span>`;
}
let assign = '<span class="muted">–</span>';
if (m) {
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
}
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: ${esc(s.cc2_question)}</span>`;
const lb = s.last_booking;
return `<div class="panel">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
<dl class="kv">
<dt>Tag</dt><dd>${tag}</dd>
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd>
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
</dl>
${m ? `<div class="row" style="margin-top:10px">
<button onclick="slotAction(${s.slot}, 'reassign')">Zuordnung erneut senden</button>
<button class="danger" onclick="slotAction(${s.slot}, 'release')">Aufheben</button></div>` : ''}
</div>`;
}
async function slotAction(slot, action) {
const text = action === 'release'
? `Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
: `Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
if (!confirm(text)) return;
try { await api('POST', `/api/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
catch (e) { toast(e.message); }
}
async function enableCC2() {
if (!confirm('Spulenverwaltung in CC2 Control einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.')) return;
try {
const r = await api('POST', '/api/cc2/enable', { confirm: true });
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
setTimeout(load, 1500);
} catch (e) { toast(e.message); }
}
async function load() {
let st;
try { st = await api('GET', '/api/status'); } catch (e) { $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
setMode(st.dry_run);
const b = [];
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
else if (!st.host_key) b.push('<div class="banner warn">Host-Key des Druckers noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
if (st.cc2_enabled === false) b.push('<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2()">Einschalten …</button></div>');
if (st.job_active) b.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
for (const [k, v] of Object.entries(st.errors || {})) if (v) b.push(`<div class="banner err">${esc(v)}</div>`);
$('banners').innerHTML = b.join('');
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
$('log').innerHTML = (st.log || []).map((e) => {
const l = LEVEL[e.level] || [e.level, ''];
return `<tr><td class="small muted">${fmtTime(e.time)}</td><td><span class="tag ${l[1]}">${l[0]}</span></td><td>${esc(e.msg)}</td></tr>`;
}).join('') || '<tr><td colspan="3" class="muted">Noch keine Einträge</td></tr>';
}
load();
setInterval(load, 3000);
</script>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Tag-Generator{% endblock %}
{% block content %}
<div class="banner err"><b>Wichtig:</b> In NFC Tools nie „Lock tag“ oder „Set password“ verwenden – der Tag wäre danach nicht mehr beschreibbar.</div>
<h2>Spoolman-Spulen</h2>
<div class="panel">
<div class="row" style="margin-bottom:10px">
<div class="field" style="margin:0"><label for="filter">Filter</label><input id="filter" placeholder="Label, Hersteller, Material …"></div>
<label style="flex:0 0 auto;margin:0 0 8px"><input type="checkbox" id="archived" style="width:auto"> archivierte zeigen</label>
</div>
<div class="table-wrap" style="max-height:340px;overflow-y:auto">
<table><thead><tr><th>ID</th><th>Label</th><th>Hersteller</th><th>Material</th><th>Farbe</th><th>Rest</th><th>Ort</th><th></th></tr></thead>
<tbody id="spools"><tr><td colspan="8" class="muted">Lade …</td></tr></tbody></table>
</div>
</div>
<div id="editor" style="display:none">
<h2 id="edtitle">Tag</h2>
<div class="panel">
<div class="row">
<div class="field"><label for="material">Elegoo-Material</label><select id="material"></select></div>
<div class="field"><label for="subtype">Untertyp (wählt das Slicer-Profil)</label><select id="subtype"></select></div>
</div>
<div class="row">
<div class="field"><label for="color">Tag-Farbe (RRGGBB)</label>
<div class="row" style="gap:6px;flex-wrap:nowrap"><input id="color" maxlength="7" class="mono"><input type="color" id="picker" style="width:48px;padding:2px"></div></div>
<div class="field"><label for="temp_min">Düse min (°C)</label><input id="temp_min" type="number" min="0" max="500"></div>
<div class="field"><label for="temp_max">Düse max (°C)</label><input id="temp_max" type="number" min="0" max="500"></div>
<div class="field"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
</div>
<div class="field"><label>Display-Palette</label><div id="palette" style="display:flex;flex-wrap:wrap;gap:4px"></div>
<div class="small muted" id="colorhint" style="margin-top:4px"></div></div>
<div class="field"><label>URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</label>
<label style="color:var(--text)"><input type="radio" name="url" value="elegoo" checked style="width:auto"> <code>https://www.elegoo.com</code> (verifizierte Vorlage)</label>
<label style="color:var(--text)"><input type="radio" name="url" value="spoolman" style="width:auto"> Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></label>
</div>
</div>
<h2>Befehlskette für NFC Tools</h2>
<div class="panel">
<div id="tagerror"></div>
<textarea id="commands" rows="5" readonly></textarea>
<div class="row" style="margin-top:8px">
<button class="primary" onclick="copyText($('commands').value)">Kopieren</button>
<label style="margin:0;color:var(--text)"><input type="checkbox" id="showqr" style="width:auto"> QR-Code zeigen</label>
</div>
<div id="qr" style="display:none;margin-top:10px;background:#fff;padding:8px;border-radius:6px;width:max-content;max-width:100%;overflow:auto"></div>
<ol class="small muted" style="margin:14px 0 0;padding-left:20px">
<li>NFC Tools → <i>Other</i> → <i>Advanced NFC commands</i>.</li>
<li>Befehlskette einfügen, leeren NTAG213/215/216 ans Handy halten, ausführen.</li>
<li>Spule in die Canvas legen und den Scan am Display bestätigen.</li>
</ol>
</div>
<h2>Dekodier-Vorschau</h2>
<div class="panel"><dl class="kv" id="decoded"></dl></div>
</div>
{% endblock %}
{% block script %}
<script>
let META = null, SPOOLS = [], current = null, timer = null, spoolmanBase = '';
async function init() {
META = await api('GET', '/api/materials');
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) =>
`<button type="button" title="#${c}" onclick="setColor('${c}')" style="width:26px;height:26px;padding:0;background:#${c}"></button>`).join('');
await loadSpools();
}
async function loadSpools() {
try {
const r = await api('GET', '/api/spoolman/spools' + ($('archived').checked ? '?archived=true' : ''));
SPOOLS = r.spools;
} catch (e) {
$('spools').innerHTML = `<tr><td colspan="8"><div class="banner err" style="margin:0">${esc(e.message)}</div></td></tr>`;
return;
}
renderSpools();
}
function renderSpools() {
const q = $('filter').value.toLowerCase();
const rows = SPOOLS.filter((s) => !q || [s.label, s.vendor, s.material, s.name, s.location].join(' ').toLowerCase().includes(q));
$('spools').innerHTML = rows.map((s) => `<tr>
<td>${s.id}</td><td><b>${esc(s.label)}</b></td><td>${esc(s.vendor)}</td><td>${esc(s.material)} <span class="muted small">${esc(s.name)}</span></td>
<td>${s.color ? `<span class="swatch" style="background:#${s.color}"></span> <span class="mono small">${s.color}</span>` : '–'}</td>
<td>${s.remaining != null ? Math.round(s.remaining) + ' g' : '–'}</td><td class="small">${esc(s.location)}</td>
<td><button onclick="pick(${s.id})">Tag erstellen</button></td></tr>`).join('')
|| '<tr><td colspan="8" class="muted">Keine Spulen</td></tr>';
}
function fillSubtypes(material, selected) {
const list = META.subtypes.filter((s) => s.material === material);
$('subtype').innerHTML = list.map((s) =>
`<option value="${s.code}" ${s.code === selected ? 'selected' : ''}>${esc(s.name)} (0x${s.code.toString(16).padStart(4, '0').toUpperCase()})${s.hidden ? ' – am CC2 ausgeblendet' : ''}</option>`).join('');
}
function setColor(c) {
c = c.replace('#', '').toUpperCase();
$('color').value = c;
if (/^[0-9A-F]{6}$/.test(c)) $('picker').value = '#' + c;
schedule();
}
function pick(id) {
current = SPOOLS.find((s) => s.id === id);
const sg = current.suggest;
$('edtitle').textContent = `Tag für ${current.label} (Spoolman #${current.id})`;
$('material').value = sg.material;
fillSubtypes(sg.material, sg.subtype);
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
$('finish').value = 'FF';
$('colorhint').innerHTML = current.color
? `Spoolman-Farbe <span class="swatch" style="background:#${current.color}"></span> <span class="mono">${current.color}</span> → Vorschlag <span class="mono">${sg.color}</span>`
: 'Spoolman-Spule hat keine Farbe.';
setColor(sg.color);
$('editor').style.display = '';
$('editor').scrollIntoView({ behavior: 'smooth' });
generate();
}
function schedule() { clearTimeout(timer); timer = setTimeout(generate, 250); }
async function generate() {
if (!current) return;
const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman';
const body = {
spool_id: current.id, material: $('material').value, subtype: Number($('subtype').value),
color: $('color').value, temp_min: Number($('temp_min').value), temp_max: Number($('temp_max').value),
finish: parseInt($('finish').value || 'FF', 16), diameter: current.diameter || 1.75,
url: useSpoolman ? current.spoolman_url : null,
};
try {
const r = await api('POST', '/api/tag', body);
$('tagerror').innerHTML = '';
$('commands').value = r.commands;
$('qr').innerHTML = r.qr_svg;
const d = r.decoded;
$('decoded').innerHTML = [
['Spoolman-ID', `${d.spool_id} <span class="muted small">(weight ${d.weight})</span>`],
['Hersteller', `${esc(d.manufacturer)} <span class="muted small">(Drucker zeigt ELEGOO)</span>`],
['Material', esc(d.material)],
['Untertyp', `${esc(d.subtype_name)} <span class="mono small">0x${d.subtype.toString(16).padStart(4, '0').toUpperCase()}</span>`],
['Farbe', `<span class="swatch" style="background:#${d.color}"></span> <span class="mono">${d.color}</span>, Finish ${d.finish.toString(16).toUpperCase()}`],
['Düse', `${d.temp_min}–${d.temp_max} °C`],
['Durchmesser', `${d.diameter.toFixed(2)} mm`],
['URL', esc(d.url || '–')],
].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
} catch (e) {
$('tagerror').innerHTML = `<div class="banner err">${esc(e.message)}</div>`;
$('commands').value = '';
}
}
$('material').addEventListener('change', () => {
const m = $('material').value;
fillSubtypes(m, META.subtypes.find((s) => s.material === m).code);
const t = META.materials[m].temps; $('temp_min').value = t[0]; $('temp_max').value = t[1];
schedule();
});
for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule);
$('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; schedule(); });
$('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
$('showqr').addEventListener('change', () => { $('qr').style.display = $('showqr').checked ? '' : 'none'; });
$('filter').addEventListener('input', renderSpools);
$('archived').addEventListener('change', loadSpools);
init();
</script>
{% endblock %}