Compare commits

..

2 commits

Author SHA1 Message Date
dlawler489
f7f7b21d36 Merge active filament display
All checks were successful
build-and-push / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:31:53 +10:00
dlawler489
1aca9a6c01 Show which filament is feeding each printer, with a tube from spool to toolhead
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 <noreply@anthropic.com>
2026-09-18 00:27:57 +10:00
6 changed files with 223 additions and 96 deletions

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -2696,27 +2696,53 @@ function drawnSlots(p) {
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>' : '';
// 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) => {
// 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 ? '<p class="form-hint">Nothing reported yet — “Test & sync” to read it.</p>' : '';
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 what = t.empty ? 'empty' : `${escapeHtml([t.material || '?', slotColourName(p, 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 feeding = isActiveSlot(p, t) ? ' <span class="loaded-active" title="Feeding the toolhead">◀ in use</span>' : '';
return `<li title="${escapeHtml(label)}"${feeding ? ' class="is-active"' : ''}>${dot}<span class="loaded-slot">${escapeHtml(label)}</span> ${what}${feeding}</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}`));
.map((t) => chip(t, slotName(t)));
const when = p.liveLoaded ? '<p class="form-hint">Live from the printer</p>' : 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}`;
}
@ -2732,7 +2758,7 @@ function printerCardHtml(p) {
return `
<section class="ai-card printer-card" data-printer-id="${key}">
<div class="printer-side">
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model, drawnSlots(p))}</div>
<div class="printer-art" aria-hidden="true">${printerArt(p, p.model)}</div>
${loadedHtml(p)}
</div>
<form class="printer-form" autocomplete="off">
@ -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 '<div class="status-head"><span class="status-pill is-off">Not connected</span> <span class="form-hint">Fill in the address, serial number and access code to connect.</span></div>';
}
@ -2857,6 +2883,14 @@ function printerStatusHtml(snap, model) {
].filter(Boolean);
if (temps.length) lines.push(`<div class="status-facts">${temps.map(escapeHtml).join(' · ')}</div>`);
// 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 || '') ? `<span class="spool-dot" style="background:${inUse.colour}"></span>` : '';
lines.push(`<div class="status-using">${dot}${active ? 'Using' : 'Loaded'}: ${escapeHtml(what)} <span class="form-hint">(${escapeHtml(slotName(inUse))})</span></div>`);
}
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 = `
<div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(p))}</div>
<div class="printer-art" aria-hidden="true">${printerArt(p, card.querySelector('select[name="model"]').value)}</div>
${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 = `
<div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(fresh))}</div>
${loadedHtml(fresh)}`;
<div class="printer-art" aria-hidden="true">${printerArt(card.livePrinter || fresh, card.querySelector('select[name="model"]').value)}</div>
${loadedHtml(card.livePrinter || fresh)}`;
}
} catch (err) {
say(`✗ ${escapeHtml(err.message)}`, 'error');

View file

@ -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 `<circle cx="${cx}" cy="${cy}" r="${r}" stroke-dasharray="2 3" opacity="0.6"/>`;
const fill = slot && HEX.test(slot.colour || '') ? slot.colour : null;
const fill = colourOf(slot);
const ring = active ? ' stroke-width="3.4"' : '';
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.
const amsLite = (x, slots) => `
<line x1="${x}" y1="104" x2="${x}" y2="52"/>
<line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>
${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 `<rect x="${x}" y="14" width="${width}" height="18" rx="3"/>${spools}`;
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"${ring}/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
: `<circle cx="${cx}" cy="${cy}" r="${r}"${ring}/>`;
};
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/>
<line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
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(`
open: {
spools: amsLiteAt(126), head: [50, 49], external: [104, 12, 6],
body: () => `
<rect x="12" y="94" width="92" height="10" rx="3"/>
<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="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>
${toolhead(50, 49)}
${amsLite(126, slots)}`),
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>`,
stand: (x = 126) => `<line x1="${x}" y1="104" x2="${x}" y2="52"/><line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>`,
},
// A1 mini: a single column holding the gantry out over the bed.
openMini: (slots) => svg(`
openMini: {
spools: amsLiteAt(122), head: [58, 47], external: [29, 18, 6],
body: () => `
<rect x="22" y="94" width="72" height="10" rx="3"/>
<rect x="36" y="84" width="50" height="6" rx="2"/>
<rect x="24" y="30" width="10" height="64" rx="2"/>
<line x1="34" y1="54" x2="90" y2="54"/>
${toolhead(58, 47)}
${amsLite(122, slots)}`),
<line x1="34" y1="54" x2="90" y2="54"/>`,
stand: () => `<line x1="122" y1="104" x2="122" y2="52"/><line x1="112" y1="104" x2="132" y2="104"/>`,
},
// A2L: the A1's form, stretched to a larger bed.
openLarge: (slots) => svg(`
openLarge: {
spools: amsLiteAt(132), head: [52, 47], external: [112, 10, 6],
body: () => `
<rect x="6" y="94" width="104" height="10" rx="3"/>
<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="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>
${toolhead(52, 47)}
${amsLite(132, slots)}`),
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>`,
stand: () => `<line x1="132" y1="104" x2="132" y2="52"/><line x1="122" y1="104" x2="142" y2="104"/>`,
},
// P1P: a bare cube frame.
frame: () => svg(`
frame: {
width: 140, spools: [], head: [56, 53], external: [124, 18, 6],
body: () => `
<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"/>
<line x1="30" y1="60" x2="96" y2="60"/>
${toolhead(56, 53)}
<rect x="40" y="90" width="46" height="5" rx="2"/>`, 140),
<rect x="40" y="90" width="46" height="5" rx="2"/>`,
},
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
box: (slots) => svg(`
${amsTop(30, 66, slots)}
box: {
width: 126, spools: amsTopAt(30, 66), head: [56, 52], external: [112, 60, 6],
body: () => `
<rect x="30" y="14" width="66" height="18" rx="3"/>
<rect x="28" y="36" width="70" height="70" rx="5"/>
<rect x="36" y="44" width="54" height="46" rx="3"/>
<line x1="36" y1="98" x2="90" y2="98"/>
${toolhead(56, 52)}`, 126),
<line x1="36" y1="98" x2="90" y2="98"/>`,
},
// H2S, H2D, H2C: a wide enclosed box with an AMS on top.
large: (slots) => svg(`
${amsTop(28, 94, slots)}
large: {
spools: amsTopAt(28, 94), head: [58, 52], secondHead: [78, 52], external: [142, 60, 6],
body: () => `
<rect x="28" y="14" width="94" height="18" rx="3"/>
<rect x="18" y="36" width="114" height="70" rx="5"/>
<rect x="26" y="44" width="98" height="46" rx="3"/>
<line x1="26" y1="98" x2="124" y2="98"/>
${toolhead(58, 52)}${toolhead(78, 52)}`),
<line x1="26" y1="98" x2="124" y2="98"/>`,
},
// Anything else: a plain box with a nozzle.
generic: () => svg(`
generic: {
width: 140, spools: [], head: [63, 50], external: [122, 22, 6],
body: () => `
<rect x="34" y="30" width="72" height="72" rx="8"/>
${toolhead(63, 50)}
<line x1="44" y1="92" x2="96" y2="92"/>`, 140),
<line x1="44" y1="92" x2="96" y2="92"/>`,
},
};
// 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 `<g class="feed${moving ? ' is-moving' : ''}">
<path d="${d}" stroke-width="4.6" opacity="0.55"/>
<path d="${d}" stroke="${colour}" stroke-width="2.6"/>
${moving ? `<path class="feed-flow" d="${d}" stroke="#ffffff" stroke-width="1.2" stroke-dasharray="1 7" opacity="0.8"/>` : ''}
</g>`;
};
// `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),
};
}());

View file

@ -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; } }

View file

@ -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]);