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 <noreply@anthropic.com>
This commit is contained in:
Phil Merricks
2026-06-19 14:38:45 +01:00
parent 0ade456be4
commit 724e98d102

View File

@@ -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