Every Bambu spool reports an RFID identity — a spool id and a tag id, both unique — so a printer's report can now keep the Filament page in step: a new tagged spool is created, named and coded from the bundled filament list; one already known is moved to its current slot; one you added by hand before the printer was connected is adopted rather than duplicated; and one the printer no longer reports is marked as taken out. It runs on "Save & test connection" and every ten minutes in the background, still only reading from the printer. Untagged spools are listed, not stored: without a tag, one spool of a colour is indistinguishable from the next, so guessing would mean duplicates. The planning is a pure function, tested against the cases that would cause duplicates. The database side was exercised separately against a real Postgres: three successive reports created, moved and unloaded spools as intended, and a deliberate duplicate was refused by the partial unique index. The amount left isn't taken from the printer yet: an AMS lite only reports full or empty, so it's left blank until printers can say which kind of AMS they have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.2 KiB
JavaScript
129 lines
5.2 KiB
JavaScript
// 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 };
|