From 1aca9a6c01777170fe7c6650e8d2e048aa5963d8 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Fri, 18 Sep 2026 00:27:57 +1000 Subject: [PATCH] Show which filament is feeding each printer, with a tube from spool to toolhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live connection reads tray_now (unit × 4 + slot, 254 external, 255 none) and works out the unit by id or, failing that, by position — so the A2L's AMS lite numbered 16 is handled. Cards say 'Using' (or 'Loaded' when not printing) with the filament's name and slot, mark the slot in the list, and draw a tube in its colour from that spool to the toolhead, with a moving flow while printing. The external spool is drawn when it's in use. Debug reports include tray_now and tray_tar. Co-Authored-By: Claude Opus 5 --- lib/printer.js | 1 + lib/printerLive.js | 23 ++++- public/app.js | 82 +++++++++++++----- public/printer-art.js | 180 ++++++++++++++++++++++++--------------- public/styles.css | 13 ++- test/printerLive.test.js | 20 +++++ 6 files changed, 223 insertions(+), 96 deletions(-) diff --git a/lib/printer.js b/lib/printer.js index ae2843c..79e943a 100644 --- a/lib/printer.js +++ b/lib/printer.js @@ -152,6 +152,7 @@ function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) { amsKeys: Object.keys(p.ams), unitKeys: Object.keys(unit), unitInfo: { info: unit.info, humidity: unit.humidity, temp: unit.temp }, + trayNow: p.ams.tray_now, trayTar: p.ams.tray_tar, tray: (unit.tray || []).find((t) => t && t.tray_type) || null, }; } diff --git a/lib/printerLive.js b/lib/printerLive.js index 645409e..c533394 100644 --- a/lib/printerLive.js +++ b/lib/printerLive.js @@ -24,6 +24,19 @@ function allTrays(c) { return [...(c.ams || []), ...(c.external ? [c.external] : [])]; } +// Which tray is feeding the toolhead. The printer sends `tray_now` as a number: +// 255 for none, 254 for the external spool, otherwise unit × 4 + slot — where +// "unit" is the AMS's id on most printers, or its position among the units. +function activeTray(c) { + const n = c.trayNow; + if (n == null || Number.isNaN(n) || n === 255) return null; + if (n === 254) return { external: true }; + const ids = c.unitIds || []; + const byId = Math.floor(n / 4); + const unit = ids.includes(byId) ? byId : ids[byId]; + return unit === undefined ? null : { unit, slot: n % 4 }; +} + function requestFullReport(c) { if (!c.client.connected) return; c.client.publish(`device/${c.serial}/request`, @@ -58,7 +71,11 @@ function handleMessage(c, payload) { // AMS details come whole when they change; a report without them changes nothing. const units = p.ams && p.ams.ams; - if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) c.ams = describeAms(units); + if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) { + c.ams = describeAms(units); + c.unitIds = units.map((u) => Number(u.id)); + } + if (p.ams && p.ams.tray_now !== undefined) c.trayNow = Number(p.ams.tray_now); if (p.vt_tray) c.external = describeTray(p.vt_tray, null); // A printer mid-print reports every second or so, so only a change in what's @@ -88,6 +105,8 @@ function open(p) { external: null, syncedKey: null, pendingKey: null, + trayNow: null, + unitIds: [], lastSync: null, traysTimer: null, waiters: [], @@ -158,7 +177,7 @@ function snapshot(id) { connected: c.connected, error: c.connected ? null : c.error, updatedAt: c.updatedAt, - status: c.status, + status: { ...c.status, active: activeTray(c) }, loaded: c.ams || c.external ? loaded : null, }; } diff --git a/public/app.js b/public/app.js index d035f69..2fcb577 100644 --- a/public/app.js +++ b/public/app.js @@ -2696,27 +2696,53 @@ function drawnSlots(p) { return slots; } +// Which of the drawn things is feeding the toolhead, for the drawing: a slot of +// the first AMS unit, or the external spool with its colour. +function drawnUse(p) { + const a = p.active; + if (!a || !p.loaded) return null; + if (a.external) { + const ext = p.loaded.find((t) => t.unit == null); + return { external: true, colour: ext && !ext.empty ? ext.colour : null }; + } + const units = p.loaded.filter((t) => t.unit != null).map((t) => t.unit); + return units.length && a.unit === Math.min(...units) ? { slot: a.slot } : null; +} + +const isActiveSlot = (p, t) => Boolean(p.active && (p.active.external + ? t.unit == null + : t.unit === p.active.unit && t.slot === p.active.slot)); + +// A tagged spool is Bambu's, so its colour has a name: from the tracked spool, +// or the Bambu colour it matches. An untagged one could be any brand, so it +// gets the colour the printer reports and nothing more. +function slotColourName(p, t) { + if (!t.tagged) return t.colour || ''; + 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 slotName = (t) => (t.unit == null ? 'External' : `${amsUnitLabel(t.unit)} · ${(t.slot ?? 0) + 1}`); + +function printerArt(p, model) { + const moving = Boolean(p.statusState && ['RUNNING', 'PREPARE'].includes(p.statusState)); + return printerModels().art(model, drawnSlots(p), drawnUse(p), moving); +} + // 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.

' : ''; - // A tagged spool is Bambu's, so its colour has a name: from the tracked spool, - // or the Bambu colour it matches. An untagged one could be any brand, so it - // gets the colour the printer reports and nothing more. - const colourName = (t) => { - if (!t.tagged) return t.colour || ''; - 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 what = t.empty ? 'empty' : `${escapeHtml([t.material || '?', slotColourName(p, t)].filter(Boolean).join(' '))}${t.tagged ? '' : ' (untagged)'}`; const dot = t.empty ? '' : ``; - return `
  • ${dot}${escapeHtml(label)} ${what}
  • `; + const feeding = isActiveSlot(p, t) ? ' ◀ in use' : ''; + return `
  • ${dot}${escapeHtml(label)} ${what}${feeding}
  • `; }; 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}`)); + .map((t) => chip(t, slotName(t))); const when = p.liveLoaded ? '

    Live from the printer

    ' : p.loaded_at ? `

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

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

    Nothing loaded.

    ${when}`; } @@ -2732,7 +2758,7 @@ function printerCardHtml(p) { return `
    - + ${loadedHtml(p)}
    @@ -2822,7 +2848,7 @@ const temp = (now, target) => { // These report a placeholder chamber temperature (5 °C) rather than a reading. const NO_CHAMBER_SENSOR = ['A1 mini', 'A1', 'A2L', 'P1P', 'P1S']; -function printerStatusHtml(snap, model) { +function printerStatusHtml(snap, model, p = {}) { if (!snap || !snap.live) { return '
    Not connected Fill in the address, serial number and access code to connect.
    '; } @@ -2857,6 +2883,14 @@ function printerStatusHtml(snap, model) { ].filter(Boolean); if (temps.length) lines.push(`
    ${temps.map(escapeHtml).join(' · ')}
    `); + // The filament in the toolhead, named as in the slot list. + const inUse = s.active && (snap.loaded || []).find((t) => isActiveSlot({ active: s.active }, t)); + if (inUse && !inUse.empty) { + const what = [inUse.material, slotColourName({ ...p, active: s.active }, inUse)].filter(Boolean).join(' '); + const dot = /^#[0-9a-f]{6}$/i.test(inUse.colour || '') ? `` : ''; + lines.push(`
    ${dot}${active ? 'Using' : 'Loaded'}: ${escapeHtml(what)} (${escapeHtml(slotName(inUse))})
    `); + } + const problems = []; if (s.printError) problems.push(`Error ${s.printError.slice(0, 4)}_${s.printError.slice(4)}`); for (const h of s.hms || []) problems.push(`HMS ${h}`); @@ -2883,17 +2917,25 @@ async function pollPrinters() { const snap = live[id]; const box = card.querySelector('.printer-status'); if (box) { - const html = printerStatusHtml(snap, card.querySelector('select[name="model"]').value); + const printer = printerList.find((x) => x.id === id) || {}; + const html = printerStatusHtml(snap, card.querySelector('select[name="model"]').value, printer); if (box.dataset.html !== html) { box.innerHTML = html; box.dataset.html = html; } } // A spool swap shows as soon as the printer reports it. if (snap && snap.loaded) { - const key = JSON.stringify(snap.loaded); + const p = { + ...(printerList.find((x) => x.id === id) || {}), + loaded: snap.loaded, + liveLoaded: true, + active: snap.status && snap.status.active, + statusState: snap.status && snap.status.state, + }; + card.livePrinter = p; + const key = JSON.stringify([p.loaded, p.active, p.statusState]); if (card.dataset.loadedKey !== key) { card.dataset.loadedKey = key; - const p = { ...(printerList.find((x) => x.id === id) || {}), loaded: snap.loaded, liveLoaded: true }; card.querySelector('.printer-side').innerHTML = ` - + ${loadedHtml(p)}`; } } @@ -2942,7 +2984,7 @@ function bindPrinterCard(card) { }; modelSelect.addEventListener('change', () => { const current = printerList.find((x) => x.id === id) || {}; - card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value, drawnSlots(current)); + card.querySelector('.printer-art').innerHTML = printerArt(card.livePrinter || current, modelSelect.value); const m = printerModels().find(modelSelect.value); if (m && !amsTouched) { const blank = amsSelect.querySelector('option[value=""]'); @@ -3056,8 +3098,8 @@ async function testPrinter(id, btn, say, card) { const fresh = printerList.find((x) => x.id === id); if (fresh && card) { card.querySelector('.printer-side').innerHTML = ` - - ${loadedHtml(fresh)}`; + + ${loadedHtml(card.livePrinter || fresh)}`; } } catch (err) { say(`✗ ${escapeHtml(err.message)}`, 'error'); diff --git a/public/printer-art.js b/public/printer-art.js index 1564173..4d3572a 100644 --- a/public/printer-art.js +++ b/public/printer-art.js @@ -9,91 +9,130 @@ // 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) => { + const colourOf = (slot) => (slot && HEX.test(slot.colour || '') ? slot.colour : null); + const spool = (cx, cy, r, slot, active) => { if (slot && slot.empty) return ``; - const fill = slot && HEX.test(slot.colour || '') ? slot.colour : null; + const fill = colourOf(slot); + const ring = active ? ' stroke-width="3.4"' : ''; return fill - ? `` - : ``; - }; - - // An AMS lite: a stand with four spools around a hub. - 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, slots) => { - const step = width / 4; - let spools = ''; - for (let i = 0; i < 4; i += 1) spools += spool(x + step / 2 + i * step, 23, 5, slots[i]); - return `${spools}`; + ? `` + : ``; }; const toolhead = (x, y) => ` `; - const shapes = { + // Where each shape's spools sit, where its toolhead takes filament in, and + // where an external spool is drawn when it's the one in use. + const amsLiteAt = (x) => [[x - 9, 40, 7], [x + 9, 40, 7], [x - 9, 58, 7], [x + 9, 58, 7]]; + const amsTopAt = (x, width) => [0, 1, 2, 3].map((i) => [x + width / 8 + (i * width) / 4, 23, 5]); + + const layouts = { // A1: a bed on a base, an upright gantry, an AMS lite alongside. - open: (slots) => svg(` - - - - - ${toolhead(50, 49)} - ${amsLite(126, slots)}`), - + open: { + spools: amsLiteAt(126), head: [50, 49], external: [104, 12, 6], + body: () => ` + + + + `, + stand: (x = 126) => ``, + }, // A1 mini: a single column holding the gantry out over the bed. - openMini: (slots) => svg(` - - - - - ${toolhead(58, 47)} - ${amsLite(122, slots)}`), - + openMini: { + spools: amsLiteAt(122), head: [58, 47], external: [29, 18, 6], + body: () => ` + + + + `, + stand: () => ``, + }, // A2L: the A1's form, stretched to a larger bed. - openLarge: (slots) => svg(` - - - - - ${toolhead(52, 47)} - ${amsLite(132, slots)}`), - + openLarge: { + spools: amsLiteAt(132), head: [52, 47], external: [112, 10, 6], + body: () => ` + + + + `, + stand: () => ``, + }, // P1P: a bare cube frame. - frame: () => svg(` - - - - ${toolhead(56, 53)} - `, 140), - + frame: { + width: 140, spools: [], head: [56, 53], external: [124, 18, 6], + body: () => ` + + + + `, + }, // P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top. - box: (slots) => svg(` - ${amsTop(30, 66, slots)} - - - - ${toolhead(56, 52)}`, 126), - + box: { + width: 126, spools: amsTopAt(30, 66), head: [56, 52], external: [112, 60, 6], + body: () => ` + + + + `, + }, // H2S, H2D, H2C: a wide enclosed box with an AMS on top. - large: (slots) => svg(` - ${amsTop(28, 94, slots)} - - - - ${toolhead(58, 52)}${toolhead(78, 52)}`), - + large: { + spools: amsTopAt(28, 94), head: [58, 52], secondHead: [78, 52], external: [142, 60, 6], + body: () => ` + + + + `, + }, // Anything else: a plain box with a nozzle. - generic: () => svg(` - - ${toolhead(63, 50)} - `, 140), + generic: { + width: 140, spools: [], head: [63, 50], external: [122, 22, 6], + body: () => ` + + `, + }, }; + // The filament's path from a spool into the toolhead: a tube curving over + // the frame, outlined so a dark filament still shows on a dark page. + const feed = (from, head, colour, moving) => { + const [sx, sy] = from; + const tx = head[0] + 7; + const ty = head[1]; + const lift = Math.max(2, Math.min(sy, ty) - 30); + const d = `M${sx} ${sy} C${sx} ${lift}, ${tx} ${lift}, ${tx} ${ty}`; + return ` + + + ${moving ? `` : ''} + `; + }; + + // `slots`: the first AMS unit's four slots, as { empty, colour }. + // `use`: which is feeding — { slot } for one of those, { external, colour } + // for the external spool — and `moving` while it's printing. + function draw(shapeName, slots = [], use = null, moving = false) { + const L = layouts[shapeName] || layouts.generic; + let parts = L.body(); + let feedPart = ''; + if (use && use.external && L.external) { + const [x, y, r] = L.external; + parts += spool(x, y, r, { colour: use.colour }, true); + feedPart = feed([x, y], L.head, colourOf(use) || 'currentColor', moving); + } else if (use && use.slot != null && L.spools[use.slot]) { + const [x, y] = L.spools[use.slot]; + const colour = colourOf(slots[use.slot]); + if (colour) feedPart = feed([x, y], L.head, colour, moving); + } + if (L.stand) parts += L.stand(); + parts += L.spools.map(([x, y, r], i) => spool(x, y, r, slots[i], use && use.slot === i)).join(''); + // The tube is drawn behind the toolhead, so it disappears into it. + parts += feedPart + toolhead(...L.head); + if (L.secondHead) parts += toolhead(...L.secondHead); + return svg(parts, L.width); + } + // ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT. const models = [ { id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' }, @@ -125,7 +164,6 @@ models, amsTypes, find, - // `slots`: the first AMS unit's four slots, as { empty, colour }, to colour in. - art: (id, slots = []) => (shapes[(find(id) || {}).shape] || shapes.generic)(slots), + art: (id, slots = [], use = null, moving = false) => draw((find(id) || {}).shape, slots, use, moving), }; }()); diff --git a/public/styles.css b/public/styles.css index e05fcc6..ab0f111 100644 --- a/public/styles.css +++ b/public/styles.css @@ -491,10 +491,10 @@ 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-side { flex: 0 0 170px; display: flex; flex-direction: column; gap: 8px; } +.printer-side { flex: 0 0 180px; min-width: 0; 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-list { min-width: 0; 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; flex-wrap: wrap; align-items: center; gap: 2px 6px; } .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; } @@ -526,3 +526,10 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .status-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.6s ease; } .status-facts { font-family: var(--mono); font-size: 12px; } .status-problems { color: var(--danger); font-size: 12px; } +.loaded-list li.is-active { color: var(--text); } +.loaded-active { color: var(--accent); font-size: 11px; } +.status-using { display: flex; align-items: center; gap: 6px; color: var(--text); } +.status-using .spool-dot { width: 12px; height: 12px; flex-shrink: 0; } +.feed-flow { animation: feed-flow 0.8s linear infinite; } +@keyframes feed-flow { to { stroke-dashoffset: -8; } } +@media (prefers-reduced-motion: reduce) { .feed-flow { animation: none; } } diff --git a/test/printerLive.test.js b/test/printerLive.test.js index 6ac4a01..e3c9a12 100644 --- a/test/printerLive.test.js +++ b/test/printerLive.test.js @@ -83,6 +83,26 @@ test('a busy printer’s steady reports don’t hold back a spool sync', async ( assert.equal(synced.length, 1); }); +test('the tray feeding the toolhead is worked out from tray_now', (t) => { + t.after(() => live.stopAll()); + clients.length = 0; + live.setTraysHandler(async () => ({})); + live.reconcile([{ ...printerRow, id: 7, serial: 'SER7' }, { ...printerRow, id: 8, serial: 'SER8' }]); + const [a, b] = clients; + const units = (ids) => ({ ams: ids.map((id) => ({ id: String(id), tray: [tray(0, 'PLA', 'FF0000FF')] })) }); + // Two AMS units numbered 0 and 1: tray 6 is the second unit's third slot. + a.emit('message', 't', report({ ams: { ...units([0, 1]), tray_now: '6' } })); + assert.deepEqual(live.snapshot(7).status.active, { unit: 1, slot: 2 }); + // A later report changing only tray_now still counts. + a.emit('message', 't', report({ ams: { tray_now: '254' } })); + assert.deepEqual(live.snapshot(7).status.active, { external: true }); + a.emit('message', 't', report({ ams: { tray_now: '255' } })); + assert.equal(live.snapshot(7).status.active, null); + // An AMS lite numbered 16 (as on the A2L): tray 1 is its second slot. + b.emit('message', 't', report({ ams: { ...units([16]), tray_now: '1' } })); + assert.deepEqual(live.snapshot(8).status.active, { unit: 16, slot: 1 }); +}); + test('changing a printer’s code reopens its connection; removing it closes it', () => { clients.length = 0; live.reconcile([printerRow]);