Recognise rolls put back, use CC2 Control's log for bookings
CI / test (push) Successful in 1m13s
CI / image (push) Successful in 48s

- A roll put back into the slot it left, with the tag data unchanged, is
  mapped again and CC2 Control's question answered; if the touchscreen then
  replaces the id, it was another roll and goes to storage.
- A touchscreen edit of a scanned roll keeps the mapping and re-answers
  CC2 Control's question so its use is counted.
- Bookings name the print and its result from CC2 Control's log.
- A weigh-in in CC2 Control sets Spoolman's remaining weight instead of
  being booked as use.
- After "Take out" with the roll still in the tray, the tray's logged use
  is booked to that roll until it leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 10:29:33 +01:00
1 parent a9041eb538
commit 67c3b54903
7 files changed
+607 -40

No files matched your search

+38 -9
View File
@@ -85,18 +85,30 @@ kleinere Werte sind echte Gewichte von Original- oder Fremdtags und werden ignor
im Slot gegenüber dem letzten Datensatz ändert.
**Zuordnung.** Die Bridge speichert Drucker/Slot ↔ Spule selbst (`/data/state.json`). Eine Spule liegt höchstens
in einem Slot eines Druckers. Fällt `weight` nach einer Bearbeitung am Display auf 1000 zurück, bleibt die
Zuordnung. Aufgehoben wird sie nur, wenn
in einem Slot eines Druckers. Fällt `weight` nach einer Bearbeitung am Display auf 1000 zurück, bleibt eine
**gescannte** Zuordnung bestehen; fragt CC2 Control deshalb nach der Spule, weist die Bridge sie erneut zu (höchstens
einmal pro Minute). Aufgehoben wird sie, wenn
- der Slot länger als 20 s leer ist (Tray-Status 0),
- CC2 Control die Spule herausnimmt („Take out“) oder ins Lager legt (Slot ohne Spule und ohne offene Rückfrage),
- im Slot ein Tag mit einer anderen ID gescannt wird oder
- die Spule in einem anderen Slot oder im anderen Drucker gescannt wird (Umzug).
- im Slot ein Tag mit einer anderen ID gescannt wird,
- die Spule in einem anderen Slot oder im anderen Drucker gescannt wird (Umzug) oder
- nach dem Einlegen einer Rolle die Tag-Daten des Slots nicht mehr ihre ID tragen (siehe unten).
Weist du eine gespiegelte Spule in CC2 Control von Hand einem belegten Slot zu, übernimmt die Bridge das.
**Rolle wieder einlegen.** Der Scanner sitzt an der Seite, die Rollen werden zum Scannen abgenommen. Der Drucker
behält die Tag-Daten eines Slots auch, wenn die Rolle draußen ist. Kommt eine Rolle in den Slot zurück (CC2 Control
fragt „eingelegt“) und stehen dort noch unverändert die Daten der Rolle, die diesen Slot zuletzt verlassen hat, gilt
sie als dieselbe Rolle: Die Bridge ordnet sie wieder zu und beantwortet die Rückfrage, mit oder ohne erneuten Scan.
Wird danach am Display ein Filament gewählt (die ID verschwindet), war es eine andere Rolle ohne Tag: Die Zuordnung
wird aufgehoben, die Rolle kommt ins Lager. Das gilt bis zum ersten Druck mit der Rolle; danach ist eine
Display-Bearbeitung wieder eine Bearbeitung derselben Rolle.
**„Take out“ in CC2 Control** zählt als Entnahme: Ort „Lager“, Felder leer. Bleibt die Rolle physisch im Tray,
rechnet CC2 Control ihren Verbrauch keiner Spule mehr zu; der Status zeigt dann „Tag steckt noch, ist aber keinem
Slot zugeordnet“. Zum erneuten Zuordnen die Rolle neu einlegen und den Scan bestätigen.
rechnet CC2 Control ihren Verbrauch keiner Spule mehr zu, protokolliert ihn aber für den Slot. Diesen Verbrauch
(Millimeter aus dem CC2-Protokoll, mit Durchmesser und Dichte der Spoolman-Spule in Gramm umgerechnet) bucht die
Bridge trotzdem auf die Rolle, bis sie den Slot verlässt oder CC2 Control nach einer Spule fragt. Zum erneuten
Zuordnen die Rolle herausnehmen und wieder einlegen.
**Spoolman synchron halten.** Nach jeder Änderung schreibt die Bridge Ort und Zusatzfelder (die Felder legt sie beim
ersten Mal selbst an). Zusätzlich vergleicht sie jede Minute alle Spoolman-Spulen: Geladene Rollen bekommen ihren
@@ -116,9 +128,26 @@ CC2 Control der Zeitstempel der letzten Nutzung, keine Menge.) Die Bridge bucht
Abnahme von `remaining` seit der letzten Buchung per `PUT /spool/{id}/use` nach Spoolman. Das geschieht erst, wenn kein
Druck läuft (`job.active`) und `remaining` 60 s unverändert ist. Der gebuchte Stand wird persistent gespeichert.
Vor jeder Buchung schreibt die Bridge die Absicht in den Zustand. Bricht eine Buchung ab, prüft sie nach dem
Neustart an `used_weight` in Spoolman, ob sie schon angekommen ist, statt doppelt zu buchen. Steigt `remaining`
(z. B. durch Nachwiegen in CC2 Control), wird nichts gebucht, nur protokolliert, und der neue Stand gilt als
Ausgangspunkt.
Neustart an `used_weight` in Spoolman, ob sie schon angekommen ist, statt doppelt zu buchen.
CC2 Control misst den echten Extruderweg (`print_stats.filament_used`, mit Spülen und Farbwechseln, mit Flow-Faktor)
und rechnet ihn dem Slot zu, der gerade fördert. Ein abgebrochener oder fehlgeschlagener Druck zählt also mit dem,
was er wirklich verbraucht hat; bei Mehrfarbdrucken bekommt jede Rolle ihren Anteil. Die Bridge nimmt Dateiname und
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.
**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.
**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
2 min). Bekommt CC2 Control eine eigene Spoolman-Anbindung, darf nur eins von beidem buchen.
**Spulenverwaltung aus.** Ist sie in CC2 Control ausgeschaltet, zählt CC2 Control keinen Verbrauch. Die Oberfläche
zeigt das deutlich an und bietet einen Button zum Einschalten (mit Rückfrage, im Dry-Run nur protokolliert).
+245 -20
View File
@@ -4,10 +4,16 @@ Rules:
- The printer is only read (SSH `cat` of the canvas record). No MQTT, no printer commands.
- Writes go to CC2 Control's spool endpoints and to Spoolman only, and only when dry-run is off.
- Slot <-> spool mappings are kept by the bridge; a tag id vanishing from the record
(weight falls back to 1000 after a touchscreen edit) does not drop a mapping.
(weight falls back to 1000 after a touchscreen edit) does not drop a mapping made by a scan.
- The record keeps a tray's tag data after the roll is taken out. A roll put back into the slot
it left, with the record unchanged, is taken to be that roll again ("reinserted"); if the
touchscreen then replaces the id, it was another roll and the mapping goes.
- Several printers: one worker each; a spool is mapped to at most one slot of one printer.
- Spoolman shows where a loaded spool is (location "<printer> Slot N", extra fields
"drucker"/"slot"); the printer wins over manual edits there.
- Consumption is the drop of CC2 Control's remaining weight, booked once the printer is idle.
CC2 Control's log adds what was printed (file, result), weigh-ins (Spoolman takes the weighed
value) and usage of a tray left without spool after "Take out" (booked to the roll still in it).
"""
from __future__ import annotations
@@ -33,7 +39,54 @@ MIN_BOOKING = 0.05 # grams; smaller changes accumulate until they reach this
SPOOL_CACHE_SECONDS = 300
SYNC_SECONDS = 60 # full Spoolman comparison (locations, extra fields)
LOG_KEEP = 1000
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
SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"}
RESULTS = {
"complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen",
"error": "Fehler", "failed": "Fehler", "ended": "beendet", "changed": "Spule gewechselt",
"moved": "Spule umgezogen", "removed": "entnommen", "runout": "leergelaufen", "late": "nachträglich zugeordnet",
}
def _num(value, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def log_key(e: dict) -> str:
return f"{e.get('time')}|{e.get('slot')}|{e.get('kind')}|{e.get('spool')}|{_num(e.get('mm')):.1f}|{_num(e.get('grams')):.3f}"
def job_text(e: dict) -> str:
"""'benchy.gcode (abgebrochen)' for a CC2 Control print log entry."""
if e.get("kind") == "runout":
return "Rolle leergelaufen"
job = str(e.get("job") or "").rsplit("/", 1)[-1] or "ohne Dateiname"
result = str(e.get("result") or "")
result = RESULTS.get(result, result)
return f"{job} ({result})" if result else job
def describe(entries: list[dict]) -> str:
parts: list[str] = []
for e in entries:
if e.get("kind") in ("print", "runout"):
text = job_text(e)
if text not in parts:
parts.append(text)
if not parts:
return ""
more = f" und {len(parts) - 4} weitere" if len(parts) > 4 else ""
return " – " + ", ".join(parts[:4]) + more
def grams_from_mm(mm: float, diameter: float, density: float) -> float:
return mm * 3.141592653589793 * (diameter / 2) ** 2 * density / 1000
class ActionLog:
@@ -205,8 +258,21 @@ class PrinterWorker:
self._process_trays(cc2)
self._process_cc2_slots(cc2)
self._reconcile_cc2(cc2)
self._process_cc2_log(cc2)
self._consumption(cc2)
def _record_id(self, n: int) -> int | None:
for s in self.record_slots:
if s["slot"] == n:
return s["spool_id"]
return None
def _close_takeout(self, n: int | None = None, spool: int | None = None) -> None:
now = self.bridge.clock()
for w in self.st["takeout"]:
if w.get("until") is None and (w["slot"] == n or w["spool"] == spool):
w["until"] = now
# ---------- record / scans ----------
def _process_record(self, record: dict) -> None:
@@ -217,6 +283,13 @@ class PrinterWorker:
mappings = self.st["slots"]
for s in slots:
n, tid = s["slot"], s["spool_id"]
if prev is not None and tid != prev[n]["spool_id"]:
# the tray's tag data was rewritten: whatever was there before is gone
self.st["last"].pop(str(n), None)
self._close_takeout(n)
m = mappings.get(str(n))
if tid is None and m and m.get("reinserted"):
self.bridge.release(self, n, "Tag-Daten am Display ersetzt, also eine andere Rolle ohne Spoolman-Tag")
if tid is None:
continue
if prev is not None:
@@ -271,6 +344,7 @@ class PrinterWorker:
if st is None:
continue
if st == 0:
self._close_takeout(n)
since = self.empty_since.setdefault(n, now)
if now - since >= self.settings.empty_grace and str(n) in self.st["slots"]:
self.bridge.release(self, n, f"Slot seit {int(now - since)} s leer")
@@ -287,14 +361,20 @@ class PrinterWorker:
sid = slot_spool_id(slot)
m = mappings.get(str(n))
mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None
question = str(slot.get("question") or "")
loaded = self.tray_status.get(n) in (1, 2)
if question:
self._close_takeout(n) # filament went in or was changed: maybe another roll
# A spool we already map elsewhere (fresh scan, maybe in the other printer) wins
# over this CC2 Control's older binding.
elsewhere = mirrored is not None and self.bridge.mapped_elsewhere(mirrored, self.id, n)
if m is None:
if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2):
if mirrored is not None and not elsewhere and loaded:
# assigned in CC2 Control by hand
self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored),
"in CC2 Control zugewiesen", sid)
elif not sid and question == "inserted" and loaded:
self._reinserted(n)
continue
if m.get("cc2") and sid == m["cc2"]:
m["cc2_seen"] = m["assigned"] = True
@@ -309,8 +389,44 @@ class PrinterWorker:
"in CC2 Control zugewiesen", sid)
elif mirrored is None:
self.bridge.release(self, n, "in CC2 Control mit anderer Spule belegt")
elif not slot.get("question"):
elif not question:
if loaded and self._record_id(n) == m["spool"]:
# "Take out" with the roll still in the tray: Spoolman says storage, but what the
# tray prints now is still that roll's
self.st["takeout"].append({"slot": n, "spool": m["spool"], "name": m.get("name") or str(m["spool"]),
"since": self.bridge.clock(), "until": None})
self.bridge.release(self, n, "in CC2 Control herausgenommen bzw. ins Lager gelegt")
elif loaded:
self._answer_again(n, m, question)
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."""
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):
return
self.bridge.map(self, n, rid, last["name"], "wieder eingelegt, Tag-Daten unverändert")
self.st["slots"][str(n)]["reinserted"] = True
def _answer_again(self, n: int, m: dict, question: str) -> None:
"""CC2 Control unbound a mapped slot and asks which spool it is."""
rid = self._record_id(n)
if question == "inserted":
if rid != m["spool"]:
self.bridge.release(self, n, "andere Rolle eingelegt")
return
m["reinserted"] = True # taken out and put back without a new scan
elif rid != m["spool"] and (rid is not None or m.get("reinserted")):
return
# same roll (or a touchscreen edit of it): tell CC2 Control again, at most once a minute
if not m.get("assigned") or self.bridge.clock() - m.get("answered_at", 0) < REANSWER_SECONDS:
return
m["assigned"] = False
m["answered_at"] = self.bridge.clock()
self.bridge.dry_logged.pop(f"{self.id}:assign:{n}", None)
self._log("info", f"Slot {n + 1}: CC2 Control fragt nach der Spule ({question}); "
f"{m.get('name') or m['spool']} wird erneut zugewiesen")
def _reconcile_cc2(self, cc2: dict) -> None:
mirrors: dict[int, dict] = {}
@@ -347,7 +463,7 @@ class PrinterWorker:
continue
self.st["booked"][cc2_id] = {
"spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))),
"pending": None, "last": None,
"pending": None, "last": None, "log_time": 0.0,
}
m["cc2"] = cc2_id
else:
@@ -371,8 +487,13 @@ class PrinterWorker:
def _consumption(self, cc2: dict) -> None:
job_active = bool((cc2.get("job") or {}).get("active"))
if job_active:
# a print ran with it: a later touchscreen edit is an edit of this roll
for m in self.st["slots"].values():
m.pop("reinserted", None)
booked = self.st["booked"]
now = self.bridge.clock()
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
for sp in cc2.get("spools") or []:
smid = spoolman_id_of(sp)
if smid is None:
@@ -382,48 +503,63 @@ class PrinterWorker:
rem = float(sp.get("remaining"))
except (TypeError, ValueError):
continue
name = sp.get("name") or cid
mine = [e for e in entries if str(e.get("spool")) == cid]
newest = max((_num(e.get("time")) for e in mine), default=0.0)
seen = self.remaining_seen.get(cid)
if seen is None or abs(seen[0] - rem) > 1e-6:
self.remaining_seen[cid] = (rem, now)
b = booked.get(cid)
if b is None or b.get("spoolman") != smid:
booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None}
self._log("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None, "log_time": newest}
self._log("info", f"{name}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
continue
b.setdefault("log_time", newest) # state from before the log was read
fresh = [e for e in mine if _num(e.get("time")) > b["log_time"]]
try:
if b.get("pending") and not self._resolve_pending(cid, b):
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):
if settled:
self._weighin(cid, b, name, smid, rem, newest)
continue
delta = b["remaining"] - rem
if delta < -MIN_BOOKING:
self._log("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
"nicht gebucht, neuer Ausgangsstand")
b["remaining"] = rem
b["log_time"] = newest
continue
if delta < MIN_BOOKING or job_active:
if delta < MIN_BOOKING or not settled:
continue
if now - self.remaining_seen[cid][1] < self.settings.settle_seconds:
continue
self._book(cid, b, sp.get("name") or cid, smid, delta, rem)
self._book(cid, b, name, smid, delta, rem, describe(fresh), newest)
except SpoolmanNotFound:
self.bridge._once_error(f"{self.id}:book:{cid}",
f"{self.name}: {sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
f"{self.name}: {name}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
except SpoolmanError as e:
self.bridge._spoolman_error(str(e))
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None:
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float,
details: str = "", log_time: float | None = None) -> None:
if self.settings.dry_run:
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None)
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen{details}", lambda: None)
return
before = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock()}
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock(),
"details": details, "log_time": log_time}
self.bridge.state.save() # intent first: a crash after the PUT must not book twice
self.bridge.spoolman.use(smid, delta)
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht")
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht{details}",
details, log_time)
def _booked(self, b: dict, grams: float, target: float, msg: str) -> None:
def _booked(self, b: dict, grams: float, target: float, msg: str, details: str = "",
log_time: float | None = None) -> None:
b["remaining"] = target
b["pending"] = None
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3)}
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3), "details": details.removeprefix(" – ")}
if log_time is not None:
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
self.bridge.state.save()
self._log("info", msg)
@@ -431,18 +567,98 @@ class PrinterWorker:
"""A booking was interrupted: check whether Spoolman already has it."""
p = b["pending"]
smid = b["spoolman"]
details, log_time = p.get("details") or "", p.get("log_time")
used = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
if used >= p["before"] + p["grams"] - 0.01:
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman")
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman",
details, log_time)
return True
if self.settings.dry_run:
self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen",
lambda: None)
return False
self.bridge.spoolman.use(smid, p["grams"])
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt")
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt",
details, log_time)
return True
def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float) -> None:
"""Weighed in CC2 Control: Spoolman takes the weighed value (and any use since) as it is."""
sm = self.bridge.spoolman.spool(smid)
base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight")
if not base:
self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein "
"Startgewicht; Nachwiegen nicht übernommen")
return
target = max(0.0, rem)
used = round(max(0.0, float(base) - target), 3)
_, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen "
"(in CC2 Control nachgewogen)",
lambda: self.bridge.spoolman.update_spool(smid, {"used_weight": used}))
if done:
b["remaining"] = rem
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
self.bridge.spool_cache.pop(smid, None)
self.bridge.state.save()
# ---------- CC2 Control's log: use of a tray left without spool after "Take out" ----------
def _process_cc2_log(self, cc2: dict) -> None:
st = self.st
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
keys = [log_key(e) for e in entries]
now = self.bridge.clock()
if st.get("log_seen") is None:
st["log_seen"] = keys[:LOG_SEEN_KEEP] # older entries are not ours to book
else:
seen = set(st["log_seen"])
for e, key in zip(entries, keys):
if key in seen:
continue
st["log_seen"].append(key)
seen.add(key)
n, t, mm = e.get("slot"), _num(e.get("time")), _num(e.get("mm"))
if e.get("kind") != "print" or e.get("spool") or n not in (0, 1, 2, 3) or mm <= 0:
continue
win = next((w for w in st["takeout"] if w["slot"] == n and
w["since"] - TAKEOUT_MARGIN <= t <= (w["until"] or now) + TAKEOUT_MARGIN), None)
if win:
st["extra"].append({"key": key, "slot": n, "spool": win["spool"], "name": win["name"], "mm": mm,
"job": job_text(e), "before": None, "grams": None})
st["log_seen"] = st["log_seen"][-LOG_SEEN_KEEP:]
st["takeout"] = [w for w in st["takeout"] if w["until"] is None or now - w["until"] < TAKEOUT_KEEP]
self._process_extra()
def _process_extra(self) -> None:
rest = []
for item in self.st["extra"]:
smid = item["spool"]
try:
sm = self.bridge.spoolman.spool(smid)
fil = sm.get("filament") or {}
grams = round(grams_from_mm(item["mm"], _num(fil.get("diameter"), 1.75) or 1.75,
_num(fil.get("density"), 1.24) or 1.24), 3)
msg = (f"{grams:.2f} g von {item['name']} auf Spoolman-Spule {smid} nachgebucht (Slot {item['slot'] + 1} "
f"druckte nach „Take out“ ohne Spule) – {item['job']}")
if self.settings.dry_run:
self._write(f"extra:{item['key']}", msg, lambda: None)
rest.append(item)
continue
used = float(sm.get("used_weight") or 0)
if item["before"] is not None and used >= item["before"] + item["grams"] - 0.01:
self._log("info", msg) # booked before a crash
continue
item["before"], item["grams"] = used, grams
self.bridge.state.save()
self.bridge.spoolman.use(smid, grams)
self._log("info", msg)
except SpoolmanNotFound:
self._log("warn", f"Spoolman-Spule {smid} existiert nicht; {item['mm']:.0f} mm aus Slot {item['slot'] + 1} nicht gebucht")
except SpoolmanError as e:
self.bridge._spoolman_error(str(e))
rest.append(item)
self.st["extra"] = rest
# ---------- UI ----------
def snapshot(self) -> dict:
@@ -457,7 +673,9 @@ class PrinterWorker:
sid = slot_spool_id(cs)
cached = self.bridge.spool_cache.get(m["spool"]) if m else None
b = self.st["booked"].get(m["cc2"]) if m and m.get("cc2") else None
takeout = next((t for t in self.st["takeout"] if t["slot"] == n and t["until"] is None), None)
slots.append({
"takeout": {"spool": takeout["spool"], "name": takeout["name"]} if takeout else None,
"slot": n,
"record": record_slots[n],
"tray_status": self.tray_status.get(n),
@@ -638,6 +856,7 @@ class Bridge:
if cur and cur["spool"] == tid:
if cc2_id is None:
cur["assigned"] = False # rescan: make sure CC2 Control has it in the slot
cur.pop("reinserted", None)
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
return
for pid, k, m in list(self.mappings()):
@@ -653,12 +872,18 @@ class Bridge:
"cc2": cc2_id, "assigned": cc2_id is not None, "cc2_seen": cc2_id is not None,
}
w.empty_since.pop(n, None)
for other in self.workers.values():
last = other.st["last"]
for k in [k for k, v in last.items() if v["spool"] == tid or (other is w and k == str(n))]:
del last[k]
other._close_takeout(n if other is w else None, tid)
w._log("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})")
def release(self, w: PrinterWorker, n: int, reason: str) -> None:
m = w.st["slots"].pop(str(n), None)
if not m:
return
w.st["last"][str(n)] = {"spool": m["spool"], "name": m.get("name") or str(m["spool"])}
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
w._log("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})")
+4
View File
@@ -240,6 +240,10 @@ class StateStore:
p.setdefault("booked", {})
p.setdefault("last_record", None)
p.setdefault("scans", [])
p.setdefault("last", {}) # slot -> roll that last left it (for putting it back)
p.setdefault("takeout", []) # windows in which a tray prints for a roll taken out in CC2 Control
p.setdefault("log_seen", None) # CC2 Control log entries already looked at
p.setdefault("extra", []) # bookings from CC2 Control's log still to make
return p
def save(self) -> None:
+10 -5
View File
@@ -12,6 +12,7 @@
{% endblock %}
{% block script %}
<script>
const QUESTION = { inserted: 'eingelegt', changed: 'geändert' };
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] };
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] };
@@ -35,8 +36,8 @@ function slotCard(p, s) {
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
}
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: ${esc(s.cc2_question)}</span>`;
const orphan = !m && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: Spule ${esc(QUESTION[s.cc2_question] || s.cc2_question)}</span>`;
const orphan = !m && !s.takeout && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
const lb = s.last_booking;
return `<div class="panel">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
@@ -47,10 +48,14 @@ function slotCard(p, s) {
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd>
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` +
(lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : '') : '<span class="muted">–</span>'}</dd>
</dl>
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steckt noch, ist aber keinem Slot zugeordnet
(z. B. nach „Take out“ in CC2 Control). Verbrauch wird so nicht gezählt – zum Zuordnen die Spule neu einlegen und scannen.</p>` : ''}
${s.takeout ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">${esc(s.takeout.name)} wurde in CC2 Control herausgenommen
und steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${esc(s.takeout.name)},
bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.</p>` : ''}
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle
zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).</p>` : ''}
${m ? `<div class="row" style="margin-top:10px">
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}
+17 -2
View File
@@ -48,6 +48,7 @@ class FakeCC2:
self.slots[4]["printer"] = None
self.library: list[dict] = []
self.job = {"active": False}
self.log: list[dict] = [] # newest first, as CC2 Control lists it
self.calls: list[tuple] = []
self.fail = False
self._ids = (f"s{i:04d}" for i in itertools.count(1))
@@ -57,7 +58,7 @@ class FakeCC2:
raise CC2Error("CC2 Control nicht erreichbar")
def spools_json(self):
return {"enabled": self.enabled, "slots": self.slots, "spools": self.library, "job": self.job, "log": []}
return {"enabled": self.enabled, "slots": self.slots, "spools": self.library, "job": self.job, "log": self.log}
def spools(self):
self._check()
@@ -103,6 +104,15 @@ class FakeCC2:
sp = next(s for s in self.library if s["id"] == cc2_id)
sp["remaining"] -= grams
def add_log(self, time, kind="print", spool="", slot=0, grams=0.0, mm=0.0, job="", result=""):
self.log.insert(0, {"time": int(time), "spool": spool, "slot": slot, "kind": kind, "grams": grams, "mm": mm,
"job": job, "result": result})
def weigh(self, cc2_id, remaining, time):
sp = next(s for s in self.library if s["id"] == cc2_id)
self.add_log(time, "correction", cc2_id, -1, remaining - sp["remaining"], 0, "", "weighed")
sp["remaining"] = remaining
def close(self):
pass
@@ -148,7 +158,12 @@ class FakeSpoolman:
for key, value in (body.get("extra") or {}).items():
if value is not None and key not in self.field_defs:
raise SpoolmanError("Spoolman PATCH: HTTP 400")
self.calls.append(("location", sid, body.get("location")))
if "location" in body:
self.calls.append(("location", sid, body.get("location")))
if "used_weight" in body:
self.calls.append(("used", sid, body["used_weight"]))
sp["used_weight"] = body["used_weight"]
sp["remaining_weight"] = sp["initial_weight"] - body["used_weight"]
if "extra" in body:
self.calls.append(("extra", sid, dict(body["extra"])))
if "location" in body:
+233
View File
@@ -526,3 +526,236 @@ def test_migration_from_v1(tmp_path):
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
# ---------- v3: putting rolls back, CC2 Control's log ----------
def take_out_of_tray(e, slot, pid="p1"):
"""Roll physically out: tray empty; CC2 Control gives the spool back to storage (record keeps the tag)."""
cc2 = e["cc2s"][pid]
cc2.slots[slot]["printer"] = {"status": 0}
cc2.slots[slot]["spool"] = ""
cc2.slots[slot]["question"] = ""
def put_back(e, slot, pid="p1"):
"""Roll in again without a new scan: the record is unchanged, CC2 Control asks."""
cc2 = e["cc2s"][pid]
cc2.slots[slot]["printer"] = {"status": 1}
cc2.slots[slot]["spool"] = ""
cc2.slots[slot]["question"] = "inserted"
def uses(e):
return [c for c in e["sm"].calls if c[0] == "use"]
def msgs(e):
return [x["msg"] for x in e["bridge"].log.recent()]
def test_roll_put_back_after_long_absence_is_mapped_again(env):
env["sm"].add(5)
insert(env, 1, 5)
poll(env)
take_out_of_tray(env, 1)
poll(env, 6) # > 20 s
assert slots(env) == {} and env["sm"].db[5]["location"] == "Lager"
put_back(env, 1)
poll(env)
assert slots(env)["1"]["spool"] == 5 and slots(env)["1"]["reinserted"]
assert env["cc2"].slots[1]["spool"] == "s0001" and env["cc2"].slots[1]["question"] == ""
assert env["sm"].db[5]["location"] == "Rechts Slot 2"
assert len([c for c in env["cc2"].calls if c[0] == "save"]) == 1 # same mirror
def test_put_back_then_touchscreen_edit_means_another_roll(env):
env["sm"].add(5)
insert(env, 1, 5)
poll(env)
take_out_of_tray(env, 1)
poll(env, 6)
put_back(env, 1) # really an untagged roll ...
poll(env)
assert slots(env)["1"]["spool"] == 5
env["reader"].record["channels1"] = channel(1000) # ... chosen on the touchscreen
env["cc2"].slots[1]["spool"] = ""
env["cc2"].slots[1]["question"] = "changed"
poll(env, 2)
assert slots(env) == {} and env["sm"].db[5]["location"] == "Lager"
poll(env, 10)
assert slots(env) == {} # and it does not come back
def test_untagged_roll_edited_before_the_bridge_looked(env):
env["sm"].add(5)
insert(env, 1, 5)
poll(env)
put_back(env, 1) # swapped within seconds ...
env["reader"].record["channels1"] = channel(1000) # ... and chosen on the touchscreen
poll(env, 2)
assert slots(env) == {} and env["sm"].db[5]["location"] == "Lager"
def test_short_removal_answers_cc2_again_and_counts(env):
env["sm"].add(5, remaining=800)
insert(env, 1, 5)
poll(env)
env["cc2"].slots[1]["printer"] = {"status": 0}
poll(env, 2) # 10 s out, mapping kept
put_back(env, 1)
poll(env)
assert env["cc2"].slots[1]["spool"] == "s0001" and env["cc2"].slots[1]["question"] == ""
assert slots(env)["1"]["reinserted"]
assert any("erneut zugewiesen" in m for m in msgs(env))
env["cc2"].consume("s0001", 2.0)
poll(env, 10)
assert uses(env) == [("use", 5, 2.0)]
def test_touchscreen_edit_of_scanned_roll_keeps_counting(env):
env["sm"].add(5)
insert(env, 2, 5)
poll(env)
env["reader"].record["channels2"] = channel(1000)
env["cc2"].slots[2]["spool"] = ""
env["cc2"].slots[2]["question"] = "changed"
poll(env)
assert slots(env)["2"]["spool"] == 5
assert env["cc2"].slots[2]["spool"] == "s0001" # question answered with the mapped roll
assign_calls = len([c for c in env["cc2"].calls if c[0] == "assign"])
env["cc2"].slots[2]["spool"] = ""
env["cc2"].slots[2]["question"] = "changed"
poll(env, 3, step=5) # not again within a minute
assert len([c for c in env["cc2"].calls if c[0] == "assign"]) == assign_calls
def test_take_out_with_roll_still_in_tray_books_from_cc2_log(env):
env["sm"].add(5, remaining=800)
insert(env, 0, 5)
poll(env, 2)
env["cc2"].assign(0, None) # "Take out", roll stays
poll(env)
assert slots(env) == {} and env["sm"].db[5]["location"] == "Lager"
t = env["clock"]()
env["cc2"].add_log(t + 30, "print", "", 0, -2.98, 1000.0, "/local/benchy.gcode", "complete")
env["cc2"].add_log(t + 30, "print", "", 1, -1.0, 300.0, "/local/benchy.gcode", "complete") # other tray
poll(env, 3)
expected = round(1000 * 3.141592653589793 * 0.875 ** 2 * 1.24 / 1000, 3)
assert uses(env) == [("use", 5, expected)]
assert any("nachgebucht" in m and "benchy.gcode (fertig)" in m for m in msgs(env))
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
poll(e2, 3)
assert uses(env) == [("use", 5, expected)]
def test_take_out_window_closes_when_roll_leaves(env):
env["sm"].add(5)
insert(env, 0, 5)
poll(env, 2)
env["cc2"].assign(0, None)
poll(env)
env["cc2"].slots[0]["printer"] = {"status": 0}
poll(env)
env["clock"].tick(600)
env["cc2"].add_log(env["clock"](), "print", "", 0, -3.0, 1000.0, "x.gcode", "complete")
poll(env, 2)
assert uses(env) == []
def test_old_log_entries_are_not_booked_on_first_start(env):
env["sm"].add(5)
env["cc2"].add_log(env["clock"]() - 50, "print", "", 0, -3.0, 1000.0, "old.gcode", "complete")
insert(env, 0, 5)
poll(env, 2)
env["cc2"].assign(0, None)
poll(env, 3)
assert uses(env) == []
def test_booking_names_print_and_result(env):
env["sm"].add(5, remaining=800)
insert(env, 0, 5)
poll(env)
env["cc2"].job = {"active": True}
env["cc2"].consume("s0001", 3.21)
poll(env)
env["cc2"].job = {"active": False}
env["cc2"].add_log(env["clock"](), "print", "s0001", 0, -3.21, 1076.0, "/local/benchy.gcode", "cancelled")
poll(env, 10)
assert uses(env) == [("use", 5, 3.21)]
assert any(m.endswith("gebucht – benchy.gcode (abgebrochen)") for m in msgs(env))
assert booked(env)["s0001"]["last"]["details"] == "benchy.gcode (abgebrochen)"
env["cc2"].consume("s0001", 1.0) # next booking does not repeat the old print
poll(env, 10)
assert msgs(env)[0].endswith("auf Spoolman-Spule 5 gebucht")
def test_weigh_in_in_cc2_sets_spoolman_remaining(env):
env["sm"].add(5, remaining=800)
env["sm"].db[5]["used_weight"] = 200.0
insert(env, 0, 5)
poll(env)
env["cc2"].weigh("s0001", 812.0, env["clock"]()) # more than counted
poll(env, 10)
assert uses(env) == []
assert ("used", 5, 188.0) in env["sm"].calls and env["sm"].db[5]["remaining_weight"] == 812.0
env["cc2"].consume("s0001", 2.5)
poll(env, 10)
assert uses(env) == [("use", 5, 2.5)]
assert len([c for c in env["sm"].calls if c[0] == "used"]) == 1
def test_weigh_in_lower_is_not_booked_as_use(env):
env["sm"].add(5, remaining=800)
env["sm"].db[5]["used_weight"] = 200.0
insert(env, 0, 5)
poll(env)
env["cc2"].weigh("s0001", 790.0, env["clock"]())
poll(env, 10)
assert uses(env) == [] and env["sm"].db[5]["remaining_weight"] == 790.0
def test_runout_and_backup_roll(env):
env["sm"].add(5, remaining=40)
env["sm"].add(8, remaining=1000, color="FFFFFF")
insert(env, 0, 5)
insert(env, 1, 8, color="0xFFFFFF")
poll(env)
cc2 = env["cc2"]
cc2.job = {"active": True}
cc2.consume("s0001", 35.0) # counted 5 g left when it ran out
poll(env)
t = env["clock"]()
cc2.slots[0]["printer"] = {"status": 0}
cc2.slots[0]["spool"] = ""
cc2.add_log(t, "print", "s0001", 0, -35.0, 11700.0, "vase.gcode", "runout")
cc2.add_log(t, "runout", "s0001", 0, -5.0, 0, "vase.gcode", "runout")
cc2.consume("s0001", 5.0)
cc2.consume("s0002", 12.0) # the printer goes on with the other roll
poll(env, 6)
assert uses(env) == [] # still printing
assert "0" not in slots(env) and env["sm"].db[5]["location"] == "Lager"
cc2.job = {"active": False}
cc2.add_log(env["clock"](), "print", "s0002", 1, -12.0, 4000.0, "vase.gcode", "complete")
poll(env, 10)
assert sorted(uses(env)) == [("use", 5, 40.0), ("use", 8, 12.0)]
assert any("Rolle leergelaufen" in m for m in msgs(env))
def test_multicolour_print_books_each_roll(env):
for sid in (5, 6, 7):
env["sm"].add(sid, remaining=900)
insert(env, 0, 5)
insert(env, 1, 6)
insert(env, 3, 7)
poll(env)
cc2 = env["cc2"]
cc2.job = {"active": True}
for _ in range(4):
cc2.consume("s0001", 1.5)
cc2.consume("s0002", 0.75)
poll(env)
cc2.job = {"active": False}
poll(env, 10)
assert sorted(uses(env)) == [("use", 5, 6.0), ("use", 6, 3.0)]
+60 -4
View File
@@ -3,8 +3,11 @@
python tools/mock_env.py --host mock --printers 2
Per printer i: SSH 2222+i (keys added via POST /authorize), CC2 Control 8081+i. Spoolman 7912, control 8099.
Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool=, /remove?slot=,
/consume?slot=&grams=, /job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool= (scanned roll in),
/remove?slot= (roll out, the record keeps its tag data), /putback?slot= (roll in without a scan),
/takeout?slot= (CC2 Control's "Take out"), /consume?slot=&grams=, /job?active=0|1,
/touch?slot= (touchscreen edit: weight falls back to 1000), /print?slot=&mm=&job=&result= (logged use of
a tray, charged to its spool if it has one), /weigh?spool=&remaining= (weigh-in in CC2 Control).
"""
from __future__ import annotations
@@ -15,6 +18,7 @@ import json
import socket
import sys
import threading
import time
from pathlib import Path
import paramiko
@@ -32,6 +36,7 @@ def printer_state():
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
"spools": [],
"job": {"active": False, "file": "", "slots": []},
"log": [],
"ids": (f"m{i:04d}" for i in itertools.count(1)),
}
st["slots"][4]["printer"] = None
@@ -91,7 +96,7 @@ def make_cc2(host_header: str, P: dict) -> FastAPI:
with LOCK:
STATE["requests"].append("GET /api/spools")
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
"job": P["job"], "log": []}
"job": P["job"], "log": P["log"]}
@app.get("/api/canvas")
def canvas():
@@ -178,6 +183,9 @@ def make_spoolman() -> FastAPI:
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",)})
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"]
for key, value in (body.get("extra") or {}).items():
if value is None:
sp["extra"].pop(key, None)
@@ -235,7 +243,55 @@ def make_control() -> FastAPI:
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["printer"] = {"status": 0}
P["record"][f"channels{slot}"] = channel("")
if P["slots"][slot]["spool"]:
P["slots"][slot]["last"] = P["slots"][slot]["spool"]
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = ""
return {"ok": True}
@app.post("/putback")
def putback(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["printer"] = {"status": 1}
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = "inserted"
return {"ok": True}
@app.post("/takeout")
def takeout(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["last"] = P["slots"][slot]["spool"]
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = ""
return {"ok": True}
def log(P, kind, spool, slot, grams, mm, job="", result=""):
P["log"].insert(0, {"time": int(time.time()), "spool": spool, "slot": slot, "kind": kind, "grams": grams,
"mm": mm, "job": job, "result": result})
del P["log"][150:]
@app.post("/print")
def print_(slot: int, mm: float, job: str = "test.gcode", result: str = "complete", printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sid = P["slots"][slot]["spool"]
sp = next((s for s in P["spools"] if s["id"] == sid), None)
d, rho = (sp["diameter"], sp["density"]) if sp else (1.75, 1.24)
grams = mm * 3.141592653589793 * (d / 2) ** 2 * rho / 1000
if sp:
sp["remaining"] -= grams
log(P, "print", sid, slot, -round(grams, 3), mm, job, result)
return {"ok": True, "grams": grams}
@app.post("/weigh")
def weigh(spool: str, remaining: float, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sp = next(s for s in P["spools"] if s["id"] == spool)
log(P, "correction", spool, -1, remaining - sp["remaining"], 0, "", "weighed")
sp["remaining"] = remaining
return {"ok": True}
@app.post("/touch")