CC2 ↔ Spoolman bridge with NFC tag generator
- 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:
commit
bf32667f77
32 files changed
+4216
No files matched your search
@@ -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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user