Printers move out of Settings into their own table and page: each has an optional nickname (the model is shown when blank), a model from the Bambu lineup with a line drawing that follows the selection, an AMS type suggested from the model, and its address, serial and access code. Access codes are never returned to the browser. The printer set up in Settings is migrated automatically and its settings rows removed. Background sync covers every printer. Grams left are filled in only from an AMS that measures (AMS, AMS 2 Pro, AMS HT), never from an AMS lite. Spools now show their RFID tag ID, product code and filament code, and say which printer they're loaded in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.9 KiB
JavaScript
138 lines
5.9 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, { measures = false } = {}) {
|
|
const actions = [];
|
|
// Grams left, only when this printer's AMS actually measures it. An AMS lite
|
|
// reports full or empty, which is not a weight.
|
|
const gramsLeft = (tray) => (measures && tray.remain != null && tray.weight
|
|
? Math.round((tray.weight * tray.remain) / 100)
|
|
: null);
|
|
const untracked = [];
|
|
const byUuid = new Map(existing.filter((s) => s.rfid_uuid).map((s) => [s.rfid_uuid, s]));
|
|
const adopted = new Set(); // hand-added spools already matched in this report
|
|
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, remaining: gramsLeft(tray) });
|
|
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, remaining: gramsLeft(tray),
|
|
});
|
|
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: gramsLeft(tray), // blank unless this printer's AMS measures it
|
|
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, remaining_g = COALESCE($9, remaining_g),
|
|
last_seen_at = now(), date_modified = now()
|
|
WHERE id = $1 AND rfid_uuid IS NULL`,
|
|
[a.id, a.uuid, a.tagUid, a.productCode, a.code, a.totalG, a.slot, a.printerSerial, a.remaining ?? null]);
|
|
summary.adopted += rowCount;
|
|
} else if (a.op === 'update') {
|
|
// A measured weight replaces the stored one; no reading leaves it alone.
|
|
await db.query(
|
|
`UPDATE filament_spools SET ams_slot = $2, printer_serial = $3, last_seen_at = now(),
|
|
remaining_g = COALESCE($4, remaining_g)
|
|
WHERE id = $1`,
|
|
[a.id, a.slot, a.printerSerial, a.remaining ?? null]);
|
|
summary.updated += 1;
|
|
} else if (a.op === 'unload') {
|
|
await db.query('UPDATE filament_spools SET ams_slot = NULL WHERE id = $1', [a.id]);
|
|
summary.unloaded += 1;
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
// Reads the shelf, plans against one printer's trays, and applies it.
|
|
async function syncPrinter(printerSerial, trays, palette, options = {}) {
|
|
const { rows: existing } = await db.query('SELECT * FROM filament_spools');
|
|
const plan = planSync(existing, trays, printerSerial, palette, options);
|
|
const summary = await applySync(plan);
|
|
return { ...summary, untracked: plan.untracked };
|
|
}
|
|
|
|
module.exports = { planSync, syncPrinter, slotKey };
|