Compare commits
2 commits
99d6bdf06c
...
1c19ac565e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c19ac565e | ||
|
|
48f564c648 |
9 changed files with 312 additions and 8 deletions
|
|
@ -28,7 +28,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
|
||||||
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 JSONB;
|
||||||
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
|
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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,23 @@ function cleanSlots(list) {
|
||||||
.filter((f) => f.type);
|
.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 = {
|
module.exports = {
|
||||||
run, pendingCount, reread, cleanSlots,
|
run, pendingCount, reread, cleanSlots, paletteMatch,
|
||||||
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
|
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ const PORT = 8883;
|
||||||
// An id the printer sends as all zeros means no RFID tag was read.
|
// 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);
|
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
|
// 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
|
// 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.
|
// 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,
|
uuid: loaded ? realId(tray.tray_uuid) : null,
|
||||||
tagUid: loaded ? realId(tray.tag_uid) : 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
|
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 };
|
||||||
|
|
|
||||||
129
lib/spoolSync.js
Normal file
129
lib/spoolSync.js
Normal file
|
|
@ -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: "<printer serial>/<unit>/<slot>", 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 };
|
||||||
|
|
@ -1485,6 +1485,17 @@ async function handlePrinterTest() {
|
||||||
lines.push(`AMS: ${loaded.length} of ${r.ams.length} slots loaded${loaded.length ? ` — ${loaded.map(trayHtml).join(', ')}` : ''}.`);
|
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.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) {
|
if (!r.reports) {
|
||||||
lines.push('But it sent nothing back — in cloud mode the printer may be refusing the request for a full report.');
|
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() {
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "<printer serial>/<unit>/<slot>" → "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) {
|
function spoolHtml(s) {
|
||||||
const left = s.remaining_g == null ? 'amount unknown' : `${count(s.remaining_g)} g left`;
|
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 of = s.total_g ? ` of ${count(s.total_g)} g` : '';
|
||||||
|
|
@ -2482,7 +2500,8 @@ function spoolHtml(s) {
|
||||||
<span class="spool-dot" style="background:${escapeHtml(s.colour_hex || '#888')}"></span>
|
<span class="spool-dot" style="background:${escapeHtml(s.colour_hex || '#888')}"></span>
|
||||||
<strong>${escapeHtml(s.material)}</strong>
|
<strong>${escapeHtml(s.material)}</strong>
|
||||||
<span>${escapeHtml(s.colour || '')}</span>
|
<span>${escapeHtml(s.colour || '')}</span>
|
||||||
<span class="form-hint">${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(s.ams_slot)}` : ''}</span>
|
<span class="form-hint">${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(slotLabel(s.ams_slot))}` : ''}${
|
||||||
|
s.rfid_uuid ? ' · <span title="Found by the printer from its RFID tag">RFID</span>' : ''}</span>
|
||||||
<span class="watch-actions">
|
<span class="watch-actions">
|
||||||
<label class="form-hint">grams left
|
<label class="form-hint">grams left
|
||||||
<input type="number" class="spool-remaining" value="${s.remaining_g ?? ''}" min="0" step="10" aria-label="Grams left">
|
<input type="number" class="spool-remaining" value="${s.remaining_g ?? ''}" min="0" step="10" aria-label="Grams left">
|
||||||
|
|
|
||||||
30
server.js
30
server.js
|
|
@ -31,6 +31,22 @@ const autotag = require('./lib/autotag');
|
||||||
const filaments = require('./lib/filaments');
|
const filaments = require('./lib/filaments');
|
||||||
const filamentPalette = require('./db/bambu-filaments.json');
|
const filamentPalette = require('./db/bambu-filaments.json');
|
||||||
const printer = require('./lib/printer');
|
const printer = require('./lib/printer');
|
||||||
|
const spoolSync = require('./lib/spoolSync');
|
||||||
|
|
||||||
|
// 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(serial.trim(), trays, filamentPalette.filaments);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
const embeddings = require('./lib/embeddings');
|
const embeddings = require('./lib/embeddings');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -337,10 +353,8 @@ app.post('/api/printer/test', async (req, res, next) => {
|
||||||
const accessCode = (await resolveSetting('printer_access_code')).value;
|
const accessCode = (await resolveSetting('printer_access_code')).value;
|
||||||
const missing = [!host && 'address', !serial && 'serial number', !accessCode && 'access code'].filter(Boolean);
|
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.` });
|
if (missing.length) return res.status(400).json({ error: `Fill in the printer’s ${missing.join(', ')} first.` });
|
||||||
res.json(await printer.probe({
|
// ?debug=1 adds one tray's raw fields — filament data, no credentials.
|
||||||
host: host.trim(), serial: serial.trim(), accessCode: accessCode.trim(),
|
res.json(await syncConfiguredPrinter({ debug: req.query.debug === '1' }));
|
||||||
debug: req.query.debug === '1', // raw fields for one tray — filament data, no credentials
|
|
||||||
}));
|
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1580,6 +1594,14 @@ db.init()
|
||||||
{ describe: true, renameUnclear: true, suggestCollections: true }),
|
{ describe: true, renameUnclear: true, suggestCollections: true }),
|
||||||
});
|
});
|
||||||
collectionWatch.start();
|
collectionWatch.start();
|
||||||
|
|
||||||
|
// 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 = () => syncConfiguredPrinter()
|
||||||
|
.catch((err) => console.warn('Printer sync skipped:', err.message));
|
||||||
|
setTimeout(syncQuietly, 30 * 1000).unref();
|
||||||
|
setInterval(syncQuietly, 10 * 60 * 1000).unref();
|
||||||
|
|
||||||
return aiRuns.init(resolveAiConfig);
|
return aiRuns.init(resolveAiConfig);
|
||||||
})
|
})
|
||||||
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ test('AMS trays are read into material, colour and how much is left', () => {
|
||||||
assert.deepEqual(trays[0], {
|
assert.deepEqual(trays[0], {
|
||||||
unit: 0, slot: 0, empty: false, material: 'PLA', colour: '#FFFFFF',
|
unit: 0, slot: 0, empty: false, material: 'PLA', colour: '#FFFFFF',
|
||||||
profile: 'GFA00', brand: 'PLA Basic', remain: 82, uuid: null, tagUid: null, idName: null,
|
profile: 'GFA00', brand: 'PLA Basic', remain: 82, uuid: null, tagUid: null, idName: null,
|
||||||
|
weight: null, type: 'Basic',
|
||||||
});
|
});
|
||||||
assert.equal(trays[1].empty, true);
|
assert.equal(trays[1].empty, true);
|
||||||
assert.equal(trays[1].material, null);
|
assert.equal(trays[1].material, null);
|
||||||
|
|
@ -59,3 +60,17 @@ test('connection failures are explained in plain words', () => {
|
||||||
'Couldn’t reach the printer from the server.');
|
'Couldn’t reach the printer from the server.');
|
||||||
assert.equal(printer.friendlyError(null), 'The connection failed.');
|
assert.equal(printer.friendlyError(null), 'The connection failed.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the product line is what follows the material in the brand', () => {
|
||||||
|
assert.equal(printer.productLine('PLA Basic', 'PLA'), 'Basic');
|
||||||
|
assert.equal(printer.productLine('PLA Silk+', 'PLA'), 'Silk+');
|
||||||
|
assert.equal(printer.productLine('PETG HF', 'PETG'), 'HF');
|
||||||
|
assert.equal(printer.productLine('Generic', 'PLA'), 'Generic', 'a brand without the material is kept whole');
|
||||||
|
assert.equal(printer.productLine('', 'PLA'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tray reports its weight when new', () => {
|
||||||
|
const t = printer.describeTray({ id: '0', tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_weight: '1000' });
|
||||||
|
assert.equal(t.weight, 1000);
|
||||||
|
assert.equal(printer.describeTray({ id: '0', tray_type: 'PLA', tray_weight: '0' }).weight, null);
|
||||||
|
});
|
||||||
|
|
|
||||||
84
test/spoolSync.test.js
Normal file
84
test/spoolSync.test.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { planSync, slotKey } = require('../lib/spoolSync');
|
||||||
|
const { paletteMatch } = require('../lib/filaments');
|
||||||
|
const palette = require('../db/bambu-filaments.json').filaments;
|
||||||
|
|
||||||
|
const SERIAL = 'PRINTER1';
|
||||||
|
const tray = (slot, hex, uuid, extra = {}) => ({
|
||||||
|
unit: 0, slot, empty: false, material: 'PLA', type: 'Basic', colour: hex, brand: 'PLA Basic',
|
||||||
|
uuid, tagUid: uuid ? `TAG${slot}` : null, idName: null, weight: 1000, ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tagged spool the shelf has never seen is created and named from the filament list', () => {
|
||||||
|
const { actions, untracked } = planSync([], [tray(0, '#C12E1F', 'U-RED', { idName: 'A00-R0' })], SERIAL, palette);
|
||||||
|
assert.equal(untracked.length, 0);
|
||||||
|
assert.equal(actions.length, 1);
|
||||||
|
assert.equal(actions[0].op, 'create');
|
||||||
|
assert.equal(actions[0].slot, 'PRINTER1/0/0');
|
||||||
|
assert.equal(actions[0].spool.colour, 'Red');
|
||||||
|
assert.equal(actions[0].spool.code, '10200');
|
||||||
|
assert.equal(actions[0].spool.colour_hex, '#C12E1F');
|
||||||
|
assert.equal(actions[0].spool.product_code, 'A00-R0');
|
||||||
|
assert.equal(actions[0].spool.total_g, 1000);
|
||||||
|
assert.equal(actions[0].spool.remaining_g, null, 'an AMS lite can’t say how much is left');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a spool added by hand is adopted by the printer rather than duplicated', () => {
|
||||||
|
// The Jade White spool added before the printer was connected.
|
||||||
|
const existing = [{ id: 1, material: 'PLA', colour_hex: '#ffffff', rfid_uuid: null, printer_serial: null, ams_slot: null }];
|
||||||
|
const { actions } = planSync(existing, [tray(3, '#FFFFFF', 'U-WHITE', { idName: 'A00-W1' })], SERIAL, palette);
|
||||||
|
assert.equal(actions.length, 1);
|
||||||
|
assert.equal(actions[0].op, 'adopt');
|
||||||
|
assert.equal(actions[0].id, 1);
|
||||||
|
assert.equal(actions[0].uuid, 'U-WHITE');
|
||||||
|
assert.equal(actions[0].code, '10100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one hand-added spool is adopted once, even with two matching trays', () => {
|
||||||
|
const existing = [{ id: 1, material: 'PLA', colour_hex: '#FFFFFF', rfid_uuid: null }];
|
||||||
|
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W1'), tray(1, '#FFFFFF', 'U-W2')], SERIAL, palette);
|
||||||
|
assert.deepEqual(actions.map((a) => a.op), ['adopt', 'create']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hand-added spool of another colour or material is left alone', () => {
|
||||||
|
const existing = [
|
||||||
|
{ id: 1, material: 'PLA', colour_hex: '#FF0000', rfid_uuid: null }, // wrong colour
|
||||||
|
{ id: 2, material: 'PETG', colour_hex: '#FFFFFF', rfid_uuid: null }, // wrong material
|
||||||
|
];
|
||||||
|
const { actions } = planSync(existing, [tray(0, '#FFFFFF', 'U-W')], SERIAL, palette);
|
||||||
|
assert.deepEqual(actions.map((a) => a.op), ['create']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an untagged spool is listed, not guessed at', () => {
|
||||||
|
const { actions, untracked } = planSync([], [tray(2, '#123456', null)], SERIAL, palette);
|
||||||
|
assert.deepEqual(actions, []);
|
||||||
|
assert.deepEqual(untracked, [{ slot: 'PRINTER1/0/2', material: 'PLA', colour: '#123456' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a spool this printer no longer reports is unloaded; other printers’ spools are not', () => {
|
||||||
|
const existing = [
|
||||||
|
{ id: 5, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-GONE', printer_serial: SERIAL, ams_slot: 'PRINTER1/0/0' },
|
||||||
|
{ id: 6, material: 'PLA', colour_hex: '#000000', rfid_uuid: 'U-ELSEWHERE', printer_serial: 'OTHER', ams_slot: 'OTHER/0/0' },
|
||||||
|
];
|
||||||
|
const { actions } = planSync(existing, [], SERIAL, palette);
|
||||||
|
assert.deepEqual(actions, [{ op: 'unload', id: 5 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty trays are ignored, and the external holder has its own slot key', () => {
|
||||||
|
assert.deepEqual(planSync([], [{ empty: true, unit: 0, slot: 0 }], SERIAL, palette).actions, []);
|
||||||
|
assert.equal(slotKey(SERIAL, { unit: null, slot: 254 }), 'PRINTER1/ext/254');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('palette matching names a colour within its product line, and doesn’t guess', () => {
|
||||||
|
assert.deepEqual(paletteMatch(palette, 'PLA', 'Basic', '#FFFFFF'), { name: 'Jade White', code: '10100', type: 'Basic' });
|
||||||
|
assert.equal(paletteMatch(palette, 'PLA', 'Matte', '#FFFFFF').name, 'Ivory White');
|
||||||
|
assert.equal(paletteMatch(palette, 'PLA', 'Basic', '#123456'), null);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue