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):