""" 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_] 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()}})