Compare commits

..

No commits in common. "697ac0ccc8c0a45dff03a3778acb40b9be1ec842" and "1c19ac565e094846f0ef5ba225fe601178c5f6cc" have entirely different histories.

11 changed files with 122 additions and 646 deletions

View file

@ -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.
- **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.
@ -73,10 +73,9 @@ 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 |
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.
| 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 |
## Notes

View file

@ -297,36 +297,3 @@ 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
);

View file

@ -1,90 +0,0 @@
// 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 };

View file

@ -15,13 +15,8 @@ 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, { measures = false } = {}) {
function planSync(existing, trays, printerSerial, palette) {
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
@ -38,7 +33,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
const known = byUuid.get(tray.uuid);
if (known) {
seen.add(known.id);
actions.push({ op: 'update', id: known.id, slot, printerSerial, remaining: gramsLeft(tray) });
actions.push({ op: 'update', id: known.id, slot, printerSerial });
continue;
}
@ -58,7 +53,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
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, remaining: gramsLeft(tray),
code: named ? named.code : null, totalG: tray.weight,
});
continue;
}
@ -72,7 +67,7 @@ function planSync(existing, trays, printerSerial, palette, { measures = false }
brand: 'Bambu Lab',
code: named ? named.code : null,
total_g: tray.weight,
remaining_g: gramsLeft(tray), // blank unless this printer's AMS measures it
remaining_g: null, // an AMS lite only knows full or empty
rfid_uuid: tray.uuid,
tag_uid: tray.tagUid,
product_code: tray.idName,
@ -106,18 +101,14 @@ 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, remaining_g = COALESCE($9, remaining_g),
last_seen_at = now(), date_modified = now()
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, a.remaining ?? 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') {
// 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(),
remaining_g = COALESCE($4, remaining_g)
WHERE id = $1`,
[a.id, a.slot, a.printerSerial, a.remaining ?? null]);
'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]);
@ -128,9 +119,9 @@ async function applySync({ actions }) {
}
// Reads the shelf, plans against one printer's trays, and applies it.
async function syncPrinter(printerSerial, trays, palette, options = {}) {
async function syncPrinter(printerSerial, trays, palette) {
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
const plan = planSync(existing, trays, printerSerial, palette, options);
const plan = planSync(existing, trays, printerSerial, palette);
const summary = await applySync(plan);
return { ...summary, untracked: plan.untracked };
}

View file

@ -6,7 +6,6 @@ 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
@ -17,7 +16,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', 'printers'];
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament'];
// DOM
const fileGrid = document.getElementById('fileGrid');
@ -149,21 +148,18 @@ async function handleLogout() {
async function loadData() {
try {
const [modelsRes, projectsRes, configRes, statsRes, filamentRes, printersRes] = await Promise.all([
const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = 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')
@ -454,14 +450,13 @@ 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', 'printers'].includes(selectedScope); // pages without the model grid
const panel = ['duplicates', 'ai', 'watch'].includes(selectedScope); // pages without the model grid
document.getElementById('filterBar').hidden = panel;
document.querySelector('.view-options').hidden = panel;
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
@ -479,7 +474,6 @@ function renderModels() {
clearTimeout(aiPoll);
if (selectedScope === 'watch') { renderWatchPanel(); return; }
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
if (selectedScope === 'printers') { renderPrintersPanel(); return; }
clearTimeout(watchPoll);
const list = getFilteredModels();
@ -911,6 +905,7 @@ 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 = ''; });
@ -1468,6 +1463,52 @@ 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) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span>
${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);
@ -2443,25 +2484,16 @@ function filamentPanelHtml() {
</section>`;
}
// "<printer serial>/<unit>/<slot>" → "in A1, AMS 1, slot 4" or "on the A1's external holder".
// "<printer serial>/<unit>/<slot>" → "in AMS 1, slot 4" or "on the external holder".
function slotLabel(slot) {
const [serial, unit, idx] = String(slot || '').split('/');
const [, unit, idx] = String(slot || '').split('/');
if (unit === undefined) return '';
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}`;
return unit === 'ext' ? 'on the external holder' : `in AMS ${Number(unit) + 1}, slot ${Number(idx) + 1}`;
}
function spoolHtml(s) {
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 <code>${escapeHtml(s.tag_uid)}</code>`,
s.product_code && `product <code>${escapeHtml(s.product_code)}</code>`,
s.code && `filament code <code>${escapeHtml(s.code)}</code>`,
].filter(Boolean).join(' · ');
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` : '';
return `
<div class="watch spool" data-spool-id="${s.id}">
<div class="watch-head">
@ -2477,8 +2509,7 @@ function spoolHtml(s) {
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
</span>
</div>
<div class="watch-meta">${escapeHtml([size, left].filter(Boolean).join(' · '))}</div>
${tag ? `<div class="watch-meta spool-tag">${tag}</div>` : ''}
<div class="watch-meta">${escapeHtml(left)}${escapeHtml(of)}</div>
</div>`;
}
@ -2590,247 +2621,6 @@ 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 = ['<option value="">Choose a model…</option>']
.concat(printerModels().models.map((m) => `<option value="${escapeHtml(m.id)}"${m.id === p.model ? ' selected' : ''}>${escapeHtml(m.id)}</option>`))
.join('');
const amsTypes = printerModels().amsTypes
.map((a) => `<option value="${a.id}"${a.id === p.ams_type ? ' selected' : ''}>${escapeHtml(a.label)}</option>`)
.join('');
return `
<section class="ai-card printer-card" data-printer-id="${key}">
<div class="printer-art" aria-hidden="true">${printerModels().art(p.model)}</div>
<form class="printer-form" autocomplete="off">
<h3 class="printer-title">${escapeHtml(p.id ? printerName(p) : 'New printer')}</h3>
${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 || '')}"
placeholder="${escapeHtml(p.model || 'Leave blank to use the model')}">
</label>
<label>Model
<select name="model">${models}</select>
</label>
<label>AMS
<select name="ams_type">${p.ams_type ? '' : '<option value="" selected>Choose…</option>'}${amsTypes}</select>
</label>
<label>Address
<input type="text" name="host" maxlength="255" value="${escapeHtml(p.host || '')}" placeholder="192.168.1.50">
</label>
<label>Serial number
<input type="text" name="serial" maxlength="40" value="${escapeHtml(p.serial || '')}" placeholder="From the printer’s screen">
</label>
<label>Access code
<input type="password" name="access_code" maxlength="40" autocomplete="new-password"
placeholder="${p.codeSet ? 'Stored — type to replace' : 'From the printer’s network settings'}">
</label>
<div class="printer-actions">
<button type="submit" class="btn btn-primary btn-sm">${p.id ? 'Save' : 'Add printer'}</button>
${p.id ? '<button type="button" class="btn btn-secondary btn-sm" data-printer="test">Test &amp; sync</button>' : ''}
<button type="button" class="btn btn-secondary btn-sm" data-printer="${p.id ? 'remove' : 'cancel'}">${p.id ? 'Remove' : 'Cancel'}</button>
</div>
<div class="form-hint ai-test-result printer-result"></div>
</form>
</section>`;
}
async function renderPrintersPanel() {
fileGrid.className = 'ai-panel';
resultCount.textContent = '';
try {
printerList = await getJson('/api/printers');
} catch (err) {
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load your printers</h3><p>${escapeHtml(err.message)}</p></div>`;
return;
}
setCount('printerCount', printerList.length);
fileGrid.innerHTML = `
<section class="ai-card">
<div class="ai-card-head">
<h3>Your printers</h3>
<button type="button" class="btn btn-primary btn-sm" id="addPrinterBtn">Add printer</button>
</div>
<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('')
: '<p class="form-hint">No printers yet — add one to get started.</p>'}
</div>`;
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) => `<span class="spool-dot" style="background:${escapeHtml(t.colour || '#888888')}"></span>
${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;

View file

@ -106,10 +106,6 @@
<span>Filament</span>
<span class="count" id="spoolCount">0</span>
</div>
<div class="project-item" data-scope="printers">
<span>Printers</span>
<span class="count" id="printerCount">0</span>
</div>
<div class="project-item" data-scope="trash">
<span>Trash</span>
<span class="count" id="trashCount">0</span>
@ -304,6 +300,33 @@
</div>
<div id="aiTestResult" class="form-hint ai-test-result"></div>
<h3 class="settings-group-title" id="printerSettings">Printer</h3>
<div class="ai-grid">
<div class="form-group">
<label for="printerHost">Printer address</label>
<input type="text" id="printerHost" data-setting="printer_host" data-clear-blank placeholder="192.168.1.50" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="printerSerial">Serial number</label>
<input type="text" id="printerSerial" data-setting="printer_serial" data-clear-blank autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="printerAccessCode">Access code</label>
<input type="password" id="printerAccessCode" data-setting="printer_access_code" placeholder="From the printer’s screen" autocomplete="off">
</div>
</div>
<p class="form-hint">
Access code: <span class="setting-status" data-status="printer_access_code">—</span>.
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.
</p>
<div class="ai-test-row">
<button type="button" class="btn btn-secondary btn-sm" id="printerTestBtn">Save &amp; test connection</button>
<button type="button" class="btn btn-link clear-setting" data-setting="printer_access_code">Clear code</button>
</div>
<div id="printerTestResult" class="form-hint ai-test-result"></div>
<h3 class="settings-group-title">Import sources</h3>
<div class="form-group">
@ -678,7 +701,6 @@
</div>
<script type="module" src="viewer.js"></script>
<script src="printer-art.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -1,119 +0,0 @@
// Printer models, and simple line drawings of their shapes, drawn for
// Printhive. The drawings suggest a model's form — open frame, enclosed, large
// format — rather than depict any product exactly.
(function () {
const svg = (inner, width = 150) =>
`<svg viewBox="0 0 ${width} 120" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" role="img">${inner}</svg>`;
// An AMS lite: a stand with four spools around a hub.
const amsLite = (x) => `
<line x1="${x}" y1="104" x2="${x}" y2="52"/>
<line x1="${x - 10}" y1="104" x2="${x + 10}" y2="104"/>
<circle cx="${x - 9}" cy="40" r="7"/><circle cx="${x + 9}" cy="40" r="7"/>
<circle cx="${x - 9}" cy="58" r="7"/><circle cx="${x + 9}" cy="58" r="7"/>`;
// An AMS on top of an enclosed printer: a box showing four spools.
const amsTop = (x, width) => {
const step = width / 4;
let spools = '';
for (let i = 0; i < 4; i += 1) spools += `<circle cx="${x + step / 2 + i * step}" cy="23" r="5"/>`;
return `<rect x="${x}" y="14" width="${width}" height="18" rx="3"/>${spools}`;
};
const toolhead = (x, y) => `<rect x="${x}" y="${y}" width="14" height="15" rx="2"/>
<line x1="${x + 7}" y1="${y + 15}" x2="${x + 7}" y2="${y + 20}"/>`;
const shapes = {
// A1: a bed on a base, an upright gantry, an AMS lite alongside.
open: svg(`
<rect x="12" y="94" width="92" height="10" rx="3"/>
<rect x="24" y="84" width="68" height="6" rx="2"/>
<line x1="20" y1="94" x2="20" y2="26"/><line x1="96" y1="94" x2="96" y2="26"/>
<line x1="16" y1="26" x2="100" y2="26"/><line x1="20" y1="56" x2="96" y2="56"/>
${toolhead(50, 49)}
${amsLite(126)}`),
// A1 mini: a single column holding the gantry out over the bed.
openMini: svg(`
<rect x="22" y="94" width="72" height="10" rx="3"/>
<rect x="36" y="84" width="50" height="6" rx="2"/>
<rect x="24" y="30" width="10" height="64" rx="2"/>
<line x1="34" y1="54" x2="90" y2="54"/>
${toolhead(58, 47)}
${amsLite(122)}`),
// A2L: the A1's form, stretched to a larger bed.
openLarge: svg(`
<rect x="6" y="94" width="104" height="10" rx="3"/>
<rect x="14" y="84" width="88" height="6" rx="2"/>
<line x1="10" y1="94" x2="10" y2="22"/><line x1="106" y1="94" x2="106" y2="22"/>
<line x1="6" y1="22" x2="110" y2="22"/><line x1="10" y1="54" x2="106" y2="54"/>
${toolhead(52, 47)}
${amsLite(132)}`),
// P1P: a bare cube frame.
frame: svg(`
<rect x="30" y="38" width="66" height="66" rx="3"/>
<path d="M30 38 L44 28 L110 28 L96 38 M110 28 L110 94 L96 104"/>
<line x1="30" y1="60" x2="96" y2="60"/>
${toolhead(56, 53)}
<rect x="40" y="90" width="46" height="5" rx="2"/>`, 140),
// P1S, P2S, X1C, X1E, X2D: an enclosed box with an AMS on top.
box: svg(`
${amsTop(30, 66)}
<rect x="28" y="36" width="70" height="70" rx="5"/>
<rect x="36" y="44" width="54" height="46" rx="3"/>
<line x1="36" y1="98" x2="90" y2="98"/>
${toolhead(56, 52)}`, 126),
// H2S, H2D, H2C: a wide enclosed box with an AMS on top.
large: svg(`
${amsTop(28, 94)}
<rect x="18" y="36" width="114" height="70" rx="5"/>
<rect x="26" y="44" width="98" height="46" rx="3"/>
<line x1="26" y1="98" x2="124" y2="98"/>
${toolhead(58, 52)}${toolhead(78, 52)}`),
// Anything else: a plain box with a nozzle.
generic: svg(`
<rect x="34" y="30" width="72" height="72" rx="8"/>
${toolhead(63, 50)}
<line x1="44" y1="92" x2="96" y2="92"/>`, 140),
};
// ams is only a suggestion — a P1S might have an AMS, an AMS 2 Pro or an AMS HT.
const models = [
{ id: 'A1 mini', shape: 'openMini', ams: 'ams_lite' },
{ id: 'A1', shape: 'open', ams: 'ams_lite' },
{ id: 'A2L', shape: 'openLarge', ams: 'ams_lite' },
{ id: 'P1P', shape: 'frame', ams: 'ams' },
{ id: 'P1S', shape: 'box', ams: 'ams' },
{ id: 'P2S', shape: 'box', ams: 'ams_2_pro' },
{ id: 'X1C', shape: 'box', ams: 'ams' },
{ id: 'X1E', shape: 'box', ams: 'ams' },
{ id: 'X2D', shape: 'box', ams: 'ams_2_pro' },
{ id: 'H2S', shape: 'large', ams: 'ams_2_pro' },
{ id: 'H2D', shape: 'large', ams: 'ams_2_pro' },
{ id: 'H2C', shape: 'large', ams: 'ams_2_pro' },
{ id: 'Other', shape: 'generic', ams: 'none' },
];
const amsTypes = [
{ id: 'none', label: 'No AMS (external spool)' },
{ id: 'ams_lite', label: 'AMS lite (full or empty only)' },
{ id: 'ams', label: 'AMS' },
{ id: 'ams_2_pro', label: 'AMS 2 Pro' },
{ id: 'ams_ht', label: 'AMS HT' },
];
const find = (id) => models.find((m) => m.id === id) || null;
window.PRINTER_MODELS = {
models,
amsTypes,
find,
art: (id) => shapes[(find(id) || {}).shape] || shapes.generic,
};
}());

View file

@ -487,22 +487,3 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
#watchStatus.is-error { color: var(--danger); }
/* ---- Printers ---- */
.printer-cards { display: flex; flex-direction: column; gap: 18px; }
.printer-card { flex-direction: row; align-items: flex-start; gap: 20px; }
.printer-art { flex: 0 0 150px; color: var(--text-muted); padding-top: 28px; }
.printer-art svg { width: 100%; height: auto; display: block; }
.printer-form { flex: 1; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px 14px; }
.printer-form label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
.printer-form input, .printer-form select { width: 100%; }
.printer-title, .printer-actions, .printer-result { grid-column: 1 / -1; }
.printer-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.printer-actions .btn-sm { align-self: auto; }
.printer-result:empty { display: none; }
.spool-tag code { color: var(--text-muted); }
@media (max-width: 600px) {
.printer-card { flex-direction: column; }
.printer-art { flex-basis: auto; width: 140px; padding-top: 0; }
}
.printer-ask { grid-column: 1 / -1; color: var(--accent); }

View file

@ -31,36 +31,22 @@ const autotag = require('./lib/autotag');
const filaments = require('./lib/filaments');
const filamentPalette = require('./db/bambu-filaments.json');
const printer = require('./lib/printer');
const printers = require('./lib/printers');
const spoolSync = require('./lib/spoolSync');
// 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 });
// Reads the configured printer and brings the Filament page up to date with
// what it has loaded. Read-only toward the printer.
async function syncConfiguredPrinter({ debug = false } = {}) {
const host = (await resolveSetting('printer_host')).value;
const serial = (await resolveSetting('printer_serial')).value;
const accessCode = (await resolveSetting('printer_access_code')).value;
if (!host || !serial || !accessCode) return null;
const result = await printer.probe({ host: host.trim(), serial: serial.trim(), accessCode: accessCode.trim(), debug });
if (result.connected) {
const trays = [...(result.ams || []), ...(result.external ? [result.external] : [])];
if (trays.length) {
result.sync = await spoolSync.syncPrinter(p.serial, trays, filamentPalette.filaments,
{ measures: printers.measures(p.ams_type) });
}
if (trays.length) result.sync = await spoolSync.syncPrinter(serial.trim(), trays, filamentPalette.filaments);
}
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();
@ -153,6 +139,9 @@ const SETTINGS = {
ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false },
ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false },
ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false },
printer_host: { env: 'PRINTER_HOST', label: 'Printer address', secret: false },
printer_serial: { env: 'PRINTER_SERIAL', label: 'Printer serial number', secret: false },
printer_access_code: { env: 'PRINTER_ACCESS_CODE', label: 'Printer access code', secret: true },
};
const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_'));
@ -355,38 +344,17 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- filament
// The spools you have. What a model needs is read from its 3MF in the
// background and kept on the model itself, so matching is a plain comparison.
// ----------------------------------------------------------------- printers
// Access codes go in but never come back out: responses carry `codeSet`.
app.get('/api/printers', async (req, res, next) => {
try { res.json(await printers.list()); } catch (e) { next(e); }
});
app.post('/api/printers', async (req, res, next) => {
try { res.status(201).json(await printers.create(req.body || {})); } catch (e) { next(e); }
});
app.put('/api/printers/:id', async (req, res, next) => {
// Tries the printer connection with the saved settings and says what came back.
// Read-only, and the access code never leaves the server.
app.post('/api/printer/test', 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' });
res.json(saved);
} catch (e) { next(e); }
});
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' });
res.json({ message: 'Printer removed' });
} catch (e) { next(e); }
});
// 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' });
res.json(await syncPrinter(p, { debug: req.query.debug === '1' }));
const host = (await resolveSetting('printer_host')).value;
const serial = (await resolveSetting('printer_serial')).value;
const accessCode = (await resolveSetting('printer_access_code')).value;
const missing = [!host && 'address', !serial && 'serial number', !accessCode && 'access code'].filter(Boolean);
if (missing.length) return res.status(400).json({ error: `Fill in the printer’s ${missing.join(', ')} first.` });
// ?debug=1 adds one tray's raw fields — filament data, no credentials.
res.json(await syncConfiguredPrinter({ debug: req.query.debug === '1' }));
} catch (e) { next(e); }
});
@ -1629,8 +1597,8 @@ db.init()
// 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));
const syncQuietly = () => syncConfiguredPrinter()
.catch((err) => console.warn('Printer sync skipped:', err.message));
setTimeout(syncQuietly, 30 * 1000).unref();
setInterval(syncQuietly, 10 * 60 * 1000).unref();

View file

@ -1,19 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { publicPrinter, measures } = require('../lib/printers');
test('a printer sent to the browser never carries its access code', () => {
const p = publicPrinter({ id: 1, nickname: null, model: 'A1', serial: 'S1', access_code: 'secret' });
assert.equal('access_code' in p, false);
assert.equal(p.codeSet, true);
assert.equal(publicPrinter({ id: 2, access_code: null }).codeSet, false);
});
test('only AMS types that weigh filament count as measuring', () => {
assert.equal(measures('ams_lite'), false);
assert.equal(measures('none'), false);
assert.equal(measures(null), false);
assert.equal(measures('ams'), true);
assert.equal(measures('ams_2_pro'), true);
assert.equal(measures('ams_ht'), true);
});

View file

@ -28,7 +28,7 @@ test('a spool the shelf already knows is moved, not added again', () => {
const existing = [{ id: 7, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-BLACK',
printer_serial: SERIAL, ams_slot: 'PRINTER1/0/3' }];
const { actions } = planSync(existing, [tray(1, '#000000', 'U-BLACK')], SERIAL, palette);
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL, remaining: null }]);
assert.deepEqual(actions, [{ op: 'update', id: 7, slot: 'PRINTER1/0/1', printerSerial: SERIAL }]);
});
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
@ -82,17 +82,3 @@ test('palette matching names a colour within its product line, and doesn’t gue
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
});
test('an AMS that measures fills in grams left; an AMS lite never does', () => {
const trays = [tray(0, '#C12E1F', 'U-RED', { remain: 42 })];
const lite = planSync([], trays, SERIAL, palette, { measures: false });
assert.equal(lite.actions[0].spool.remaining_g, null);
const pro = planSync([], trays, SERIAL, palette, { measures: true });
assert.equal(pro.actions[0].spool.remaining_g, 420);
const existing = [{ id: 7, material: 'PLA', colour_hex: '#C12E1F', rfid_uuid: 'U-RED', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' }];
assert.equal(planSync(existing, trays, SERIAL, palette, { measures: true }).actions[0].remaining, 420);
// No reading at all leaves the stored amount alone.
const unknown = [tray(0, '#C12E1F', 'U-RED', { remain: null })];
assert.equal(planSync(existing, unknown, SERIAL, palette, { measures: true }).actions[0].remaining, null);
});