User report: with 4 spools mapped, every print deducted the same several-gram amount from ALL 4 spools regardless of which one was actually printing with, adding up to ~150g of drift over time on an unused spool. Root cause: _spoolman_last_sync was reset to 0.0 (epoch) at print start instead of the current time. The poll loop calls _spoolman_sync_midprint() before _spoolman_attribute_tick() in the same iteration, so with last_sync=0.0 the sync-due check was true on the very first tick after print start - before any per-slot attribution existed yet. _spoolman_unreported() then fell back to splitting the printer's full (possibly already nonzero) supplies_usage equally across every mapped spool, matching the reported log (identical mm reported to all 4 spools, right after upload before the purge even started). Fixes: - _on_print() now resets _spoolman_last_sync to time.time(), not 0.0, so the first sync check is only due after a real interval has passed with attribution data available. - _spoolman_unreported()'s fallback now only applies with exactly one mapped slot (single-extruder, no ambiguity) - with multiple slots and no attribution data it reports nothing instead of guessing, since a wrong equal-split is worse than a temporarily-missed report. Added tests/test_spoolman_unreported.py covering both fixes.
162 lines
7.4 KiB
Python
162 lines
7.4 KiB
Python
"""
|
|
bridge_spoolman.py - SpoolmanMixin for KobraXBridge.
|
|
|
|
Filament-usage attribution + reporting to a Spoolman server, and the
|
|
/kx/spoolman/* endpoints. Mixed into KobraXBridge; relies on the shared bridge
|
|
state (self._state, self._store, self._spoolman*, self._json_cors) provided by
|
|
the core class.
|
|
|
|
────────────────────────────────────────────────────────────────────────────
|
|
Copyright (C) 2026 viewit (KX-Bridge contributors)
|
|
|
|
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
|
|
log = logging.getLogger("bridge")
|
|
|
|
|
|
class SpoolmanMixin:
|
|
# ── Spoolman helpers ──────────────────────────────────────────────────────
|
|
|
|
def _spoolman_filament_mm(self) -> float:
|
|
"""Total filament_used_mm for the current print file from the GCode DB."""
|
|
filename = self._state.get("filename", "")
|
|
if not filename:
|
|
return 0.0
|
|
try:
|
|
gf = self._store.get_file_by_name(filename)
|
|
return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def _spoolman_attribute_tick(self, activity_map: dict) -> None:
|
|
"""Attribute the supplies_usage delta since last tick to the active slot.
|
|
|
|
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
|
|
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
|
|
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 crediting the single mapped slot with the full
|
|
supplies_usage when per-slot attribution data is absent (single-
|
|
extruder setup with no AMS - there's only ever one spool it could be).
|
|
With more than one mapped slot, splitting unattributed usage equally
|
|
across all of them would silently deduct filament from spools not
|
|
even used in the current print (Issue: filament removed from spools
|
|
not part of the print) - safer to report nothing for those slots and
|
|
wait for real attribution data than to guess wrong."""
|
|
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
|
|
}
|
|
if len(self._spoolman_slot_spools) == 1:
|
|
slot = next(iter(self._spoolman_slot_spools))
|
|
already = self._spoolman_slot_reported.get(slot, 0.0)
|
|
return {slot: total_used - already}
|
|
return {}
|
|
|
|
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
|
|
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, length)
|
|
log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
|
|
except Exception as e:
|
|
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 during a print (sync_rate interval)."""
|
|
if not self._spoolman or not self._spoolman_slot_spools:
|
|
return
|
|
self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)
|
|
|
|
# ── Spoolman API handlers ─────────────────────────────────────────────────
|
|
|
|
async def handle_kx_spoolman_status(self, request):
|
|
"""GET /kx/spoolman/status"""
|
|
return self._json_cors({
|
|
"configured": bool(self._spoolman),
|
|
"reachable": self._spoolman_reachable if self._spoolman else False,
|
|
"server": self._spoolman.server_url if self._spoolman else "",
|
|
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
|
|
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
|
|
})
|
|
|
|
async def handle_kx_spoolman_spools(self, request):
|
|
"""GET /kx/spoolman/spools — proxied from Spoolman."""
|
|
if not self._spoolman:
|
|
return self._json_cors({"error": "Spoolman not configured"}, status=503)
|
|
try:
|
|
spools = await asyncio.get_event_loop().run_in_executor(
|
|
None, self._spoolman.list_spools
|
|
)
|
|
return self._json_cors({"spools": spools})
|
|
except Exception as e:
|
|
log.warning(f"Spoolman: list_spools failed: {e}")
|
|
return self._json_cors({"error": str(e)}, status=502)
|
|
|
|
async def handle_kx_spoolman_set_active(self, request):
|
|
"""POST /kx/spoolman/active-spool
|
|
Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID."""
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return self._json_cors({"error": "invalid JSON"}, status=400)
|
|
slot_map = data.get("slot_map") or data.get("slot_spools") or {}
|
|
self._spoolman_slot_spools = {
|
|
int(k): int(v) for k, v in slot_map.items()
|
|
if str(v).isdigit() and int(v) > 0
|
|
}
|
|
# Persist per printer (own [spoolman_<id>] section) so the
|
|
# assignment survives bridge restarts and two AMS units don't overwrite each other.
|
|
# (Previously: NameError on `config_loader` -> nothing was ever saved.)
|
|
try:
|
|
import config_loader as _cl
|
|
_cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
|
|
except Exception as _e:
|
|
log.warning("Spoolman: failed to save slot map: %s", _e)
|
|
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()}})
|