From 724e98d1029ca86140511a416bf7f0f7f397f025 Mon Sep 17 00:00:00 2001 From: Phil Merricks Date: Fri, 19 Jun 2026 14:38:45 +0100 Subject: [PATCH] feat: per-slot filament attribution for Spoolman multi-colour tracking Replace equal-split with poll-based per-slot accumulation using loaded_slot from multiColorBox/report: - _spoolman_attribute_tick(activity_map): called each poll cycle after both print/report and multiColorBox/report are processed. Attributes the supplies_usage delta to whichever slot is currently loaded. Skips attribution during loading/unloading transitions (tool changes + purges) so filament consumed during a slot swap is not charged to the wrong spool. - _spoolman_unreported(): returns {slot_idx: mm} not yet sent to Spoolman. Uses per-slot data when available, falls back to equal split for single-extruder/no-AMS setups where _ams_loaded_slot stays -1 throughout the print. - _spoolman_report(): shared fire-and-forget sender used by both notify_end and sync_midprint, eliminating duplicated loop logic. Per-slot state (_spoolman_slot_usage, _spoolman_slot_reported, _spoolman_last_usage) is reset at print start and when spool assignments change. Co-Authored-By: Claude Sonnet 4.6 --- kobrax_moonraker_bridge.py | 113 ++++++++++++++++++++++++------------- 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0b16133..6e4771b 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -861,7 +861,9 @@ class KobraXBridge: if _sm_url else None ) self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id} - self._spoolman_reported_mm: float = 0.0 + self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print + self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman + self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick self._spoolman_last_sync: float = 0.0 # Theme-Name prüfen (keine Sonderzeichen oder Umlaute) @@ -904,54 +906,81 @@ class KobraXBridge: except Exception: return 0.0 - def _spoolman_notify_end(self): - """Report remaining filament to Spoolman on print end (fire-and-forget). + def _spoolman_attribute_tick(self, activity_map: dict) -> None: + """Attribute the supplies_usage delta since last tick to the active slot. - Uses supplies_usage from the MQTT stream — the printer's own cumulative - extrusion counter in mm, reset each print. Accurate for both completed - and cancelled prints with no estimation needed. + Called every poll cycle after both print/report and multiColorBox/report + have been processed, so _state["supplies_usage"] and _ams_loaded_slot + are both current. - For multi-slot prints the delta is split equally across all mapped spools - (v1 approximation — per-slot breakdown is not in the MQTT stream).""" + Skips attribution during loading/unloading transitions (tool changes + + purges) to avoid charging the wrong spool for purge material.""" if not self._spoolman or not self._spoolman_slot_spools: return - used_mm = self._state.get("supplies_usage", 0) - delta_mm = used_mm - self._spoolman_reported_mm - if delta_mm < 0.1: + if self._state.get("print_state") != "printing": return + current = self._state.get("supplies_usage", 0) + delta = current - self._spoolman_last_usage + self._spoolman_last_usage = current + if delta <= 0: + return + loaded = self._ams_loaded_slot + if loaded < 0: + return + # Don't attribute during a loading/unloading transition + if activity_map.get(loaded): + return + self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta + + def _spoolman_unreported(self) -> dict[int, float]: + """Return {slot_idx: mm} of usage not yet reported to Spoolman. + + Falls back to equal split of total supplies_usage when per-slot + attribution data is absent (e.g. single-extruder with no AMS).""" + total_used = self._state.get("supplies_usage", 0) + if self._spoolman_slot_usage: + return { + slot: self._spoolman_slot_usage.get(slot, 0.0) + - self._spoolman_slot_reported.get(slot, 0.0) + for slot in self._spoolman_slot_spools + } + # Fallback: equal split n = len(self._spoolman_slot_spools) - per_spool_mm = delta_mm / n + already = sum(self._spoolman_slot_reported.values()) + per = (total_used - already) / n if n else 0.0 + return {slot: per for slot in self._spoolman_slot_spools} + + def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None: + """Fire-and-forget report of unreported mm to each mapped spool.""" sm = self._spoolman - self._spoolman_reported_mm = used_mm - for spool_id in self._spoolman_slot_spools.values(): - def _report(sid=spool_id, mm=per_spool_mm): + for slot_idx, mm in unreported.items(): + if mm < min_mm: + continue + spool_id = self._spoolman_slot_spools.get(slot_idx) + if not spool_id: + continue + self._spoolman_slot_reported[slot_idx] = ( + self._spoolman_slot_reported.get(slot_idx, 0.0) + mm + ) + def _send(sid=spool_id, length=mm): try: - sm.use_filament(sid, mm) - log.info(f"Spoolman: reported {mm:.1f} mm to spool {sid}") + sm.use_filament(sid, length) + log.info(f"Spoolman: {length:.1f} mm → spool {sid}") except Exception as e: - log.warning(f"Spoolman: end-report failed (spool {sid}): {e}") - threading.Thread(target=_report, daemon=True, name="spoolman-end").start() + log.warning(f"Spoolman: report failed (spool {sid}): {e}") + threading.Thread(target=_send, daemon=True, name="spoolman-report").start() + + def _spoolman_notify_end(self): + """Report remaining filament on print end.""" + if not self._spoolman or not self._spoolman_slot_spools: + return + self._spoolman_report(self._spoolman_unreported()) def _spoolman_sync_midprint(self): - """Report incremental filament usage to Spoolman during a print.""" + """Report incremental filament usage during a print (sync_rate interval).""" if not self._spoolman or not self._spoolman_slot_spools: return - used_mm = self._state.get("supplies_usage", 0) - delta_mm = used_mm - self._spoolman_reported_mm - if delta_mm < 10.0: - return - n = len(self._spoolman_slot_spools) - per_spool_mm = delta_mm / n - sm = self._spoolman - self._spoolman_reported_mm = used_mm - for spool_id in self._spoolman_slot_spools.values(): - def _report(sid=spool_id, mm=per_spool_mm): - try: - sm.use_filament(sid, mm) - log.info(f"Spoolman: mid-print {mm:.1f} mm to spool {sid}") - except Exception as e: - log.warning(f"Spoolman: mid-print sync failed (spool {sid}): {e}") - threading.Thread(target=_report, daemon=True, name="spoolman-sync").start() + self._spoolman_report(self._spoolman_unreported(), min_mm=10.0) # ── Spoolman API handlers ───────────────────────────────────────────────── @@ -990,7 +1019,9 @@ class KobraXBridge: int(k): int(v) for k, v in slot_map.items() if str(v).isdigit() and int(v) > 0 } - self._spoolman_reported_mm = 0.0 + self._spoolman_slot_usage = {} + self._spoolman_slot_reported = {} + self._spoolman_last_usage = 0.0 return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}}) def _default_ace_dry_presets(self) -> dict[str, dict]: @@ -1107,7 +1138,9 @@ class KobraXBridge: printer_id=self._printer_id, ) log.info(f"Job started: {self._current_job_id} for {filename}") - self._spoolman_reported_mm = 0.0 + self._spoolman_slot_usage = {} + self._spoolman_slot_reported = {} + self._spoolman_last_usage = 0.0 self._spoolman_last_sync = 0.0 # Job-History: Druckende erkennen @@ -4934,6 +4967,10 @@ class KobraXBridge: if global_slots: self._ams_slots = global_slots self._ams_loaded_slot = global_loaded + self._spoolman_attribute_tick(activity_map) + else: + # No multiColorBox data — still attribute (no transitions to skip) + self._spoolman_attribute_tick({}) except Exception as e: log.warning(f"Poll-Fehler: {e}") # Prüfen ob Drucker wirklich weg ist