Compare commits

..

2 commits

Author SHA1 Message Date
dlawler489
c7d4194243 Merge loaded filament on printer cards
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:23:22 +10:00
dlawler489
c2bae07e54 Show each printer's loaded filament on its card
Each sync keeps what the printer reported, slot by slot (material, colour,
empty, tagged — nothing that identifies a spool). The card's drawing colours
in the first AMS unit's spools, dashes empty slots, and lists every slot and
the external holder underneath with its colour name. "Test & sync" redraws
the card with what it just read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:21:03 +10:00
6 changed files with 109 additions and 25 deletions

View file

@ -312,6 +312,10 @@ CREATE TABLE IF NOT EXISTS printers (
date_modified TIMESTAMPTZ date_modified TIMESTAMPTZ
); );
-- What each printer last reported loaded, slot by slot, for its card.
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded JSONB;
ALTER TABLE printers ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
-- The one printer that used to live in Settings becomes the first entry here. -- The one printer that used to live in Settings becomes the first entry here.
INSERT INTO printers (host, serial, access_code) INSERT INTO printers (host, serial, access_code)
SELECT h.value, s.value, c.value SELECT h.value, s.value, c.value

View file

@ -85,6 +85,21 @@ async function remove(id) {
return true; return true;
} }
// Keeps what a printer reported loaded — enough to draw its card, nothing that
// identifies a spool.
async function recordLoaded(id, trays) {
const loaded = (trays || []).map((t) => ({
unit: t.unit ?? null,
slot: t.slot ?? null,
empty: Boolean(t.empty),
material: t.material || null,
colour: t.colour || null,
tagged: Boolean(t.uuid),
}));
await db.query('UPDATE printers SET loaded = $2::jsonb, loaded_at = now() WHERE id = $1',
[id, JSON.stringify(loaded)]);
}
const measures = (amsType) => Boolean(AMS_TYPES[amsType]); const measures = (amsType) => Boolean(AMS_TYPES[amsType]);
module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, measures, publicPrinter }; module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, recordLoaded, measures, publicPrinter };

View file

@ -2617,6 +2617,38 @@ function printerName(p) {
return (p.nickname || '').trim() || p.model || (p.host ? `Printer at ${p.host}` : 'Printer'); return (p.nickname || '').trim() || p.model || (p.host ? `Printer at ${p.host}` : 'Printer');
} }
// The first AMS unit's four slots, in order, for colouring in the drawing.
function drawnSlots(p) {
const units = (p.loaded || []).filter((t) => t.unit != null);
if (!units.length) return [];
const first = Math.min(...units.map((t) => t.unit));
const slots = [];
for (const t of units) if (t.unit === first && t.slot >= 0 && t.slot < 4) slots[t.slot] = t;
return slots;
}
// What's in each slot, as last reported, under the drawing.
function loadedHtml(p) {
if (!p.loaded) return p.id ? '<p class="form-hint">Nothing reported yet — “Test & sync” to read it.</p>' : '';
// The tracked spool in that slot names the colour; otherwise the nearest Bambu colour.
const colourName = (t) => {
const s = spools.find((x) => x.ams_slot === `${p.serial}/${t.unit == null ? 'ext' : t.unit}/${t.slot == null ? 0 : t.slot}`);
return (s && s.colour) || (t.colour ? paletteName(t.colour, t.material) : '') || '';
};
const chip = (t, label) => {
const what = t.empty ? 'empty' : `${escapeHtml([t.material || '?', colourName(t)].filter(Boolean).join(' '))}${t.tagged ? '' : ' <span title="No RFID tag, so not tracked on the Filament page">(untagged)</span>'}`;
const dot = t.empty
? '<span class="spool-dot is-empty"></span>'
: `<span class="spool-dot" style="background:${/^#[0-9a-f]{6}$/i.test(t.colour || '') ? t.colour : '#888888'}"></span>`;
return `<li title="${escapeHtml(label)}">${dot}<span class="loaded-slot">${escapeHtml(label)}</span> ${what}</li>`;
};
const rows = [...p.loaded]
.sort((a, b) => (a.unit ?? 1e9) - (b.unit ?? 1e9) || (a.slot ?? 0) - (b.slot ?? 0))
.map((t) => chip(t, t.unit == null ? 'External' : `${amsUnitLabel(t.unit)} · ${(t.slot ?? 0) + 1}`));
const when = p.loaded_at ? `<p class="form-hint">As of ${escapeHtml(new Date(p.loaded_at).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</p>` : '';
return rows.length ? `<ul class="loaded-list">${rows.join('')}</ul>${when}` : `<p class="form-hint">Nothing loaded.</p>${when}`;
}
function printerCardHtml(p) { function printerCardHtml(p) {
const key = p.id || 'new'; const key = p.id || 'new';
const models = ['<option value="">Choose a model…</option>'] const models = ['<option value="">Choose a model…</option>']
@ -2627,7 +2659,10 @@ function printerCardHtml(p) {
.join(''); .join('');
return ` return `
<section class="ai-card printer-card" data-printer-id="${key}"> <section class="ai-card printer-card" data-printer-id="${key}">
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model)}</div> <div class="printer-side">
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model, drawnSlots(p))}</div>
${loadedHtml(p)}
</div>
<form class="printer-form" autocomplete="off"> <form class="printer-form" autocomplete="off">
<h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3> <h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3>
${p.id && !p.model ? '<p class="form-hint printer-ask">Which model is this? Pick it below and save.</p>' : ''} ${p.id && !p.model ? '<p class="form-hint printer-ask">Which model is this? Pick it below and save.</p>' : ''}
@ -2723,7 +2758,8 @@ function bindPrinterCard(card) {
form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model'; form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model';
}; };
modelSelect.addEventListener('change', () => { modelSelect.addEventListener('change', () => {
card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value); const current = printerList.find((x) => x.id === id) || {};
card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value, drawnSlots(current));
const m = printerModels().find(modelSelect.value); const m = printerModels().find(modelSelect.value);
if (m && !amsTouched) { if (m && !amsTouched) {
const blank = amsSelect.querySelector('option[value=""]'); const blank = amsSelect.querySelector('option[value=""]');
@ -2793,12 +2829,12 @@ function bindPrinterCard(card) {
} }
const test = card.querySelector('[data-printer="test"]'); const test = card.querySelector('[data-printer="test"]');
if (test) test.addEventListener('click', () => testPrinter(id, test, say)); if (test) test.addEventListener('click', () => testPrinter(id, test, say, card));
} }
// Listens to one printer for a few seconds, says what it reported, and updates // Listens to one printer for a few seconds, says what it reported, and updates
// the spools it has loaded. Only reads; the printer is never sent a command. // the spools it has loaded. Only reads; the printer is never sent a command.
async function testPrinter(id, btn, say) { async function testPrinter(id, btn, say, card) {
say('Listening to the printer for a few seconds…'); say('Listening to the printer for a few seconds…');
btn.disabled = true; btn.disabled = true;
const trayHtml = (t) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span> const trayHtml = (t) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span>
@ -2832,6 +2868,14 @@ async function testPrinter(id, btn, say) {
lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.'); lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.');
} }
say(lines.join(' '), r.reports ? 'ok' : 'error'); say(lines.join(' '), r.reports ? 'ok' : 'error');
// Redraw this card with what was just read, keeping any unsaved edits.
printerList = await getJson('/api/printers');
const fresh = printerList.find((x) => x.id === id);
if (fresh && card) {
card.querySelector('.printer-side').innerHTML = `
<div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(fresh))}</div>
${loadedHtml(fresh)}`;
}
} catch (err) { } catch (err) {
say(`✗ ${escapeHtml(err.message)}`, 'error'); say(`✗ ${escapeHtml(err.message)}`, 'error');
} finally { } finally {

View file

@ -6,18 +6,29 @@
`<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" `<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`; stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`;
// One spool in a drawing, filled with what's loaded there: a colour for a
// spool, dashed for an empty slot, plain when nothing is known.
const HEX = /^#[0-9a-f]{6}$/i;
const spool = (cx, cy, r, slot) => {
if (slot && slot.empty) return `<circle cx="${cx}" cy="${cy}" r="${r}" stroke-dasharray="2 3" opacity="0.6"/>`;
const fill = slot && HEX.test(slot.colour || '') ? slot.colour : null;
return fill
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
: `<circle cx="${cx}" cy="${cy}" r="${r}"/>`;
};
// An AMS lite: a stand with four spools around a hub. // An AMS lite: a stand with four spools around a hub.
const amsLite = (x) => ` const amsLite = (x, slots) => `
<line x1="${x}" y1="104" x2="${x}" y2="52"/> <line x1="${x}" y1="104" x2="${x}" y2="52"/>
<line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/> <line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>
<circle cx="${x - 9}" cy="40" r="7"/><circle cx="${x + 9}" cy="40" r="7"/> ${spool(x - 9, 40, 7, slots[0])}${spool(x + 9, 40, 7, slots[1])}
<circle cx="${x - 9}" cy="58" r="7"/><circle cx="${x + 9}" cy="58" r="7"/>`; ${spool(x - 9, 58, 7, slots[2])}${spool(x + 9, 58, 7, slots[3])}`;
// An AMS on top of an enclosed printer: a box showing four spools. // An AMS on top of an enclosed printer: a box showing four spools.
const amsTop = (x, width) => { const amsTop = (x, width, slots) => {
const step = width / 4; const step = width / 4;
let spools = ''; let spools = '';
for (let i = 0; i < 4; i += 1) spools += `<circle cx="${x + step / 2 + i * step}" cy="23" r="5"/>`; for (let i = 0; i < 4; i += 1) spools += spool(x + step / 2 + i * step, 23, 5, slots[i]);
return `<rect x="${x}" y="14" width="${width}" height="18" rx="3"/>${spools}`; return `<rect x="${x}" y="14" width="${width}" height="18" rx="3"/>${spools}`;
}; };
@ -26,34 +37,34 @@
const shapes = { const shapes = {
// A1: a bed on a base, an upright gantry, an AMS lite alongside. // A1: a bed on a base, an upright gantry, an AMS lite alongside.
open: svg(` open: (slots) => svg(`
<rect x="12" y="94" width="92" height="10" rx="3"/> <rect x="12" y="94" width="92" height="10" rx="3"/>
<rect x="24" y="84" width="68" height="6" rx="2"/> <rect x="24" y="84" width="68" height="6" rx="2"/>
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/> <line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/> <line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>
${toolhead(50, 49)} ${toolhead(50, 49)}
${amsLite(126)}`), ${amsLite(126, slots)}`),
// A1 mini: a single column holding the gantry out over the bed. // A1 mini: a single column holding the gantry out over the bed.
openMini: svg(` openMini: (slots) => svg(`
<rect x="22" y="94" width="72" height="10" rx="3"/> <rect x="22" y="94" width="72" height="10" rx="3"/>
<rect x="36" y="84" width="50" height="6" rx="2"/> <rect x="36" y="84" width="50" height="6" rx="2"/>
<rect x="24" y="30" width="10" height="64" rx="2"/> <rect x="24" y="30" width="10" height="64" rx="2"/>
<line x1="34" y1="54" x2="90" y2="54"/> <line x1="34" y1="54" x2="90" y2="54"/>
${toolhead(58, 47)} ${toolhead(58, 47)}
${amsLite(122)}`), ${amsLite(122, slots)}`),
// A2L: the A1's form, stretched to a larger bed. // A2L: the A1's form, stretched to a larger bed.
openLarge: svg(` openLarge: (slots) => svg(`
<rect x="6" y="94" width="104" height="10" rx="3"/> <rect x="6" y="94" width="104" height="10" rx="3"/>
<rect x="14" y="84" width="88" height="6" rx="2"/> <rect x="14" y="84" width="88" height="6" rx="2"/>
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/> <line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/> <line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>
${toolhead(52, 47)} ${toolhead(52, 47)}
${amsLite(132)}`), ${amsLite(132, slots)}`),
// P1P: a bare cube frame. // P1P: a bare cube frame.
frame: svg(` frame: () => svg(`
<rect x="30" y="38" width="66" height="66" rx="3"/> <rect x="30" y="38" width="66" height="66" rx="3"/>
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/> <path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
<line x1="30" y1="60" x2="96" y2="60"/> <line x1="30" y1="60" x2="96" y2="60"/>
@ -61,23 +72,23 @@
<rect x="40" y="90" width="46" height="5" rx="2"/>`, 140), <rect x="40" y="90" width="46" height="5" rx="2"/>`, 140),
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top. // P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
box: svg(` box: (slots) => svg(`
${amsTop(30, 66)} ${amsTop(30, 66, slots)}
<rect x="28" y="36" width="70" height="70" rx="5"/> <rect x="28" y="36" width="70" height="70" rx="5"/>
<rect x="36" y="44" width="54" height="46" rx="3"/> <rect x="36" y="44" width="54" height="46" rx="3"/>
<line x1="36" y1="98" x2="90" y2="98"/> <line x1="36" y1="98" x2="90" y2="98"/>
${toolhead(56, 52)}`, 126), ${toolhead(56, 52)}`, 126),
// H2S, H2D, H2C: a wide enclosed box with an AMS on top. // H2S, H2D, H2C: a wide enclosed box with an AMS on top.
large: svg(` large: (slots) => svg(`
${amsTop(28, 94)} ${amsTop(28, 94, slots)}
<rect x="18" y="36" width="114" height="70" rx="5"/> <rect x="18" y="36" width="114" height="70" rx="5"/>
<rect x="26" y="44" width="98" height="46" rx="3"/> <rect x="26" y="44" width="98" height="46" rx="3"/>
<line x1="26" y1="98" x2="124" y2="98"/> <line x1="26" y1="98" x2="124" y2="98"/>
${toolhead(58, 52)}${toolhead(78, 52)}`), ${toolhead(58, 52)}${toolhead(78, 52)}`),
// Anything else: a plain box with a nozzle. // Anything else: a plain box with a nozzle.
generic: svg(` generic: () => svg(`
<rect x="34" y="30" width="72" height="72" rx="8"/> <rect x="34" y="30" width="72" height="72" rx="8"/>
${toolhead(63, 50)} ${toolhead(63, 50)}
<line x1="44" y1="92" x2="96" y2="92"/>`, 140), <line x1="44" y1="92" x2="96" y2="92"/>`, 140),
@ -114,6 +125,7 @@
models, models,
amsTypes, amsTypes,
find, find,
art: (id) => shapes[(find(id) || {}).shape] || shapes.generic, // `slots`: the first AMS unit's four slots, as { empty, colour }, to colour in.
art: (id, slots = []) => (shapes[(find(id) || {}).shape] || shapes.generic)(slots),
}; };
}()); }());

View file

@ -491,7 +491,12 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
/* ---- Printers ---- */ /* ---- Printers ---- */
.printer-cards { display: flex; flex-direction: column; gap: 18px; } .printer-cards { display: flex; flex-direction: column; gap: 18px; }
.printer-card { flex-direction: row; align-items: flex-start; gap: 20px; } .printer-card { flex-direction: row; align-items: flex-start; gap: 20px; }
.printer-art { flex: 0 0 150px; color: var(--text-muted); padding-top: 28px; } .printer-side { flex: 0 0 170px; display: flex; flex-direction: column; gap: 8px; }
.printer-art { color: var(--text-muted); padding: 20px 10px 0; }
.loaded-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: var(--text-muted); }
.loaded-list li { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loaded-slot { font-family: var(--mono); color: var(--text-dim); min-width: 62px; }
.spool-dot.is-empty { background: transparent; border: 1px dashed var(--text-dim); }
.printer-art svg { width: 100%; height: auto; display: block; } .printer-art svg { width: 100%; height: auto; display: block; }
.printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; } .printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; }
.printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); } .printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
@ -503,6 +508,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.spool-tag code { color: var(--text-muted); } .spool-tag code { color: var(--text-muted); }
@media (max-width: 600px) { @media (max-width: 600px) {
.printer-card { flex-direction: column; } .printer-card { flex-direction: column; }
.printer-art { flex-basis: auto; width: 140px; padding-top: 0; } .printer-side { flex-basis: auto; width: 100%; flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
.printer-art { width: 140px; padding: 0; }
} }
.printer-ask { grid-column: 1 / -1; color: var(--accent); } .printer-ask { grid-column: 1 / -1; color: var(--accent); }
.loaded-list .spool-dot { flex-shrink: 0; width: 12px; height: 12px; }

View file

@ -43,6 +43,8 @@ async function syncPrinter(p, { debug = false } = {}) {
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug }); const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug });
if (result.connected) { if (result.connected) {
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])]; const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
// Only a full report says what's loaded; a status-only one would blank the card.
if (result.ams || result.external) await printers.recordLoaded(p.id, trays);
if (trays.length) { if (trays.length) {
result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments, result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
{ measures: printers.measures(p.ams_type) }); { measures: printers.measures(p.ams_type) });