""" 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 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 } n = len(self._spoolman_slot_spools) 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 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()}})