From d03668e3c5ebb887baf265eb7b47c9e7a8a4ae6e Mon Sep 17 00:00:00 2001 From: Jonas Menzel Date: Sun, 25 Oct 2026 20:45:05 +0100 Subject: [PATCH] Runout sets Spoolman to zero; surface prints waiting on a spool - A roll that ran out is set to 0 g in Spoolman (like a weigh-in) instead of booking CC2 Control's delta, so a mirror whose count was off cannot book too much or too little. - A tray that printed while CC2 Control still asked which spool it holds (untagged roll) shows the waiting millimetres on the status page and gets one log warning after the print. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC --- app/bridge.py | 45 +++++++++++++++++++++++++++++++++------ app/templates/status.html | 14 ++++++++++-- tests/test_bridge.py | 35 +++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/bridge.py b/app/bridge.py index 34b27b5..842c3d1 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -199,6 +199,7 @@ class PrinterWorker: self.record: 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.noted_questions: dict[int, str] = {} # slot -> question already reported @property def id(self) -> str: @@ -267,6 +268,7 @@ class PrinterWorker: self._reconcile_cc2(cc2) self._process_cc2_log(cc2) self._consumption(cc2) + self._note_open_questions(cc2) def _record_id(self, n: int) -> int | None: for s in self.record_slots: @@ -406,6 +408,23 @@ class PrinterWorker: elif loaded: self._answer_again(n, m, question) + def _note_open_questions(self, cc2: dict) -> None: + """A tray printed while CC2 Control was still asking which spool it holds: say so once, after the print.""" + if (cc2.get("job") or {}).get("active"): + return + for slot in cc2.get("slots") or []: + n = slot.get("slot") + mm = _num(slot.get("question_mm")) + if n not in (0, 1, 2, 3) or not slot.get("question") or mm < 1 or str(n) in self.st["slots"]: + continue + key = f"{n}|{slot.get('question_since')}" + if self.noted_questions.get(n) == key: + continue + self.noted_questions[n] = key + self._log("warn", f"Slot {n + 1}: {mm:.0f} mm (ca. {grams_from_mm(mm, 1.75, 1.24):.1f} g) ohne Spule gedruckt; " + "CC2 Control wartet auf die Zuordnung. Rolle taggen und scannen, dann wird der Verbrauch " + "nachgebucht, oder in CC2 Control „keine Spule“ wählen.") + def _reinserted(self, n: int) -> None: """No mapping, CC2 Control asks about filament put in, and the record still names the roll that last left this slot: it is that roll again.""" @@ -531,6 +550,11 @@ class PrinterWorker: if settled: self._weighin(cid, b, name, smid, rem, newest) continue + if any(e.get("kind") == "runout" for e in fresh): + # CC2 Control sets a roll that ran out to zero; Spoolman follows absolutely, so a count + # that was off (e.g. a mirror left with an old value) does not book too much or too little + if not settled or self._weighin(cid, b, name, smid, rem, newest, describe(fresh)): + continue delta = b["remaining"] - rem if delta < -MIN_BOOKING: self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; " @@ -589,24 +613,32 @@ class PrinterWorker: 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.""" + def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float, + runout: str | None = None) -> bool: + """Weighed in CC2 Control, or ran out (`runout` holds the print details): Spoolman takes CC2 Control's + value as it is. False when Spoolman has no start weight to set it against.""" sm = self.bridge.spoolman.spool(smid) base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight") if not base: + what = "Leerlaufen per Verbrauch gebucht" if runout is not None else "Nachwiegen nicht übernommen" self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein " - "Startgewicht; Nachwiegen nicht übernommen") - return + f"Startgewicht; {what}") + return False 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)", + 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)" + _, 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})) if done: b["remaining"] = rem b["log_time"] = max(b.get("log_time") or 0.0, log_time) + if runout is not None: + b["last"] = {"time": self.bridge.clock(), "grams": round(max(0.0, prev - target), 3), + "details": ("Rolle leergelaufen" + runout).replace("leergelaufen – ", "leergelaufen: ")} self.bridge.spool_cache.pop(smid, None) self.bridge.state.save() + return True # ---------- CC2 Control's log: use of a tray left without spool after "Take out" ---------- @@ -691,6 +723,7 @@ class PrinterWorker: "spoolman": summary(cached[1]) if cached else None, "cc2_spool": cc2_spools.get(sid) if sid else None, "cc2_question": cs.get("question") or "", + "cc2_question_mm": _num(cs.get("question_mm")) if cs.get("question") else 0.0, "cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]), "last_booking": (b or {}).get("last"), }) diff --git a/app/templates/status.html b/app/templates/status.html index 4acfdbb..d7daca4 100644 --- a/app/templates/status.html +++ b/app/templates/status.html @@ -120,6 +120,14 @@ function info(s) { out.title = 'Ohne Spoolman-Tag'; out.hint = 'Verbrauch wird nicht gebucht. Rolle taggen und scannen, um sie zu verwalten.'; } + if (s.cc2_question) { + const mm = s.cc2_question_mm || 0; + out.question = 'CC2 fragt: Spule ' + (QUESTION[s.cc2_question] || s.cc2_question); + out.hint = mm >= 1 + ? `Gedruckt ohne Spule: ${fmtNum(mm)} mm (ca. ${fmtNum(mm * Math.PI * 0.765625 * 1.24 / 1000, 1)} g). CC2 Control hält den Verbrauch, bis die Spule feststeht: Rolle taggen und scannen, dann wird er nachgebucht – oder in CC2 Control „keine Spule“ wählen.` + : 'CC2 Control fragt, welche Spule das ist. Rolle taggen und scannen oder in CC2 Control beantworten.'; + out.warn = mm >= 1; + } } return out; } @@ -195,7 +203,8 @@ function slotCard(p, s) { ${esc(i.title)} ${esc(i.sub)} -

${esc(i.hint)}

`; + ${i.question ? `
${esc(i.question)}
` : ''} +

${esc(i.hint)}

`; } return `
Slot ${s.slot + 1}${tray}
${body}
`; } @@ -205,6 +214,7 @@ function slotRow(p, s) { const inner = `${s.slot + 1}`; let title = i.title, sub = i.sub, rest = ''; if (i.kind === 'empty') { title = 'Leer'; sub = 'Kein Filament'; } + if (i.kind === 'unmanaged' && i.question) sub = i.warn ? `${fmtNum(s.cc2_question_mm)} mm ohne Spule gedruckt` : i.question; if (i.kind === 'managed') { rest = i.rest == null ? '?' : Math.round(i.rest) + ' g'; sub = [i.sub, '#' + s.mapping.spool, i.question || i.cc2[0]].filter(Boolean).join(' · '); @@ -282,7 +292,7 @@ function sheetHtml(p, s) {

${esc(i.title || 'Leer')}

${i.sub ? `
${esc(i.sub)}
` : ''} - ${i.hint ? `

${esc(i.hint)}

` : ''} + ${i.hint ? `

${esc(i.hint)}

` : ''}
${rows.map(([k, v]) => `
${k}
${v}
`).join('')}
${m ? `
` : ''}`; diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3d30cac..3340bef 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -740,8 +740,41 @@ def test_runout_and_backup_roll(env): 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 uses(env) == [("use", 8, 12.0)] + assert ("used", 5, 1000.0) in env["sm"].calls # ran out: Spoolman's rest is 0, whatever the count said + assert env["sm"].db[5]["remaining_weight"] == 0 assert any("Rolle leergelaufen" in m for m in msgs(env)) + assert booked(env)["s0001"]["last"]["grams"] == 40.0 + + +def test_runout_sets_spoolman_to_zero_even_if_the_mirror_was_off(env): + env["sm"].add(5, remaining=300) + insert(env, 0, 5) + poll(env) + cc2 = env["cc2"] + cc2.library[0]["remaining"] = 400.0 # e.g. an old mirror reused after the roll printed elsewhere + booked(env)["s0001"]["remaining"] = 400.0 + cc2.job = {"active": True} + cc2.consume("s0001", 380.0) + poll(env) + cc2.slots[0]["printer"] = {"status": 0} + cc2.slots[0]["spool"] = "" + t = env["clock"]() + cc2.add_log(t, "runout", "s0001", 0, -20.0, 0, "big.gcode", "runout") + cc2.library[0]["remaining"] = 0.0 + cc2.job = {"active": False} + cc2.add_log(t + 5, "print", "s0001", 0, -380.0, 127000.0, "big.gcode", "runout") + poll(env, 15) + assert uses(env) == [] and env["sm"].db[5]["remaining_weight"] == 0 + + +def test_open_question_after_print_is_reported_once(env): + cc2 = env["cc2"] + cc2.slots[3].update({"printer": {"status": 1}, "question": "changed", "question_since": 100, "question_mm": 2605.0}) + poll(env, 3) + warn = [m for m in msgs(env) if "ohne Spule gedruckt" in m] + assert len(warn) == 1 and "2605 mm" in warn[0] and "7.8 g" in warn[0] + assert env["bridge"].snapshot["printers"][0]["slots"][3]["cc2_question_mm"] == 2605.0 def test_multicolour_print_books_each_roll(env):