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
+304 -49

No files matched your search

+16 -3
View File
@@ -72,12 +72,25 @@ Felder in Spoolman ziehen dann mit.
### Neue Spule anlegen ### Neue Spule anlegen
Im *Tag-Generator* unter **Neue Spule**: ein vorhandenes Filament wählen (weitere Rolle davon) oder ein neues Im *Tag-Generator* unter **Neue Spule**: Hersteller aus Spoolman wählen (oder einen neuen), dann ein vorhandenes
anlegen – Hersteller, Name, Material, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht, Filament dieses Herstellers (weitere Rolle davon) oder ein neues – Material aus den in Spoolman verwendeten (oder den
Preis, Anzahl. Material setzt Dichte und Temperaturen vor. Die Bridge legt Hersteller (falls neu), Filament und ü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 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. 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 ### Am iPhone als Web-App
`http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne `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) self.spool_cache[smid] = (self.clock(), sm)
return 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: def _name(self, smid: int) -> str:
cached = self.spool_cache.get(smid) cached = self.spool_cache.get(smid)
return label(cached[1]) if cached else f"Spoolman-Spule {smid}" return label(cached[1]) if cached else f"Spoolman-Spule {smid}"
+8 -4
View File
@@ -121,10 +121,14 @@ def create(client: Spoolman, p: dict, location: str) -> tuple[list[dict], str]:
v = client.create_vendor(body) v = client.create_vendor(body)
created_parts.append(f"Hersteller {vendor}") created_parts.append(f"Hersteller {vendor}")
vendor = v["name"] vendor = v["name"]
fbody = {k: val for k, val in p["filament"].items() if val is not None} name = p["filament"]["name"].lower()
fbody["vendor_id"] = v["id"] fil = next((f for f in client.filaments() if (f.get("vendor") or {}).get("id") == v["id"]
fil = client.create_filament(fbody) and (f.get("name") or "").strip().lower() == name), None)
created_parts.append(f"Filament {fil.get('name')}") 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)
created_parts.append(f"Filament {fil.get('name')}")
spools = [] spools = []
for _ in range(p["count"]): for _ in range(p["count"]):
body: dict = {"filament_id": fil["id"], "location": location} body: dict = {"filament_id": fil["id"], "location": location}
+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 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 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 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 dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list) printers: list[PrinterConfig] = field(default_factory=list)
@@ -162,6 +163,9 @@ class Settings:
if not self.spoolman_url.startswith(("http://", "https://")): if not self.spoolman_url.startswith(("http://", "https://")):
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen") raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
self.spoolman_url = self.spoolman_url.rstrip("/") 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(): if not self.location_storage.strip():
raise ValueError("Ortsname für das Lager fehlt") raise ValueError("Ortsname für das Lager fehlt")
ids = [p.id for p in self.printers] ids = [p.id for p in self.printers]
+1
View File
@@ -42,6 +42,7 @@
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; } 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; body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent; } -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> <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()"> <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="label section-label">Spoolman &amp; Buchung</div>
<div class="group"> <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> <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 %} {% endblock %}
{% block script %} {% block script %}
<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 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>'; 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(); 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 %}
+121 -40
View File
@@ -55,18 +55,21 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<div id="created"></div> <div id="created"></div>
<div class="card newcard stack" id="newcard" hidden> <div class="card newcard stack" id="newcard" hidden>
<div class="label">Neue Spule in Spoolman</div> <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="field"><label for="nf_fil">Filament</label><select id="nf_fil"></select></div>
<div class="small muted" id="nf_info"></div> <div class="small muted" id="nf_info"></div>
<div class="stack" id="nf_new"> <div class="stack" id="nf_new">
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))"> <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_mat_sel">Material</label><select id="nf_mat_sel"></select></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" 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</label><input id="nf_name" placeholder="z. B. PLA+ Olive Green"></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_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_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_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 class="field"><label for="nf_bed">Bett (°C)</label><input id="nf_bed" type="number" min="0" max="150" inputmode="numeric"></div>
</div> </div>
<div class="small muted" id="nf_from"></div>
<div class="field"> <div class="field">
<span class="flabel">Farbe</span> <span class="flabel">Farbe</span>
<div class="palette" id="nf_palette"></div> <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"> <input type="color" id="nf_picker" aria-label="Farbe wählen">
</div> </div>
</div> </div>
<datalist id="nf_vendors"></datalist><datalist id="nf_mats"></datalist> <div id="nf_dupe"></div>
</div> </div>
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))"> <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_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>
<div class="small" id="colorhint"></div> <div class="small" id="colorhint"></div>
</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> <details>
<summary>Erweitert</summary> <summary>Erweitert</summary>
<div class="stack" style="padding-top:8px"> <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" 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> </div>
</details> </details>
</div> </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], 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], 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] }; 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) { function setMode2(m) {
MODE = m; MODE = m;
@@ -177,15 +183,24 @@ async function loadCatalog() {
$('nf_preview').textContent = 'Lade Spoolman …'; $('nf_preview').textContent = 'Lade Spoolman …';
try { CAT = await api('GET', '/api/spoolman/catalog'); } try { CAT = await api('GET', '/api/spoolman/catalog'); }
catch (e) { $('nf_preview').innerHTML = `<span style="color:var(--err)">${esc(e.message)}</span>`; return; } catch (e) { $('nf_preview').innerHTML = `<span style="color:var(--err)">${esc(e.message)}</span>`; return; }
const groups = {}; $('nf_vendor_sel').innerHTML = CAT.vendors.map((v) => `<option value="${v.id}">${esc(v.name)}</option>`).join('')
for (const f of CAT.filaments) (groups[f.vendor] = groups[f.vendor] || []).push(f); + '<option value="new">Neuer Hersteller …</option>';
$('nf_fil').innerHTML = '<option value="">Neues Filament …</option>' + Object.entries(groups).map(([v, fs]) => // materials you already use first, then the other Elegoo types
`<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(''); const used = [...new Set(CAT.filaments.map((f) => f.material).filter(Boolean))].sort();
$('nf_vendors').innerHTML = CAT.vendors.map((v) => `<option value="${esc(v.name)}">`).join(''); const more = META.subtypes.map((t) => t.name).filter((n) => !used.some((u) => u.toUpperCase() === n.toUpperCase()));
$('nf_mats').innerHTML = META.subtypes.map((t) => `<option value="${esc(t.name)}">`).join(''); $('nf_mat_sel').innerHTML = `<optgroup label="In Spoolman verwendet">${used.map((m) => `<option>${esc(m)}</option>`).join('')}</optgroup>`
newFilamentChanged(); + `<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) { function baseMaterial(m) {
const u = String(m || '').toUpperCase(); 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; 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 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() { function newFilamentChanged() {
const f = fil(); const f = fil();
$('nf_new').hidden = !!f;
$('nf_new').style.display = f ? 'none' : ''; $('nf_new').style.display = f ? 'none' : '';
if (f) { 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; if (f.weight) $('nf_weight').value = f.weight;
$('nf_spool').value = f.spool_weight ?? ''; $('nf_spool').value = f.spool_weight ?? '';
$('nf_price').value = f.price ?? ''; $('nf_price').value = f.price ?? '';
} else { } else {
$('nf_info').textContent = ''; $('nf_info').textContent = '';
materialChanged();
} }
preview(); preview();
} }
function materialChanged() { function materialChanged() {
const d = MATDEF[baseMaterial($('nf_material').value)]; $('nf_mat_new').hidden = $('nf_mat_sel').value !== 'new';
if (!d) return; const m = materialName();
let density = d[0]; if (!m) { preview(); return; }
if (/-(CF|GF)\b/i.test($('nf_material').value)) density = +(density + 0.05).toFixed(2); // like the filaments you already have with this material (this vendor's first), else typical values
$('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2]; const same = CAT.filaments.filter((f) => f.material.toUpperCase() === m.toUpperCase());
preview(); const ref = same.find((f) => f.vendor === vendorName()) || same[0];
} if (ref) {
$('nf_density').value = ref.density ?? ''; $('nf_diameter').value = ref.diameter ?? 1.75;
function vendorChanged() { $('nf_extruder').value = ref.extruder_temp ?? ''; $('nf_bed').value = ref.bed_temp ?? '';
const v = CAT && CAT.vendors.find((x) => x.name.toLowerCase() === $('nf_vendor').value.trim().toLowerCase()); $('nf_from').textContent = `Werte übernommen von ${ref.vendor} ${ref.name}.`;
if (v && v.empty_spool_weight != null && !$('nf_spool').value) $('nf_spool').value = v.empty_spool_weight; } else {
const d = MATDEF[baseMaterial(m)];
if (d) {
let density = d[0];
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(); preview();
} }
let nameAuto = '';
function setNewColor(c) { function setNewColor(c) {
c = c.replace('#', '').toUpperCase(); c = c.replace('#', '').toUpperCase();
$('nf_color').value = c; $('nf_color').value = c;
if (/^[0-9A-F]{6}$/.test(c)) $('nf_picker').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))); 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 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() { function preview() {
if (!CAT) return; if (!CAT) return;
const f = fil(), vendor = f ? f.vendor : $('nf_vendor').value.trim(); const dupe = duplicate();
const v = CAT.vendors.find((x) => x.name.toLowerCase() === vendor.toLowerCase()); $('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>
const shown = v ? v.name : vendor; <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 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“. $('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> <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() { async function createSpools() {
const f = fil(); const f = fil();
const body = { count: $('nf_count').value, weight: $('nf_weight').value, spool_weight: $('nf_spool').value, price: $('nf_price').value }; 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; 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, color: $('nf_color').value, density: $('nf_density').value, diameter: $('nf_diameter').value,
extruder_temp: $('nf_extruder').value, bed_temp: $('nf_bed').value }); extruder_temp: $('nf_extruder').value, bed_temp: $('nf_bed').value });
const n = Number(body.count) || 1; 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(''); $('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) => $('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(''); `<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(); 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() { 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="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> <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>`; <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; $('material').value = sg.material;
fillSubtypes(sg.material, sg.subtype); fillSubtypes(sg.material, sg.subtype);
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max; $('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
@@ -363,12 +440,11 @@ function toggleQR() {
async function generate() { async function generate() {
if (!current) return; if (!current) return;
const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman';
const body = { const body = {
spool_id: current.id, material: $('material').value, subtype: Number($('subtype').value), 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), 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, finish: parseInt($('finish').value || 'FF', 16), diameter: current.diameter || 1.75,
url: useSpoolman ? current.spoolman_url : null, url: tagUrl(),
}; };
try { try {
const r = await api('POST', '/api/tag', body); 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); 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(); }); $('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)); $('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); $('filter').addEventListener('input', renderSpools);
$('nf_fil').addEventListener('change', newFilamentChanged); $('nf_fil').addEventListener('change', newFilamentChanged);
$('nf_vendor_sel').addEventListener('change', vendorChanged);
$('nf_mat_sel').addEventListener('change', materialChanged);
$('nf_material').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_color').addEventListener('input', () => setNewColor($('nf_color').value));
$('nf_picker').addEventListener('input', () => setNewColor($('nf_picker').value)); $('nf_picker').addEventListener('input', () => setNewColor($('nf_picker').value));
for (const id of ['nf_count', 'nf_name']) $(id).addEventListener('input', preview); 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 . import materials, newspool, tagcodec
from .bridge import Bridge from .bridge import Bridge
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
from .spoolman import SpoolmanError, summary from .spoolman import SpoolmanError, SpoolmanNotFound, summary
from .storage import SettingsStore from .storage import SettingsStore
log = logging.getLogger("web") 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): def index(request: Request):
return page(request, "status.html") 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) @app.get("/log", response_class=HTMLResponse)
def log_page(request: Request): def log_page(request: Request):
return page(request, "log.html") 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"]) out.sort(key=lambda x: x["id"])
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset} 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") @app.get("/api/spoolman/catalog")
def spoolman_catalog(): def spoolman_catalog():
client = bridge.spoolman_factory(settings.get()) 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] 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 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 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") 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=bad, headers=H).status_code == 400
assert c.post("/api/spoolman/create", json=dict(body, count=50), 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