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>
This commit is contained in:
parent
859554e345
commit
1aca9a6c01
6 changed files with 223 additions and 96 deletions
|
|
@ -152,6 +152,7 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,19 @@ 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`,
|
||||||
|
|
@ -58,7 +71,11 @@ 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))) 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);
|
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
|
||||||
|
|
@ -88,6 +105,8 @@ 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: [],
|
||||||
|
|
@ -158,7 +177,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,
|
status: { ...c.status, active: activeTray(c) },
|
||||||
loaded: c.ams || c.external ? loaded : null,
|
loaded: c.ams || c.external ? loaded : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2696,27 +2696,53 @@ function drawnSlots(p) {
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What's in each slot, as last reported, under the drawing.
|
// Which of the drawn things is feeding the toolhead, for the drawing: a slot of
|
||||||
function loadedHtml(p) {
|
// the first AMS unit, or the external spool with its colour.
|
||||||
if (!p.loaded) return p.id ? '<p class="form-hint">Nothing reported yet — “Test & sync” to read it.</p>' : '';
|
function drawnUse(p) {
|
||||||
// A tagged spool is Bambu's, so its colour has a name: from the tracked spool,
|
const a = p.active;
|
||||||
// or the Bambu colour it matches. An untagged one could be any brand, so it
|
if (!a || !p.loaded) return null;
|
||||||
// gets the colour the printer reports and nothing more.
|
if (a.external) {
|
||||||
const colourName = (t) => {
|
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 || '';
|
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}`);
|
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) : '') || '';
|
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 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
|
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>`;
|
||||||
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]
|
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, 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>` : '';
|
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}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2732,7 +2758,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">${printerModels().art(p.model, drawnSlots(p))}</div>
|
<div class="printer-art" aria-hidden="true">${printerArt(p, p.model)}</div>
|
||||||
${loadedHtml(p)}
|
${loadedHtml(p)}
|
||||||
</div>
|
</div>
|
||||||
<form class="printer-form" autocomplete="off">
|
<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.
|
// 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) {
|
function printerStatusHtml(snap, model, p = {}) {
|
||||||
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>';
|
||||||
}
|
}
|
||||||
|
|
@ -2857,6 +2883,14 @@ function printerStatusHtml(snap, model) {
|
||||||
].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}`);
|
||||||
|
|
@ -2883,17 +2917,25 @@ 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 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; }
|
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 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) {
|
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">${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)}`;
|
${loadedHtml(p)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2942,7 +2984,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 = printerModels().art(modelSelect.value, drawnSlots(current));
|
card.querySelector('.printer-art').innerHTML = printerArt(card.livePrinter || current, modelSelect.value);
|
||||||
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=""]');
|
||||||
|
|
@ -3056,8 +3098,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">${printerModels().art(card.querySelector('select[name="model"]').value, drawnSlots(fresh))}</div>
|
<div class="printer-art" aria-hidden="true">${printerArt(card.livePrinter || fresh, card.querySelector('select[name="model"]').value)}</div>
|
||||||
${loadedHtml(fresh)}`;
|
${loadedHtml(card.livePrinter || fresh)}`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
say(`✗ ${escapeHtml(err.message)}`, 'error');
|
||||||
|
|
|
||||||
|
|
@ -9,91 +9,130 @@
|
||||||
// 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 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"/>`;
|
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
|
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}" fill="${fill}"${ring}/><circle cx="${cx}" cy="${cy}" r="${r / 3}"/>`
|
||||||
: `<circle cx="${cx}" cy="${cy}" r="${r}"/>`;
|
: `<circle cx="${cx}" cy="${cy}" r="${r}"${ring}/>`;
|
||||||
};
|
|
||||||
|
|
||||||
// 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}"/>`;
|
||||||
|
|
||||||
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.
|
// 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="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)}
|
stand: (x = 126) => `<line x1="${x}" y1="104" x2="${x}" y2="52"/><line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>`,
|
||||||
${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: (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="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)}
|
stand: () => `<line x1="122" y1="104" x2="122" y2="52"/><line x1="112" y1="104" x2="132" y2="104"/>`,
|
||||||
${amsLite(122, slots)}`),
|
},
|
||||||
|
|
||||||
// A2L: the A1's form, stretched to a larger bed.
|
// 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="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)}
|
stand: () => `<line x1="132" y1="104" x2="132" y2="52"/><line x1="122" y1="104" x2="142" y2="104"/>`,
|
||||||
${amsLite(132, slots)}`),
|
},
|
||||||
|
|
||||||
// P1P: a bare cube frame.
|
// 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"/>
|
<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"/>
|
||||||
${toolhead(56, 53)}
|
<rect x="40" y="90" width="46" height="5" rx="2"/>`,
|
||||||
<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: (slots) => svg(`
|
box: {
|
||||||
${amsTop(30, 66, slots)}
|
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="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),
|
},
|
||||||
|
|
||||||
// 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: (slots) => svg(`
|
large: {
|
||||||
${amsTop(28, 94, slots)}
|
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="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)}`),
|
},
|
||||||
|
|
||||||
// Anything else: a plain box with a nozzle.
|
// 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"/>
|
<rect x="34" y="30" width="72" height="72" rx="8"/>
|
||||||
${toolhead(63, 50)}
|
<line x1="44" y1="92" x2="96" y2="92"/>`,
|
||||||
<line x1="44" y1="92" x2="96" y2="92"/>`, 140),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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' },
|
||||||
|
|
@ -125,7 +164,6 @@
|
||||||
models,
|
models,
|
||||||
amsTypes,
|
amsTypes,
|
||||||
find,
|
find,
|
||||||
// `slots`: the first AMS unit's four slots, as { empty, colour }, to colour in.
|
art: (id, slots = [], use = null, moving = false) => draw((find(id) || {}).shape, slots, use, moving),
|
||||||
art: (id, slots = []) => (shapes[(find(id) || {}).shape] || shapes.generic)(slots),
|
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|
|
||||||
|
|
@ -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 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; }
|
.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 { 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; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.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; }
|
.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,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-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; } }
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,26 @@ 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]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue