From c2bae07e5410cc4ea5c71fd73a03744f4f77cd1d Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 21:21:03 +1000 Subject: [PATCH] Show each printer's loaded filament on its card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- db/schema.sql | 4 ++++ lib/printers.js | 17 +++++++++++++- public/app.js | 52 +++++++++++++++++++++++++++++++++++++++---- public/printer-art.js | 48 ++++++++++++++++++++++++--------------- public/styles.css | 11 +++++++-- server.js | 2 ++ 6 files changed, 109 insertions(+), 25 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 9f5a07f..d27c19c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -312,6 +312,10 @@ CREATE TABLE IF NOT EXISTS printers ( 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. INSERT INTO printers (host, serial, access_code) SELECT h.value, s.value, c.value diff --git a/lib/printers.js b/lib/printers.js index 2174867..e34e3cf 100644 --- a/lib/printers.js +++ b/lib/printers.js @@ -85,6 +85,21 @@ async function remove(id) { 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]); -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 }; diff --git a/public/app.js b/public/app.js index 0b3e6f2..1c4ba8a 100644 --- a/public/app.js +++ b/public/app.js @@ -2617,6 +2617,38 @@ function printerName(p) { 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 ? '

Nothing reported yet — “Test & sync” to read it.

' : ''; + // 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 ? '' : ' (untagged)'}`; + const dot = t.empty + ? '' + : ``; + return `
  • ${dot}${escapeHtml(label)} ${what}
  • `; + }; + 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 ? `

    As of ${escapeHtml(new Date(p.loaded_at).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}

    ` : ''; + return rows.length ? `${when}` : `

    Nothing loaded.

    ${when}`; +} + function printerCardHtml(p) { const key = p.id || 'new'; const models = [''] @@ -2627,7 +2659,10 @@ function printerCardHtml(p) { .join(''); return `
    - +
    + + ${loadedHtml(p)} +

    ${escapeHtml(p.id ? printerName(p) : 'New printer')}

    ${p.id && !p.model ? '

    Which model is this? Pick it below and save.

    ' : ''} @@ -2723,7 +2758,8 @@ function bindPrinterCard(card) { form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model'; }; 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); if (m && !amsTouched) { const blank = amsSelect.querySelector('option[value=""]'); @@ -2793,12 +2829,12 @@ function bindPrinterCard(card) { } 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 // 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…'); btn.disabled = true; const trayHtml = (t) => ` @@ -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.'); } 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 = ` + + ${loadedHtml(fresh)}`; + } } catch (err) { say(`✗ ${escapeHtml(err.message)}`, 'error'); } finally { diff --git a/public/printer-art.js b/public/printer-art.js index 02811de..1564173 100644 --- a/public/printer-art.js +++ b/public/printer-art.js @@ -6,18 +6,29 @@ `${inner}`; + // 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 ``; + const fill = slot && HEX.test(slot.colour || '') ? slot.colour : null; + return fill + ? `` + : ``; + }; + // An AMS lite: a stand with four spools around a hub. - const amsLite = (x) => ` + const amsLite = (x, slots) => ` - - `; + ${spool(x - 9, 40, 7, slots[0])}${spool(x + 9, 40, 7, slots[1])} + ${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. - const amsTop = (x, width) => { + const amsTop = (x, width, slots) => { const step = width / 4; let spools = ''; - for (let i = 0; i < 4; i += 1) spools += ``; + for (let i = 0; i < 4; i += 1) spools += spool(x + step / 2 + i * step, 23, 5, slots[i]); return `${spools}`; }; @@ -26,34 +37,34 @@ const shapes = { // A1: a bed on a base, an upright gantry, an AMS lite alongside. - open: svg(` + open: (slots) => svg(` ${toolhead(50, 49)} - ${amsLite(126)}`), + ${amsLite(126, slots)}`), // A1 mini: a single column holding the gantry out over the bed. - openMini: svg(` + openMini: (slots) => svg(` ${toolhead(58, 47)} - ${amsLite(122)}`), + ${amsLite(122, slots)}`), // A2L: the A1's form, stretched to a larger bed. - openLarge: svg(` + openLarge: (slots) => svg(` ${toolhead(52, 47)} - ${amsLite(132)}`), + ${amsLite(132, slots)}`), // P1P: a bare cube frame. - frame: svg(` + frame: () => svg(` @@ -61,23 +72,23 @@ `, 140), // P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top. - box: svg(` - ${amsTop(30, 66)} + box: (slots) => svg(` + ${amsTop(30, 66, slots)} ${toolhead(56, 52)}`, 126), // H2S, H2D, H2C: a wide enclosed box with an AMS on top. - large: svg(` - ${amsTop(28, 94)} + large: (slots) => svg(` + ${amsTop(28, 94, slots)} ${toolhead(58, 52)}${toolhead(78, 52)}`), // Anything else: a plain box with a nozzle. - generic: svg(` + generic: () => svg(` ${toolhead(63, 50)} `, 140), @@ -114,6 +125,7 @@ models, amsTypes, 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), }; }()); diff --git a/public/styles.css b/public/styles.css index 2841ac3..431f6ee 100644 --- a/public/styles.css +++ b/public/styles.css @@ -491,7 +491,12 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut /* ---- Printers ---- */ .printer-cards { display: flex; flex-direction: column; gap: 18px; } .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-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); } @@ -503,6 +508,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .spool-tag code { color: var(--text-muted); } @media (max-width: 600px) { .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); } +.loaded-list .spool-dot { flex-shrink: 0; width: 12px; height: 12px; } diff --git a/server.js b/server.js index a32ab8c..6d5c43a 100644 --- a/server.js +++ b/server.js @@ -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 }); if (result.connected) { 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) { result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments, { measures: printers.measures(p.ams_type) });