diff --git a/README.md b/README.md index 23d097b..5f1056d 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Com | System | Zugriff | |---|---| | Drucker | **nur lesen**: `cat /opt/usr/canvas_record_info.json` per SSH. Kein MQTT, keine Druckerbefehle, keine Registrierung als MQTT-Client. | -| CC2 Control | Spulenverwaltung: `GET /api/spools`, `POST /api/spools/save`, `/assign`, `/enable` (nur nach Bestätigung); während eines Drucks zusätzlich `GET /api/printer` (nur für Fortschritt und Restzeit in der Oberfläche) | -| Spoolman | `GET /spool`, `PATCH /spool/{id}` (`location`), `PUT /spool/{id}/use` | +| CC2 Control | Spulenverwaltung: `GET /api/spools`, `POST /api/spools/save`, `/assign`, `/adjust` (Rest aus Spoolman), `/delete` (nur Kopien archivierter/gelöschter Spoolman-Spulen), `/enable` (nur nach Bestätigung); während eines Drucks zusätzlich `GET /api/printer` (nur für Fortschritt und Restzeit in der Oberfläche) | +| Spoolman | `GET /spool`, `/vendor`, `/filament`; `PATCH /spool/{id}` (Ort, Zusatzfelder, Rest nach Nachwiegen/Leerlaufen, Archiv), `PUT /spool/{id}/use`; `POST /vendor`, `/filament`, `/spool` nur über „Neue Spule“ im Tag-Generator | **Dry-Run ist Standard.** Solange er aktiv ist, werden alle Schreibaktionen nur als „würde …“ protokolliert. Ausschalten unter *Einstellungen* (mit Rückfrage). @@ -70,6 +70,14 @@ Ein Update von v1 übernimmt Einstellungen, Zuordnungen, Buchungsstände und den Drucker heißt danach „CC2“ (Ort bleibt „CC2 Slot N“) und kann in den Einstellungen umbenannt werden – Ort und Felder in Spoolman ziehen dann mit. +### Neue Spule anlegen + +Im *Tag-Generator* unter **Neue Spule**: ein vorhandenes Filament wählen (weitere Rolle davon) oder ein neues +anlegen – Hersteller, Name, Material, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht, +Preis, Anzahl. Material setzt Dichte und Temperaturen vor. Die Bridge legt Hersteller (falls neu), Filament und +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. + ### Am iPhone als Web-App `http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne @@ -143,14 +151,31 @@ was er wirklich verbraucht hat; bei Mehrfarbdrucken bekommt jede Rolle ihren Ant Ergebnis aus dem CC2-Protokoll in die Buchung auf, z. B. „2.98 g von SUNLU001 … gebucht – benchy.gcode (abgebrochen)“. -**Rolle leer.** Läuft eine Rolle im Druck leer, setzt CC2 Control sie auf 0 g; die Bridge bucht den Rest -(„Rolle leergelaufen“), sodass auch Spoolman 0 g zeigt. Druckt der Drucker mit einer anderen Rolle weiter, zählt -alles ab dem Wechsel für deren Slot. Archiviert wird die leere Rolle nicht. +**Rolle leer.** Läuft eine Rolle im Druck leer, setzt CC2 Control sie auf 0 g; die Bridge setzt den Rest in Spoolman +ebenfalls fest auf 0 g („Rolle leergelaufen“), statt eine Differenz zu buchen – so stimmt Spoolman auch dann, wenn +CC2 Controls Zählung vorher daneben lag. Danach archiviert sie die Spule in Spoolman (abschaltbar unter +*Einstellungen*), und ihre Kopie verschwindet aus CC2 Control. Druckt der Drucker mit einer anderen Rolle weiter, +zählt alles ab dem Wechsel für deren Slot. Hat CC2 Control fälschlich „leergelaufen“ erkannt (Filament gerissen), +die Spule in Spoolman zurückholen und nachwiegen. Die ID einer archivierten Spule wird nicht wieder vergeben; ihren +Tag kann man für eine neue Rolle einfach neu beschreiben. Ein Scan des alten Tags ordnet nichts zu. **Nachwiegen.** Wird eine Spule in CC2 Control nachgewogen (Protokolleintrag „correction“), setzt die Bridge den Rest in Spoolman auf denselben Wert (`used_weight` = Startgewicht − Rest), statt die Differenz als Verbrauch zu buchen. Steigt `remaining` ohne Nachwiegen, wird nichts gebucht, nur protokolliert, und der neue Stand gilt als -Ausgangspunkt. Ein Nachwiegen in Spoolman geht nicht zurück an CC2 Control. +Ausgangspunkt. + +**Abgleich Spoolman → CC2 Control.** Spoolman ist die Quelle für die Stammdaten: Ändern sich dort Name, Hersteller, +Material, Farbe, Durchmesser, Dichte, Netto-/Leergewicht oder Preis, zieht die Bridge die Kopie in CC2 Control +nach (höchstens einmal pro Minute, nie während eines Drucks). Für eingelegte Spulen gilt das auch für den Rest: +Weicht er mehr als 1 g von Spoolman ab und ist aller Verbrauch gebucht, setzt die Bridge ihn per Nachwiegen +(`/api/spools/adjust`) auf den Spoolman-Wert. Das betrifft vor allem eine Rolle, die im anderen Drucker gedruckt +hat und zurückkommt (CC2 Control hätte sonst ihren alten Stand), und ein Nachwiegen in Spoolman. Kopien von +Spulen, die in Spoolman archiviert oder gelöscht sind und in keinem Slot stecken, löscht die Bridge aus CC2 +Control (dort passen höchstens 128 Spulen). + +**Rolle ohne Tag.** Druckt ein Slot, für den CC2 Control noch fragt, welche Spule es ist, hält CC2 Control die +Millimeter fest. Die Bridge zeigt das am Slot an und schreibt nach dem Druck einen Hinweis ins Protokoll. Wird die +Rolle danach getaggt und gescannt, rechnet CC2 Control die Millimeter dieser Spule an und die Bridge bucht sie. **Grenzen.** Slots ohne Spoolman-Tag und mit „keine Spule“ beantwortete Rückfragen werden nicht gebucht. Bei einem Stromausfall des Druckers gehen bis zu 2 min Verbrauch verloren (CC2 Control speichert beim Drucken höchstens alle diff --git a/app/bridge.py b/app/bridge.py index 842c3d1..0f90cf1 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -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: diff --git a/app/cc2control.py b/app/cc2control.py index 90aca85..7672a62 100644 --- a/app/cc2control.py +++ b/app/cc2control.py @@ -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") diff --git a/app/newspool.py b/app/newspool.py new file mode 100644 index 0000000..ad8c238 --- /dev/null +++ b/app/newspool.py @@ -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) diff --git a/app/spoolman.py b/app/spoolman.py index 2f18aef..b729e4d 100644 --- a/app/spoolman.py +++ b/app/spoolman.py @@ -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: diff --git a/app/storage.py b/app/storage.py index 9bdb59b..9789cc6 100644 --- a/app/storage.py +++ b/app/storage.py @@ -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) diff --git a/app/templates/settings.html b/app/templates/settings.html index 1a0f985..a0edae2 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -51,6 +51,7 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }