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); }
+
Abfrage
@@ -147,6 +148,7 @@ async function loadSettings() { current = await api('GET', '/api/settings'); for (const f of FIELDS) $(f).value = current[f]; $('spoolman_fields').checked = current.spoolman_fields; + $('archive_empty').checked = current.archive_empty; printers = current.printers.map((p) => ({ ...p })); saved = new Set(printers.map((p) => p.id)); showMode(current.dry_run); @@ -156,6 +158,7 @@ async function saveSettings() { const body = { printers }; for (const f of FIELDS) body[f] = $(f).value; body.spoolman_fields = $('spoolman_fields').checked; + body.archive_empty = $('archive_empty').checked; try { current = await api('POST', '/api/settings', body); printers = current.printers.map((p) => ({ ...p })); diff --git a/app/templates/tags.html b/app/templates/tags.html index a104baf..377b2cf 100644 --- a/app/templates/tags.html +++ b/app/templates/tags.html @@ -1,11 +1,16 @@ {% extends "base.html" %} {% block title %}CC2 Bridge – Tag-Generator{% endblock %} -{% block heading %}Tag-Generator{% endblock %} +{% block heading %}Tags{% endblock %} {% block head %} @@ -43,7 +48,45 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
-
+
+ + +
+
+ +
Spoolman-Spule
@@ -115,8 +158,120 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px diff --git a/app/web.py b/app/web.py index 7a253c2..803b237 100644 --- a/app/web.py +++ b/app/web.py @@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from . import materials, tagcodec +from . import materials, newspool, tagcodec from .bridge import Bridge from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint from .spoolman import SpoolmanError, summary @@ -276,6 +276,39 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st out.sort(key=lambda x: x["id"]) return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset} + @app.get("/api/spoolman/catalog") + def spoolman_catalog(): + client = bridge.spoolman_factory(settings.get()) + try: + return newspool.catalog(client) + except SpoolmanError as e: + raise HTTPException(502, str(e)) from None + finally: + client.close() + + @app.post("/api/spoolman/create") + async def spoolman_create(request: Request): + body = await _json(request) + s = settings.get() + try: + p = newspool.plan(body) + except (newspool.NewSpoolError, TypeError, ValueError) as e: + raise HTTPException(400, str(e)) from None + if s.dry_run: + bridge.log.add("dry", f"würde in Spoolman {p['count']} neue Spule(n) anlegen") + raise HTTPException(409, "Dry-Run aktiv: in Spoolman wird nichts angelegt") + client = bridge.spoolman_factory(s) + try: + spools, what = newspool.create(client, p, s.location_storage) + except newspool.NewSpoolError as e: + raise HTTPException(400, str(e)) from None + except SpoolmanError as e: + raise HTTPException(502, str(e)) from None + finally: + client.close() + bridge.log.add("info", f"erledigt: in Spoolman angelegt: {what}") + return {"spools": spools} + @app.post("/api/tag") async def tag(request: Request): body = await _json(request) diff --git a/tests/fakes.py b/tests/fakes.py index 5563035..a4d3a25 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -52,6 +52,7 @@ class FakeCC2: self.log: list[dict] = [] # newest first, as CC2 Control lists it self.calls: list[tuple] = [] 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)) def _check(self): @@ -78,8 +79,11 @@ class FakeCC2: self.calls.append(("save", dict(fields))) if fields.get("id"): sp = next(s for s in self.library if s["id"] == fields["id"]) - if "archived" in fields: - sp["archived"] = fields["archived"] in ("1", 1, True) + for key, value in fields.items(): + if key == "archived": + sp["archived"] = value in ("1", 1, True) + elif key != "id": + sp[key] = value return {"ok": True, "id": sp["id"]} 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), @@ -89,6 +93,18 @@ class FakeCC2: self.library.append(sp) 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): self._check() self.calls.append(("assign", slot, spool_id)) @@ -173,6 +189,11 @@ class FakeSpoolman: self.calls.append(("extra", sid, dict(body["extra"]))) if "location" in body: 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(): if value is None: sp["extra"].pop(key, None) @@ -184,6 +205,47 @@ class FakeSpoolman: self._check() 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): self._check() self.calls.append(("field", key)) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3340bef..eee9fc1 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -93,8 +93,10 @@ def test_existing_mirror_is_reused(env): env["cc2"].calls.clear() insert(env, 0, 5) 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 + # 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): @@ -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 (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 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 ---------- @@ -743,8 +745,9 @@ def test_runout_and_backup_roll(env): 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 env["sm"].db[5]["remaining_weight"] == 0 - assert any("Rolle leergelaufen" in m for m in msgs(env)) - assert booked(env)["s0001"]["last"]["grams"] == 40.0 + assert any("Rolle leergelaufen" in m and "archivieren" in m for m in msgs(env)) + 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): @@ -825,3 +828,159 @@ def test_printer_names_written_are_remembered(env): insert(env, 0, 5) poll(env) 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 diff --git a/tests/test_web.py b/tests/test_web.py index 96b97af..ee6c4e8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -133,3 +133,34 @@ def test_printers_editable(tmp_path): assert dup.status_code == 400 bad = c.post("/api/settings", json={"location_slot": "Slot {n}"}, headers=H) 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 diff --git a/tools/mock_env.py b/tools/mock_env.py index 1419082..48af62e 100644 --- a/tools/mock_env.py +++ b/tools/mock_env.py @@ -44,7 +44,7 @@ def printer_state(): PRINTERS: list[dict] = [] -STATE = {"spoolman": {}, "fields": {}, "requests": []} +STATE = {"spoolman": {}, "fields": {}, "requests": [], "vendors": [], "filaments": []} LOCK = threading.Lock() @@ -58,7 +58,8 @@ def seed_spoolman(): "extra": {}, "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, - "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"): if k in f: 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: sp["archived"] = f["archived"] == "1" return {"ok": True, "id": sp["id"]} @@ -147,6 +151,33 @@ def make_cc2(host_header: str, P: dict) -> FastAPI: P["spools"].append(sp) 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") async def assign(request: Request): f = form(await request.body()) @@ -189,7 +220,7 @@ def make_spoolman() -> FastAPI: for key, value in (body.get("extra") or {}).items(): if value is not None and key not in STATE["fields"]: 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: sp["used_weight"] = float(body["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)}") 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") def fields(): return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()]