Spoolman leads CC2 Control's copies; new spools from the bridge
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s

- Master data of CC2 Control's copies follows Spoolman (name, brand,
  colour, diameter, density, net, tare, price; not the material, which was
  fitted to the tray).
- A loaded spool's rest in CC2 Control is set to Spoolman's via
  /api/spools/adjust once everything is booked (old copy after the roll
  printed in the other printer, weigh-in in Spoolman); the resulting
  correction is not booked back.
- Copies of spools archived or deleted in Spoolman are deleted from CC2
  Control when no tray holds them (library limit 128); an outage never
  counts as deleted.
- A roll that ran out is archived in Spoolman (setting, default on); a
  scan of an archived spool maps nothing.
- A roll scanned again with unchanged tag data and put back into its old
  slot (after printing in the other printer) is recognised.
- Tag generator: "Neue Spule" creates spools in Spoolman from an existing
  or a new filament (and vendor), labelled vendor + id, then opens the tag.

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 21:26:49 +01:00
1 parent d03668e3c5
commit b37cd64909
13 files changed
+873 -27

No files matched your search

+3
View File
@@ -51,6 +51,7 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }
<div class="frow"><label for="location_slot">Ort im Slot<small><code>{printer}</code> = Druckername, <code>{n}</code> = 1–4</small></label><input id="location_slot" required></div>
<div class="frow"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
<div class="frow"><label for="spoolman_fields">Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label><span class="switch"><input type="checkbox" id="spoolman_fields"><span></span></span></div>
<div class="frow"><label for="archive_empty">Leergelaufene Rollen in Spoolman archivieren<small>Ihre Kopie in CC2 Control wird danach gelöscht</small></label><span class="switch"><input type="checkbox" id="archive_empty"><span></span></span></div>
</div>
<div class="label section-label">Abfrage</div>
@@ -147,6 +148,7 @@ async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('spoolman_fields').checked = current.spoolman_fields;
$('archive_empty').checked = current.archive_empty;
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
showMode(current.dry_run);
@@ -156,6 +158,7 @@ async function saveSettings() {
const body = { printers };
for (const f of FIELDS) body[f] = $(f).value;
body.spoolman_fields = $('spoolman_fields').checked;
body.archive_empty = $('archive_empty').checked;
try {
current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p }));
+164 -3
View File
@@ -1,11 +1,16 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Tag-Generator{% endblock %}
{% block heading %}Tag-Generator{% endblock %}
{% block heading %}Tags{% endblock %}
{% block head %}
<style>
.tg { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 20px; align-items: start; }
@media (max-width: 999px) { .tg { grid-template-columns: minmax(0, 1fr); } }
.picker { display: flex; flex-direction: column; gap: 12px; }
.srcmode { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; background: var(--line); padding: 4px; border-radius: 12px; }
.srcmode button { border: 0; min-height: 36px; border-radius: 9px; background: transparent; color: var(--muted); font-weight: 600; }
.srcmode button[aria-selected=true] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.newcard[hidden], .listcard[hidden] { display: none; }
.preview { background: var(--surface2); border-radius: 12px; padding: 12px 14px; font-size: 14px; }
.spools { max-height: 560px; overflow-y: auto; margin: 0 -16px -16px; border-top: 1px solid var(--line2); }
.spools .item { padding-left: 16px; padding-right: 16px; }
.spools .item[aria-current=true] { background: var(--accent-bg); }
@@ -34,7 +39,7 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
@media (max-width: 999px) {
/* no scroll areas inside the page on phones: a swipe there would not move the page */
.spools { max-height: none; overflow: visible; }
.tg.picked .picker .listcard { display: none; }
.tg.picked .picker .listcard, .tg.picked .picker .newcard, .tg.picked .picker .srcmode { display: none; }
.tg.picked .chosen { display: flex; }
}
</style>
@@ -43,7 +48,45 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<div class="tg" id="tg">
<div class="picker">
<button class="card item chosen" id="chosen" onclick="unpick()" aria-label="Andere Spule wählen"></button>
<div class="card listcard">
<div class="srcmode" role="tablist" aria-label="Spule">
<button role="tab" id="m_list" aria-selected="true" onclick="setMode2('list')">Vorhandene Spule</button>
<button role="tab" id="m_new" aria-selected="false" onclick="setMode2('new')">Neue Spule</button>
</div>
<div id="created"></div>
<div class="card newcard stack" id="newcard" hidden>
<div class="label">Neue Spule in Spoolman</div>
<div class="field"><label for="nf_fil">Filament</label><select id="nf_fil"></select></div>
<div class="small muted" id="nf_info"></div>
<div class="stack" id="nf_new">
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))">
<div class="field"><label for="nf_vendor">Hersteller</label><input id="nf_vendor" list="nf_vendors" autocapitalize="characters" spellcheck="false"></div>
<div class="field"><label for="nf_material">Material</label><input id="nf_material" list="nf_mats" autocapitalize="characters" spellcheck="false" placeholder="PLA+, PETG-CF …"></div>
<div class="field" style="grid-column:1/-1"><label for="nf_name">Name</label><input id="nf_name" placeholder="z. B. PLA+ Olive Green"></div>
<div class="field"><label for="nf_density">Dichte (g/cm³)</label><input id="nf_density" type="number" step="0.01" min="0.5" max="3" inputmode="decimal"></div>
<div class="field"><label for="nf_diameter">Durchmesser (mm)</label><input id="nf_diameter" type="number" step="0.01" min="1" max="3.5" value="1.75" inputmode="decimal"></div>
<div class="field"><label for="nf_extruder">Düse (°C)</label><input id="nf_extruder" type="number" min="150" max="450" inputmode="numeric"></div>
<div class="field"><label for="nf_bed">Bett (°C)</label><input id="nf_bed" type="number" min="0" max="150" inputmode="numeric"></div>
</div>
<div class="field">
<span class="flabel">Farbe</span>
<div class="palette" id="nf_palette"></div>
<div class="row" style="flex-wrap:nowrap;gap:8px;margin-top:4px">
<input id="nf_color" maxlength="7" class="mono" aria-label="Farbe als Hex (RRGGBB)" autocapitalize="characters" spellcheck="false" placeholder="RRGGBB">
<input type="color" id="nf_picker" aria-label="Farbe wählen">
</div>
</div>
<datalist id="nf_vendors"></datalist><datalist id="nf_mats"></datalist>
</div>
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))">
<div class="field"><label for="nf_weight">Nettogewicht (g)</label><input id="nf_weight" type="number" min="1" max="20000" inputmode="numeric" value="1000"></div>
<div class="field"><label for="nf_spool">Leergewicht Spule (g)</label><input id="nf_spool" type="number" min="0" max="5000" inputmode="numeric"></div>
<div class="field"><label for="nf_price">Preis (optional)</label><input id="nf_price" type="number" min="0" step="0.01" inputmode="decimal"></div>
<div class="field"><label for="nf_count">Anzahl Rollen</label><input id="nf_count" type="number" min="1" max="20" value="1" inputmode="numeric"></div>
</div>
<div class="preview" id="nf_preview"></div>
<button class="primary" style="min-height:48px" id="nf_go" onclick="createSpools()">In Spoolman anlegen</button>
</div>
<div class="card listcard" id="listcard">
<div class="label" style="margin-bottom:10px">Spoolman-Spule</div>
<div class="row" style="flex-wrap:nowrap;margin-bottom:12px">
<input id="filter" type="search" placeholder="Suchen: Label, Hersteller, Material …" aria-label="Spulen filtern">
@@ -115,8 +158,120 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<script>
let META = null, SPOOLS = [], current = null, timer = null;
// starting values for a new filament, by base material: density g/cm³, nozzle, bed
const MATDEF = { PLA: [1.24, 210, 60], PETG: [1.27, 240, 75], ABS: [1.04, 250, 100], ASA: [1.07, 255, 100], TPU: [1.21, 225, 50],
PA: [1.14, 275, 90], PC: [1.20, 270, 110], PVA: [1.23, 200, 60], HIPS: [1.04, 240, 100], PP: [0.90, 230, 85], CPE: [1.25, 250, 80],
PPA: [1.20, 295, 100], PPS: [1.35, 315, 110], BVOH: [1.14, 200, 60], EVA: [0.95, 200, 50] };
let CAT = null, MODE = 'list';
function setMode2(m) {
MODE = m;
$('m_list').setAttribute('aria-selected', String(m === 'list'));
$('m_new').setAttribute('aria-selected', String(m === 'new'));
$('listcard').hidden = m !== 'list';
$('newcard').hidden = m !== 'new';
if (m === 'new' && !CAT) loadCatalog();
}
async function loadCatalog() {
$('nf_preview').textContent = 'Lade Spoolman …';
try { CAT = await api('GET', '/api/spoolman/catalog'); }
catch (e) { $('nf_preview').innerHTML = `<span style="color:var(--err)">${esc(e.message)}</span>`; return; }
const groups = {};
for (const f of CAT.filaments) (groups[f.vendor] = groups[f.vendor] || []).push(f);
$('nf_fil').innerHTML = '<option value="">Neues Filament …</option>' + Object.entries(groups).map(([v, fs]) =>
`<optgroup label="${esc(v)}">${fs.map((f) => `<option value="${f.id}">${esc(f.name)} · ${esc(f.material)}${f.color ? ' · #' + f.color : ''}</option>`).join('')}</optgroup>`).join('');
$('nf_vendors').innerHTML = CAT.vendors.map((v) => `<option value="${esc(v.name)}">`).join('');
$('nf_mats').innerHTML = META.subtypes.map((t) => `<option value="${esc(t.name)}">`).join('');
newFilamentChanged();
}
function baseMaterial(m) {
const u = String(m || '').toUpperCase();
return Object.keys(MATDEF).sort((a, b) => b.length - a.length).find((k) => u.replace(/^RAPID\s*/, '').startsWith(k)) || null;
}
function fil() { return CAT && CAT.filaments.find((f) => String(f.id) === $('nf_fil').value); }
function newFilamentChanged() {
const f = fil();
$('nf_new').hidden = !!f;
$('nf_new').style.display = f ? 'none' : '';
if (f) {
$('nf_info').innerHTML = `${f.color ? `<span class="swatch" style="background:#${f.color}"></span> ` : ''}${esc(f.vendor)} · ${esc(f.material)} · ${f.weight ? f.weight + ' g' : 'Gewicht?'}${f.density ? ' · ' + f.density + ' g/cm³' : ''} · ${f.spools} Spule${f.spools === 1 ? '' : 'n'} vorhanden`;
if (f.weight) $('nf_weight').value = f.weight;
$('nf_spool').value = f.spool_weight ?? '';
$('nf_price').value = f.price ?? '';
} else {
$('nf_info').textContent = '';
}
preview();
}
function materialChanged() {
const d = MATDEF[baseMaterial($('nf_material').value)];
if (!d) return;
let density = d[0];
if (/-(CF|GF)\b/i.test($('nf_material').value)) density = +(density + 0.05).toFixed(2);
$('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2];
preview();
}
function vendorChanged() {
const v = CAT && CAT.vendors.find((x) => x.name.toLowerCase() === $('nf_vendor').value.trim().toLowerCase());
if (v && v.empty_spool_weight != null && !$('nf_spool').value) $('nf_spool').value = v.empty_spool_weight;
preview();
}
function setNewColor(c) {
c = c.replace('#', '').toUpperCase();
$('nf_color').value = c;
if (/^[0-9A-F]{6}$/.test(c)) $('nf_picker').value = '#' + c;
document.querySelectorAll('#nf_palette button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c.toUpperCase() === c)));
}
function labelFor(vendor, id) { return (String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '') || 'SPOOL') + String(id).padStart(3, '0'); }
function preview() {
if (!CAT) return;
const f = fil(), vendor = f ? f.vendor : $('nf_vendor').value.trim();
const v = CAT.vendors.find((x) => x.name.toLowerCase() === vendor.toLowerCase());
const shown = v ? v.name : vendor;
const n = Math.max(1, Math.min(20, Number($('nf_count').value) || 1));
const first = labelFor(shown || '?', CAT.next_id), last = labelFor(shown || '?', CAT.next_id + n - 1);
$('nf_preview').innerHTML = `Wird angelegt als <b class="mono">${esc(first)}</b>${n > 1 ? ` bis <b class="mono">${esc(last)}</b>` : ''}, Ort „Lager“.
<span class="muted">Die ID vergibt Spoolman (voraussichtlich ${CAT.next_id}${n > 1 ? '–' + (CAT.next_id + n - 1) : ''}); das Label folgt ihr.</span>
${!f && vendor && !v ? `<br><span class="muted">Neuer Hersteller „${esc(vendor)}“ wird ebenfalls angelegt.</span>` : ''}`;
}
async function createSpools() {
const f = fil();
const body = { count: $('nf_count').value, weight: $('nf_weight').value, spool_weight: $('nf_spool').value, price: $('nf_price').value };
if (f) body.filament_id = f.id;
else Object.assign(body, { vendor: $('nf_vendor').value, name: $('nf_name').value, material: $('nf_material').value,
color: $('nf_color').value, density: $('nf_density').value, diameter: $('nf_diameter').value,
extruder_temp: $('nf_extruder').value, bed_temp: $('nf_bed').value });
const n = Number(body.count) || 1;
if (!confirm(`${n} neue Spule${n > 1 ? 'n' : ''} in Spoolman anlegen?`)) return;
$('nf_go').disabled = true;
try {
const r = await api('POST', '/api/spoolman/create', body);
const labels = r.spools.map((x) => x.label);
toast('Angelegt: ' + labels.join(', '));
CAT = null;
$('created').innerHTML = `<div class="banner ok">In Spoolman angelegt: ${labels.map(esc).join(', ')}.${labels.length > 1 ? ' Für jede Rolle einen eigenen Tag schreiben.' : ''}</div>`;
setMode2('list');
$('filter').value = labels.length > 1 ? '' : labels[0];
await loadSpools();
pick(r.spools[0].id);
} catch (e) { toast(e.message); }
finally { $('nf_go').disabled = false; }
}
async function init() {
META = await api('GET', '/api/materials');
$('nf_palette').innerHTML = META.palette.map((c) =>
`<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setNewColor('${c}')" style="background:#${c}"></button>`).join('');
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) =>
`<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setColor('${c}')" style="background:#${c}"></button>`).join('');
@@ -251,6 +406,12 @@ $('color').addEventListener('input', () => { const c = $('color').value.replace(
$('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
$('filter').addEventListener('input', renderSpools);
$('nf_fil').addEventListener('change', newFilamentChanged);
$('nf_material').addEventListener('change', materialChanged);
$('nf_vendor').addEventListener('input', vendorChanged);
$('nf_color').addEventListener('input', () => setNewColor($('nf_color').value));
$('nf_picker').addEventListener('input', () => setNewColor($('nf_picker').value));
for (const id of ['nf_count', 'nf_name']) $(id).addEventListener('input', preview);
$('archived').addEventListener('change', loadSpools);
init();
</script>