Spool page for NFC links, bridge address; vendor-first new spool form
- /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:
1 parent
b37cd64909
commit
64ea91d107
10 files changed
+304
-49
No files matched your search
@@ -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}"
|
||||
|
||||
+8
-4
@@ -121,10 +121,14 @@ 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"]
|
||||
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')}")
|
||||
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)
|
||||
created_parts.append(f"Filament {fil.get('name')}")
|
||||
spools = []
|
||||
for _ in range(p["count"]):
|
||||
body: dict = {"filament_id": fil["id"], "location": location}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 & 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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
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;
|
||||
$('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(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();
|
||||
}
|
||||
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
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user