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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 & Buchung</div>
|
<div class="label section-label">Spoolman & 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();
|
||||||
|
|||||||
@@ -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 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
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user