Spoolman leads CC2 Control's copies; new spools from the bridge
- Master data of CC2 Control's copies follows Spoolman (name, brand, colour, diameter, density, net, tare, price; not the material, which was fitted to the tray). - A loaded spool's rest in CC2 Control is set to Spoolman's via /api/spools/adjust once everything is booked (old copy after the roll printed in the other printer, weigh-in in Spoolman); the resulting correction is not booked back. - Copies of spools archived or deleted in Spoolman are deleted from CC2 Control when no tray holds them (library limit 128); an outage never counts as deleted. - A roll that ran out is archived in Spoolman (setting, default on); a scan of an archived spool maps nothing. - A roll scanned again with unchanged tag data and put back into its old slot (after printing in the other printer) is recognised. - Tag generator: "Neue Spule" creates spools in Spoolman from an existing or a new filament (and vendor), labelled vendor + id, then opens the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
1 parent
d03668e3c5
commit
b37cd64909
13 files changed
+873
-27
No files matched your search
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,139 @@
|
|||||||
|
"""New spools in Spoolman from the bridge UI: an existing filament, or a new one (and its vendor)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .spoolman import Spoolman, make_label
|
||||||
|
|
||||||
|
MAX_COUNT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class NewSpoolError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _num(body: dict, key: str, lo: float, hi: float, required: bool = True, what: str = "") -> float | None:
|
||||||
|
raw = body.get(key)
|
||||||
|
if raw in (None, ""):
|
||||||
|
if required:
|
||||||
|
raise NewSpoolError(f"{what or key} fehlt")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise NewSpoolError(f"{what or key}: keine Zahl") from None
|
||||||
|
if not lo <= v <= hi:
|
||||||
|
raise NewSpoolError(f"{what or key} muss zwischen {lo:g} und {hi:g} liegen")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _text(body: dict, key: str, what: str, limit: int = 64) -> str:
|
||||||
|
v = re.sub(r"\s+", " ", str(body.get(key) or "")).strip()
|
||||||
|
if not v:
|
||||||
|
raise NewSpoolError(f"{what} fehlt")
|
||||||
|
if len(v) > limit:
|
||||||
|
raise NewSpoolError(f"{what} ist zu lang (höchstens {limit} Zeichen)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def catalog(client: Spoolman) -> dict:
|
||||||
|
"""Vendors and filaments for the form, and the id Spoolman will most likely give the next spool."""
|
||||||
|
vendors = sorted(client.vendors(), key=lambda v: v["name"].lower())
|
||||||
|
spools = client.spools(archived=True)
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
for sp in spools:
|
||||||
|
if not sp.get("archived"):
|
||||||
|
fid = (sp.get("filament") or {}).get("id")
|
||||||
|
counts[fid] = counts.get(fid, 0) + 1
|
||||||
|
filaments = []
|
||||||
|
for f in client.filaments():
|
||||||
|
if f.get("archived"):
|
||||||
|
continue
|
||||||
|
filaments.append({
|
||||||
|
"id": f["id"], "name": f.get("name") or "", "vendor": (f.get("vendor") or {}).get("name") or "",
|
||||||
|
"material": f.get("material") or "", "color": (f.get("color_hex") or "").upper()[:6],
|
||||||
|
"density": f.get("density"), "diameter": f.get("diameter"), "weight": f.get("weight"),
|
||||||
|
"spool_weight": f.get("spool_weight"), "extruder_temp": f.get("settings_extruder_temp"),
|
||||||
|
"bed_temp": f.get("settings_bed_temp"), "price": f.get("price"), "spools": counts.get(f["id"], 0),
|
||||||
|
})
|
||||||
|
filaments.sort(key=lambda f: (f["vendor"].lower(), f["name"].lower()))
|
||||||
|
next_id = max((int(sp["id"]) for sp in spools), default=0) + 1
|
||||||
|
return {"vendors": [{"id": v["id"], "name": v["name"], "empty_spool_weight": v.get("empty_spool_weight")}
|
||||||
|
for v in vendors],
|
||||||
|
"filaments": filaments, "next_id": next_id}
|
||||||
|
|
||||||
|
|
||||||
|
def plan(body: dict) -> dict:
|
||||||
|
"""Checks the form; returns what to create."""
|
||||||
|
count = int(_num(body, "count", 1, MAX_COUNT, what="Anzahl"))
|
||||||
|
out: dict = {"count": count,
|
||||||
|
"initial_weight": _num(body, "weight", 1, 20000, required=False, what="Nettogewicht"),
|
||||||
|
"spool_weight": _num(body, "spool_weight", 0, 5000, required=False, what="Leergewicht"),
|
||||||
|
"price": _num(body, "price", 0, 1e6, required=False, what="Preis")}
|
||||||
|
if body.get("filament_id") not in (None, ""):
|
||||||
|
out["filament_id"] = int(body["filament_id"])
|
||||||
|
return out
|
||||||
|
color = str(body.get("color") or "").strip().lstrip("#").upper()
|
||||||
|
if not re.fullmatch(r"[0-9A-F]{6}", color):
|
||||||
|
raise NewSpoolError("Farbe als RRGGBB angeben")
|
||||||
|
out["vendor"] = _text(body, "vendor", "Hersteller")
|
||||||
|
out["filament"] = {
|
||||||
|
"name": _text(body, "name", "Name", 96),
|
||||||
|
"material": _text(body, "material", "Material", 32),
|
||||||
|
"color_hex": color,
|
||||||
|
"density": _num(body, "density", 0.5, 3, what="Dichte"),
|
||||||
|
"diameter": _num(body, "diameter", 1, 3.5, what="Durchmesser"),
|
||||||
|
"weight": out["initial_weight"],
|
||||||
|
"spool_weight": out["spool_weight"],
|
||||||
|
"settings_extruder_temp": _int(_num(body, "extruder_temp", 150, 450, required=False, what="Düsentemperatur")),
|
||||||
|
"settings_bed_temp": _int(_num(body, "bed_temp", 0, 150, required=False, what="Betttemperatur")),
|
||||||
|
"price": out["price"],
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _int(v: float | None) -> int | None:
|
||||||
|
return None if v is None else int(round(v))
|
||||||
|
|
||||||
|
|
||||||
|
def describe(p: dict, filament_name: str, vendor: str) -> str:
|
||||||
|
n = p["count"]
|
||||||
|
weight = f", {p['initial_weight']:g} g" if p.get("initial_weight") else ""
|
||||||
|
return f"{n} Spule{'n' if n > 1 else ''} {vendor} {filament_name}{weight}"
|
||||||
|
|
||||||
|
|
||||||
|
def create(client: Spoolman, p: dict, location: str) -> tuple[list[dict], str]:
|
||||||
|
"""Creates what `plan` returned. Returns the new spools (id, label) and what was created, for the log."""
|
||||||
|
created_parts = []
|
||||||
|
if "filament_id" in p:
|
||||||
|
fil = next((f for f in client.filaments() if f["id"] == p["filament_id"]), None)
|
||||||
|
if fil is None:
|
||||||
|
raise NewSpoolError("Filament gibt es in Spoolman nicht mehr")
|
||||||
|
vendor = (fil.get("vendor") or {}).get("name") or ""
|
||||||
|
else:
|
||||||
|
vendor = p["vendor"]
|
||||||
|
v = next((v for v in client.vendors() if v["name"].strip().lower() == vendor.lower()), None)
|
||||||
|
if v is None:
|
||||||
|
body = {"name": vendor}
|
||||||
|
if p["filament"].get("spool_weight") is not None:
|
||||||
|
body["empty_spool_weight"] = p["filament"]["spool_weight"]
|
||||||
|
v = client.create_vendor(body)
|
||||||
|
created_parts.append(f"Hersteller {vendor}")
|
||||||
|
vendor = v["name"]
|
||||||
|
fbody = {k: val for k, val in p["filament"].items() if val is not None}
|
||||||
|
fbody["vendor_id"] = v["id"]
|
||||||
|
fil = client.create_filament(fbody)
|
||||||
|
created_parts.append(f"Filament {fil.get('name')}")
|
||||||
|
spools = []
|
||||||
|
for _ in range(p["count"]):
|
||||||
|
body: dict = {"filament_id": fil["id"], "location": location}
|
||||||
|
for key in ("initial_weight", "spool_weight", "price"):
|
||||||
|
if p.get(key) is not None:
|
||||||
|
body[key] = p[key]
|
||||||
|
sp = client.create_spool(body)
|
||||||
|
lot = make_label(vendor, int(sp["id"]))
|
||||||
|
client.update_spool(int(sp["id"]), {"lot_nr": lot})
|
||||||
|
spools.append({"id": int(sp["id"]), "label": lot})
|
||||||
|
created_parts.insert(0, describe(p, fil.get("name") or "", vendor) + " (" + ", ".join(s["label"] for s in spools) + ")")
|
||||||
|
return spools, "; ".join(created_parts)
|
||||||
+20
-2
@@ -55,6 +55,21 @@ class Spoolman:
|
|||||||
def update_spool(self, spool_id: int, body: dict) -> dict:
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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()]
|
||||||
|
|||||||
Reference in new issue
Block a user