Spool page for NFC links, bridge address; vendor-first new spool form
CI / test (push) Successful in 2m29s
CI / image (push) Successful in 39s

- /s/<id> shows a spool: rest in Spoolman and CC2 Control, where it is,
  last booking, history, links to Spoolman and the tag generator.
- Setting "Adresse der Bridge"; the tag generator can put <address>/s/<id>
  on the tag, so an iPhone held to the roll opens that page.
- New spool: vendor and material chosen from Spoolman, the vendor's
  filaments offered, values taken from similar filaments, duplicates
  pointed out and never created twice.

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:52:23 +01:00
1 parent b37cd64909
commit 64ea91d107
10 files changed
+298 -43

No files matched your search

+16 -3
View File
@@ -72,12 +72,25 @@ Felder in Spoolman ziehen dann mit.
### Neue Spule anlegen
Im *Tag-Generator* unter **Neue Spule**: ein vorhandenes Filament wählen (weitere Rolle davon) oder ein neues
anlegen – Hersteller, Name, Material, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht,
Preis, Anzahl. Material setzt Dichte und Temperaturen vor. Die Bridge legt Hersteller (falls neu), Filament und
Im *Tag-Generator* unter **Neue Spule**: Hersteller aus Spoolman wählen (oder einen neuen), dann ein vorhandenes
Filament dieses Herstellers (weitere Rolle davon) oder ein neues – Material aus den in Spoolman verwendeten (oder den
übrigen Elegoo-Typen), Name, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht, Preis,
Anzahl. Dichte und Temperaturen kommen von einem vorhandenen Filament mit demselben Material (bevorzugt desselben
Herstellers), Gewichte von den übrigen Filamenten des Herstellers. Gibt es das Filament schon (gleicher Name oder
gleiches Material und gleiche Farbe), weist das Formular darauf hin; ein gleichnamiges wird nie doppelt angelegt. Die Bridge legt Hersteller (falls neu), Filament und
Spule(n) in Spoolman an, Ort „Lager“, Label wie gewohnt Hersteller + ID dreistellig (`SUNLU027`), und öffnet
gleich den Tag dazu. Die ID vergibt Spoolman (fortlaufend). Im Dry-Run wird nichts angelegt.
### Spule mit dem iPhone auslesen
Safari kann NFC nicht selbst lesen, das iPhone liest Tags aber im Hintergrund: Steht auf dem Tag ein Link, genügt es,
das entsperrte iPhone an die Rolle zu halten und die Mitteilung anzutippen. Dafür unter *Einstellungen* → *Adresse
der Bridge* die Adresse eintragen, unter der das iPhone die Bridge erreicht (z. B. `https://cc2.menzel.center`), und
im Tag-Generator als Link **Spule in der Bridge** wählen (die Wahl wird gemerkt). Der Tag enthält dann
`<Adresse>/s/<ID>`; die Seite zeigt Rest (Spoolman und CC2 Control), wo die Spule steckt, die letzte Buchung und den
Verlauf, mit Links zu Spoolman und zum Tag-Generator. Spoolman-ID und Druckerdaten auf dem Tag bleiben gleich;
ob der Drucker Tags mit einem längeren Link genauso liest, ist noch an einem Test-Tag zu prüfen.
### Am iPhone als Web-App
`http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne
+24
View File
@@ -1224,6 +1224,30 @@ class Bridge:
self.spool_cache[smid] = (self.clock(), sm)
return sm
def spool_whereabouts(self, smid: int) -> dict:
"""Where a Spoolman spool is as the bridge sees it, for the spool page."""
with self.lock:
slot = None
for pid, n, m in self.mappings():
if m["spool"] == smid:
w = self.workers.get(pid)
slot = {"printer": w.name if w else pid, "printer_id": pid, "slot": n + 1,
"since": m.get("since"), "assigned": bool(m.get("assigned"))}
copies, last = [], None
for w in self.workers.values():
st = w.st
for t in st["takeout"]:
if t["spool"] == smid and t["until"] is None and slot is None:
slot = {"printer": w.name, "printer_id": w.id, "slot": t["slot"] + 1, "takeout": True}
for sp in (w.cc2_data or {}).get("spools") or []:
if spoolman_id_of(sp) == smid:
copies.append({"printer": w.name, "remaining": _num(sp.get("remaining"), None),
"archived": bool(sp.get("archived"))})
for b in st["booked"].values():
if b.get("spoolman") == smid and b.get("last") and (last is None or b["last"]["time"] > last["time"]):
last = {**b["last"], "printer": w.name}
return {"slot": slot, "copies": copies, "last_booking": last}
def _name(self, smid: int) -> str:
cached = self.spool_cache.get(smid)
return label(cached[1]) if cached else f"Spoolman-Spule {smid}"
+4
View File
@@ -121,6 +121,10 @@ def create(client: Spoolman, p: dict, location: str) -> tuple[list[dict], str]:
v = client.create_vendor(body)
created_parts.append(f"Hersteller {vendor}")
vendor = v["name"]
name = p["filament"]["name"].lower()
fil = next((f for f in client.filaments() if (f.get("vendor") or {}).get("id") == v["id"]
and (f.get("name") or "").strip().lower() == name), None)
if fil is None: # the same vendor and name again would only clutter Spoolman
fbody = {k: val for k, val in p["filament"].items() if val is not None}
fbody["vendor_id"] = v["id"]
fil = client.create_filament(fbody)
+4
View File
@@ -122,6 +122,7 @@ class Settings:
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
archive_empty: bool = True # archive a Spoolman spool whose roll ran out
public_url: str = "" # the bridge as phones reach it (links on NFC tags); empty: the address in the browser
dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list)
@@ -162,6 +163,9 @@ class Settings:
if not self.spoolman_url.startswith(("http://", "https://")):
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
self.spoolman_url = self.spoolman_url.rstrip("/")
self.public_url = self.public_url.strip().rstrip("/")
if self.public_url and not re.fullmatch(r"https?://[^\s/?#]+(/[^\s?#]*)?", self.public_url):
raise ValueError("Adresse der Bridge muss mit http:// oder https:// beginnen, ohne ? oder #")
if not self.location_storage.strip():
raise ValueError("Ortsname für das Lager fehlt")
ids = [p.id for p in self.printers]
+1
View File
@@ -42,6 +42,7 @@
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent; }
+6 -1
View File
@@ -45,6 +45,11 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }
<p class="small muted" style="margin:8px 4px 0">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
<form id="settings" onsubmit="event.preventDefault(); saveSettings()">
<div class="label section-label">Bridge</div>
<div class="group">
<div class="frow"><label for="public_url">Adresse der Bridge<small>für den Link auf den NFC-Tags, z. B. https://cc2.menzel.center</small></label><input id="public_url" inputmode="url" autocapitalize="off" spellcheck="false" placeholder="https://…"></div>
</div>
<div class="label section-label">Spoolman &amp; Buchung</div>
<div class="group">
<div class="frow"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required inputmode="url" autocapitalize="off" spellcheck="false"></div>
@@ -84,7 +89,7 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }
{% endblock %}
{% block script %}
<script>
const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
const FIELDS = ['public_url', 'spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']];
const PRINTER_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v16H4zM4 9h16M10 13h4"/></svg>';
let current = {}, printers = [], hostKeys = {}, saved = new Set(), openIdx = new Set();
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Spule {{ spool_id }}{% endblock %}
{% block heading %}Spule{% endblock %}
{% block head %}
<style>
.wrap { max-width: 720px; }
.hero { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
.hero h2 { font-size: 26px; letter-spacing: -.02em; overflow-wrap: anywhere; }
.big { font-family: var(--mono); font-size: 28px; }
.where { font-size: 15px; padding: 6px 12px; }
.acts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.acts .btn { min-height: 48px; }
.entry { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
.entry:first-child { border-top: 0; }
</style>
{% endblock %}
{% block content %}
<div class="wrap stack" id="page" data-id="{{ spool_id }}"><div class="card muted">Lade …</div></div>
{% endblock %}
{% block script %}
<script>
const ID = Number($('page').dataset.id);
function render(d) {
const s = d.spool, color = s.color ? '#' + s.color : 'var(--faint)';
const initial = s.initial || 1000, rest = s.remaining;
const pct = rest == null ? 0 : Math.max(0, Math.min(100, rest / initial * 100));
let where;
if (s.archived) where = '<span class="chip where">Archiviert (leer)</span>';
else if (d.slot && d.slot.takeout) where = `<span class="chip warn where">${esc(d.slot.printer)} · Slot ${d.slot.slot} (herausgenommen, steckt noch)</span>`;
else if (d.slot) where = `<span class="chip accent dot where">${esc(d.slot.printer)} · Slot ${d.slot.slot}</span>`;
else where = `<span class="chip where">${esc(s.location || d.storage)}</span>`;
const rows = [
['Rest in Spoolman', rest == null ? '–' : `${fmtNum(rest, 1)} g`],
...d.copies.map((c) => [`Rest in CC2 Control (${esc(c.printer)})`, c.remaining == null ? '–' : `${fmtNum(c.remaining, 1)} g`]),
['Ort in Spoolman', esc(s.location || '–')],
['Zuletzt gebucht', d.last_booking ? `${fmtNum(d.last_booking.grams, 2)} g · ${fmtTime(d.last_booking.time)}${d.last_booking.details ? `<br><span class="small muted">${esc(d.last_booking.details)}</span>` : ''}` : '–'],
['Material', `${esc(s.vendor)} ${esc(s.material)}${s.diameter ? ` · ${s.diameter} mm` : ''}${s.density ? ` · ${s.density} g/cm³` : ''}`],
['Düse', s.extruder_temp ? `${s.extruder_temp} °C` : '–'],
['Leergewicht', s.spool_weight ? `${fmtNum(s.spool_weight)} g` : '–'],
];
$('page').innerHTML = `
<div class="card stack">
<div class="hero">
<div class="ring" style="width:120px;height:120px;background:conic-gradient(${color} 0 ${pct}%, var(--track) ${pct}% 100%)" aria-hidden="true">
<div style="width:88px;height:88px"><span class="mono" style="font-size:20px;line-height:1">${rest == null ? '?' : Math.round(rest)}</span><span class="small muted">von ${Math.round(initial)} g</span></div>
</div>
<div style="flex:1;min-width:200px" class="stack">
<div><div class="small muted">Spoolman #${s.id}</div><h2>${esc(s.label)}</h2><div class="muted">${esc(d.filament_name)}</div></div>
<div>${where}</div>
</div>
</div>
<dl class="kv">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>
<div class="acts">
<a class="btn" href="${esc(d.spoolman_url)}" target="_blank" rel="noopener">In Spoolman öffnen</a>
<a class="btn" href="/tags?spool=${s.id}">Tag neu schreiben</a>
</div>
</div>
${d.log.length ? `<div class="label section-label" style="margin:8px 4px 0">Verlauf</div>
<div class="group" style="padding:0 14px">${d.log.map((e) => {
const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, d.copies.map((c) => c.printer).concat(d.slot ? [d.slot.printer] : []));
return `<div class="entry"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
<div style="min-width:0"><div style="font-size:14px;overflow-wrap:anywhere">${esc(msg)}</div><div class="small muted">${esc(pn)} · ${fmtTime(e.time)}</div></div></div>`;
}).join('')}</div>` : ''}`;
}
async function load() {
try {
const d = await api('GET', `/api/spool/${ID}`);
setMode(d.dry_run);
render(d);
} catch (e) { $('page').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
load();
setInterval(() => { if (!document.hidden) load(); }, 10000);
</script>
{% endblock %}
+119 -38
View File
@@ -55,18 +55,21 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<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_vendor_sel">Hersteller</label><select id="nf_vendor_sel"></select></div>
<div class="field" id="nf_vendor_new" hidden><label for="nf_vendor">Name des neuen Herstellers</label><input id="nf_vendor" autocapitalize="characters" spellcheck="false"></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_mat_sel">Material</label><select id="nf_mat_sel"></select></div>
<div class="field" id="nf_mat_new" hidden><label for="nf_material">Anderes Material</label><input id="nf_material" autocapitalize="characters" spellcheck="false" placeholder="z. B. PLA Silk"></div>
<div class="field" style="grid-column:1/-1"><label for="nf_name">Name (Material und Farbname, wie bei deinen anderen)</label><input id="nf_name"></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="small muted" id="nf_from"></div>
<div class="field">
<span class="flabel">Farbe</span>
<div class="palette" id="nf_palette"></div>
@@ -75,7 +78,7 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<input type="color" id="nf_picker" aria-label="Farbe wählen">
</div>
</div>
<datalist id="nf_vendors"></datalist><datalist id="nf_mats"></datalist>
<div id="nf_dupe"></div>
</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>
@@ -115,15 +118,18 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
</div>
<div class="small" id="colorhint"></div>
</div>
<div class="field">
<span class="flabel">Link auf dem Tag (fürs Handy; der Drucker liest ihn nicht)</span>
<label class="radio"><input type="radio" name="url" value="bridge"><span>Spule in der Bridge – iPhone an den Tag halten, Seite der Spule öffnet sich<br>
<code id="bridgeurl"></code> <span class="muted">(am Drucker noch nicht getestet)</span></span></label>
<label class="radio"><input type="radio" name="url" value="elegoo" checked><span><code>https://www.elegoo.com</code> (verifizierte Vorlage)</span></label>
<label class="radio"><input type="radio" name="url" value="spoolman"><span>Spule in Spoolman <span class="muted">(am Drucker noch nicht getestet)</span></span></label>
<div class="small" id="urlhint"></div>
</div>
<details>
<summary>Erweitert</summary>
<div class="stack" style="padding-top:8px">
<div class="field" style="max-width:200px"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
<div class="field">
<span class="flabel">URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</span>
<label class="radio"><input type="radio" name="url" value="elegoo" checked><span><code>https://www.elegoo.com</code> (verifizierte Vorlage)</span></label>
<label class="radio"><input type="radio" name="url" value="spoolman"><span>Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></span></label>
</div>
</div>
</details>
</div>
@@ -162,7 +168,7 @@ let META = null, SPOOLS = [], current = null, timer = null;
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';
let CAT = null, MODE = 'list', SETTINGS = {}, BRIDGE = location.origin;
function setMode2(m) {
MODE = m;
@@ -177,15 +183,24 @@ 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();
$('nf_vendor_sel').innerHTML = CAT.vendors.map((v) => `<option value="${v.id}">${esc(v.name)}</option>`).join('')
+ '<option value="new">Neuer Hersteller …</option>';
// materials you already use first, then the other Elegoo types
const used = [...new Set(CAT.filaments.map((f) => f.material).filter(Boolean))].sort();
const more = META.subtypes.map((t) => t.name).filter((n) => !used.some((u) => u.toUpperCase() === n.toUpperCase()));
$('nf_mat_sel').innerHTML = `<optgroup label="In Spoolman verwendet">${used.map((m) => `<option>${esc(m)}</option>`).join('')}</optgroup>`
+ `<optgroup label="Weitere">${more.map((m) => `<option>${esc(m)}</option>`).join('')}</optgroup>`
+ '<option value="new">Anderes …</option>';
vendorChanged();
}
function vendorSel() {
const v = $('nf_vendor_sel').value;
return v === 'new' ? null : CAT.vendors.find((x) => String(x.id) === v);
}
function vendorName() { const v = vendorSel(); return v ? v.name : $('nf_vendor').value.trim(); }
function materialName() { return $('nf_mat_sel').value === 'new' ? $('nf_material').value.trim() : $('nf_mat_sel').value; }
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;
@@ -193,62 +208,105 @@ function baseMaterial(m) {
function fil() { return CAT && CAT.filaments.find((f) => String(f.id) === $('nf_fil').value); }
function mostCommon(values) {
const n = {};
for (const v of values) if (v != null) n[v] = (n[v] || 0) + 1;
const best = Object.entries(n).sort((a, b) => b[1] - a[1])[0];
return best ? Number(best[0]) : null;
}
function vendorChanged() {
const v = vendorSel();
$('nf_vendor_new').hidden = !!v;
const own = v ? CAT.filaments.filter((f) => f.vendor === v.name) : [];
$('nf_fil').innerHTML = '<option value="">Neues Filament …</option>' + own.map((f) =>
`<option value="${f.id}">${esc(f.name)}${f.color ? ' · #' + f.color : ''} (${f.spools} vorhanden)</option>`).join('');
// weights as this vendor's other filaments have them
const w = mostCommon(own.map((f) => f.weight)), sw = mostCommon(own.map((f) => f.spool_weight)) ?? (v ? v.empty_spool_weight : null);
if (w) $('nf_weight').value = w;
$('nf_spool').value = sw ?? '';
newFilamentChanged();
}
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`;
$('nf_info').innerHTML = `Weitere Rolle von: ${f.color ? `<span class="swatch" style="background:#${f.color}"></span> ` : ''}${esc(f.material)} · ${f.weight ? f.weight + ' g' : 'Gewicht?'}${f.density ? ' · ' + f.density + ' g/cm³' : ''}${f.extruder_temp ? ' · ' + f.extruder_temp + ' °C' : ''}`;
if (f.weight) $('nf_weight').value = f.weight;
$('nf_spool').value = f.spool_weight ?? '';
$('nf_price').value = f.price ?? '';
} else {
$('nf_info').textContent = '';
materialChanged();
}
preview();
}
function materialChanged() {
const d = MATDEF[baseMaterial($('nf_material').value)];
if (!d) return;
$('nf_mat_new').hidden = $('nf_mat_sel').value !== 'new';
const m = materialName();
if (!m) { preview(); return; }
// like the filaments you already have with this material (this vendor's first), else typical values
const same = CAT.filaments.filter((f) => f.material.toUpperCase() === m.toUpperCase());
const ref = same.find((f) => f.vendor === vendorName()) || same[0];
if (ref) {
$('nf_density').value = ref.density ?? ''; $('nf_diameter').value = ref.diameter ?? 1.75;
$('nf_extruder').value = ref.extruder_temp ?? ''; $('nf_bed').value = ref.bed_temp ?? '';
$('nf_from').textContent = `Werte übernommen von ${ref.vendor} ${ref.name}.`;
} else {
const d = MATDEF[baseMaterial(m)];
if (d) {
let density = d[0];
if (/-(CF|GF)\b/i.test($('nf_material').value)) density = +(density + 0.05).toFixed(2);
if (/-(CF|GF)\b/i.test(m)) density = +(density + 0.05).toFixed(2);
$('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2];
$('nf_from').textContent = 'Typische Werte für ' + m + ' – bitte mit dem Etikett der Rolle vergleichen.';
} else $('nf_from').textContent = '';
}
const name = $('nf_name').value.trim();
if (!name || (nameAuto && name === nameAuto)) { nameAuto = m + ' '; $('nf_name').value = nameAuto; }
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();
}
let nameAuto = '';
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)));
preview();
}
function labelFor(vendor, id) { return (String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '') || 'SPOOL') + String(id).padStart(3, '0'); }
function duplicate() {
if (fil() || !vendorSel()) return null;
const name = $('nf_name').value.trim().toUpperCase(), color = $('nf_color').value.replace('#', '').toUpperCase();
const m = materialName().toUpperCase(), own = CAT.filaments.filter((f) => f.vendor === vendorSel().name);
return own.find((f) => name && f.name.toUpperCase() === name)
|| own.find((f) => color && f.color === color && f.material.toUpperCase() === m) || null;
}
function useFilament(id) { $('nf_fil').value = String(id); newFilamentChanged(); }
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 dupe = duplicate();
$('nf_dupe').innerHTML = dupe ? `<div class="banner warn"><span style="flex:1 1 200px">Gibt es bei ${esc(dupe.vendor)} schon: ${esc(dupe.name)}${dupe.color ? ' (#' + dupe.color + ')' : ''}.</span>
<button onclick="useFilament(${dupe.id})">Dieses verwenden</button></div>` : '';
const vendor = vendorName() || '?';
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);
const first = labelFor(vendor, CAT.next_id), last = labelFor(vendor, 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>` : ''}`;
${!vendorSel() && vendor !== '?' ? `<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,
else Object.assign(body, { vendor: vendorName(), name: $('nf_name').value, material: materialName(),
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;
@@ -275,7 +333,25 @@ async function init() {
$('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('');
try { SETTINGS = await api('GET', '/api/settings'); } catch (e) { SETTINGS = {}; }
BRIDGE = SETTINGS.public_url || location.origin;
$('urlhint').innerHTML = SETTINGS.public_url ? ''
: `<span style="color:var(--warn)">Keine Adresse der Bridge eingestellt; verwendet wird ${esc(location.origin)}. Unter <a href="/settings">Einstellungen</a> festlegen.</span>`;
try {
const saved = localStorage.getItem('tagurl');
const el = saved && document.querySelector(`input[name=url][value=${saved}]`);
if (el) el.checked = true;
} catch (e) {}
await loadSpools();
const want = Number(new URLSearchParams(location.search).get('spool'));
if (want && SPOOLS.some((x) => x.id === want)) pick(want);
}
function tagUrl() {
const v = document.querySelector('input[name=url]:checked').value;
if (v === 'bridge') return `${BRIDGE}/s/${current.id}`;
if (v === 'spoolman') return current.spoolman_url;
return null;
}
async function loadSpools() {
@@ -329,6 +405,7 @@ function pick(id) {
<span class="grow"><span class="title" style="display:block">${esc(current.label)} <span class="muted small" style="font-weight:500">#${current.id}</span></span>
<span class="sub" style="display:block">${esc(spoolLine(current))}</span></span>
<svg class="chev" viewBox="0 0 14 8" style="width:14px;height:8px;flex-basis:14px" aria-hidden="true"><path d="M1 1l6 6 6-6"/></svg>`;
$('bridgeurl').textContent = `${BRIDGE}/s/${current.id}`;
$('material').value = sg.material;
fillSubtypes(sg.material, sg.subtype);
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
@@ -363,12 +440,11 @@ function toggleQR() {
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,
url: tagUrl(),
};
try {
const r = await api('POST', '/api/tag', body);
@@ -404,11 +480,16 @@ $('material').addEventListener('change', () => {
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; markPalette(); schedule(); });
$('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', () => {
try { localStorage.setItem('tagurl', el.value); } catch (e) {}
schedule();
}));
$('filter').addEventListener('input', renderSpools);
$('nf_fil').addEventListener('change', newFilamentChanged);
$('nf_vendor_sel').addEventListener('change', vendorChanged);
$('nf_mat_sel').addEventListener('change', materialChanged);
$('nf_material').addEventListener('change', materialChanged);
$('nf_vendor').addEventListener('input', vendorChanged);
$('nf_vendor').addEventListener('input', preview);
$('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);
+29 -1
View File
@@ -20,7 +20,7 @@ from fastapi.templating import Jinja2Templates
from . import materials, newspool, tagcodec
from .bridge import Bridge
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
from .spoolman import SpoolmanError, summary
from .spoolman import SpoolmanError, SpoolmanNotFound, summary
from .storage import SettingsStore
log = logging.getLogger("web")
@@ -89,6 +89,10 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
def index(request: Request):
return page(request, "status.html")
@app.get("/s/{smid}", response_class=HTMLResponse)
def spool_page(request: Request, smid: int):
return page(request, "spool.html", spool_id=smid)
@app.get("/log", response_class=HTMLResponse)
def log_page(request: Request):
return page(request, "log.html")
@@ -276,6 +280,30 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
out.sort(key=lambda x: x["id"])
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset}
@app.get("/api/spool/{smid}")
def spool_info(smid: int):
s = settings.get()
client = bridge.spoolman_factory(s)
try:
sm = client.spool(smid)
except SpoolmanNotFound:
raise HTTPException(404, f"Spoolman-Spule {smid} gibt es nicht") from None
except SpoolmanError as e:
raise HTTPException(502, str(e)) from None
finally:
client.close()
info = summary(sm)
name = info["label"]
return {
"spool": info,
"filament_name": (sm.get("filament") or {}).get("name") or "",
"spoolman_url": f"{s.spoolman_url}/spool/show/{smid}",
**bridge.spool_whereabouts(smid),
"storage": s.location_storage,
"log": [e for e in bridge.log.recent(300) if name in e["msg"]][:8],
"dry_run": s.dry_run,
}
@app.get("/api/spoolman/catalog")
def spoolman_catalog():
client = bridge.spoolman_factory(settings.get())
+18
View File
@@ -161,6 +161,24 @@ def test_create_spool_with_new_filament_and_vendor(tmp_path):
fil = [x[1] for x in sm.calls if x[0] == "create_filament"][0]
assert fil["color_hex"] == "1A1A1A" and fil["settings_extruder_temp"] == 210 and fil["vendor_id"] == 2
assert ("create_vendor", {"name": "Elegoo", "empty_spool_weight": 154.0}) in sm.calls
again = c.post("/api/spoolman/create", json=dict(body, vendor="ELEGOO", name="pla matte black"), headers=H)
assert again.status_code == 200 and len([x for x in sm.calls if x[0] == "create_filament"]) == 1 # reused
assert len([x for x in sm.calls if x[0] == "create_vendor"]) == 1
bad = dict(body, color="red")
assert c.post("/api/spoolman/create", json=bad, headers=H).status_code == 400
assert c.post("/api/spoolman/create", json=dict(body, count=50), headers=H).status_code == 400
def test_public_url_and_spool_page(tmp_path):
c, bridge = make_client(tmp_path)
assert c.post("/api/settings", json={"public_url": "cc2.menzel.center"}, headers=H).status_code == 400
r = c.post("/api/settings", json={"public_url": "https://cc2.menzel.center/"}, headers=H)
assert r.json()["public_url"] == "https://cc2.menzel.center"
assert c.get("/s/5").status_code == 200
d = c.get("/api/spool/5").json()
assert d["spool"]["id"] == 5 and d["slot"] is None and d["spoolman_url"].endswith("/spool/show/5")
assert c.get("/api/spool/99").status_code == 404
url = "https://cc2.menzel.center/s/5"
t = c.post("/api/tag", json={"spool_id": 5, "material": "PLA", "subtype": 1, "color": "000000", "temp_min": 190,
"temp_max": 230, "url": url}, headers=H).json()
assert t["decoded"]["url"] == url and t["decoded"]["spool_id"] == 5