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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 21:26:49 +01:00
1 parent d03668e3c5
commit b37cd64909
13 files changed
+873 -27

No files matched your search

+137 -6
View File
@@ -27,7 +27,7 @@ from collections import deque
from pathlib import Path
from . import tagcodec
from .cc2control import CC2Control, CC2Error, slot_spool_id, spoolman_id_of, tray_statuses
from .cc2control import TEXT_LIMITS, CC2Control, CC2Error, clean_text, slot_spool_id, spoolman_id_of, tray_statuses
from .spoolman import (FIELD_DEFS, FIELD_PRINTER, FIELD_SLOT, Spoolman, SpoolmanError, SpoolmanNotFound, color_hex,
extra_value, label, summary, vendor_name)
from .sshsource import CanvasRecordReader, KeyStore, SSHError
@@ -43,6 +43,12 @@ LOG_SEEN_KEEP = 400 # CC2 Control lists its newest 150 entries
TAKEOUT_MARGIN = 120 # seconds: the printer's clock and ours may differ a little
TAKEOUT_KEEP = 86400
REANSWER_SECONDS = 60
MIRROR_SYNC_SECONDS = 60 # compare CC2 Control's spool copies with Spoolman
REMAINING_TOLERANCE = 1.0 # grams CC2 Control's rest may differ from Spoolman's before it is set again
# CC2 Control's JSON precision per field, so a compare does not find differences it cannot store
FIELD_PRECISION = {"diameter": 0.0005, "density": 0.0005, "net": 0.05, "tare": 0.05, "price": 0.005}
FIELD_NAMES = {"name": "Name", "brand": "Hersteller", "color": "Farbe", "diameter": "Durchmesser",
"density": "Dichte", "net": "Nettogewicht", "tare": "Leergewicht", "price": "Preis"}
SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"}
RESULTS = {
"complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen",
@@ -200,6 +206,7 @@ class PrinterWorker:
self.cc2_data: dict | None = None
self.print_info: dict | None = None # CC2 Control /api/printer, only read while a job runs
self.noted_questions: dict[int, str] = {} # slot -> question already reported
self.last_mirror_sync = 0.0
@property
def id(self) -> str:
@@ -269,6 +276,7 @@ class PrinterWorker:
self._process_cc2_log(cc2)
self._consumption(cc2)
self._note_open_questions(cc2)
self._sync_mirrors(cc2)
def _record_id(self, n: int) -> int | None:
for s in self.record_slots:
@@ -327,6 +335,10 @@ class PrinterWorker:
pending.append(scan) # retry next cycle
continue
self.bridge.spool_cache[tid] = (self.bridge.clock(), sm)
if sm.get("archived"):
self._log("warn", f"Slot {n + 1}: {label(sm)} (Spoolman {tid}) ist archiviert, also leer; nicht zugeordnet. "
"Tag für die neue Rolle neu beschreiben oder die Spule in Spoolman zurückholen.")
continue
self.bridge.map(self, n, tid, label(sm), reason="Tag gescannt")
self.st["scans"] = pending
@@ -426,13 +438,22 @@ class PrinterWorker:
"nachgebucht, oder in CC2 Control „keine Spule“ wählen.")
def _reinserted(self, n: int) -> None:
"""No mapping, CC2 Control asks about filament put in, and the record still names the roll
that last left this slot: it is that roll again."""
"""No mapping, CC2 Control asks about filament put in, and the record names a roll that is nowhere
else: the roll that last left this slot, or one scanned again with the same tag data as before (a roll
that printed in the other printer and came back). If the touchscreen then replaces the id, it was
another roll after all and the mapping goes."""
last = self.st["last"].get(str(n))
rid = self._record_id(n)
if not last or rid != last["spool"] or self.bridge.mapped_elsewhere(rid, self.id, n):
if rid is None or (last and rid != last["spool"]) or self.bridge.mapped_elsewhere(rid, self.id, n):
return
self.bridge.map(self, n, rid, last["name"], "wieder eingelegt, Tag-Daten unverändert")
if last:
name, why = last["name"], "wieder eingelegt, Tag-Daten unverändert"
else:
sm = self.bridge.spool(rid)
if sm is None or sm.get("archived"):
return
name, why = label(sm), "Tag erkannt, Tag-Daten wie beim letzten Scan in diesem Slot"
self.bridge.map(self, n, rid, name, why)
self.st["slots"][str(n)]["reinserted"] = True
def _answer_again(self, n: int, m: dict, question: str) -> None:
@@ -547,6 +568,13 @@ class PrinterWorker:
continue
settled = not job_active and now - self.remaining_seen[cid][1] >= self.settings.settle_seconds
if any(e.get("kind") == "correction" for e in fresh):
adjusted = b.get("adjusted")
if adjusted is not None and abs(rem - adjusted) < MIN_BOOKING:
# our own correction from Spoolman's value: nothing to book or to send back
b.pop("adjusted")
b["remaining"] = rem
b["log_time"] = newest
continue
if settled:
self._weighin(cid, b, name, smid, rem, newest)
continue
@@ -628,8 +656,12 @@ class PrinterWorker:
used = round(max(0.0, float(base) - target), 3)
prev = _num(sm.get("remaining_weight"), float(base) - _num(sm.get("used_weight")))
why = f"(Rolle leergelaufen){runout}" if runout is not None else "(in CC2 Control nachgewogen)"
body: dict = {"used_weight": used}
if runout is not None and target <= 0 and self.settings.archive_empty:
body["archived"] = True
why += " und archivieren"
_, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen {why}",
lambda: self.bridge.spoolman.update_spool(smid, {"used_weight": used}))
lambda: self.bridge.spoolman.update_spool(smid, body))
if done:
b["remaining"] = rem
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
@@ -640,6 +672,105 @@ class PrinterWorker:
self.bridge.state.save()
return True
# ---------- CC2 Control's spool copies follow Spoolman ----------
def _sync_mirrors(self, cc2: dict) -> None:
"""Master data and, for loaded spools, the rest follow Spoolman; copies of spools that are
archived or gone in Spoolman leave CC2 Control's library (it holds at most 128)."""
now = self.bridge.clock()
if (cc2.get("job") or {}).get("active") or now - self.last_mirror_sync < MIRROR_SYNC_SECONDS:
return
self.last_mirror_sync = now
in_slot = {slot_spool_id(s): s.get("slot") for s in cc2.get("slots") or [] if slot_spool_id(s)}
mapped = {m["cc2"]: int(k) for k, m in self.st["slots"].items() if m.get("cc2")}
in_tray = {s["spool_id"] for s in self.record_slots
if s["spool_id"] is not None and self.tray_status.get(s["slot"]) in (1, 2)}
for sp in cc2.get("spools") or []:
smid = spoolman_id_of(sp)
if smid is None:
continue # a spool of yours in CC2 Control, not a copy
cid = str(sp.get("id"))
name = sp.get("name") or cid
loaded = cid in mapped or cid in in_slot
try:
# loaded spools fresh (a cached rest from before a booking would be sent back), others cached;
# before a copy is deleted, Spoolman is asked again: an outage must not look like a deleted spool
sm = self.bridge.spoolman.spool(smid) if loaded else self.bridge.spool(smid)
if not loaded and (sm is None or sm.get("archived")):
sm = self.bridge.spoolman.spool(smid)
except SpoolmanNotFound:
sm = None
except SpoolmanError as e:
self.bridge._spoolman_error(str(e))
return
try:
if sm is None or sm.get("archived"):
if not loaded and smid not in in_tray:
self._delete_mirror(cid, sp, name, "in Spoolman archiviert" if sm else "in Spoolman gelöscht")
continue
if sp.get("archived"):
continue
self._sync_fields(cid, sp, sm, name)
if cid in mapped:
self._sync_remaining(cid, sp, sm, name)
except CC2Error as e:
self._log("error", f"{name}: {e}")
def _sync_fields(self, cid: str, sp: dict, sm: dict, name: str) -> None:
fields, _ = cc2_spool_fields(sm, None)
changed = {}
for key, want in fields.items():
if key in ("remaining", "note", "material"):
continue # material was fitted to the tray when the copy was made; changing it would make CC2 Control ask
have = sp.get(key)
if key in FIELD_PRECISION:
if have is None or abs(_num(have) - float(want)) > FIELD_PRECISION[key]:
changed[key] = want
elif key == "color":
if str(have or "").upper() != str(want).upper():
changed[key] = want
elif str(have or "") != clean_text(want, TEXT_LIMITS.get(key)):
changed[key] = want
if not changed:
return
what = ", ".join(FIELD_NAMES.get(k, k) for k in changed)
self._write(f"meta:{cid}", f"in CC2 Control {name} aus Spoolman aktualisieren ({what})",
lambda: self.cc2.save_spool({"id": cid, **changed}))
def _sync_remaining(self, cid: str, sp: dict, sm: dict, name: str) -> None:
"""A loaded spool's rest in CC2 Control is set to Spoolman's once everything is booked: the copy may be
old (the roll printed in the other printer meanwhile) or the roll was weighed in Spoolman."""
b = self.st["booked"].get(cid)
rem, target = _num(sp.get("remaining"), None), sm.get("remaining_weight")
if b is None or b.get("pending") or rem is None or target is None:
return
if abs(b["remaining"] - rem) >= MIN_BOOKING:
return # use not booked yet: first that
seen = self.remaining_seen.get(cid)
if seen is None or self.bridge.clock() - seen[1] < self.settings.settle_seconds:
return
target = min(20000.0, max(0.0, float(target)))
if abs(target - rem) < REMAINING_TOLERANCE:
return
_, done = self._write(f"adjust:{cid}", f"in CC2 Control den Rest von {name} auf {target:.1f} g setzen "
f"(Stand in Spoolman, bisher {rem:.1f} g)",
lambda: self.cc2.adjust(cid, target))
if done:
b["remaining"] = target
b["adjusted"] = target
self.bridge.state.save()
def _delete_mirror(self, cid: str, sp: dict, name: str, why: str) -> None:
b = self.st["booked"].get(cid)
rem = _num(sp.get("remaining"), None)
if b and (b.get("pending") or (rem is not None and abs(b["remaining"] - rem) >= MIN_BOOKING)):
return # use not booked yet
_, done = self._write(f"delete:{cid}", f"in CC2 Control die Spule {name} löschen ({why})",
lambda: self.cc2.delete(cid))
if done:
self.st["booked"].pop(cid, None)
self.remaining_seen.pop(cid, None)
# ---------- CC2 Control's log: use of a tray left without spool after "Take out" ----------
def _process_cc2_log(self, cc2: dict) -> None:
+7
View File
@@ -134,6 +134,13 @@ class CC2Control:
def assign(self, slot: int, spool_id: int | None) -> dict:
return self._post("/api/spools/assign", {"slot": slot, "spool": "" if spool_id is None else spool_id})
def adjust(self, spool_id: str, remaining: float) -> dict:
"""Weigh-in: sets the remaining weight (CC2 Control logs it as a correction)."""
return self._post("/api/spools/adjust", {"id": spool_id, "remaining": round(float(remaining), 1)})
def delete(self, spool_id: str) -> dict:
return self._post("/api/spools/delete", {"id": spool_id})
def enable(self, on: bool) -> dict:
return self._post("/api/spools/enable", "on" if on else "off")
+139
View File
@@ -0,0 +1,139 @@
"""New spools in Spoolman from the bridge UI: an existing filament, or a new one (and its vendor)."""
from __future__ import annotations
import re
from .spoolman import Spoolman, make_label
MAX_COUNT = 20
class NewSpoolError(ValueError):
pass
def _num(body: dict, key: str, lo: float, hi: float, required: bool = True, what: str = "") -> float | None:
raw = body.get(key)
if raw in (None, ""):
if required:
raise NewSpoolError(f"{what or key} fehlt")
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise NewSpoolError(f"{what or key}: keine Zahl") from None
if not lo <= v <= hi:
raise NewSpoolError(f"{what or key} muss zwischen {lo:g} und {hi:g} liegen")
return v
def _text(body: dict, key: str, what: str, limit: int = 64) -> str:
v = re.sub(r"\s+", " ", str(body.get(key) or "")).strip()
if not v:
raise NewSpoolError(f"{what} fehlt")
if len(v) > limit:
raise NewSpoolError(f"{what} ist zu lang (höchstens {limit} Zeichen)")
return v
def catalog(client: Spoolman) -> dict:
"""Vendors and filaments for the form, and the id Spoolman will most likely give the next spool."""
vendors = sorted(client.vendors(), key=lambda v: v["name"].lower())
spools = client.spools(archived=True)
counts: dict[int, int] = {}
for sp in spools:
if not sp.get("archived"):
fid = (sp.get("filament") or {}).get("id")
counts[fid] = counts.get(fid, 0) + 1
filaments = []
for f in client.filaments():
if f.get("archived"):
continue
filaments.append({
"id": f["id"], "name": f.get("name") or "", "vendor": (f.get("vendor") or {}).get("name") or "",
"material": f.get("material") or "", "color": (f.get("color_hex") or "").upper()[:6],
"density": f.get("density"), "diameter": f.get("diameter"), "weight": f.get("weight"),
"spool_weight": f.get("spool_weight"), "extruder_temp": f.get("settings_extruder_temp"),
"bed_temp": f.get("settings_bed_temp"), "price": f.get("price"), "spools": counts.get(f["id"], 0),
})
filaments.sort(key=lambda f: (f["vendor"].lower(), f["name"].lower()))
next_id = max((int(sp["id"]) for sp in spools), default=0) + 1
return {"vendors": [{"id": v["id"], "name": v["name"], "empty_spool_weight": v.get("empty_spool_weight")}
for v in vendors],
"filaments": filaments, "next_id": next_id}
def plan(body: dict) -> dict:
"""Checks the form; returns what to create."""
count = int(_num(body, "count", 1, MAX_COUNT, what="Anzahl"))
out: dict = {"count": count,
"initial_weight": _num(body, "weight", 1, 20000, required=False, what="Nettogewicht"),
"spool_weight": _num(body, "spool_weight", 0, 5000, required=False, what="Leergewicht"),
"price": _num(body, "price", 0, 1e6, required=False, what="Preis")}
if body.get("filament_id") not in (None, ""):
out["filament_id"] = int(body["filament_id"])
return out
color = str(body.get("color") or "").strip().lstrip("#").upper()
if not re.fullmatch(r"[0-9A-F]{6}", color):
raise NewSpoolError("Farbe als RRGGBB angeben")
out["vendor"] = _text(body, "vendor", "Hersteller")
out["filament"] = {
"name": _text(body, "name", "Name", 96),
"material": _text(body, "material", "Material", 32),
"color_hex": color,
"density": _num(body, "density", 0.5, 3, what="Dichte"),
"diameter": _num(body, "diameter", 1, 3.5, what="Durchmesser"),
"weight": out["initial_weight"],
"spool_weight": out["spool_weight"],
"settings_extruder_temp": _int(_num(body, "extruder_temp", 150, 450, required=False, what="Düsentemperatur")),
"settings_bed_temp": _int(_num(body, "bed_temp", 0, 150, required=False, what="Betttemperatur")),
"price": out["price"],
}
return out
def _int(v: float | None) -> int | None:
return None if v is None else int(round(v))
def describe(p: dict, filament_name: str, vendor: str) -> str:
n = p["count"]
weight = f", {p['initial_weight']:g} g" if p.get("initial_weight") else ""
return f"{n} Spule{'n' if n > 1 else ''} {vendor} {filament_name}{weight}"
def create(client: Spoolman, p: dict, location: str) -> tuple[list[dict], str]:
"""Creates what `plan` returned. Returns the new spools (id, label) and what was created, for the log."""
created_parts = []
if "filament_id" in p:
fil = next((f for f in client.filaments() if f["id"] == p["filament_id"]), None)
if fil is None:
raise NewSpoolError("Filament gibt es in Spoolman nicht mehr")
vendor = (fil.get("vendor") or {}).get("name") or ""
else:
vendor = p["vendor"]
v = next((v for v in client.vendors() if v["name"].strip().lower() == vendor.lower()), None)
if v is None:
body = {"name": vendor}
if p["filament"].get("spool_weight") is not None:
body["empty_spool_weight"] = p["filament"]["spool_weight"]
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')}")
spools = []
for _ in range(p["count"]):
body: dict = {"filament_id": fil["id"], "location": location}
for key in ("initial_weight", "spool_weight", "price"):
if p.get(key) is not None:
body[key] = p[key]
sp = client.create_spool(body)
lot = make_label(vendor, int(sp["id"]))
client.update_spool(int(sp["id"]), {"lot_nr": lot})
spools.append({"id": int(sp["id"]), "label": lot})
created_parts.insert(0, describe(p, fil.get("name") or "", vendor) + " (" + ", ".join(s["label"] for s in spools) + ")")
return spools, "; ".join(created_parts)
+20 -2
View File
@@ -55,6 +55,21 @@ class Spoolman:
def update_spool(self, spool_id: int, body: dict) -> dict:
return self._request("PATCH", f"/spool/{int(spool_id)}", json=body)
def vendors(self) -> list[dict]:
return self._request("GET", "/vendor")
def filaments(self) -> list[dict]:
return self._request("GET", "/filament")
def create_vendor(self, body: dict) -> dict:
return self._request("POST", "/vendor", json=body)
def create_filament(self, body: dict) -> dict:
return self._request("POST", "/filament", json=body)
def create_spool(self, body: dict) -> dict:
return self._request("POST", "/spool", json=body)
def fields(self) -> list[dict]:
return self._request("GET", "/field/spool")
@@ -74,8 +89,11 @@ def label(spool: dict) -> str:
lot = (spool.get("lot_nr") or "").strip()
if lot:
return lot
vendor = re.sub(r"[^A-Z0-9]", "", vendor_name(spool).upper()) or "SPOOL"
return f"{vendor}{int(spool['id']):03d}"
return make_label(vendor_name(spool), int(spool["id"]))
def make_label(vendor: str, spool_id: int) -> str:
return f"{re.sub(r'[^A-Z0-9]', '', vendor.upper()) or 'SPOOL'}{spool_id:03d}"
def color_hex(spool: dict) -> str | None:
+1
View File
@@ -121,6 +121,7 @@ class Settings:
spoolman_fields: bool = True # also write the extra fields "drucker" and "slot"
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
dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list)
+3
View File
@@ -51,6 +51,7 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }
<div class="frow"><label for="location_slot">Ort im Slot<small><code>{printer}</code> = Druckername, <code>{n}</code> = 1–4</small></label><input id="location_slot" required></div>
<div class="frow"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
<div class="frow"><label for="spoolman_fields">Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label><span class="switch"><input type="checkbox" id="spoolman_fields"><span></span></span></div>
<div class="frow"><label for="archive_empty">Leergelaufene Rollen in Spoolman archivieren<small>Ihre Kopie in CC2 Control wird danach gelöscht</small></label><span class="switch"><input type="checkbox" id="archive_empty"><span></span></span></div>
</div>
<div class="label section-label">Abfrage</div>
@@ -147,6 +148,7 @@ async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('spoolman_fields').checked = current.spoolman_fields;
$('archive_empty').checked = current.archive_empty;
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
showMode(current.dry_run);
@@ -156,6 +158,7 @@ async function saveSettings() {
const body = { printers };
for (const f of FIELDS) body[f] = $(f).value;
body.spoolman_fields = $('spoolman_fields').checked;
body.archive_empty = $('archive_empty').checked;
try {
current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p }));
+164 -3
View File
@@ -1,11 +1,16 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Tag-Generator{% endblock %}
{% block heading %}Tag-Generator{% endblock %}
{% block heading %}Tags{% endblock %}
{% block head %}
<style>
.tg { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 20px; align-items: start; }
@media (max-width: 999px) { .tg { grid-template-columns: minmax(0, 1fr); } }
.picker { display: flex; flex-direction: column; gap: 12px; }
.srcmode { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; background: var(--line); padding: 4px; border-radius: 12px; }
.srcmode button { border: 0; min-height: 36px; border-radius: 9px; background: transparent; color: var(--muted); font-weight: 600; }
.srcmode button[aria-selected=true] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.newcard[hidden], .listcard[hidden] { display: none; }
.preview { background: var(--surface2); border-radius: 12px; padding: 12px 14px; font-size: 14px; }
.spools { max-height: 560px; overflow-y: auto; margin: 0 -16px -16px; border-top: 1px solid var(--line2); }
.spools .item { padding-left: 16px; padding-right: 16px; }
.spools .item[aria-current=true] { background: var(--accent-bg); }
@@ -34,7 +39,7 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
@media (max-width: 999px) {
/* no scroll areas inside the page on phones: a swipe there would not move the page */
.spools { max-height: none; overflow: visible; }
.tg.picked .picker .listcard { display: none; }
.tg.picked .picker .listcard, .tg.picked .picker .newcard, .tg.picked .picker .srcmode { display: none; }
.tg.picked .chosen { display: flex; }
}
</style>
@@ -43,7 +48,45 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<div class="tg" id="tg">
<div class="picker">
<button class="card item chosen" id="chosen" onclick="unpick()" aria-label="Andere Spule wählen"></button>
<div class="card listcard">
<div class="srcmode" role="tablist" aria-label="Spule">
<button role="tab" id="m_list" aria-selected="true" onclick="setMode2('list')">Vorhandene Spule</button>
<button role="tab" id="m_new" aria-selected="false" onclick="setMode2('new')">Neue Spule</button>
</div>
<div id="created"></div>
<div class="card newcard stack" id="newcard" hidden>
<div class="label">Neue Spule in Spoolman</div>
<div class="field"><label for="nf_fil">Filament</label><select id="nf_fil"></select></div>
<div class="small muted" id="nf_info"></div>
<div class="stack" id="nf_new">
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))">
<div class="field"><label for="nf_vendor">Hersteller</label><input id="nf_vendor" list="nf_vendors" autocapitalize="characters" spellcheck="false"></div>
<div class="field"><label for="nf_material">Material</label><input id="nf_material" list="nf_mats" autocapitalize="characters" spellcheck="false" placeholder="PLA+, PETG-CF …"></div>
<div class="field" style="grid-column:1/-1"><label for="nf_name">Name</label><input id="nf_name" placeholder="z. B. PLA+ Olive Green"></div>
<div class="field"><label for="nf_density">Dichte (g/cm³)</label><input id="nf_density" type="number" step="0.01" min="0.5" max="3" inputmode="decimal"></div>
<div class="field"><label for="nf_diameter">Durchmesser (mm)</label><input id="nf_diameter" type="number" step="0.01" min="1" max="3.5" value="1.75" inputmode="decimal"></div>
<div class="field"><label for="nf_extruder">Düse (°C)</label><input id="nf_extruder" type="number" min="150" max="450" inputmode="numeric"></div>
<div class="field"><label for="nf_bed">Bett (°C)</label><input id="nf_bed" type="number" min="0" max="150" inputmode="numeric"></div>
</div>
<div class="field">
<span class="flabel">Farbe</span>
<div class="palette" id="nf_palette"></div>
<div class="row" style="flex-wrap:nowrap;gap:8px;margin-top:4px">
<input id="nf_color" maxlength="7" class="mono" aria-label="Farbe als Hex (RRGGBB)" autocapitalize="characters" spellcheck="false" placeholder="RRGGBB">
<input type="color" id="nf_picker" aria-label="Farbe wählen">
</div>
</div>
<datalist id="nf_vendors"></datalist><datalist id="nf_mats"></datalist>
</div>
<div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))">
<div class="field"><label for="nf_weight">Nettogewicht (g)</label><input id="nf_weight" type="number" min="1" max="20000" inputmode="numeric" value="1000"></div>
<div class="field"><label for="nf_spool">Leergewicht Spule (g)</label><input id="nf_spool" type="number" min="0" max="5000" inputmode="numeric"></div>
<div class="field"><label for="nf_price">Preis (optional)</label><input id="nf_price" type="number" min="0" step="0.01" inputmode="decimal"></div>
<div class="field"><label for="nf_count">Anzahl Rollen</label><input id="nf_count" type="number" min="1" max="20" value="1" inputmode="numeric"></div>
</div>
<div class="preview" id="nf_preview"></div>
<button class="primary" style="min-height:48px" id="nf_go" onclick="createSpools()">In Spoolman anlegen</button>
</div>
<div class="card listcard" id="listcard">
<div class="label" style="margin-bottom:10px">Spoolman-Spule</div>
<div class="row" style="flex-wrap:nowrap;margin-bottom:12px">
<input id="filter" type="search" placeholder="Suchen: Label, Hersteller, Material …" aria-label="Spulen filtern">
@@ -115,8 +158,120 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<script>
let META = null, SPOOLS = [], current = null, timer = null;
// starting values for a new filament, by base material: density g/cm³, nozzle, bed
const MATDEF = { PLA: [1.24, 210, 60], PETG: [1.27, 240, 75], ABS: [1.04, 250, 100], ASA: [1.07, 255, 100], TPU: [1.21, 225, 50],
PA: [1.14, 275, 90], PC: [1.20, 270, 110], PVA: [1.23, 200, 60], HIPS: [1.04, 240, 100], PP: [0.90, 230, 85], CPE: [1.25, 250, 80],
PPA: [1.20, 295, 100], PPS: [1.35, 315, 110], BVOH: [1.14, 200, 60], EVA: [0.95, 200, 50] };
let CAT = null, MODE = 'list';
function setMode2(m) {
MODE = m;
$('m_list').setAttribute('aria-selected', String(m === 'list'));
$('m_new').setAttribute('aria-selected', String(m === 'new'));
$('listcard').hidden = m !== 'list';
$('newcard').hidden = m !== 'new';
if (m === 'new' && !CAT) loadCatalog();
}
async function loadCatalog() {
$('nf_preview').textContent = 'Lade Spoolman …';
try { CAT = await api('GET', '/api/spoolman/catalog'); }
catch (e) { $('nf_preview').innerHTML = `<span style="color:var(--err)">${esc(e.message)}</span>`; return; }
const groups = {};
for (const f of CAT.filaments) (groups[f.vendor] = groups[f.vendor] || []).push(f);
$('nf_fil').innerHTML = '<option value="">Neues Filament …</option>' + Object.entries(groups).map(([v, fs]) =>
`<optgroup label="${esc(v)}">${fs.map((f) => `<option value="${f.id}">${esc(f.name)} · ${esc(f.material)}${f.color ? ' · #' + f.color : ''}</option>`).join('')}</optgroup>`).join('');
$('nf_vendors').innerHTML = CAT.vendors.map((v) => `<option value="${esc(v.name)}">`).join('');
$('nf_mats').innerHTML = META.subtypes.map((t) => `<option value="${esc(t.name)}">`).join('');
newFilamentChanged();
}
function baseMaterial(m) {
const u = String(m || '').toUpperCase();
return Object.keys(MATDEF).sort((a, b) => b.length - a.length).find((k) => u.replace(/^RAPID\s*/, '').startsWith(k)) || null;
}
function fil() { return CAT && CAT.filaments.find((f) => String(f.id) === $('nf_fil').value); }
function newFilamentChanged() {
const f = fil();
$('nf_new').hidden = !!f;
$('nf_new').style.display = f ? 'none' : '';
if (f) {
$('nf_info').innerHTML = `${f.color ? `<span class="swatch" style="background:#${f.color}"></span> ` : ''}${esc(f.vendor)} · ${esc(f.material)} · ${f.weight ? f.weight + ' g' : 'Gewicht?'}${f.density ? ' · ' + f.density + ' g/cm³' : ''} · ${f.spools} Spule${f.spools === 1 ? '' : 'n'} vorhanden`;
if (f.weight) $('nf_weight').value = f.weight;
$('nf_spool').value = f.spool_weight ?? '';
$('nf_price').value = f.price ?? '';
} else {
$('nf_info').textContent = '';
}
preview();
}
function materialChanged() {
const d = MATDEF[baseMaterial($('nf_material').value)];
if (!d) return;
let density = d[0];
if (/-(CF|GF)\b/i.test($('nf_material').value)) density = +(density + 0.05).toFixed(2);
$('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2];
preview();
}
function vendorChanged() {
const v = CAT && CAT.vendors.find((x) => x.name.toLowerCase() === $('nf_vendor').value.trim().toLowerCase());
if (v && v.empty_spool_weight != null && !$('nf_spool').value) $('nf_spool').value = v.empty_spool_weight;
preview();
}
function setNewColor(c) {
c = c.replace('#', '').toUpperCase();
$('nf_color').value = c;
if (/^[0-9A-F]{6}$/.test(c)) $('nf_picker').value = '#' + c;
document.querySelectorAll('#nf_palette button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c.toUpperCase() === c)));
}
function labelFor(vendor, id) { return (String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '') || 'SPOOL') + String(id).padStart(3, '0'); }
function preview() {
if (!CAT) return;
const f = fil(), vendor = f ? f.vendor : $('nf_vendor').value.trim();
const v = CAT.vendors.find((x) => x.name.toLowerCase() === vendor.toLowerCase());
const shown = v ? v.name : vendor;
const n = Math.max(1, Math.min(20, Number($('nf_count').value) || 1));
const first = labelFor(shown || '?', CAT.next_id), last = labelFor(shown || '?', CAT.next_id + n - 1);
$('nf_preview').innerHTML = `Wird angelegt als <b class="mono">${esc(first)}</b>${n > 1 ? ` bis <b class="mono">${esc(last)}</b>` : ''}, Ort „Lager“.
<span class="muted">Die ID vergibt Spoolman (voraussichtlich ${CAT.next_id}${n > 1 ? '–' + (CAT.next_id + n - 1) : ''}); das Label folgt ihr.</span>
${!f && vendor && !v ? `<br><span class="muted">Neuer Hersteller „${esc(vendor)}“ wird ebenfalls angelegt.</span>` : ''}`;
}
async function createSpools() {
const f = fil();
const body = { count: $('nf_count').value, weight: $('nf_weight').value, spool_weight: $('nf_spool').value, price: $('nf_price').value };
if (f) body.filament_id = f.id;
else Object.assign(body, { vendor: $('nf_vendor').value, name: $('nf_name').value, material: $('nf_material').value,
color: $('nf_color').value, density: $('nf_density').value, diameter: $('nf_diameter').value,
extruder_temp: $('nf_extruder').value, bed_temp: $('nf_bed').value });
const n = Number(body.count) || 1;
if (!confirm(`${n} neue Spule${n > 1 ? 'n' : ''} in Spoolman anlegen?`)) return;
$('nf_go').disabled = true;
try {
const r = await api('POST', '/api/spoolman/create', body);
const labels = r.spools.map((x) => x.label);
toast('Angelegt: ' + labels.join(', '));
CAT = null;
$('created').innerHTML = `<div class="banner ok">In Spoolman angelegt: ${labels.map(esc).join(', ')}.${labels.length > 1 ? ' Für jede Rolle einen eigenen Tag schreiben.' : ''}</div>`;
setMode2('list');
$('filter').value = labels.length > 1 ? '' : labels[0];
await loadSpools();
pick(r.spools[0].id);
} catch (e) { toast(e.message); }
finally { $('nf_go').disabled = false; }
}
async function init() {
META = await api('GET', '/api/materials');
$('nf_palette').innerHTML = META.palette.map((c) =>
`<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setNewColor('${c}')" style="background:#${c}"></button>`).join('');
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) =>
`<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setColor('${c}')" style="background:#${c}"></button>`).join('');
@@ -251,6 +406,12 @@ $('color').addEventListener('input', () => { const c = $('color').value.replace(
$('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
$('filter').addEventListener('input', renderSpools);
$('nf_fil').addEventListener('change', newFilamentChanged);
$('nf_material').addEventListener('change', materialChanged);
$('nf_vendor').addEventListener('input', vendorChanged);
$('nf_color').addEventListener('input', () => setNewColor($('nf_color').value));
$('nf_picker').addEventListener('input', () => setNewColor($('nf_picker').value));
for (const id of ['nf_count', 'nf_name']) $(id).addEventListener('input', preview);
$('archived').addEventListener('change', loadSpools);
init();
</script>
+34 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import materials, tagcodec
from . import materials, newspool, tagcodec
from .bridge import Bridge
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
from .spoolman import SpoolmanError, summary
@@ -276,6 +276,39 @@ 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/spoolman/catalog")
def spoolman_catalog():
client = bridge.spoolman_factory(settings.get())
try:
return newspool.catalog(client)
except SpoolmanError as e:
raise HTTPException(502, str(e)) from None
finally:
client.close()
@app.post("/api/spoolman/create")
async def spoolman_create(request: Request):
body = await _json(request)
s = settings.get()
try:
p = newspool.plan(body)
except (newspool.NewSpoolError, TypeError, ValueError) as e:
raise HTTPException(400, str(e)) from None
if s.dry_run:
bridge.log.add("dry", f"würde in Spoolman {p['count']} neue Spule(n) anlegen")
raise HTTPException(409, "Dry-Run aktiv: in Spoolman wird nichts angelegt")
client = bridge.spoolman_factory(s)
try:
spools, what = newspool.create(client, p, s.location_storage)
except newspool.NewSpoolError as e:
raise HTTPException(400, str(e)) from None
except SpoolmanError as e:
raise HTTPException(502, str(e)) from None
finally:
client.close()
bridge.log.add("info", f"erledigt: in Spoolman angelegt: {what}")
return {"spools": spools}
@app.post("/api/tag")
async def tag(request: Request):
body = await _json(request)