From 13b5e550a3e1295e733d49aeb49968231a0ca7ee Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 21:30:13 +1000 Subject: [PATCH] Show live printer status over a standing read-only connection Each printer now has one MQTT connection that stays open, merging the partial reports printers send while printing. Cards poll every 3 seconds (paused while the tab is hidden) and show state, file, progress with time left and finish time, layer, nozzle/bed/chamber temperatures, speed setting, and print error and HMS codes. A spool change syncs the Filament page and the card's drawing within seconds; a full report is requested every 10 minutes as a backstop, replacing the 10-minute probe loop. "Test & sync" uses the open connection rather than making a second one (?debug=1 still makes a short separate one). Connections are reconciled when printers are added, edited or removed. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- lib/printer.js | 40 +++++++- lib/printerLive.js | 210 +++++++++++++++++++++++++++++++++++++++ public/app.js | 116 ++++++++++++++++++++- public/styles.css | 13 +++ server.js | 82 ++++++++------- test/printer.test.js | 18 ++++ test/printerLive.test.js | 75 ++++++++++++++ 8 files changed, 513 insertions(+), 43 deletions(-) create mode 100644 lib/printerLive.js create mode 100644 test/printerLive.test.js diff --git a/README.md b/README.md index 9ca0192..db21d37 100644 --- a/README.md +++ b/README.md @@ -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. "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. +- **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. - **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. diff --git a/lib/printer.js b/lib/printer.js index 4a73a72..ae2843c 100644 --- a/lib/printer.js +++ b/lib/printer.js @@ -53,6 +53,44 @@ 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 || ''); @@ -128,4 +166,4 @@ function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) { }); } -module.exports = { probe, describeAms, describeTray, friendlyError, productLine }; +module.exports = { probe, describeAms, describeTray, friendlyError, productLine, readStatus, PORT }; diff --git a/lib/printerLive.js b/lib/printerLive.js new file mode 100644 index 0000000..b27fe01 --- /dev/null +++ b/lib/printerLive.js @@ -0,0 +1,210 @@ +// 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 }; diff --git a/public/app.js b/public/app.js index 1c4ba8a..fc35f86 100644 --- a/public/app.js +++ b/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.loaded_at ? `

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

` : ''; + const when = p.liveLoaded ? '

Live from the printer

' : p.loaded_at ? `

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

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

Nothing loaded.

${when}`; } @@ -2665,6 +2665,7 @@ function printerCardHtml(p) {

${escapeHtml(p.id ? printerName(p) : 'New printer')}

+ ${p.id ? '
' : ''} ${p.id && !p.model ? '

Which model is this? Pick it below and save.

' : ''}