Spoolman leads CC2 Control's copies; new spools from the bridge
- 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:
1 parent
d03668e3c5
commit
b37cd64909
13 files changed
+873
-27
No files matched your search
+137
-6
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user