feat: Spoolman filament tracking integration

Adds optional Spoolman (https://github.com/Donkie/Spoolman) integration
for live filament consumption tracking.

Config (config.ini [spoolman] section or env vars):
  SPOOLMAN_SERVER    = http://192.168.x.x:7912
  SPOOLMAN_SYNC_RATE = 30   # mid-print sync interval in seconds (0=end only)

How it works:
- Uses supplies_usage from the printer's own MQTT print/report payload —
  the printer's cumulative extrusion counter in mm, reset each print.
  Discovered via MQTT payload inspection: matches slicer estimate within
  ~1% for model filament, and also captures the printer's purge/prime
  sequence that the slicer doesn't count.
- Reports to Spoolman via PUT /api/v1/spool/{id}/use with use_length (mm),
  letting Spoolman convert to weight using the spool's filament profile.
- Accurate for both completed and cancelled prints — no estimation needed.
- For multi-slot prints, total filament is split equally across mapped spools
  (v1 approximation; per-slot MQTT breakdown not available).

UI changes:
- Filament assignment dialog gains a Spoolman section (hidden when not
  configured): one spool dropdown per assigned AMS slot, loaded async
  from GET /kx/spoolman/spools.
- AMS slot display shows a 🧵 #N badge on any slot with a mapped spool.
- Spool assignments persist across prints (sticky until changed).

New API endpoints:
  GET  /kx/spoolman/status        — configured flag, server URL, slot map
  GET  /kx/spoolman/spools        — spool list proxied from Spoolman
  POST /kx/spoolman/active-spool  — body: {"slot_map": {"0": 42, "1": 17}}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Phil Merricks
2026-06-18 17:28:54 +01:00
parent 891b679230
commit 0ade456be4
5 changed files with 282 additions and 0 deletions

View File

@ -43,6 +43,66 @@ var ACE_DRY_PRESETS={
custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600}
};
// Spoolman state
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanSpools=[];
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
function _loadSpoolmanStatus(){
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
_spoolmanStatus=d;
_slotSpoolMap=d.slot_spools||{};
}).catch(function(){});
}
function _buildSpoolmanSection(){
var sec=document.getElementById('fd-spoolman-section');
var rows=document.getElementById('fd-spoolman-rows');
var loading=document.getElementById('fd-spoolman-loading');
if(!sec||!rows)return;
if(!_spoolmanStatus.configured){sec.style.display='none';return;}
sec.style.display='';
rows.innerHTML='';
if(loading)loading.style.display='';
// Collect the unique AMS slots the user has selected in the paint→slot rows
var usedSlots={};
document.querySelectorAll('#fd-slots select').forEach(function(sel){
var idx=parseInt(sel.value);
if(idx>=0){
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
}
});
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
if(loading)loading.style.display='none';
_spoolmanSpools=d.spools||[];
var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;});
if(!slotKeys.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)"></span>';return;}
rows.innerHTML=slotKeys.map(function(idx){
var slot=usedSlots[idx];
var col=(slot.color_hex||'#888');
var currentSpool=_slotSpoolMap[String(idx)]||'';
var opts='<option value=""></option>'+_spoolmanSpools.map(function(sp){
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
}).join('');
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
'<span style="color:var(--txt2);min-width:46px">Slot '+(idx+1)+'</span>'+
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:11px">'+opts+'</select>'+
'</div>';
}).join('');
}).catch(function(){
if(loading)loading.style.display='none';
if(rows)rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman unavailable</span>';
});
}
function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];}
function _aceAutoRefillSet(aceId,on){
aceAutoRefillPrefs[String(aceId)]=!!on;
@ -480,6 +540,7 @@ function ensureAceDryCards(){
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
}).catch(function(){});
_loadSpoolmanStatus();
});
})();
@ -870,11 +931,16 @@ function applyState(){
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
}
var spoolId=_slotSpoolMap&&_slotSpoolMap[String(globalIdx)];
var spoolBadge=(!empty&&spoolId)
?'<div class="slot-label" style="font-size:9px;color:var(--ok);margin-top:1px" title="Spoolman spool #'+spoolId+'">🧵 #'+spoolId+'</div>'
:'';
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
+'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+vendorBadge
+spoolBadge
+'<div class="slot-label">'+slotLabel+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
@ -2485,6 +2551,8 @@ function openFilamentDialog(slots){
}).join('');
}
if(dlg)dlg.classList.add('open');
// Spoolman spool picker — loaded async after dialog is visible
setTimeout(_buildSpoolmanSection, 0);
}
function closeFilamentDialog(){
@ -2533,6 +2601,22 @@ function confirmFilamentPrint(){
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
var fdAlEl=document.getElementById('fd-auto-leveling');
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
// Spoolman: collect slot→spool assignments and submit to backend
if(_spoolmanStatus.configured){
var newSlotMap={};
document.querySelectorAll('[data-spool-slot]').forEach(function(sel){
var spoolId=parseInt(sel.value);
if(!Number.isNaN(spoolId)&&spoolId>0)newSlotMap[sel.dataset.spoolSlot]=spoolId;
});
fetch(_apiUrl('/kx/spoolman/active-spool'),{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({slot_map:newSlotMap})
}).then(function(r){return r.json();}).then(function(d){
_slotSpoolMap=d.slot_spools||{};
}).catch(function(){});
}
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: normaler print/start mit Slot-Override

View File

@ -644,6 +644,13 @@
<label for="fd-auto-leveling" style="margin:0;cursor:pointer;font-size:13px" id="fd-lbl-auto-leveling">Auto-Leveling</label>
</div>
</div>
<div id="fd-spoolman-section" style="display:none;margin-bottom:16px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:12px;color:var(--txt2);margin-bottom:8px;display:flex;align-items:center;gap:6px">
<span id="fd-spoolman-lbl">🧵 Spoolman</span>
<span id="fd-spoolman-loading" style="display:none;font-size:10px"></span>
</p>
<div id="fd-spoolman-rows" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>