diff --git a/README.md b/README.md
index 814e1c0..9ca0192 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. 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.
+- **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.
@@ -73,9 +73,10 @@ Settings live in the database (editable in the app's **Settings** menu) and fall
| AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama |
| AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) |
| Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only |
-| Printer address | `PRINTER_HOST` | A Bambu Lab printer's IP on your network |
-| Printer serial number | `PRINTER_SERIAL` | Shown on the printer's screen and sticker |
-| Printer access code | `PRINTER_ACCESS_CODE` | From the printer's network settings; kept on the server |
+
+Printers are added on the **Printers** page instead — as many as you like, each with a nickname, model, AMS type,
+address, serial number and access code. Access codes stay on the server. A printer set up in Settings by an earlier
+version moves there automatically; the old `PRINTER_*` environment variables are no longer read.
## Notes
diff --git a/db/schema.sql b/db/schema.sql
index 7db4959..9f5a07f 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -297,3 +297,36 @@ 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;
+
+-- ------------------------------------------------------------------ printers
+-- The printers you've added. access_code is read by the server only.
+CREATE TABLE IF NOT EXISTS printers (
+ id SERIAL PRIMARY KEY,
+ nickname TEXT, -- shown instead of the model when set
+ model TEXT, -- A1, P1S, … or Other
+ host TEXT,
+ serial TEXT,
+ access_code TEXT,
+ ams_type TEXT, -- none | ams_lite | ams | ams_2_pro | ams_ht
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ date_modified TIMESTAMPTZ
+);
+
+-- The one printer that used to live in Settings becomes the first entry here.
+INSERT INTO printers (host, serial, access_code)
+SELECT h.value, s.value, c.value
+FROM settings h
+JOIN settings s ON s.key = 'printer_serial'
+JOIN settings c ON c.key = 'printer_access_code'
+WHERE h.key = 'printer_host'
+ AND coalesce(h.value, '') <> '' AND coalesce(s.value, '') <> '' AND coalesce(c.value, '') <> ''
+ AND NOT EXISTS (SELECT 1 FROM printers);
+
+-- …and its copy in Settings goes, so the access code isn't kept twice — but
+-- only once that printer is safely here.
+DELETE FROM settings
+WHERE key IN ('printer_host', 'printer_serial', 'printer_access_code')
+ AND EXISTS (
+ SELECT 1 FROM printers p
+ JOIN settings s ON s.key = 'printer_serial' AND s.value = p.serial
+ );
diff --git a/lib/printers.js b/lib/printers.js
new file mode 100644
index 0000000..2174867
--- /dev/null
+++ b/lib/printers.js
@@ -0,0 +1,90 @@
+// The printers you've added.
+//
+// Access codes are stored here but never handed back to the browser: every
+// read returns `codeSet` instead, and saving with a blank code keeps the one
+// already stored.
+const db = require('./db');
+
+// Which AMS types can say how much filament is left. An AMS lite only knows
+// full or empty, so its reading is never used as a weight.
+const AMS_TYPES = { none: false, ams_lite: false, ams: true, ams_2_pro: true, ams_ht: true };
+
+const clean = (value, max) => {
+ const s = String(value ?? '').trim();
+ return s ? s.slice(0, max) : null;
+};
+
+function publicPrinter(row) {
+ if (!row) return null;
+ const { access_code: accessCode, ...rest } = row;
+ return { ...rest, codeSet: Boolean(accessCode) };
+}
+
+function fields(data) {
+ return {
+ nickname: clean(data.nickname, 60),
+ model: clean(data.model, 40),
+ host: clean(data.host, 255),
+ serial: clean(data.serial, 40),
+ ams_type: Object.prototype.hasOwnProperty.call(AMS_TYPES, data.ams_type) ? data.ams_type : null,
+ };
+}
+
+async function list() {
+ const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
+ return rows.map(publicPrinter);
+}
+
+// Server-side only: includes the access code, for connecting.
+async function listWithCodes() {
+ const { rows } = await db.query('SELECT * FROM printers ORDER BY id');
+ return rows;
+}
+
+async function get(id, { withCode = false } = {}) {
+ const { rows: [row] } = await db.query('SELECT * FROM printers WHERE id = $1', [id]);
+ if (!row) return null;
+ return withCode ? row : publicPrinter(row);
+}
+
+async function create(data) {
+ const f = fields(data);
+ const { rows: [row] } = await db.query(
+ `INSERT INTO printers (nickname, model, host, serial, access_code, ams_type)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
+ [f.nickname, f.model, f.host, f.serial, clean(data.access_code, 64), f.ams_type]);
+ return publicPrinter(row);
+}
+
+// Only the fields sent are changed; a blank access code keeps the stored one.
+async function update(id, data) {
+ const f = fields(data);
+ const has = (key) => Object.prototype.hasOwnProperty.call(data, key);
+ const { rows: [row] } = await db.query(
+ `UPDATE printers SET
+ nickname = CASE WHEN $2::boolean THEN $3 ELSE nickname END,
+ model = CASE WHEN $4::boolean THEN $5 ELSE model END,
+ host = CASE WHEN $6::boolean THEN $7 ELSE host END,
+ serial = CASE WHEN $8::boolean THEN $9 ELSE serial END,
+ ams_type = CASE WHEN $10::boolean THEN $11 ELSE ams_type END,
+ access_code = COALESCE($12, access_code),
+ date_modified = now()
+ WHERE id = $1 RETURNING *`,
+ [id, has('nickname'), f.nickname, has('model'), f.model, has('host'), f.host,
+ has('serial'), f.serial, has('ams_type'), f.ams_type, clean(data.access_code, 64)]);
+ return publicPrinter(row);
+}
+
+// Removing a printer also marks its spools as no longer loaded anywhere.
+async function remove(id) {
+ const { rows: [row] } = await db.query('DELETE FROM printers WHERE id = $1 RETURNING serial', [id]);
+ if (!row) return false;
+ if (row.serial) {
+ await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE printer_serial = $1', [row.serial]);
+ }
+ return true;
+}
+
+const measures = (amsType) => Boolean(AMS_TYPES[amsType]);
+
+module.exports = { AMS_TYPES, list, listWithCodes, get, create, update, remove, measures, publicPrinter };
diff --git a/lib/spoolSync.js b/lib/spoolSync.js
index 550fb9a..521113c 100644
--- a/lib/spoolSync.js
+++ b/lib/spoolSync.js
@@ -15,8 +15,13 @@ const ADOPT_TOLERANCE = 12;
const slotKey = (printerSerial, tray) =>
`${printerSerial}/${tray.unit == null ? 'ext' : tray.unit}/${tray.slot == null ? 0 : tray.slot}`;
-function planSync(existing, trays, printerSerial, palette) {
+function planSync(existing, trays, printerSerial, palette, { measures = false } = {}) {
const actions = [];
+ // Grams left, only when this printer's AMS actually measures it. An AMS lite
+ // reports full or empty, which is not a weight.
+ const gramsLeft = (tray) => (measures && tray.remain != null && tray.weight
+ ? Math.round((tray.weight * tray.remain) / 100)
+ : null);
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
@@ -33,7 +38,7 @@ function planSync(existing, trays, printerSerial, palette) {
const known = byUuid.get(tray.uuid);
if (known) {
seen.add(known.id);
- actions.push({ op: 'update', id: known.id, slot, printerSerial });
+ actions.push({ op: 'update', id: known.id, slot, printerSerial, remaining: gramsLeft(tray) });
continue;
}
@@ -53,7 +58,7 @@ function planSync(existing, trays, printerSerial, palette) {
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,
+ code: named ? named.code : null, totalG: tray.weight, remaining: gramsLeft(tray),
});
continue;
}
@@ -67,7 +72,7 @@ function planSync(existing, trays, printerSerial, palette) {
brand: 'Bambu Lab',
code: named ? named.code : null,
total_g: tray.weight,
- remaining_g: null, // an AMS lite only knows full or empty
+ remaining_g: gramsLeft(tray), // blank unless this printer's AMS measures it
rfid_uuid: tray.uuid,
tag_uid: tray.tagUid,
product_code: tray.idName,
@@ -101,14 +106,18 @@ async function applySync({ actions }) {
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()
+ ams_slot = $7, printer_serial = $8, remaining_g = COALESCE($9, remaining_g),
+ 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]);
+ [a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial, a.remaining ?? null]);
summary.adopted += rowCount;
} else if (a.op === 'update') {
+ // A measured weight replaces the stored one; no reading leaves it alone.
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]);
+ `UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now(),
+ remaining_g = COALESCE($4, remaining_g)
+ WHERE id = $1`,
+ [a.id, a.slot, a.printerSerial, a.remaining ?? null]);
summary.updated += 1;
} else if (a.op === 'unload') {
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
@@ -119,9 +128,9 @@ async function applySync({ actions }) {
}
// Reads the shelf, plans against one printer's trays, and applies it.
-async function syncPrinter(printerSerial, trays, palette) {
+async function syncPrinter(printerSerial, trays, palette, options = {}) {
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
- const plan = planSync(existing, trays, printerSerial, palette);
+ const plan = planSync(existing, trays, printerSerial, palette, options);
const summary = await applySync(plan);
return { ...summary, untracked: plan.untracked };
}
diff --git a/public/app.js b/public/app.js
index 27fff76..f397739 100644
--- a/public/app.js
+++ b/public/app.js
@@ -6,6 +6,7 @@ let config = { importers: {} };
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
let spools = []; // filament on hand, so cards can say what's printable
+let printerList = []; // your printers, so spools can say which one they're in
let palette = []; // the maker's filament list: names, codes and colours
let tagFilters = {}; // tag -> 'include' | 'exclude'
let duplicateData = null; // last /api/duplicates response
@@ -16,7 +17,7 @@ const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license:
const PREFS_KEY = 'printhive.prefs';
// Scopes that aren't a collection id. Leaving one out means loadData() decides
// the collection vanished and drops you back to All Models on the next refresh.
-const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament'];
+const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament', 'printers'];
// DOM
const fileGrid = document.getElementById('fileGrid');
@@ -148,18 +149,21 @@ async function handleLogout() {
async function loadData() {
try {
- const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([
+ const [modelsRes, projectsRes, configRes, statsRes, filamentRes, printersRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
fetch('/api/library/stats'),
fetch('/api/filament'),
+ fetch('/api/printers'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
stats = statsRes.ok ? await statsRes.json() : stats;
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
+ printerList = printersRes.ok ? await printersRes.json() : [];
+ setCount('printerCount', printerList.length);
// Static and ~40KB, so fetched once rather than on every refresh.
if (!palette.length) {
palette = await fetch('/api/filament/palette')
@@ -450,13 +454,14 @@ function scopeTitle() {
if (selectedScope === 'ai') return 'AI & automatic tags';
if (selectedScope === 'watch') return 'Watched collections';
if (selectedScope === 'filament') return 'Filament';
+ if (selectedScope === 'printers') return 'Printers';
const p = projects.find((x) => String(x.id) === String(selectedScope));
return p ? p.name : 'Collection';
}
function updateScopeChrome() {
contentTitle.textContent = scopeTitle();
- const panel = ['duplicates', 'ai', 'watch'].includes(selectedScope); // pages without the model grid
+ const panel = ['duplicates', 'ai', 'watch', 'printers'].includes(selectedScope); // pages without the model grid
document.getElementById('filterBar').hidden = panel;
document.querySelector('.view-options').hidden = panel;
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
@@ -474,6 +479,7 @@ function renderModels() {
clearTimeout(aiPoll);
if (selectedScope === 'watch') { renderWatchPanel(); return; }
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
+ if (selectedScope === 'printers') { renderPrintersPanel(); return; }
clearTimeout(watchPoll);
const list = getFilteredModels();
@@ -905,7 +911,6 @@ function setupEventListeners() {
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
});
document.getElementById('aiTestBtn').addEventListener('click', handleAiTest);
- document.getElementById('printerTestBtn').addEventListener('click', handlePrinterTest);
document.getElementById('aiProvider').addEventListener('change', () => {
// A new provider starts from its own defaults.
['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; });
@@ -1463,52 +1468,6 @@ async function handleAiTest() {
}
}
-// Saves the printer settings, then listens to the printer for a few seconds and
-// says what it reported. Only reads; the printer is never sent a command.
-async function handlePrinterTest() {
- const out = document.getElementById('printerTestResult');
- const btn = document.getElementById('printerTestBtn');
- out.className = 'form-hint ai-test-result';
- out.textContent = 'Saving and listening to the printer for a few seconds…';
- btn.disabled = true;
- const trayHtml = (t) => `
- ${escapeHtml(t.material || '?')}${t.remain != null ? ` ${t.remain}%` : ''}`;
- try {
- await postJson('/api/settings', settingsPayload(), 'PUT');
- const r = await postJson('/api/printer/test');
- if (!r.connected) throw new Error(r.error || 'Couldn’t connect to the printer.');
- const lines = [`✓ Connected in ${(r.ms / 1000).toFixed(1)} s; ${plural(r.reports, 'report')} received.`];
- if (r.printer && r.printer.state) lines.push(`The printer is ${escapeHtml(String(r.printer.state).toLowerCase())}.`);
- if (r.error) lines.push(`But: ${escapeHtml(r.error)}`);
- const loaded = (r.ams || []).filter((t) => !t.empty);
- if (r.ams && r.ams.length) {
- 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.');
- }
- out.classList.add(r.reports ? 'is-ok' : 'is-error');
- out.innerHTML = lines.join(' ');
- } catch (err) {
- out.classList.add('is-error');
- out.textContent = `✗ ${err.message}`;
- } finally {
- btn.disabled = false;
- }
-}
-
// Tags the open model on its own, then refreshes its tags and description.
async function handleAiTagModel() {
const id = Number(fileDetailModal.dataset.currentModelId);
@@ -2484,16 +2443,25 @@ function filamentPanelHtml() {
`;
}
-// "//" → "in AMS 1, slot 4" or "on the external holder".
+// "//" → "in A1, AMS 1, slot 4" or "on the A1's external holder".
function slotLabel(slot) {
- const [, unit, idx] = String(slot || '').split('/');
+ const [serial, 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}`;
+ const p = printerList.find((x) => x.serial === serial);
+ const name = p ? printerName(p) : '';
+ if (unit === 'ext') return name ? `on the ${name}’s external holder` : 'on the external holder';
+ return `in ${name ? `${name}, ` : ''}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` : '';
+ const size = s.total_g ? `${count(s.total_g)} g spool` : '';
+ const left = s.remaining_g == null ? 'amount left unknown' : `${count(s.remaining_g)} g left`;
+ // What the printer read off the spool's RFID tag.
+ const tag = [
+ s.tag_uid && `RFID tag ${escapeHtml(s.tag_uid)}`,
+ s.product_code && `product ${escapeHtml(s.product_code)}`,
+ s.code && `filament code ${escapeHtml(s.code)}`,
+ ].filter(Boolean).join(' · ');
return `
`;
}
@@ -2621,6 +2590,247 @@ async function setSpoolRemaining(id, value) {
}
}
+// ---------------------------------------------------------------- printers
+// Each printer is a card: a drawing of its model, a nickname, and how to reach
+// it. Access codes are sent to the server but never come back — a card only
+// knows whether one is stored.
+// A function, not a constant, so it can be used before this line has run.
+function printerModels() {
+ return window.PRINTER_MODELS || { models: [], amsTypes: [], find: () => null, art: () => '' };
+}
+
+function setCount(id, n) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = n;
+}
+
+// The nickname when there is one, otherwise the model.
+function printerName(p) {
+ return (p.nickname || '').trim() || p.model || (p.host ? `Printer at ${p.host}` : 'Printer');
+}
+
+function printerCardHtml(p) {
+ const key = p.id || 'new';
+ const models = ['']
+ .concat(printerModels().models.map((m) => ``))
+ .join('');
+ const amsTypes = printerModels().amsTypes
+ .map((a) => ``)
+ .join('');
+ return `
+
+
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.
`;
+ document.getElementById('addPrinterBtn').addEventListener('click', addPrinterCard);
+ document.querySelectorAll('.printer-card').forEach(bindPrinterCard);
+}
+
+function addPrinterCard() {
+ const cards = document.getElementById('printerCards');
+ if (cards.querySelector('[data-printer-id="new"]')) {
+ cards.querySelector('[data-printer-id="new"] input[name="nickname"]').focus();
+ return;
+ }
+ if (!printerList.length) cards.innerHTML = '';
+ cards.insertAdjacentHTML('beforeend', printerCardHtml({}));
+ const card = cards.lastElementChild;
+ bindPrinterCard(card);
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ card.querySelector('select[name="model"]').focus();
+}
+
+function bindPrinterCard(card) {
+ const form = card.querySelector('form');
+ const id = card.dataset.printerId === 'new' ? null : Number(card.dataset.printerId);
+ const out = card.querySelector('.printer-result');
+ const say = (text, kind) => {
+ out.className = `form-hint ai-test-result printer-result${kind ? ` is-${kind}` : ''}`;
+ out.innerHTML = text;
+ };
+
+ // Choosing a model redraws the picture and suggests the AMS that usually comes with it.
+ const modelSelect = form.elements.model;
+ const amsSelect = form.elements.ams_type;
+ let amsTouched = Boolean(id && amsSelect.value);
+ amsSelect.addEventListener('change', () => { amsTouched = true; });
+ const refreshTitle = () => {
+ const name = form.elements.nickname.value.trim() || modelSelect.value;
+ card.querySelector('.printer-title').textContent = name || 'New printer';
+ form.elements.nickname.placeholder = modelSelect.value || 'Leave blank to use the model';
+ };
+ modelSelect.addEventListener('change', () => {
+ card.querySelector('.printer-art').innerHTML = printerModels().art(modelSelect.value);
+ const m = printerModels().find(modelSelect.value);
+ if (m && !amsTouched) {
+ const blank = amsSelect.querySelector('option[value=""]');
+ if (blank) blank.remove();
+ amsSelect.value = m.ams;
+ }
+ refreshTitle();
+ });
+ form.elements.nickname.addEventListener('input', refreshTitle);
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const body = {
+ nickname: form.elements.nickname.value,
+ model: modelSelect.value,
+ ams_type: amsSelect.value,
+ host: form.elements.host.value,
+ serial: form.elements.serial.value,
+ };
+ // Left blank, the stored code is kept.
+ if (form.elements.access_code.value.trim()) body.access_code = form.elements.access_code.value.trim();
+ const submit = form.querySelector('button[type="submit"]');
+ submit.disabled = true;
+ try {
+ const saved = id
+ ? await postJson(`/api/printers/${id}`, body, 'PUT')
+ : await postJson('/api/printers', body);
+ form.elements.access_code.value = '';
+ if (!id) {
+ await renderPrintersPanel();
+ const fresh = document.querySelector(`[data-printer-id="${saved.id}"] .printer-result`);
+ if (fresh) {
+ fresh.classList.add('is-ok');
+ fresh.textContent = '✓ Added. Use “Test & sync” to check the connection.';
+ }
+ return;
+ }
+ printerList = printerList.map((p) => (p.id === saved.id ? saved : p));
+ form.elements.access_code.placeholder = saved.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings';
+ say('✓ Saved.', 'ok');
+ } catch (err) {
+ say(`✗ ${escapeHtml(err.message)}`, 'error');
+ } finally {
+ submit.disabled = false;
+ }
+ });
+
+ const cancel = card.querySelector('[data-printer="cancel"]');
+ if (cancel) {
+ cancel.addEventListener('click', () => {
+ card.remove();
+ if (!printerList.length) renderPrintersPanel();
+ });
+ }
+
+ const remove = card.querySelector('[data-printer="remove"]');
+ if (remove) {
+ remove.addEventListener('click', async () => {
+ if (!confirm(`Remove ${printerName(printerList.find((p) => p.id === id) || {})}? Its spools stay on the Filament page.`)) return;
+ try {
+ await getJson(`/api/printers/${id}`, { method: 'DELETE' });
+ await renderPrintersPanel();
+ } catch (err) {
+ say(`✗ ${escapeHtml(err.message)}`, 'error');
+ }
+ });
+ }
+
+ const test = card.querySelector('[data-printer="test"]');
+ if (test) test.addEventListener('click', () => testPrinter(id, test, say));
+}
+
+// Listens to one printer for a few seconds, says what it reported, and updates
+// the spools it has loaded. Only reads; the printer is never sent a command.
+async function testPrinter(id, btn, say) {
+ say('Listening to the printer for a few seconds…');
+ btn.disabled = true;
+ const trayHtml = (t) => `
+ ${escapeHtml(t.material || '?')}${t.remain != null ? ` ${t.remain}%` : ''}`;
+ try {
+ const r = await postJson(`/api/printers/${id}/test`);
+ if (!r.connected) throw new Error(r.error || 'Couldn’t connect to the printer.');
+ const p = printerList.find((x) => x.id === id) || {};
+ const lines = [`✓ Connected in ${(r.ms / 1000).toFixed(1)} s; ${plural(r.reports, 'report')} received.`];
+ if (r.printer && r.printer.state) lines.push(`The printer is ${escapeHtml(String(r.printer.state).toLowerCase())}.`);
+ if (r.error) lines.push(`But: ${escapeHtml(r.error)}`);
+ const loaded = (r.ams || []).filter((t) => !t.empty);
+ if (r.ams && r.ams.length) {
+ // An AMS lite's percentage is only full-or-empty, so it isn't shown.
+ const show = p.ams_type === 'ams_lite' ? (t) => trayHtml({ ...t, remain: null }) : trayHtml;
+ lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(show).join(', ')}` : ''}.`);
+ }
+ if (r.external && !r.external.empty) lines.push(`External spool: ${trayHtml({ ...r.external, remain: null })}.`);
+ 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.');
+ }
+ say(lines.join(' '), r.reports ? 'ok' : 'error');
+ } catch (err) {
+ say(`✗ ${escapeHtml(err.message)}`, 'error');
+ } finally {
+ btn.disabled = false;
+ }
+}
+
// ---------------------------------------------------------------- watched collections
let watchPoll = null;
diff --git a/public/index.html b/public/index.html
index c0f0aae..0e75bfa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -106,6 +106,10 @@
Filament0
+
+ Printers
+ 0
+
Trash0
@@ -300,33 +304,6 @@
-
Printer
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Access code: —.
- Bambu Lab printers show it in their network settings, and the serial number is on the screen and the sticker.
- Printhive only reads what the printer reports — filament loaded, progress — and never sends it commands.
- The code is stored on your server.
-