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

+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 %}