diff --git a/README.md b/README.md index 45715f0..b609971 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/bridge.py b/app/bridge.py index 682702a..3ee5146 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -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 " 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})") diff --git a/app/storage.py b/app/storage.py index 7dbd33f..2899bfd 100644 --- a/app/storage.py +++ b/app/storage.py @@ -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: diff --git a/app/templates/status.html b/app/templates/status.html index 91b80c2..b417fd3 100644 --- a/app/templates/status.html +++ b/app/templates/status.html @@ -12,6 +12,7 @@ {% endblock %} {% block script %}