diff --git a/bridge_spoolman.py b/bridge_spoolman.py new file mode 100644 index 0000000..9f6aaae --- /dev/null +++ b/bridge_spoolman.py @@ -0,0 +1,154 @@ +""" +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()}}) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 272b597..51a24bf 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -67,6 +67,7 @@ from gcode_meta import ( ) from camera import CameraCache, _find_ffmpeg from credentials import _kx_fetch_credentials, _kx_generate_signature, _kx_decrypt_info +from bridge_spoolman import SpoolmanMixin try: @@ -135,7 +136,7 @@ MOONRAKER_VERSION = "v0.9.3-1" KLIPPER_VERSION = "v0.12.0-1" -class KobraXBridge: +class KobraXBridge(SpoolmanMixin): def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None): self.client = client self._args = args @@ -303,139 +304,6 @@ class KobraXBridge: log.info(f"Spoolman: {'OK' if ok else 'unreachable'} at {self._spoolman.server_url}") threading.Thread(target=_check, daemon=True, name="spoolman-health").start() - # ── 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()}}) - def _default_ace_dry_presets(self) -> dict[str, dict]: return { "pla": {"temp": 45, "duration_sec": 4 * 3600},