Compare commits

..

No commits in common. "f7f7b21d36d0c6b3b77e121b7c1c957c7c0a9980" and "859554e3453a2ac49558ed53f5793f40cd1104b9" have entirely different histories.

6 changed files with 96 additions and 223 deletions

View file

@ -152,7 +152,6 @@ function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) {
amsKeys: Object.keys(p.ams), amsKeys: Object.keys(p.ams),
unitKeys: Object.keys(unit), unitKeys: Object.keys(unit),
unitInfo: { info: unit.info, humidity: unit.humidity, temp: unit.temp }, 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, tray: (unit.tray || []).find((t) => t && t.tray_type) || null,
}; };
} }

View file

@ -24,19 +24,6 @@ function allTrays(c) {
return [...(c.ams || []), ...(c.external ? [c.external] : [])]; 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) { function requestFullReport(c) {
if (!c.client.connected) return; if (!c.client.connected) return;
c.client.publish(`device/${c.serial}/request`, c.client.publish(`device/${c.serial}/request`,
@ -71,11 +58,7 @@ function handleMessage(c, payload) {
// AMS details come whole when they change; a report without them changes nothing. // AMS details come whole when they change; a report without them changes nothing.
const units = p.ams && p.ams.ams; const units = p.ams && p.ams.ams;
if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) { if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) c.ams = describeAms(units);
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); 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 // A printer mid-print reports every second or so, so only a change in what's
@ -105,8 +88,6 @@ function open(p) {
external: null, external: null,
syncedKey: null, syncedKey: null,
pendingKey: null, pendingKey: null,
trayNow: null,
unitIds: [],
lastSync: null, lastSync: null,
traysTimer: null, traysTimer: null,
waiters: [], waiters: [],
@ -177,7 +158,7 @@ function snapshot(id) {
connected: c.connected, connected: c.connected,
error: c.connected ? null : c.error, error: c.connected ? null : c.error,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
status: { ...c.status, active: activeTray(c) }, status: c.status,
loaded: c.ams || c.external ? loaded : null, loaded: c.ams || c.external ? loaded : null,
}; };
} }

View file

@ -2696,53 +2696,27 @@ function drawnSlots(p) {
return slots; 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. // What's in each slot, as last reported, under the drawing.
function loadedHtml(p) { function loadedHtml(p) {
if (!p.loaded) return p.id ? '<p class="form-hint">Nothing reported yet — “Test & sync” to read it.</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) => {
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 chip = (t, label) => {
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 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 const dot = t.empty
? '<span class="spool-dot is-empty"></span>' ? '<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>`; : `<span class="spool-dot" style="background:${/^#[0-9a-f]{6}$/i.test(t.colour || '') ? t.colour : '#888888'}"></span>`;
const feeding = isActiveSlot(p, t) ? ' <span class="loaded-active" title="Feeding the toolhead">◀ in use</span>' : ''; return `<li title="${escapeHtml(label)}">${dot}<span class="loaded-slot">${escapeHtml(label)}</span> ${what}</li>`;
return `<li title="${escapeHtml(label)}"${feeding ? ' class="is-active"' : ''}>${dot}<span class="loaded-slot">${escapeHtml(label)}</span> ${what}${feeding}</li>`;
}; };
const rows = [...p.loaded] const rows = [...p.loaded]
.sort((a, b) => (a.unit ?? 1e9) - (b.unit ?? 1e9) || (a.slot ?? 0) - (b.slot ?? 0)) .sort((a, b) => (a.unit ?? 1e9) - (b.unit ?? 1e9) || (a.slot ?? 0) - (b.slot ?? 0))
.map((t) => chip(t, slotName(t))); .map((t) => chip(t, t.unit == null ? 'External' : `${amsUnitLabel(t.unit)} · ${(t.slot ?? 0) + 1}`));
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>` : ''; 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}`; return rows.length ? `<ul class="loaded-list">${rows.join('')}</ul>${when}` : `<p class="form-hint">Nothing loaded.</p>${when}`;
} }
@ -2758,7 +2732,7 @@ function printerCardHtml(p) {
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-side"> <div class="printer-side">
<div class="printer-art" aria-hidden="true">${printerArt(p, p.model)}</div> <div class="printer-art" aria-hidden="true">${printerModels().art(p.model, drawnSlots(p))}</div>
${loadedHtml(p)} ${loadedHtml(p)}
</div> </div>
<form class="printer-form" autocomplete="off"> <form class="printer-form" autocomplete="off">
@ -2848,7 +2822,7 @@ const temp = (now, target) => {
// These report a placeholder chamber temperature (5 °C) rather than a reading. // These report a placeholder chamber temperature (5 °C) rather than a reading.
const NO_CHAMBER_SENSOR = ['A1 mini', 'A1', 'A2L', 'P1P', 'P1S']; const NO_CHAMBER_SENSOR = ['A1 mini', 'A1', 'A2L', 'P1P', 'P1S'];
function printerStatusHtml(snap, model, p = {}) { function printerStatusHtml(snap, model) {
if (!snap || !snap.live) { 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>'; 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>';
} }
@ -2883,14 +2857,6 @@ function printerStatusHtml(snap, model, p = {}) {
].filter(Boolean); ].filter(Boolean);
if (temps.length) lines.push(`<div class="status-facts">${temps.map(escapeHtml).join(' · ')}</div>`); 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 = []; const problems = [];
if (s.printError) problems.push(`Error ${s.printError.slice(0, 4)}_${s.printError.slice(4)}`); 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}`); for (const h of s.hms || []) problems.push(`HMS ${h}`);
@ -2917,25 +2883,17 @@ async function pollPrinters() {
const snap = live[id]; const snap = live[id];
const box = card.querySelector('.printer-status'); const box = card.querySelector('.printer-status');
if (box) { if (box) {
const printer = printerList.find((x) => x.id === id) || {}; const html = printerStatusHtml(snap, card.querySelector('select[name="model"]').value);
const html = printerStatusHtml(snap, card.querySelector('select[name="model"]').value, printer);
if (box.dataset.html !== html) { box.innerHTML = html; box.dataset.html = html; } if (box.dataset.html !== html) { box.innerHTML = html; box.dataset.html = html; }
} }
// A spool swap shows as soon as the printer reports it. // A spool swap shows as soon as the printer reports it.
if (snap && snap.loaded) { if (snap && snap.loaded) {
const p = { const key = JSON.stringify(snap.loaded);
...(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) { if (card.dataset.loadedKey !== key) {
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 = ` card.querySelector('.printer-side').innerHTML = `
<div class="printer-art" aria-hidden="true">${printerArt(p, card.querySelector('select[name="model"]').value)}</div> <div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(p))}</div>
${loadedHtml(p)}`; ${loadedHtml(p)}`;
} }
} }
@ -2984,7 +2942,7 @@ function bindPrinterCard(card) {
}; };
modelSelect.addEventListener('change', () => { modelSelect.addEventListener('change', () => {
const current = printerList.find((x) => x.id === id) || {}; const current = printerList.find((x) => x.id === id) || {};
card.querySelector('.printer-art').innerHTML = printerArt(card.livePrinter || current, modelSelect.value); 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=""]');
@ -3098,8 +3056,8 @@ async function testPrinter(id, btn, say, card) {
const fresh = printerList.find((x) => x.id === id); const fresh = printerList.find((x) => x.id === id);
if (fresh && card) { if (fresh && card) {
card.querySelector('.printer-side').innerHTML = ` card.querySelector('.printer-side').innerHTML = `
<div class="printer-art" aria-hidden="true">${printerArt(card.livePrinter || fresh, card.querySelector('select[name="model"]').value)}</div> <div class="printer-art" aria-hidden="true">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(fresh))}</div>
${loadedHtml(card.livePrinter || fresh)}`; ${loadedHtml(fresh)}`;
} }
} catch (err) { } catch (err) {
say(`✗ ${escapeHtml(err.message)}`, 'error'); say(`✗ ${escapeHtml(err.message)}`, 'error');

View file

@ -9,130 +9,91 @@
// One spool in a drawing, filled with what's loaded there: a colour for a // 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. // spool, dashed for an empty slot, plain when nothing is known.
const HEX = /^#[0-9a-f]{6}$/i; const HEX = /^#[0-9a-f]{6}$/i;
const colourOf = (slot) => (slot && HEX.test(slot.colour || '') ? slot.colour : null); const spool = (cx, cy, r, slot) => {
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"/>`; if (slot && slot.empty) return `<circle cx="${cx}" cy="${cy}" r="${r}" stroke-dasharray="2 3" opacity="0.6"/>`;
const fill = colourOf(slot); const fill = slot && HEX.test(slot.colour || '') ? slot.colour : null;
const ring = active ? ' stroke-width="3.4"' : '';
return fill return fill
? `<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}" fill="${fill}"/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
: `<circle cx="${cx}" cy="${cy}" r="${r}"${ring}/>`; : `<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}`;
}; };
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/> 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}"/>`; <line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
// Where each shape's spools sit, where its toolhead takes filament in, and const shapes = {
// 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. // A1: a bed on a base, an upright gantry, an AMS lite alongside.
open: { open: (slots) => svg(`
spools: amsLiteAt(126), head: [50, 49], external: [104, 12, 6], <rect x="12" y="94" width="92" height="10" rx="3"/>
body: () => ` <rect x="24" y="84" width="68" height="6" rx="2"/>
<rect x="12" y="94" width="92" height="10" rx="3"/> <line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
<rect x="24" y="84" width="68" height="6" rx="2"/> <line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/> ${toolhead(50, 49)}
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>`, ${amsLite(126, slots)}`),
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. // A1 mini: a single column holding the gantry out over the bed.
openMini: { openMini: (slots) => svg(`
spools: amsLiteAt(122), head: [58, 47], external: [29, 18, 6], <rect x="22" y="94" width="72" height="10" rx="3"/>
body: () => ` <rect x="36" y="84" width="50" height="6" rx="2"/>
<rect x="22" y="94" width="72" height="10" rx="3"/> <rect x="24" y="30" width="10" height="64" rx="2"/>
<rect x="36" y="84" width="50" height="6" rx="2"/> <line x1="34" y1="54" x2="90" y2="54"/>
<rect x="24" y="30" width="10" height="64" rx="2"/> ${toolhead(58, 47)}
<line x1="34" y1="54" x2="90" y2="54"/>`, ${amsLite(122, slots)}`),
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. // A2L: the A1's form, stretched to a larger bed.
openLarge: { openLarge: (slots) => svg(`
spools: amsLiteAt(132), head: [52, 47], external: [112, 10, 6], <rect x="6" y="94" width="104" height="10" rx="3"/>
body: () => ` <rect x="14" y="84" width="88" height="6" rx="2"/>
<rect x="6" y="94" width="104" height="10" rx="3"/> <line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
<rect x="14" y="84" width="88" height="6" rx="2"/> <line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/> ${toolhead(52, 47)}
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>`, ${amsLite(132, slots)}`),
stand: () => `<line x1="132" y1="104" x2="132" y2="52"/><line x1="122" y1="104" x2="142" y2="104"/>`,
},
// P1P: a bare cube frame. // P1P: a bare cube frame.
frame: { frame: () => svg(`
width: 140, spools: [], head: [56, 53], external: [124, 18, 6], <rect x="30" y="38" width="66" height="66" rx="3"/>
body: () => ` <path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
<rect x="30" y="38" width="66" height="66" rx="3"/> <line x1="30" y1="60" x2="96" y2="60"/>
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/> ${toolhead(56, 53)}
<line x1="30" y1="60" x2="96" y2="60"/> <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. // P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
box: { box: (slots) => svg(`
width: 126, spools: amsTopAt(30, 66), head: [56, 52], external: [112, 60, 6], ${amsTop(30, 66, slots)}
body: () => ` <rect x="28" y="36" width="70" height="70" rx="5"/>
<rect x="30" y="14" width="66" height="18" rx="3"/> <rect x="36" y="44" width="54" height="46" rx="3"/>
<rect x="28" y="36" width="70" height="70" rx="5"/> <line x1="36" y1="98" x2="90" y2="98"/>
<rect x="36" y="44" width="54" height="46" rx="3"/> ${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. // H2S, H2D, H2C: a wide enclosed box with an AMS on top.
large: { large: (slots) => svg(`
spools: amsTopAt(28, 94), head: [58, 52], secondHead: [78, 52], external: [142, 60, 6], ${amsTop(28, 94, slots)}
body: () => ` <rect x="18" y="36" width="114" height="70" rx="5"/>
<rect x="28" y="14" width="94" height="18" rx="3"/> <rect x="26" y="44" width="98" height="46" rx="3"/>
<rect x="18" y="36" width="114" height="70" rx="5"/> <line x1="26" y1="98" x2="124" y2="98"/>
<rect x="26" y="44" width="98" height="46" rx="3"/> ${toolhead(58, 52)}${toolhead(78, 52)}`),
<line x1="26" y1="98" x2="124" y2="98"/>`,
},
// Anything else: a plain box with a nozzle. // Anything else: a plain box with a nozzle.
generic: { generic: () => svg(`
width: 140, spools: [], head: [63, 50], external: [122, 22, 6], <rect x="34" y="30" width="72" height="72" rx="8"/>
body: () => ` ${toolhead(63, 50)}
<rect x="34" y="30" width="72" height="72" rx="8"/> <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. // ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT.
const models = [ const models = [
{ id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' }, { id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' },
@ -164,6 +125,7 @@
models, models,
amsTypes, amsTypes,
find, find,
art: (id, slots = [], use = null, moving = false) => draw((find(id) || {}).shape, slots, use, moving), // `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,10 +491,10 @@ 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-side { flex: 0 0 180px; min-width: 0; display: flex; flex-direction: column; gap: 8px; } .printer-side { flex: 0 0 170px; display: flex; flex-direction: column; gap: 8px; }
.printer-art { color: var(--text-muted); padding: 20px 10px 0; } .printer-art { color: var(--text-muted); padding: 20px 10px 0; }
.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 { 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-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; } .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); } .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; }
@ -526,10 +526,3 @@ 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-bar span { display: block; height: 100%; background: var(--accent); transition: width 0.6s ease; }
.status-facts { font-family: var(--mono); font-size: 12px; } .status-facts { font-family: var(--mono); font-size: 12px; }
.status-problems { color: var(--danger); 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,26 +83,6 @@ test('a busy printer’s steady reports don’t hold back a spool sync', async (
assert.equal(synced.length, 1); 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', () => { test('changing a printer’s code reopens its connection; removing it closes it', () => {
clients.length = 0; clients.length = 0;
live.reconcile([printerRow]); live.reconcile([printerRow]);