Compare commits
No commits in common. "4daf15d3905eb09083166e68456c48cfdab9f99b" and "c7d4194243b4e1510cf8de61e631170026e350bc" have entirely different histories.
4daf15d390
...
c7d4194243
8 changed files with 43 additions and 513 deletions
|
|
@ -28,7 +28,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
|||
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
|
||||
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
|
||||
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
|
||||
- **Printers** — add as many Bambu Lab printers as you have on the Printers page, each with an optional nickname (the model is shown when it's blank), a model picked from the lineup with a drawing of its shape, its AMS type, and its address, serial number and access code. Printhive stays connected to each printer and its card shows what it's doing live: state, the file printing, progress, time left and finish time, layer, nozzle, bed and chamber temperatures, the speed setting, and any error or HMS codes. The drawing colours in the spools loaded in the AMS, with every slot listed underneath. "Test & sync" asks a printer for a full report straight away. The connection only listens — Printhive never sends a printer a command, other than asking for a full report — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves, within seconds of going in: named and coded from Bambu's filament list, shown with their tag ID, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Grams left are filled in from an AMS, AMS 2 Pro or AMS HT; an AMS lite only knows full or empty, so those are left to you. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
|
||||
- **Printers** — add as many Bambu Lab printers as you have on the Printers page, each with an optional nickname (the model is shown when it's blank), a model picked from the lineup with a drawing of its shape, its AMS type, and its address, serial number and access code. "Test & sync" listens to a printer for a few seconds and reports its state and the filament in each AMS slot or on the external holder. The connection is read-only — Printhive never sends a printer a command — and access codes stay on your server. Spools a printer reads from their RFID tags are added to the Filament page by themselves and kept up to date every ten minutes: named and coded from Bambu's filament list, shown with their tag ID, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. Grams left are filled in from an AMS, AMS 2 Pro or AMS HT; an AMS lite only knows full or empty, so those are left to you. Spools without a tag can't be told apart from one another, so they're listed for you to add by hand.
|
||||
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
|
||||
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,44 +53,6 @@ function describeAms(units) {
|
|||
return trays;
|
||||
}
|
||||
|
||||
// Speed settings as the printer numbers them.
|
||||
const SPEEDS = { 1: 'Silent', 2: 'Standard', 3: 'Sport', 4: 'Ludicrous' };
|
||||
|
||||
const num = (v) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v));
|
||||
|
||||
// The status fields in one report. Printers often send only what changed, so
|
||||
// anything a report leaves out is left out here too, for the caller to merge.
|
||||
function readStatus(p) {
|
||||
const out = {};
|
||||
const set = (key, value) => { if (value !== undefined) out[key] = value; };
|
||||
set('state', p.gcode_state || undefined);
|
||||
set('progress', num(p.mc_percent));
|
||||
set('remainingMin', num(p.mc_remaining_time));
|
||||
set('file', p.subtask_name || p.gcode_file || undefined);
|
||||
set('layer', num(p.layer_num));
|
||||
set('totalLayers', num(p.total_layer_num));
|
||||
set('nozzle', num(p.nozzle_temper));
|
||||
set('nozzleTarget', num(p.nozzle_target_temper));
|
||||
set('bed', num(p.bed_temper));
|
||||
set('bedTarget', num(p.bed_target_temper));
|
||||
set('chamber', num(p.chamber_temper));
|
||||
if (p.spd_lvl !== undefined) set('speed', SPEEDS[Number(p.spd_lvl)] || null);
|
||||
if (p.print_error !== undefined) {
|
||||
const code = Number(p.print_error) || 0;
|
||||
set('printError', code ? code.toString(16).toUpperCase().padStart(8, '0') : null);
|
||||
}
|
||||
if (Array.isArray(p.hms)) {
|
||||
// Health messages: a module/severity word and a code, shown as Bambu writes them.
|
||||
set('hms', p.hms.map((h) => {
|
||||
const hex = (n) => (Number(n) >>> 0).toString(16).toUpperCase().padStart(8, '0');
|
||||
const a = hex(h.attr);
|
||||
const c = hex(h.code);
|
||||
return `${a.slice(0, 4)}_${a.slice(4)}_${c.slice(0, 4)}_${c.slice(4)}`;
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The messages the MQTT library gives are terse; say what they mean.
|
||||
function friendlyError(err) {
|
||||
const m = String((err && err.message) || err || '');
|
||||
|
|
@ -166,4 +128,4 @@ function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) {
|
|||
});
|
||||
}
|
||||
|
||||
module.exports = { probe, describeAms, describeTray, friendlyError, productLine, readStatus, PORT };
|
||||
module.exports = { probe, describeAms, describeTray, friendlyError, productLine };
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
// Live, read-only connections to your printers.
|
||||
//
|
||||
// One connection per printer stays open, so status arrives as the printer sends
|
||||
// it (every second or two while printing) instead of on a timer. The printer is
|
||||
// only ever asked for a full report — never sent anything that changes what it's
|
||||
// doing. Access codes are held in memory for the connection and never leave
|
||||
// this module.
|
||||
const mqtt = require('mqtt');
|
||||
const { describeAms, describeTray, friendlyError, readStatus, PORT } = require('./printer');
|
||||
|
||||
const RECONNECT_MS = 15 * 1000;
|
||||
const FULL_REPORT_MS = 10 * 60 * 1000; // a fresh full report now and then, in case a change was missed
|
||||
const TRAYS_SETTLE_MS = 5 * 1000; // let a spool swap finish reporting before syncing it
|
||||
|
||||
const connections = new Map(); // printer id → connection
|
||||
let onTrays = async () => null;
|
||||
let connect = (url, options) => mqtt.connect(url, options); // replaceable in tests
|
||||
|
||||
// What's loaded, reduced to what decides whether spools need syncing.
|
||||
const traysKey = (trays) => JSON.stringify((trays || [])
|
||||
.map((t) => [t.unit, t.slot, t.empty, t.material, t.colour, t.uuid]));
|
||||
|
||||
function allTrays(c) {
|
||||
return [...(c.ams || []), ...(c.external ? [c.external] : [])];
|
||||
}
|
||||
|
||||
function requestFullReport(c) {
|
||||
if (!c.client.connected) return;
|
||||
c.client.publish(`device/${c.serial}/request`,
|
||||
JSON.stringify({ pushing: { sequence_id: String(Date.now()), command: 'pushall' } }));
|
||||
}
|
||||
|
||||
// Syncs spools with what's loaded now, and hands back what that did.
|
||||
async function flushTrays(c) {
|
||||
clearTimeout(c.traysTimer);
|
||||
c.traysTimer = null;
|
||||
const trays = allTrays(c);
|
||||
if (!c.ams && !c.external) return null;
|
||||
c.syncedKey = traysKey(trays);
|
||||
try {
|
||||
c.lastSync = await onTrays(c.id, trays);
|
||||
return c.lastSync;
|
||||
} catch (err) {
|
||||
console.warn(`Printer ${c.id} spool sync failed:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(c, payload) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(payload.toString()); } catch { return; }
|
||||
const p = msg && msg.print;
|
||||
if (!p) return;
|
||||
c.reports += 1;
|
||||
c.updatedAt = new Date();
|
||||
Object.assign(c.status, readStatus(p));
|
||||
|
||||
// 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 (p.vt_tray) c.external = describeTray(p.vt_tray, null);
|
||||
|
||||
if ((c.ams || c.external) && traysKey(allTrays(c)) !== c.syncedKey) {
|
||||
clearTimeout(c.traysTimer);
|
||||
c.traysTimer = setTimeout(() => flushTrays(c), TRAYS_SETTLE_MS);
|
||||
}
|
||||
// Someone waiting for a full report is answered by one with the trays in it.
|
||||
if (Array.isArray(units) || p.vt_tray) for (const resolve of c.waiters.splice(0)) resolve();
|
||||
}
|
||||
|
||||
function open(p) {
|
||||
const c = {
|
||||
id: p.id,
|
||||
serial: p.serial,
|
||||
key: [p.host, p.serial, p.access_code].join('\n'),
|
||||
connected: false,
|
||||
error: null,
|
||||
reports: 0,
|
||||
updatedAt: null,
|
||||
connectedAt: null,
|
||||
status: {},
|
||||
ams: null,
|
||||
external: null,
|
||||
syncedKey: null,
|
||||
lastSync: null,
|
||||
traysTimer: null,
|
||||
waiters: [],
|
||||
};
|
||||
c.client = connect(`mqtts://${p.host}:${PORT}`, {
|
||||
username: 'bblp',
|
||||
password: p.access_code,
|
||||
rejectUnauthorized: false, // the printer's certificate is self-signed
|
||||
connectTimeout: 10 * 1000,
|
||||
reconnectPeriod: RECONNECT_MS,
|
||||
keepalive: 30,
|
||||
clientId: `printhive-${p.id}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
});
|
||||
c.client.on('connect', () => {
|
||||
c.connected = true;
|
||||
c.error = null;
|
||||
c.connectedAt = new Date();
|
||||
c.client.subscribe(`device/${c.serial}/report`, (err) => {
|
||||
if (err) { c.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
|
||||
requestFullReport(c);
|
||||
});
|
||||
});
|
||||
c.client.on('message', (_topic, payload) => handleMessage(c, payload));
|
||||
c.client.on('error', (err) => { c.error = friendlyError(err); });
|
||||
c.client.on('close', () => { c.connected = false; });
|
||||
c.client.on('offline', () => { c.connected = false; });
|
||||
c.fullReportTimer = setInterval(() => requestFullReport(c), FULL_REPORT_MS);
|
||||
c.fullReportTimer.unref();
|
||||
return c;
|
||||
}
|
||||
|
||||
function close(c) {
|
||||
clearInterval(c.fullReportTimer);
|
||||
clearTimeout(c.traysTimer);
|
||||
for (const resolve of c.waiters.splice(0)) resolve();
|
||||
c.client.end(true);
|
||||
}
|
||||
|
||||
// Brings the connections in line with the printers you have: new ones opened,
|
||||
// removed ones closed, and any whose address, serial or code changed reopened.
|
||||
function reconcile(printerRows) {
|
||||
const wanted = new Map();
|
||||
for (const p of printerRows) {
|
||||
if (p.host && p.serial && p.access_code) wanted.set(p.id, p);
|
||||
}
|
||||
for (const [id, c] of connections) {
|
||||
const p = wanted.get(id);
|
||||
if (!p || [p.host, p.serial, p.access_code].join('\n') !== c.key) {
|
||||
close(c);
|
||||
connections.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, p] of wanted) {
|
||||
if (!connections.has(id)) connections.set(id, open(p));
|
||||
}
|
||||
}
|
||||
|
||||
// What one printer is doing, as last reported. Nothing here identifies a spool
|
||||
// or carries a credential.
|
||||
function snapshot(id) {
|
||||
const c = connections.get(id);
|
||||
if (!c) return { live: false };
|
||||
const loaded = allTrays(c).map((t) => ({
|
||||
unit: t.unit, slot: t.slot, empty: t.empty, material: t.material, colour: t.colour, tagged: Boolean(t.uuid),
|
||||
}));
|
||||
return {
|
||||
live: true,
|
||||
connected: c.connected,
|
||||
error: c.connected ? null : c.error,
|
||||
updatedAt: c.updatedAt,
|
||||
status: c.status,
|
||||
loaded: c.ams || c.external ? loaded : null,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshots() {
|
||||
const out = {};
|
||||
for (const id of connections.keys()) out[id] = snapshot(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Asks for a full report now, waits for it, and syncs spools from it — what
|
||||
// "Test & sync" does. Uses the open connection rather than making another.
|
||||
async function refresh(id, waitMs = 8000) {
|
||||
const c = connections.get(id);
|
||||
if (!c) return null;
|
||||
const started = Date.now();
|
||||
const before = c.reports;
|
||||
if (!c.connected && !c.client.reconnecting) c.client.reconnect();
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, waitMs);
|
||||
const ask = () => {
|
||||
if (c.connected) { requestFullReport(c); return; }
|
||||
if (Date.now() - started < waitMs) setTimeout(ask, 250);
|
||||
};
|
||||
c.waiters.push(() => { clearTimeout(timer); setTimeout(resolve, 1000); }); // let the rest of the report arrive
|
||||
ask();
|
||||
});
|
||||
const sync = c.reports > before ? await flushTrays(c) : null;
|
||||
return {
|
||||
connected: c.connected,
|
||||
reports: c.reports - before,
|
||||
printer: { state: c.status.state, progress: c.status.progress },
|
||||
ams: c.ams,
|
||||
external: c.external,
|
||||
error: c.connected ? null : (c.error || 'Couldn’t reach the printer.'),
|
||||
sync,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
function setTraysHandler(fn) {
|
||||
onTrays = fn;
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
for (const c of connections.values()) close(c);
|
||||
connections.clear();
|
||||
}
|
||||
|
||||
// For tests: connect with a stand-in instead of the network.
|
||||
function setConnector(fn) {
|
||||
connect = fn;
|
||||
}
|
||||
|
||||
module.exports = { reconcile, snapshot, snapshots, refresh, setTraysHandler, stopAll, setConnector };
|
||||
116
public/app.js
116
public/app.js
|
|
@ -2645,7 +2645,7 @@ function loadedHtml(p) {
|
|||
const rows = [...p.loaded]
|
||||
.sort((a, b) => (a.unit ?? 1e9) - (b.unit ?? 1e9) || (a.slot ?? 0) - (b.slot ?? 0))
|
||||
.map((t) => chip(t, t.unit == null ? 'External' : `${amsUnitLabel(t.unit)} · ${(t.slot ?? 0) + 1}`));
|
||||
const when = p.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.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}`;
|
||||
}
|
||||
|
||||
|
|
@ -2665,7 +2665,6 @@ function printerCardHtml(p) {
|
|||
</div>
|
||||
<form class="printer-form" autocomplete="off">
|
||||
<h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3>
|
||||
${p.id ? '<div class="printer-status" aria-live="polite"></div>' : ''}
|
||||
${p.id && !p.model ? '<p class="form-hint printer-ask">Which model is this? Pick it below and save.</p>' : ''}
|
||||
<label>Nickname
|
||||
<input type="text" name="nickname" maxlength="60" value="${escapeHtml(p.nickname || '')}"
|
||||
|
|
@ -2713,9 +2712,9 @@ async function renderPrintersPanel() {
|
|||
<h3>Your printers</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="addPrinterBtn">Add printer</button>
|
||||
</div>
|
||||
<p class="form-hint">Printhive stays connected to each printer on your network and shows what it’s doing as it
|
||||
happens. It only listens — it never sends a print or changes a setting — so this works in cloud mode too. When
|
||||
a spool goes in or comes out, the Filament page follows within seconds. The address, serial number and access code are on the printer’s screen under network settings.</p>
|
||||
<p class="form-hint">Printhive listens to each printer on your network — it never sends a print or changes a
|
||||
setting, so this works in cloud mode too. Every 10 minutes it reads what’s loaded and keeps the Filament page up
|
||||
to date. The address, serial number and access code are on the printer’s screen under network settings.</p>
|
||||
</section>
|
||||
<div id="printerCards" class="printer-cards">
|
||||
${printerList.length ? printerList.map(printerCardHtml).join('')
|
||||
|
|
@ -2723,115 +2722,8 @@ async function renderPrintersPanel() {
|
|||
</div>`;
|
||||
document.getElementById('addPrinterBtn').addEventListener('click', addPrinterCard);
|
||||
document.querySelectorAll('.printer-card').forEach(bindPrinterCard);
|
||||
pollPrinters();
|
||||
}
|
||||
|
||||
// ------------------------------------------------ live printer status
|
||||
let printersPoll = null;
|
||||
const PRINTER_POLL_MS = 3000;
|
||||
|
||||
const PRINT_STATES = {
|
||||
IDLE: 'Idle', INIT: 'Starting up', PREPARE: 'Preparing', SLICING: 'Slicing', RUNNING: 'Printing',
|
||||
PAUSE: 'Paused', FINISH: 'Finished', FAILED: 'Failed', OFFLINE: 'Offline',
|
||||
};
|
||||
|
||||
function minutesText(min) {
|
||||
if (min == null) return '';
|
||||
const m = Math.max(0, Math.round(min));
|
||||
return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
||||
}
|
||||
|
||||
const temp = (now, target) => {
|
||||
if (now == null) return null;
|
||||
const t = Math.round(now);
|
||||
return target ? `${t} / ${Math.round(target)} °C` : `${t} °C`;
|
||||
};
|
||||
|
||||
function printerStatusHtml(snap) {
|
||||
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>';
|
||||
}
|
||||
if (!snap.connected) {
|
||||
return `<div class="status-head"><span class="status-pill is-off">Can’t reach it</span> <span class="form-hint">${escapeHtml(snap.error || 'Trying again every 15 seconds — is it switched on?')}</span></div>`;
|
||||
}
|
||||
const s = snap.status || {};
|
||||
if (!s.state) return '<div class="status-head"><span class="status-pill">Connected</span> <span class="form-hint">Waiting for its first report…</span></div>';
|
||||
|
||||
const active = ['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'].includes(s.state);
|
||||
const kind = { RUNNING: 'is-busy', PAUSE: 'is-warn', PREPARE: 'is-busy', FAILED: 'is-bad', FINISH: 'is-ok' }[s.state] || '';
|
||||
const head = [`<span class="status-pill ${kind}">${escapeHtml(PRINT_STATES[s.state] || s.state)}</span>`];
|
||||
if (s.file && s.state !== 'IDLE') head.push(`<span class="status-file" title="${escapeHtml(s.file)}">${escapeHtml(s.file)}</span>`);
|
||||
|
||||
const lines = [`<div class="status-head">${head.join(' ')}</div>`];
|
||||
if (active && s.progress != null) {
|
||||
const done = s.remainingMin ? new Date(Date.now() + s.remainingMin * 60000)
|
||||
.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : null;
|
||||
const facts = [
|
||||
`${s.progress}%`,
|
||||
s.remainingMin != null && `${minutesText(s.remainingMin)} left${done ? ` (done about ${done})` : ''}`,
|
||||
s.totalLayers ? `layer ${s.layer ?? 0} of ${s.totalLayers}` : null,
|
||||
s.speed && s.speed !== 'Standard' ? `${s.speed} speed` : null,
|
||||
].filter(Boolean);
|
||||
lines.push(`<div class="status-bar"><span style="width:${Math.min(100, Math.max(0, s.progress))}%"></span></div>`);
|
||||
lines.push(`<div class="status-facts">${facts.map(escapeHtml).join(' · ')}</div>`);
|
||||
}
|
||||
const temps = [
|
||||
temp(s.nozzle, s.nozzleTarget) && `Nozzle ${temp(s.nozzle, s.nozzleTarget)}`,
|
||||
temp(s.bed, s.bedTarget) && `Bed ${temp(s.bed, s.bedTarget)}`,
|
||||
s.chamber ? `Chamber ${temp(s.chamber)}` : null,
|
||||
].filter(Boolean);
|
||||
if (temps.length) lines.push(`<div class="status-facts">${temps.map(escapeHtml).join(' · ')}</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}`);
|
||||
if (problems.length) {
|
||||
lines.push(`<div class="status-problems" title="Look these codes up in Bambu Lab’s wiki or on the printer’s screen">⚠ ${problems.map(escapeHtml).join(' · ')}</div>`);
|
||||
}
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
async function pollPrinters() {
|
||||
clearTimeout(printersPoll);
|
||||
if (selectedScope !== 'printers' || !document.getElementById('printerCards')) return;
|
||||
let live = {};
|
||||
try {
|
||||
live = await getJson('/api/printers/live');
|
||||
} catch {
|
||||
live = null; // keep what's shown and try again
|
||||
}
|
||||
if (selectedScope !== 'printers') return;
|
||||
if (live) {
|
||||
for (const card of document.querySelectorAll('.printer-card[data-printer-id]')) {
|
||||
const id = Number(card.dataset.printerId);
|
||||
if (!id) continue;
|
||||
const snap = live[id];
|
||||
const box = card.querySelector('.printer-status');
|
||||
if (box) {
|
||||
const html = printerStatusHtml(snap);
|
||||
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);
|
||||
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>
|
||||
${loadedHtml(p)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Paused while the tab is hidden; picked up again when it's shown.
|
||||
if (!document.hidden) printersPoll = setTimeout(pollPrinters, PRINTER_POLL_MS);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && selectedScope === 'printers') pollPrinters();
|
||||
});
|
||||
|
||||
function addPrinterCard() {
|
||||
const cards = document.getElementById('printerCards');
|
||||
if (cards.querySelector('[data-printer-id="new"]')) {
|
||||
|
|
|
|||
|
|
@ -513,16 +513,3 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
|||
}
|
||||
.printer-ask { grid-column: 1 / -1; color: var(--accent); }
|
||||
.loaded-list .spool-dot { flex-shrink: 0; width: 12px; height: 12px; }
|
||||
.printer-status { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
|
||||
.printer-status:empty { display: none; }
|
||||
.status-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.status-pill { flex-shrink: 0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
|
||||
.status-pill.is-busy { background: var(--accent-soft); color: var(--accent); }
|
||||
.status-pill.is-warn { background: var(--accent-soft); color: var(--text); }
|
||||
.status-pill.is-ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||
.status-pill.is-bad, .status-pill.is-off { background: var(--danger-soft); color: var(--danger); }
|
||||
.status-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.status-bar { height: 6px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
||||
.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; }
|
||||
|
|
|
|||
78
server.js
78
server.js
|
|
@ -34,23 +34,35 @@ const printer = require('./lib/printer');
|
|||
const printers = require('./lib/printers');
|
||||
const spoolSync = require('./lib/spoolSync');
|
||||
|
||||
const printerLive = require('./lib/printerLive');
|
||||
|
||||
// What a printer has loaded → its card and the Filament page.
|
||||
async function syncTrays(printerId, trays) {
|
||||
const p = await printers.get(printerId);
|
||||
if (!p || !p.serial) return null;
|
||||
await printers.recordLoaded(p.id, trays);
|
||||
return spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
|
||||
// Reads one printer and brings the Filament page up to date with what it has
|
||||
// loaded. Read-only toward the printer.
|
||||
async function syncPrinter(p, { debug = false } = {}) {
|
||||
const missing = [!p.host && 'address', !p.serial && 'serial number', !p.access_code && 'access code']
|
||||
.filter(Boolean);
|
||||
if (missing.length) return { connected: false, error: `Fill in the printer’s ${missing.join(', ')} first.` };
|
||||
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug });
|
||||
if (result.connected) {
|
||||
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
|
||||
// Only a full report says what's loaded; a status-only one would blank the card.
|
||||
if (result.ams || result.external) await printers.recordLoaded(p.id, trays);
|
||||
if (trays.length) {
|
||||
result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
|
||||
{ measures: printers.measures(p.ams_type) });
|
||||
}
|
||||
printerLive.setTraysHandler(syncTrays);
|
||||
|
||||
// Opens, closes or reopens live connections to match the printers saved.
|
||||
async function reconnectPrinters() {
|
||||
printerLive.reconcile(await printers.listWithCodes());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Every printer, one after another; one that's off or unreachable is skipped.
|
||||
async function syncAllPrinters() {
|
||||
for (const p of await printers.listWithCodes()) {
|
||||
try {
|
||||
await syncPrinter(p);
|
||||
} catch (err) {
|
||||
console.warn(`Printer ${p.nickname || p.model || p.id} skipped:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
const embeddings = require('./lib/embeddings');
|
||||
|
||||
const app = express();
|
||||
|
|
@ -352,18 +364,13 @@ app.get('/api/printers', async (req, res, next) => {
|
|||
});
|
||||
|
||||
app.post('/api/printers', async (req, res, next) => {
|
||||
try {
|
||||
const saved = await printers.create(req.body || {});
|
||||
await reconnectPrinters();
|
||||
res.status(201).json(saved);
|
||||
} catch (e) { next(e); }
|
||||
try { res.status(201).json(await printers.create(req.body || {})); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/printers/:id', async (req, res, next) => {
|
||||
try {
|
||||
const saved = await printers.update(Number(req.params.id), req.body || {});
|
||||
if (!saved) return res.status(404).json({ error: 'No such printer' });
|
||||
await reconnectPrinters();
|
||||
res.json(saved);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
|
@ -371,35 +378,17 @@ app.put('/api/printers/:id', async (req, res, next) => {
|
|||
app.delete('/api/printers/:id', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await printers.remove(Number(req.params.id)))) return res.status(404).json({ error: 'No such printer' });
|
||||
await reconnectPrinters();
|
||||
res.json({ message: 'Printer removed' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// What every printer is doing right now, from the live connections.
|
||||
app.get('/api/printers/live', (req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(printerLive.snapshots());
|
||||
});
|
||||
|
||||
// Asks one printer for a full report, says what it reported, and updates the
|
||||
// spools it has loaded. ?debug=1 instead makes a separate short connection and
|
||||
// adds one tray's raw fields — filament data, no credentials.
|
||||
// Connects to one printer, says what it reported, and updates the spools it
|
||||
// has loaded. ?debug=1 adds one tray's raw fields — filament data, no credentials.
|
||||
app.post('/api/printers/:id/test', async (req, res, next) => {
|
||||
try {
|
||||
const p = await printers.get(Number(req.params.id), { withCode: true });
|
||||
if (!p) return res.status(404).json({ error: 'No such printer' });
|
||||
const missing = [!p.host && 'address', !p.serial && 'serial number', !p.access_code && 'access code']
|
||||
.filter(Boolean);
|
||||
if (missing.length) return res.json({ connected: false, error: `Fill in the printer’s ${missing.join(', ')} first.` });
|
||||
if (req.query.debug === '1') {
|
||||
const result = await printer.probe({ host: p.host, serial: p.serial, accessCode: p.access_code, debug: true });
|
||||
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
|
||||
if (result.connected && (result.ams || result.external)) result.sync = await syncTrays(p.id, trays);
|
||||
return res.json(result);
|
||||
}
|
||||
await reconnectPrinters();
|
||||
res.json(await printerLive.refresh(p.id));
|
||||
res.json(await syncPrinter(p, { debug: req.query.debug === '1' }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
|
@ -1640,9 +1629,12 @@ db.init()
|
|||
});
|
||||
collectionWatch.start();
|
||||
|
||||
// Live connections to the printers keep status and spools current. One
|
||||
// that's off or unreachable keeps retrying quietly.
|
||||
reconnectPrinters().catch((err) => console.warn('Printer connections failed:', err.message));
|
||||
// Keep spools in step with the printer: shortly after start, then every
|
||||
// ten minutes. A printer that's off or unreachable is simply skipped.
|
||||
const syncQuietly = () => syncAllPrinters()
|
||||
.catch((err) => console.warn('Printer sync failed:', err.message));
|
||||
setTimeout(syncQuietly, 30 * 1000).unref();
|
||||
setInterval(syncQuietly, 10 * 60 * 1000).unref();
|
||||
|
||||
return aiRuns.init(resolveAiConfig);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,21 +74,3 @@ test('a tray reports its weight when new', () => {
|
|||
assert.equal(t.weight, 1000);
|
||||
assert.equal(printer.describeTray({ id: '0', tray_type: 'PLA', tray_weight: '0' }).weight, null);
|
||||
});
|
||||
|
||||
test('status is read from a report, and fields a report leaves out stay out', () => {
|
||||
const { readStatus } = require('../lib/printer');
|
||||
const full = readStatus({
|
||||
gcode_state: 'RUNNING', mc_percent: 42, mc_remaining_time: 73, subtask_name: 'Benchy',
|
||||
layer_num: 80, total_layer_num: 210, nozzle_temper: 219.6, nozzle_target_temper: 220,
|
||||
bed_temper: 59.8, bed_target_temper: 60, spd_lvl: 2, print_error: 0,
|
||||
hms: [{ attr: 0x07002000, code: 0x00020001 }],
|
||||
});
|
||||
assert.deepEqual(full, {
|
||||
state: 'RUNNING', progress: 42, remainingMin: 73, file: 'Benchy', layer: 80, totalLayers: 210,
|
||||
nozzle: 219.6, nozzleTarget: 220, bed: 59.8, bedTarget: 60, speed: 'Standard', printError: null,
|
||||
hms: ['0700_2000_0002_0001'],
|
||||
});
|
||||
// A delta with only the nozzle temperature says nothing about the rest.
|
||||
assert.deepEqual(readStatus({ nozzle_temper: 200 }), { nozzle: 200 });
|
||||
assert.equal(readStatus({ print_error: 117473298 }).printError, '07008012');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
// A stand-in for the MQTT client, so no network is involved.
|
||||
const clients = [];
|
||||
const live = require('../lib/printerLive');
|
||||
|
||||
live.setConnector((url, options) => {
|
||||
const c = new EventEmitter();
|
||||
Object.assign(c, {
|
||||
url, options, connected: false, reconnecting: false, published: [], ended: false,
|
||||
subscribe: (_topic, cb) => cb(null),
|
||||
publish: (topic, body) => c.published.push({ topic, body: JSON.parse(body) }),
|
||||
end: () => { c.ended = true; },
|
||||
reconnect: () => {},
|
||||
});
|
||||
clients.push(c);
|
||||
return c;
|
||||
});
|
||||
|
||||
const report = (print) => Buffer.from(JSON.stringify({ print }));
|
||||
const printerRow = { id: 1, host: '10.0.0.5', serial: 'SER1', access_code: 'code1' };
|
||||
const tray = (id, type, colour) => ({ id: String(id), tray_type: type, tray_color: colour, tray_uuid: type ? `U${id}` : '0' });
|
||||
|
||||
test('live status merges partial reports, and a tray change syncs spools once', async (t) => {
|
||||
t.after(() => live.stopAll());
|
||||
const synced = [];
|
||||
live.setTraysHandler(async (id, trays) => { synced.push({ id, trays }); return { updated: trays.length }; });
|
||||
live.reconcile([printerRow, { id: 2, host: '', serial: 'X', access_code: 'y' }]);
|
||||
assert.equal(clients.length, 1, 'a printer missing its address isn’t connected');
|
||||
const c = clients[0];
|
||||
assert.equal(c.options.password, 'code1');
|
||||
|
||||
c.connected = true;
|
||||
c.emit('connect');
|
||||
assert.equal(c.published[0].topic, 'device/SER1/request');
|
||||
assert.equal(c.published[0].body.pushing.command, 'pushall');
|
||||
|
||||
c.emit('message', 'device/SER1/report', report({
|
||||
gcode_state: 'RUNNING', mc_percent: 10, nozzle_temper: 220,
|
||||
ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] },
|
||||
}));
|
||||
c.emit('message', 'device/SER1/report', report({ mc_percent: 11 }));
|
||||
const snap = live.snapshot(1);
|
||||
assert.equal(snap.connected, true);
|
||||
assert.equal(snap.status.state, 'RUNNING');
|
||||
assert.equal(snap.status.progress, 11);
|
||||
assert.equal(snap.status.nozzle, 220, 'kept from the earlier report');
|
||||
assert.deepEqual(snap.loaded.map((l) => [l.slot, l.empty, l.colour, l.tagged]),
|
||||
[[0, false, '#FF0000', true], [1, true, null, false]]);
|
||||
assert.equal(JSON.stringify(snap).includes('code1'), false, 'no access code in a snapshot');
|
||||
|
||||
// "Test & sync" asks again and syncs straight away.
|
||||
const pending = live.refresh(1, 3000);
|
||||
c.emit('message', 'device/SER1/report', report({ ams: { ams: [{ id: '0', tray: [tray(0, 'PLA', 'FF0000FF'), tray(1, '', '')] }] } }));
|
||||
const result = await pending;
|
||||
assert.equal(result.connected, true);
|
||||
assert.equal(result.sync.updated, 2);
|
||||
assert.equal(synced.length, 1);
|
||||
assert.equal(c.published.at(-1).body.pushing.command, 'pushall');
|
||||
});
|
||||
|
||||
test('changing a printer’s code reopens its connection; removing it closes it', () => {
|
||||
clients.length = 0;
|
||||
live.reconcile([printerRow]);
|
||||
live.reconcile([printerRow]);
|
||||
assert.equal(clients.length, 1, 'no change, no new connection');
|
||||
live.reconcile([{ ...printerRow, access_code: 'code2' }]);
|
||||
assert.equal(clients.length, 2);
|
||||
assert.equal(clients[0].ended, true);
|
||||
live.reconcile([]);
|
||||
assert.equal(clients[1].ended, true);
|
||||
assert.deepEqual(live.snapshot(1), { live: false });
|
||||
});
|
||||
Loading…
Reference in a new issue