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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user