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:
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user