printhive/lib/spoolSync.js
dlawler489 48f564c648 Add spools automatically from what the printer has loaded
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>
2026-09-17 20:39:30 +10:00

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 };