From 48f564c648ba3eb9151563138f95e31b3c24ec8d Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 20:39:30 +1000 Subject: [PATCH] Add spools automatically from what the printer has loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Bambu spool reports an RFID identity — a spool id and a tag id, both unique — so a printer's report can now keep the Filament page in step: a new tagged spool is created, named and coded from the bundled filament list; one already known is moved to its current slot; one you added by hand before the printer was connected is adopted rather than duplicated; and one the printer no longer reports is marked as taken out. It runs on "Save & test connection" and every ten minutes in the background, still only reading from the printer. Untagged spools are listed, not stored: without a tag, one spool of a colour is indistinguishable from the next, so guessing would mean duplicates. The planning is a pure function, tested against the cases that would cause duplicates. The database side was exercised separately against a real Postgres: three successive reports created, moved and unloaded spools as intended, and a deliberate duplicate was refused by the partial unique index. The amount left isn't taken from the printer yet: an AMS lite only reports full or empty, so it's left blank until printers can say which kind of AMS they have. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- db/schema.sql | 8 +++ lib/filaments.js | 18 +++++- lib/printer.js | 13 ++++- lib/spoolSync.js | 129 +++++++++++++++++++++++++++++++++++++++++ public/app.js | 21 ++++++- server.js | 30 ++++++++-- test/printer.test.js | 15 +++++ test/spoolSync.test.js | 84 +++++++++++++++++++++++++++ 9 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 lib/spoolSync.js create mode 100644 test/spoolSync.test.js diff --git a/README.md b/README.md index 1541444..814e1c0 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. -- **Printer** — connect a Bambu Lab printer on your network in Settings → Printer (its address, serial number and access code), and "Save & test connection" listens to it for a few seconds and reports what it said: its state and the filament in each AMS slot or on the external spool holder. The connection is read-only — Printhive never sends the printer a command — and the access code stays on your server. +- **Printer** — connect a Bambu Lab printer on your network in Settings → Printer (its address, serial number and access code), and "Save & test connection" listens to it for a few seconds and reports what it said: its state and the filament in each AMS slot or on the external spool holder. The connection is read-only — Printhive never sends the printer a command — and the access code stays on your server. Spools the 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, matched to any you'd already added by hand rather than duplicated, and marked as taken out when they leave the printer. 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/db/schema.sql b/db/schema.sql index b90d866..7db4959 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -289,3 +289,11 @@ CREATE TABLE IF NOT EXISTS filament_spools ( ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB; ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT; +-- A Bambu spool's RFID identity and where it was last seen, so a printer's +-- report finds the same spool again instead of adding it twice. +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS rfid_uuid TEXT; +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS tag_uid TEXT; +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS product_code TEXT; +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS printer_serial TEXT; +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ; +CREATE UNIQUE INDEX IF NOT EXISTS filament_spools_rfid ON filament_spools (rfid_uuid) WHERE rfid_uuid IS NOT NULL; diff --git a/lib/filaments.js b/lib/filaments.js index 07eaaf6..19c5b2a 100644 --- a/lib/filaments.js +++ b/lib/filaments.js @@ -167,7 +167,23 @@ function cleanSlots(list) { .filter((f) => f.type); } +// Bambu's own name and code for a spool, from the bundled filament list: the +// same material and product line, and the closest colour within a tight +// threshold — this identifies a colour rather than substituting for one. +function paletteMatch(palette, material, type, hex) { + let best = null; + for (const f of palette || []) { + if (normalise(f.material) !== normalise(material)) continue; + if (type && normalise(f.type) !== normalise(type)) continue; + for (const c of f.colours || []) { + const d = colourDistance(hex, c); + if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, code: f.code || null, type: f.type }; + } + } + return best && { name: best.name, code: best.code, type: best.type }; +} + module.exports = { - run, pendingCount, reread, cleanSlots, + run, pendingCount, reread, cleanSlots, paletteMatch, assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE, }; diff --git a/lib/printer.js b/lib/printer.js index 19965ae..4a73a72 100644 --- a/lib/printer.js +++ b/lib/printer.js @@ -12,6 +12,15 @@ const PORT = 8883; // An id the printer sends as all zeros means no RFID tag was read. const realId = (v) => (v && !/^0+$/.test(String(v)) ? String(v) : null); +// The product line within a material: "PLA Basic" with material "PLA" → "Basic". +function productLine(brand, material) { + const b = String(brand || '').trim(); + const m = String(material || '').trim(); + if (!b) return null; + const line = m && b.toUpperCase().startsWith(`${m.toUpperCase()} `) ? b.slice(m.length) : b; + return line.trim() || null; +} + // One tray, as the printer describes it. `remain` is a percentage; the printer // sends -1 when it can't tell. An AMS lite only knows full or empty, so its // figure isn't a measurement — callers decide whether to trust it. @@ -30,6 +39,8 @@ function describeTray(tray, unit = null) { uuid: loaded ? realId(tray.tray_uuid) : null, tagUid: loaded ? realId(tray.tag_uid) : null, idName: loaded ? tray.tray_id_name || null : null, // e.g. "A00-W1", on firmware that sends it + weight: loaded && Number(tray.tray_weight) > 0 ? Number(tray.tray_weight) : null, // grams, when new + type: loaded ? productLine(tray.tray_sub_brands, tray.tray_type) : null, }; } @@ -117,4 +128,4 @@ function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) { }); } -module.exports = { probe, describeAms, describeTray, friendlyError }; +module.exports = { probe, describeAms, describeTray, friendlyError, productLine }; diff --git a/lib/spoolSync.js b/lib/spoolSync.js new file mode 100644 index 0000000..550fb9a --- /dev/null +++ b/lib/spoolSync.js @@ -0,0 +1,129 @@ +// Keeps the Filament page in step with what a printer has loaded. +// +// planSync() works out, without touching the database, what a printer's report +// means for your spools; applySync() carries that out. Only spools with an RFID +// tag are handled automatically: an untagged spool looks identical to any other +// of its colour, so there's no way to tell it from one you already have. +const db = require('./db'); +const { colourDistance, normalise, paletteMatch } = require('./filaments'); + +// How close a hand-added spool's colour must be for it to adopt a printer's tag. +const ADOPT_TOLERANCE = 12; + +// Where a tray is, as stored on a spool: "//", with +// "ext" as the unit for the external spool holder. +const slotKey = (printerSerial, tray) => + `${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`; + +function planSync(existing, trays, printerSerial, palette) { + const actions = []; + const untracked = []; + const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s])); + const adopted = new Set(); // hand-added spools already matched in this report + const seen = new Set(); // existing spools this report accounts for + + for (const tray of trays || []) { + if (!tray || tray.empty) continue; + const slot = slotKey(printerSerial, tray); + if (!tray.uuid) { + untracked.push({ slot, material: tray.material, colour: tray.colour }); + continue; + } + + const known = byUuid.get(tray.uuid); + if (known) { + seen.add(known.id); + actions.push({ op: 'update', id: known.id, slot, printerSerial }); + continue; + } + + const named = paletteMatch(palette, tray.material, tray.type, tray.colour); + + // A spool you added by hand before the printer reported it: take it over + // rather than adding a second copy. + const adopt = existing.find((s) => { + if (s.rfid_uuid || adopted.has(s.id)) return false; + if (normalise(s.material) !== normalise(tray.material)) return false; + const d = colourDistance(s.colour_hex, tray.colour); + return d !== null && d <= ADOPT_TOLERANCE; + }); + if (adopt) { + adopted.add(adopt.id); + seen.add(adopt.id); + actions.push({ + op: 'adopt', id: adopt.id, slot, printerSerial, + uuid: tray.uuid, tagUid: tray.tagUid, productCode: tray.idName, + code: named ? named.code : null, totalG: tray.weight, + }); + continue; + } + + actions.push({ + op: 'create', slot, printerSerial, + spool: { + material: tray.material, + colour: named ? named.name : (tray.idName || tray.colour || tray.material), + colour_hex: tray.colour, + brand: 'Bambu Lab', + code: named ? named.code : null, + total_g: tray.weight, + remaining_g: null, // an AMS lite only knows full or empty + rfid_uuid: tray.uuid, + tag_uid: tray.tagUid, + product_code: tray.idName, + }, + }); + } + + // Anything this printer used to hold that it no longer reports has come out. + for (const s of existing) { + if (s.printer_serial === printerSerial && s.ams_slot && !seen.has(s.id)) { + actions.push({ op: 'unload', id: s.id }); + } + } + return { actions, untracked }; +} + +async function applySync({ actions }) { + const summary = { created: 0, adopted: 0, updated: 0, unloaded: 0 }; + for (const a of actions) { + if (a.op === 'create') { + const s = a.spool; + const { rowCount } = await db.query( + `INSERT INTO filament_spools (material, colour, colour_hex, brand, code, total_g, remaining_g, + rfid_uuid, tag_uid, product_code, ams_slot, printer_serial, last_seen_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now()) + ON CONFLICT (rfid_uuid) WHERE rfid_uuid IS NOT NULL DO NOTHING`, + [s.material, s.colour, s.colour_hex, s.brand, s.code, s.total_g, s.remaining_g, + s.rfid_uuid, s.tag_uid, s.product_code, a.slot, a.printerSerial]); + summary.created += rowCount; + } else if (a.op === 'adopt') { + const { rowCount } = await db.query( + `UPDATE filament_spools SET rfid_uuid = $2, tag_uid = $3, product_code = $4, + code = COALESCE(code, $5), total_g = COALESCE(total_g, $6), + ams_slot = $7, printer_serial = $8, last_seen_at = now(), date_modified = now() + WHERE id = $1 AND rfid_uuid IS NULL`, + [a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial]); + summary.adopted += rowCount; + } else if (a.op === 'update') { + await db.query( + 'UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now() WHERE id = $1', + [a.id, a.slot, a.printerSerial]); + summary.updated += 1; + } else if (a.op === 'unload') { + await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]); + summary.unloaded += 1; + } + } + return summary; +} + +// Reads the shelf, plans against one printer's trays, and applies it. +async function syncPrinter(printerSerial, trays, palette) { + const { rows: existing } = await db.query('SELECT * FROM filament_spools'); + const plan = planSync(existing, trays, printerSerial, palette); + const summary = await applySync(plan); + return { ...summary, untracked: plan.untracked }; +} + +module.exports = { planSync, syncPrinter, slotKey }; diff --git a/public/app.js b/public/app.js index 2f1dd11..27fff76 100644 --- a/public/app.js +++ b/public/app.js @@ -1485,6 +1485,17 @@ async function handlePrinterTest() { lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(trayHtml).join(', ')}` : ''}.`); } if (r.external && !r.external.empty) lines.push(`External spool: ${trayHtml(r.external)}.`); + if (r.sync) { + const s = r.sync; + const parts = []; + if (s.created) parts.push(`${plural(s.created, 'spool')} added`); + if (s.adopted) parts.push(`${plural(s.adopted, 'spool')} matched to ones you’d added`); + if (s.updated) parts.push(`${plural(s.updated, 'spool')} already known`); + if (s.unloaded) parts.push(`${plural(s.unloaded, 'spool')} marked as taken out`); + if (parts.length) lines.push(`Filament page: ${parts.join(', ')}.`); + const n = (s.untracked || []).length; + if (n) lines.push(`${plural(n, 'spool')} without an RFID tag ${n === 1 ? 'isn’t' : 'aren’t'} tracked automatically — add ${n === 1 ? 'it' : 'them'} by hand.`); + } if (!r.reports) { lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.'); } @@ -2473,6 +2484,13 @@ function filamentPanelHtml() { `; } +// "//" → "in AMS 1, slot 4" or "on the external holder". +function slotLabel(slot) { + const [, unit, idx] = String(slot || '').split('/'); + if (unit === undefined) return ''; + return unit === 'ext' ? 'on the external holder' : `in AMS ${Number(unit) + 1}, slot ${Number(idx) + 1}`; +} + function spoolHtml(s) { const left = s.remaining_g == null ? 'amount unknown' : `${count(s.remaining_g)} g left`; const of = s.total_g ? ` of ${count(s.total_g)} g` : ''; @@ -2482,7 +2500,8 @@ function spoolHtml(s) { ${escapeHtml(s.material)} ${escapeHtml(s.colour || '')} - ${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(s.ams_slot)}` : ''} + ${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(slotLabel(s.ams_slot))}` : ''}${ + s.rfid_uuid ? ' · RFID' : ''}