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

+31 -6
View File
@@ -15,8 +15,8 @@ Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Com
| System | Zugriff | | System | Zugriff |
|---|---| |---|---|
| Drucker | **nur lesen**: `cat /opt/usr/canvas_record_info.json` per SSH. Kein MQTT, keine Druckerbefehle, keine Registrierung als MQTT-Client. | | 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) | | 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`, `PATCH /spool/{id}` (`location`), `PUT /spool/{id}/use` | | 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. **Dry-Run ist Standard.** Solange er aktiv ist, werden alle Schreibaktionen nur als „würde …“ protokolliert.
Ausschalten unter *Einstellungen* (mit Rückfrage). 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 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. 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 ### Am iPhone als Web-App
`http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne `http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne
@@ -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 Ergebnis aus dem CC2-Protokoll in die Buchung auf, z. B. „2.98 g von SUNLU001 … gebucht – benchy.gcode
(abgebrochen)“. (abgebrochen)“.
**Rolle leer.** Läuft eine Rolle im Druck leer, setzt CC2 Control sie auf 0 g; die Bridge bucht den Rest **Rolle leer.** Läuft eine Rolle im Druck leer, setzt CC2 Control sie auf 0 g; die Bridge setzt den Rest in Spoolman
(„Rolle leergelaufen“), sodass auch Spoolman 0 g zeigt. Druckt der Drucker mit einer anderen Rolle weiter, zählt ebenfalls fest auf 0 g („Rolle leergelaufen“), statt eine Differenz zu buchen – so stimmt Spoolman auch dann, wenn
alles ab dem Wechsel für deren Slot. Archiviert wird die leere Rolle nicht. 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 **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 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 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 **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 Stromausfall des Druckers gehen bis zu 2 min Verbrauch verloren (CC2 Control speichert beim Drucken höchstens alle
+137 -6
View File
@@ -27,7 +27,7 @@ from collections import deque
from pathlib import Path from pathlib import Path
from . import tagcodec 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, from .spoolman import (FIELD_DEFS, FIELD_PRINTER, FIELD_SLOT, Spoolman, SpoolmanError, SpoolmanNotFound, color_hex,
extra_value, label, summary, vendor_name) extra_value, label, summary, vendor_name)
from .sshsource import CanvasRecordReader, KeyStore, SSHError 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_MARGIN = 120 # seconds: the printer's clock and ours may differ a little
TAKEOUT_KEEP = 86400 TAKEOUT_KEEP = 86400
REANSWER_SECONDS = 60 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"} SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"}
RESULTS = { RESULTS = {
"complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen", "complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen",
@@ -200,6 +206,7 @@ class PrinterWorker:
self.cc2_data: dict | None = None self.cc2_data: dict | None = None
self.print_info: dict | None = None # CC2 Control /api/printer, only read while a job runs 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.noted_questions: dict[int, str] = {} # slot -> question already reported
self.last_mirror_sync = 0.0
@property @property
def id(self) -> str: def id(self) -> str:
@@ -269,6 +276,7 @@ class PrinterWorker:
self._process_cc2_log(cc2) self._process_cc2_log(cc2)
self._consumption(cc2) self._consumption(cc2)
self._note_open_questions(cc2) self._note_open_questions(cc2)
self._sync_mirrors(cc2)
def _record_id(self, n: int) -> int | None: def _record_id(self, n: int) -> int | None:
for s in self.record_slots: for s in self.record_slots:
@@ -327,6 +335,10 @@ class PrinterWorker:
pending.append(scan) # retry next cycle pending.append(scan) # retry next cycle
continue continue
self.bridge.spool_cache[tid] = (self.bridge.clock(), sm) 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.bridge.map(self, n, tid, label(sm), reason="Tag gescannt")
self.st["scans"] = pending self.st["scans"] = pending
@@ -426,13 +438,22 @@ class PrinterWorker:
"nachgebucht, oder in CC2 Control „keine Spule“ wählen.") "nachgebucht, oder in CC2 Control „keine Spule“ wählen.")
def _reinserted(self, n: int) -> None: def _reinserted(self, n: int) -> None:
"""No mapping, CC2 Control asks about filament put in, and the record still names the roll """No mapping, CC2 Control asks about filament put in, and the record names a roll that is nowhere
that last left this slot: it is that roll again.""" 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)) last = self.st["last"].get(str(n))
rid = self._record_id(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 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 self.st["slots"][str(n)]["reinserted"] = True
def _answer_again(self, n: int, m: dict, question: str) -> None: def _answer_again(self, n: int, m: dict, question: str) -> None:
@@ -547,6 +568,13 @@ class PrinterWorker:
continue continue
settled = not job_active and now - self.remaining_seen[cid][1] >= self.settings.settle_seconds 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): 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: if settled:
self._weighin(cid, b, name, smid, rem, newest) self._weighin(cid, b, name, smid, rem, newest)
continue continue
@@ -628,8 +656,12 @@ class PrinterWorker:
used = round(max(0.0, float(base) - target), 3) used = round(max(0.0, float(base) - target), 3)
prev = _num(sm.get("remaining_weight"), float(base) - _num(sm.get("used_weight"))) 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)" 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}", _, 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: if done:
b["remaining"] = rem b["remaining"] = rem
b["log_time"] = max(b.get("log_time") or 0.0, log_time) b["log_time"] = max(b.get("log_time") or 0.0, log_time)
@@ -640,6 +672,105 @@ class PrinterWorker:
self.bridge.state.save() self.bridge.state.save()
return True 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" ---------- # ---------- CC2 Control's log: use of a tray left without spool after "Take out" ----------
def _process_cc2_log(self, cc2: dict) -> None: 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: 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}) 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: def enable(self, on: bool) -> dict:
return self._post("/api/spools/enable", "on" if on else "off") 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: def update_spool(self, spool_id: int, body: dict) -> dict:
return self._request("PATCH", f"/spool/{int(spool_id)}", json=body) 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]: def fields(self) -> list[dict]:
return self._request("GET", "/field/spool") return self._request("GET", "/field/spool")
@@ -74,8 +89,11 @@ def label(spool: dict) -> str:
lot = (spool.get("lot_nr") or "").strip() lot = (spool.get("lot_nr") or "").strip()
if lot: if lot:
return lot return lot
vendor = re.sub(r"[^A-Z0-9]", "", vendor_name(spool).upper()) or "SPOOL" return make_label(vendor_name(spool), int(spool["id"]))
return f"{vendor}{int(spool['id']):03d}"
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: 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" 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 empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
archive_empty: bool = True # archive a Spoolman spool whose roll ran out
dry_run: bool = True dry_run: bool = True
printers: list[PrinterConfig] = field(default_factory=list) 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_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="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="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>
<div class="label section-label">Abfrage</div> <div class="label section-label">Abfrage</div>
@@ -147,6 +148,7 @@ async function loadSettings() {
current = await api('GET', '/api/settings'); current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f]; for (const f of FIELDS) $(f).value = current[f];
$('spoolman_fields').checked = current.spoolman_fields; $('spoolman_fields').checked = current.spoolman_fields;
$('archive_empty').checked = current.archive_empty;
printers = current.printers.map((p) => ({ ...p })); printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id)); saved = new Set(printers.map((p) => p.id));
showMode(current.dry_run); showMode(current.dry_run);
@@ -156,6 +158,7 @@ async function saveSettings() {
const body = { printers }; const body = { printers };
for (const f of FIELDS) body[f] = $(f).value; for (const f of FIELDS) body[f] = $(f).value;
body.spoolman_fields = $('spoolman_fields').checked; body.spoolman_fields = $('spoolman_fields').checked;
body.archive_empty = $('archive_empty').checked;
try { try {
current = await api('POST', '/api/settings', body); current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p })); printers = current.printers.map((p) => ({ ...p }));
+164 -3
View File
@@ -1,11 +1,16 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}CC2 Bridge – Tag-Generator{% endblock %} {% block title %}CC2 Bridge – Tag-Generator{% endblock %}
{% block heading %}Tag-Generator{% endblock %} {% block heading %}Tags{% endblock %}
{% block head %} {% block head %}
<style> <style>
.tg { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 20px; align-items: start; } .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); } } @media (max-width: 999px) { .tg { grid-template-columns: minmax(0, 1fr); } }
.picker { display: flex; flex-direction: column; gap: 12px; } .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 { 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 { padding-left: 16px; padding-right: 16px; }
.spools .item[aria-current=true] { background: var(--accent-bg); } .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) { @media (max-width: 999px) {
/* no scroll areas inside the page on phones: a swipe there would not move the page */ /* no scroll areas inside the page on phones: a swipe there would not move the page */
.spools { max-height: none; overflow: visible; } .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; } .tg.picked .chosen { display: flex; }
} }
</style> </style>
@@ -43,7 +48,45 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
<div class="tg" id="tg"> <div class="tg" id="tg">
<div class="picker"> <div class="picker">
<button class="card item chosen" id="chosen" onclick="unpick()" aria-label="Andere Spule wählen"></button> <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="label" style="margin-bottom:10px">Spoolman-Spule</div>
<div class="row" style="flex-wrap:nowrap;margin-bottom:12px"> <div class="row" style="flex-wrap:nowrap;margin-bottom:12px">
<input id="filter" type="search" placeholder="Suchen: Label, Hersteller, Material …" aria-label="Spulen filtern"> <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> <script>
let META = null, SPOOLS = [], current = null, timer = null; 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() { async function init() {
META = await api('GET', '/api/materials'); 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(''); $('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) => $('palette').innerHTML = META.palette.map((c) =>
`<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setColor('${c}')" style="background:#${c}"></button>`).join(''); `<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setColor('${c}')" style="background:#${c}"></button>`).join('');
@@ -251,6 +406,12 @@ $('color').addEventListener('input', () => { const c = $('color').value.replace(
$('picker').addEventListener('input', () => setColor($('picker').value)); $('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule)); document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
$('filter').addEventListener('input', renderSpools); $('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); $('archived').addEventListener('change', loadSpools);
init(); init();
</script> </script>
+34 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from . import materials, tagcodec from . import materials, newspool, tagcodec
from .bridge import Bridge from .bridge import Bridge
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
from .spoolman import SpoolmanError, summary from .spoolman import SpoolmanError, 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"]) out.sort(key=lambda x: x["id"])
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset} return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset}
@app.get("/api/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") @app.post("/api/tag")
async def tag(request: Request): async def tag(request: Request):
body = await _json(request) body = await _json(request)
+64 -2
View File
@@ -52,6 +52,7 @@ class FakeCC2:
self.log: list[dict] = [] # newest first, as CC2 Control lists it self.log: list[dict] = [] # newest first, as CC2 Control lists it
self.calls: list[tuple] = [] self.calls: list[tuple] = []
self.fail = False self.fail = False
self.clock = None # set by tests that need log times for adjust
self._ids = (f"s{i:04d}" for i in itertools.count(1)) self._ids = (f"s{i:04d}" for i in itertools.count(1))
def _check(self): def _check(self):
@@ -78,8 +79,11 @@ class FakeCC2:
self.calls.append(("save", dict(fields))) self.calls.append(("save", dict(fields)))
if fields.get("id"): if fields.get("id"):
sp = next(s for s in self.library if s["id"] == fields["id"]) sp = next(s for s in self.library if s["id"] == fields["id"])
if "archived" in fields: for key, value in fields.items():
sp["archived"] = fields["archived"] in ("1", 1, True) if key == "archived":
sp["archived"] = value in ("1", 1, True)
elif key != "id":
sp[key] = value
return {"ok": True, "id": sp["id"]} return {"ok": True, "id": sp["id"]}
sp = {"id": next(self._ids), "name": fields.get("name", ""), "brand": fields.get("brand", ""), sp = {"id": next(self._ids), "name": fields.get("name", ""), "brand": fields.get("brand", ""),
"material": fields["material"], "color": fields["color"], "diameter": fields.get("diameter", 1.75), "material": fields["material"], "color": fields["color"], "diameter": fields.get("diameter", 1.75),
@@ -89,6 +93,18 @@ class FakeCC2:
self.library.append(sp) self.library.append(sp)
return {"ok": True, "id": sp["id"]} return {"ok": True, "id": sp["id"]}
def adjust(self, cc2_id, remaining):
self._check()
self.calls.append(("adjust", cc2_id, remaining))
self.weigh(cc2_id, remaining, self.clock() if self.clock else 0)
return {"ok": True, "remaining": remaining}
def delete(self, cc2_id):
self._check()
self.calls.append(("delete", cc2_id))
self.library = [s for s in self.library if s["id"] != cc2_id]
return {"ok": True}
def assign(self, slot, spool_id): def assign(self, slot, spool_id):
self._check() self._check()
self.calls.append(("assign", slot, spool_id)) self.calls.append(("assign", slot, spool_id))
@@ -173,6 +189,11 @@ class FakeSpoolman:
self.calls.append(("extra", sid, dict(body["extra"]))) self.calls.append(("extra", sid, dict(body["extra"])))
if "location" in body: if "location" in body:
sp["location"] = body["location"] sp["location"] = body["location"]
if "lot_nr" in body:
sp["lot_nr"] = body["lot_nr"]
if "archived" in body:
self.calls.append(("archived", sid, body["archived"]))
sp["archived"] = bool(body["archived"])
for key, value in (body.get("extra") or {}).items(): for key, value in (body.get("extra") or {}).items():
if value is None: if value is None:
sp["extra"].pop(key, None) sp["extra"].pop(key, None)
@@ -184,6 +205,47 @@ class FakeSpoolman:
self._check() self._check()
return [{"key": k, **v} for k, v in self.field_defs.items()] return [{"key": k, **v} for k, v in self.field_defs.items()]
vendor_list: list | None = None
filament_list: list | None = None
def vendors(self):
self._check()
if self.vendor_list is None:
self.vendor_list = [{"id": 1, "name": "SUNLU"}]
return copy.deepcopy(self.vendor_list)
def filaments(self):
self._check()
if self.filament_list is None:
self.filament_list = [{"id": 1, "name": "PLA+ Black", "vendor": {"id": 1, "name": "SUNLU"}, "material": "PLA+",
"color_hex": "000000", "density": 1.24, "diameter": 1.75, "weight": 1000.0}]
return copy.deepcopy(self.filament_list)
def create_vendor(self, body):
self._check()
self.calls.append(("create_vendor", dict(body)))
v = {"id": len(self.vendors()) + 1, **body}
self.vendor_list.append(v)
return v
def create_filament(self, body):
self._check()
self.calls.append(("create_filament", dict(body)))
vendor = next(v for v in self.vendors() if v["id"] == body["vendor_id"])
f = {"id": len(self.filaments()) + 1, "vendor": vendor, **body}
self.filament_list.append(f)
return f
def create_spool(self, body):
self._check()
self.calls.append(("create_spool", dict(body)))
sid = max(self.db, default=0) + 1
fil = next(f for f in self.filaments() if f["id"] == body["filament_id"])
self.db[sid] = {"id": sid, "lot_nr": None, "remaining_weight": body.get("initial_weight") or fil.get("weight"),
"initial_weight": body.get("initial_weight"), "used_weight": 0.0, "location": body.get("location", ""),
"archived": False, "extra": {}, "filament": fil}
return copy.deepcopy(self.db[sid])
def add_field(self, key, params): def add_field(self, key, params):
self._check() self._check()
self.calls.append(("field", key)) self.calls.append(("field", key))
+163 -4
View File
@@ -93,8 +93,10 @@ def test_existing_mirror_is_reused(env):
env["cc2"].calls.clear() env["cc2"].calls.clear()
insert(env, 0, 5) insert(env, 0, 5)
poll(env) poll(env)
assert [c for c in env["cc2"].calls if c[0] == "save"] == [] assert [c for c in env["cc2"].calls if c[0] == "save" and "id" not in c[1]] == [] # not created again
assert ("assign", 0, "s0001") in env["cc2"].calls assert ("assign", 0, "s0001") in env["cc2"].calls
# the copy made by hand lacked brand and empty weight: taken from Spoolman
assert ("save", {"id": "s0001", "brand": "SUNLU", "tare": 150.0}) in env["cc2"].calls
def test_unknown_spool_id_is_ignored(env): def test_unknown_spool_id_is_ignored(env):
@@ -526,7 +528,7 @@ def test_migration_from_v1(tmp_path):
assert slots(e)["1"]["spool"] == 1 and booked(e)["abc"]["remaining"] == 250.0 assert slots(e)["1"]["spool"] == 1 and booked(e)["abc"]["remaining"] == 250.0
assert (tmp_path / "ssh" / "host_key_p1.json").exists() assert (tmp_path / "ssh" / "host_key_p1.json").exists()
assert sm.db[1]["location"] == "Rechts Slot 2" and sm.extra(1) == {"drucker": "Rechts", "slot": 2} assert sm.db[1]["location"] == "Rechts Slot 2" and sm.extra(1) == {"drucker": "Rechts", "slot": 2}
assert not [c for c in cc2.calls if c[0] in ("save", "assign")] # nothing re-created assert not [c for c in cc2.calls if c[0] == "assign" or (c[0] == "save" and "id" not in c[1])] # nothing re-created
# ---------- v3: putting rolls back, CC2 Control's log ---------- # ---------- v3: putting rolls back, CC2 Control's log ----------
@@ -743,8 +745,9 @@ def test_runout_and_backup_roll(env):
assert uses(env) == [("use", 8, 12.0)] assert uses(env) == [("use", 8, 12.0)]
assert ("used", 5, 1000.0) in env["sm"].calls # ran out: Spoolman's rest is 0, whatever the count said assert ("used", 5, 1000.0) in env["sm"].calls # ran out: Spoolman's rest is 0, whatever the count said
assert env["sm"].db[5]["remaining_weight"] == 0 assert env["sm"].db[5]["remaining_weight"] == 0
assert any("Rolle leergelaufen" in m for m in msgs(env)) assert any("Rolle leergelaufen" in m and "archivieren" in m for m in msgs(env))
assert booked(env)["s0001"]["last"]["grams"] == 40.0 assert env["sm"].db[5]["archived"] # empty: archived in Spoolman ...
assert ("delete", "s0001") in env["cc2"].calls # ... and its copy leaves CC2 Control
def test_runout_sets_spoolman_to_zero_even_if_the_mirror_was_off(env): def test_runout_sets_spoolman_to_zero_even_if_the_mirror_was_off(env):
@@ -825,3 +828,159 @@ def test_printer_names_written_are_remembered(env):
insert(env, 0, 5) insert(env, 0, 5)
poll(env) poll(env)
assert env["bridge"].state.data["written_printers"] == ["Rechts"] assert env["bridge"].state.data["written_printers"] == ["Rechts"]
def test_rest_follows_spoolman_when_roll_comes_back(tmp_path):
e = make_env(tmp_path, printers=2)
for c in e["cc2s"].values():
c.clock = e["clock"]
e["sm"].add(5, remaining=800)
insert(e, 0, 5)
poll(e, 2)
take_out_of_tray(e, 0)
e["readers"]["p1"].record["channels0"] = channel("")
insert(e, 1, 5, pid="p2") # into the other printer: a fresh copy there with 800 g
poll(e, 6)
e["cc2s"]["p2"].consume("s0001", 100.0)
poll(e, 15)
assert e["sm"].db[5]["remaining_weight"] == 700
take_out_of_tray(e, 1, pid="p2")
e["readers"]["p2"].record["channels1"] = channel("")
insert(e, 0, 5) # back into the first printer: its old copy still says 800 g
poll(e, 30)
assert ("adjust", "s0001", 700.0) in e["cc2s"]["p1"].calls
assert e["cc2s"]["p1"].library[0]["remaining"] == 700.0
assert [c for c in e["sm"].calls if c[0] in ("use", "used")] == [("use", 5, 100.0)] # nothing booked back
poll(e, 30)
assert len([c for c in e["cc2s"]["p1"].calls if c[0] == "adjust"]) == 1
def test_weighed_in_spoolman_goes_to_cc2(env):
env["cc2"].clock = env["clock"]
env["sm"].add(5, remaining=800)
insert(env, 0, 5)
poll(env, 2)
env["sm"].db[5]["remaining_weight"] = 612.0 # weighed and entered in Spoolman
poll(env, 30)
assert ("adjust", "s0001", 612.0) in env["cc2"].calls
assert not [c for c in env["sm"].calls if c[0] in ("use", "used")]
assert booked(env)["s0001"]["remaining"] == 612.0 and "adjusted" not in booked(env)["s0001"]
def test_rest_not_adjusted_before_use_is_booked(env):
env["sm"].add(5, remaining=800)
insert(env, 0, 5)
poll(env, 2)
env["cc2"].job = {"active": True}
env["cc2"].consume("s0001", 50.0)
poll(env, 20) # printing: neither booked nor adjusted
assert not [c for c in env["cc2"].calls if c[0] == "adjust"]
env["cc2"].job = {"active": False}
poll(env, 30)
assert uses(env) == [("use", 5, 50.0)]
assert not [c for c in env["cc2"].calls if c[0] == "adjust"]
def test_master_data_follows_spoolman(env):
env["sm"].add(5, color="4A4A4A")
insert(env, 0, 5)
poll(env, 2)
env["sm"].db[5]["filament"]["color_hex"] = "898989"
env["sm"].db[5]["filament"]["density"] = 1.3
env["bridge"].spool_cache.clear()
poll(env, 14)
assert ("save", {"id": "s0001", "color": "#898989", "density": 1.3}) in env["cc2"].calls
assert env["cc2"].library[0]["color"] == "#898989"
poll(env, 14)
assert len([c for c in env["cc2"].calls if c[0] == "save" and "id" in c[1]]) == 1
def test_copies_of_archived_or_deleted_spools_are_removed_when_not_loaded(env):
env["sm"].add(5)
env["sm"].add(6)
env["sm"].add(7)
insert(env, 0, 5)
insert(env, 1, 6)
insert(env, 2, 7)
poll(env, 2)
take_out_of_tray(env, 0)
take_out_of_tray(env, 1)
poll(env, 6)
env["sm"].db[5]["archived"] = True
del env["sm"].db[6]
env["sm"].db[7]["archived"] = True # still in a tray: kept
env["bridge"].spool_cache.clear()
poll(env, 14)
deleted = [c[1] for c in env["cc2"].calls if c[0] == "delete"]
assert deleted == ["s0001", "s0002"]
assert [s["id"] for s in env["cc2"].library] == ["s0003"]
def test_scan_of_archived_spool_is_not_mapped(env):
env["sm"].add(5)["archived"] = True
insert(env, 0, 5)
poll(env, 2)
assert slots(env) == {} and not env["cc2"].calls
assert any("ist archiviert" in m for m in msgs(env))
def test_runout_does_not_archive_when_switched_off(env):
env["settings"].save({"archive_empty": False})
env["sm"].add(5, remaining=40)
insert(env, 0, 5)
poll(env)
cc2 = env["cc2"]
cc2.job = {"active": True}
cc2.consume("s0001", 40.0)
poll(env)
cc2.slots[0]["printer"] = {"status": 0}
cc2.slots[0]["spool"] = ""
cc2.add_log(env["clock"](), "runout", "s0001", 0, 0.0, 0, "vase.gcode", "runout")
cc2.job = {"active": False}
poll(env, 15)
assert env["sm"].db[5]["remaining_weight"] == 0 and not env["sm"].db[5]["archived"]
def test_roll_back_in_its_old_slot_after_the_other_printer(tmp_path):
"""Scanned again with identical tag data: the record does not change, CC2 Control asks; the roll is recognised."""
e = make_env(tmp_path, printers=2)
e["sm"].add(5, remaining=800)
insert(e, 3, 5)
poll(e, 2)
take_out_of_tray(e, 3)
insert(e, 0, 5, pid="p2") # scanned and loaded in the other printer
poll(e, 6)
assert slots(e, "p2")["0"]["spool"] == 5 and slots(e) == {}
take_out_of_tray(e, 0, pid="p2")
e["readers"]["p2"].record["channels0"] = channel("")
poll(e, 6)
assert slots(e, "p2") == {}
put_back(e, 3) # scanned at the side again (same data) and back into its old tray
poll(e, 2)
assert slots(e)["3"]["spool"] == 5 and slots(e)["3"].get("reinserted")
assert e["cc2s"]["p1"].slots[3]["spool"] == "s0001"
def test_archived_spool_with_roll_in_tray_is_not_deleted(env):
env["sm"].add(5)
insert(env, 0, 5)
poll(env, 2)
env["bridge"].release(env["bridge"].workers["p1"], 0, "Test")
env["cc2"].slots[0]["spool"] = "" # unbound, roll still in the tray with its tag
env["sm"].db[5]["archived"] = True
env["bridge"].spool_cache.clear()
poll(env, 14)
assert not [c for c in env["cc2"].calls if c[0] == "delete"]
def test_spoolman_outage_does_not_delete_copies(env):
env["sm"].add(5)
insert(env, 0, 5)
poll(env, 2)
take_out_of_tray(env, 0)
poll(env, 6)
env["bridge"].spool_cache.clear()
env["sm"].fail = True
poll(env, 14)
assert not [c for c in env["cc2"].calls if c[0] == "delete"]
assert len(env["cc2"].library) == 1
+31
View File
@@ -133,3 +133,34 @@ def test_printers_editable(tmp_path):
assert dup.status_code == 400 assert dup.status_code == 400
bad = c.post("/api/settings", json={"location_slot": "Slot {n}"}, headers=H) bad = c.post("/api/settings", json={"location_slot": "Slot {n}"}, headers=H)
assert bad.status_code == 400 # two printers need {printer} assert bad.status_code == 400 # two printers need {printer}
def test_create_spools_from_existing_filament(tmp_path):
c, bridge = make_client(tmp_path)
sm = bridge.spoolman_factory(None)
cat = c.get("/api/spoolman/catalog").json()
assert cat["next_id"] == 6 and cat["filaments"][0]["name"] == "PLA+ Black"
body = {"filament_id": 1, "count": 2, "weight": 1000, "spool_weight": 130}
assert c.post("/api/spoolman/create", json=body, headers=H).status_code == 409 # dry-run
assert not [x for x in sm.calls if x[0].startswith("create")]
c.post("/api/settings", json={"dry_run": False}, headers=H)
r = c.post("/api/spoolman/create", json=body, headers=H)
assert r.status_code == 200 and r.json()["spools"] == [{"id": 6, "label": "SUNLU006"}, {"id": 7, "label": "SUNLU007"}]
assert sm.db[7]["lot_nr"] == "SUNLU007" and sm.db[7]["location"] == "Lager" and sm.db[7]["initial_weight"] == 1000
assert ("create_spool", {"filament_id": 1, "location": "Lager", "initial_weight": 1000.0, "spool_weight": 130.0}) in sm.calls
def test_create_spool_with_new_filament_and_vendor(tmp_path):
c, bridge = make_client(tmp_path)
sm = bridge.spoolman_factory(None)
c.post("/api/settings", json={"dry_run": False}, headers=H)
body = {"vendor": "Elegoo", "name": "PLA Matte Black", "material": "PLA Matte", "color": "#1a1a1a", "density": 1.24,
"diameter": 1.75, "weight": 1000, "spool_weight": 154, "extruder_temp": 210, "bed_temp": 60, "count": 1}
r = c.post("/api/spoolman/create", json=body, headers=H)
assert r.status_code == 200 and r.json()["spools"][0]["label"] == "ELEGOO006"
fil = [x[1] for x in sm.calls if x[0] == "create_filament"][0]
assert fil["color_hex"] == "1A1A1A" and fil["settings_extruder_temp"] == 210 and fil["vendor_id"] == 2
assert ("create_vendor", {"name": "Elegoo", "empty_spool_weight": 154.0}) in sm.calls
bad = dict(body, color="red")
assert c.post("/api/spoolman/create", json=bad, headers=H).status_code == 400
assert c.post("/api/spoolman/create", json=dict(body, count=50), headers=H).status_code == 400
+79 -3
View File
@@ -44,7 +44,7 @@ def printer_state():
PRINTERS: list[dict] = [] PRINTERS: list[dict] = []
STATE = {"spoolman": {}, "fields": {}, "requests": []} STATE = {"spoolman": {}, "fields": {}, "requests": [], "vendors": [], "filaments": []}
LOCK = threading.Lock() LOCK = threading.Lock()
@@ -58,7 +58,8 @@ def seed_spoolman():
"extra": {}, "extra": {},
"filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat, "filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat,
"color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0, "color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0,
"settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}}, "settings_extruder_temp": 215,
"vendor": {"id": 1 if vendor == "Sunlu" else 2 if vendor == "Elegoo" else 3, "name": vendor}},
} }
@@ -134,6 +135,9 @@ def make_cc2(host_header: str, P: dict) -> FastAPI:
for k in ("name", "brand", "material", "color", "note"): for k in ("name", "brand", "material", "color", "note"):
if k in f: if k in f:
sp[k] = f[k] sp[k] = f[k]
for k in ("diameter", "density", "net", "tare", "low", "price"):
if k in f:
sp[k] = float(f[k])
if "archived" in f: if "archived" in f:
sp["archived"] = f["archived"] == "1" sp["archived"] = f["archived"] == "1"
return {"ok": True, "id": sp["id"]} return {"ok": True, "id": sp["id"]}
@@ -147,6 +151,33 @@ def make_cc2(host_header: str, P: dict) -> FastAPI:
P["spools"].append(sp) P["spools"].append(sp)
return JSONResponse({"ok": True, "id": sp["id"]}, 201) return JSONResponse({"ok": True, "id": sp["id"]}, 201)
@app.post("/api/spools/adjust")
async def adjust(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST adjust " + json.dumps(f))
sp = next((s for s in P["spools"] if s["id"] == f.get("id")), None)
if not sp:
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
value = float(f["remaining"])
P["log"].insert(0, {"time": int(time.time()), "spool": sp["id"], "slot": -1, "kind": "correction",
"grams": value - sp["remaining"], "mm": 0.0, "job": "", "result": "set"})
sp["remaining"] = value
return {"ok": True, "remaining": value}
@app.post("/api/spools/delete")
async def delete(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST delete " + json.dumps(f))
if not any(s["id"] == f.get("id") for s in P["spools"]):
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
P["spools"][:] = [s for s in P["spools"] if s["id"] != f["id"]]
for s in P["slots"]:
if s["spool"] == f["id"]:
s["spool"] = ""
return {"ok": True}
@app.post("/api/spools/assign") @app.post("/api/spools/assign")
async def assign(request: Request): async def assign(request: Request):
f = form(await request.body()) f = form(await request.body())
@@ -189,7 +220,7 @@ def make_spoolman() -> FastAPI:
for key, value in (body.get("extra") or {}).items(): for key, value in (body.get("extra") or {}).items():
if value is not None and key not in STATE["fields"]: if value is not None and key not in STATE["fields"]:
raise HTTPException(400, f"Unknown extra field {key}") raise HTTPException(400, f"Unknown extra field {key}")
sp.update({k: v for k, v in body.items() if k in ("location",)}) sp.update({k: v for k, v in body.items() if k in ("location", "lot_nr", "archived")})
if "used_weight" in body: if "used_weight" in body:
sp["used_weight"] = float(body["used_weight"]) sp["used_weight"] = float(body["used_weight"])
sp["remaining_weight"] = (sp.get("initial_weight") or 1000.0) - sp["used_weight"] sp["remaining_weight"] = (sp.get("initial_weight") or 1000.0) - sp["used_weight"]
@@ -201,6 +232,51 @@ def make_spoolman() -> FastAPI:
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}") STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
return sp return sp
@app.get("/api/v1/vendor")
def vendors():
found = {s["filament"]["vendor"]["id"]: s["filament"]["vendor"] for s in STATE["spoolman"].values()}
found.update({v["id"]: v for v in STATE["vendors"]})
return list(found.values())
@app.get("/api/v1/filament")
def filaments():
found = {s["filament"]["id"]: s["filament"] for s in STATE["spoolman"].values()}
found.update({f["id"]: f for f in STATE["filaments"]})
return list(found.values())
@app.post("/api/v1/vendor")
async def add_vendor(request: Request):
body = await request.json()
with LOCK:
v = {"id": max([x["id"] for x in vendors()], default=0) + 1, **body}
STATE["vendors"].append(v)
STATE["requests"].append(f"POST vendor {json.dumps(body, ensure_ascii=False)}")
return v
@app.post("/api/v1/filament")
async def add_filament(request: Request):
body = await request.json()
with LOCK:
vendor = next(v for v in vendors() if v["id"] == body["vendor_id"])
f = {"id": max([x["id"] for x in filaments()], default=0) + 1, "vendor": vendor, **body}
STATE["filaments"].append(f)
STATE["requests"].append(f"POST filament {json.dumps(body, ensure_ascii=False)}")
return f
@app.post("/api/v1/spool")
async def add_spool(request: Request):
body = await request.json()
with LOCK:
fil = next(f for f in filaments() if f["id"] == body["filament_id"])
sid = max(STATE["spoolman"], default=0) + 1
weight = body.get("initial_weight") or fil.get("weight") or 1000.0
STATE["spoolman"][sid] = {"id": sid, "lot_nr": None, "remaining_weight": weight, "used_weight": 0.0,
"initial_weight": weight, "spool_weight": body.get("spool_weight"),
"location": body.get("location", ""), "archived": False, "price": body.get("price"),
"extra": {}, "filament": fil}
STATE["requests"].append(f"POST spool {json.dumps(body, ensure_ascii=False)}")
return STATE["spoolman"][sid]
@app.get("/api/v1/field/spool") @app.get("/api/v1/field/spool")
def fields(): def fields():
return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()] return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()]