refactor(bridge): extract SpoolmanMixin (stage 3, mixin 1/6)
This commit is contained in:
154
bridge_spoolman.py
Normal file
154
bridge_spoolman.py
Normal file
@@ -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_<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()}})
|
||||
@@ -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_<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()}})
|
||||
|
||||
def _default_ace_dry_presets(self) -> dict[str, dict]:
|
||||
return {
|
||||
"pla": {"temp": 45, "duration_sec": 4 * 3600},
|
||||
|
||||
Reference in New Issue
Block a user