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>
131 lines
5.7 KiB
JavaScript
131 lines
5.7 KiB
JavaScript
// A read-only connection to a Bambu Lab printer on your network.
|
||
//
|
||
// The printer runs an MQTT broker on port 8883 — TLS with a self-signed
|
||
// certificate, username "bblp", and the access code from its screen as the
|
||
// password. Printhive only subscribes to the printer's reports and asks once for
|
||
// a full one ("pushall"); it never sends anything that changes what the printer
|
||
// is doing.
|
||
const mqtt = require('mqtt');
|
||
|
||
const PORT = 8883;
|
||
|
||
// 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);
|
||
|
||
// 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
|
||
// 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.
|
||
function describeTray(tray, unit = null) {
|
||
const loaded = Boolean(tray && tray.tray_type);
|
||
return {
|
||
unit,
|
||
slot: tray && tray.id != null ? Number(tray.id) : null,
|
||
empty: !loaded,
|
||
material: loaded ? tray.tray_type : null,
|
||
colour: loaded && tray.tray_color ? `#${String(tray.tray_color).slice(0, 6).toUpperCase()}` : null,
|
||
profile: loaded ? tray.tray_info_idx || null : null, // Bambu's filament profile, e.g. GFA00
|
||
brand: loaded ? tray.tray_sub_brands || null : null,
|
||
remain: loaded && Number(tray.remain) >= 0 ? Number(tray.remain) : null,
|
||
// A Bambu spool's RFID tag: tray_uuid names the spool, tag_uid the tag.
|
||
uuid: loaded ? realId(tray.tray_uuid) : 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
|
||
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,
|
||
};
|
||
}
|
||
|
||
// Every tray in every AMS unit, in order.
|
||
function describeAms(units) {
|
||
const trays = [];
|
||
for (const unit of Array.isArray(units) ? units : []) {
|
||
for (const tray of unit.tray || []) trays.push(describeTray(tray, Number(unit.id)));
|
||
}
|
||
return trays;
|
||
}
|
||
|
||
// The messages the MQTT library gives are terse; say what they mean.
|
||
function friendlyError(err) {
|
||
const m = String((err && err.message) || err || '');
|
||
if (/not authori[sz]ed|bad user ?name or password/i.test(m)) return 'The printer refused the access code.';
|
||
if (/ECONNREFUSED/.test(m)) return 'Nothing answered on port 8883 at that address.';
|
||
if (/ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|timed? ?out/i.test(m)) return 'Couldn’t reach the printer from the server.';
|
||
return m.slice(0, 200) || 'The connection failed.';
|
||
}
|
||
|
||
// Connects, asks for a full report, and gathers what arrives for a few seconds.
|
||
// Resolves with what was seen — never with the access code.
|
||
// With `debug`, also returns one loaded tray exactly as the printer sent it, to
|
||
// see which fields a given printer and firmware actually provide.
|
||
function probe({ host, serial, accessCode, waitMs = 8000, debug = false }) {
|
||
return new Promise((resolve) => {
|
||
const started = Date.now();
|
||
const seen = { connected: false, reports: 0, printer: null, ams: null, external: null, error: null, raw: null };
|
||
let done = false;
|
||
let timer = null;
|
||
const client = mqtt.connect(`mqtts://${host}:${PORT}`, {
|
||
username: 'bblp',
|
||
password: accessCode,
|
||
rejectUnauthorized: false, // the printer's certificate is self-signed
|
||
connectTimeout: 6000,
|
||
reconnectPeriod: 0, // one attempt: this is a test, not a session
|
||
clientId: `printhive-${Math.random().toString(16).slice(2, 10)}`,
|
||
});
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
clearTimeout(timer);
|
||
client.end(true);
|
||
resolve({ ...seen, ms: Date.now() - started });
|
||
};
|
||
timer = setTimeout(finish, waitMs);
|
||
|
||
client.on('connect', () => {
|
||
seen.connected = true;
|
||
client.subscribe(`device/${serial}/report`, (err) => {
|
||
if (err) { seen.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
|
||
// The printer only sends everything when asked.
|
||
client.publish(`device/${serial}/request`,
|
||
JSON.stringify({ pushing: { sequence_id: '0', command: 'pushall' } }));
|
||
});
|
||
});
|
||
client.on('message', (_topic, payload) => {
|
||
seen.reports += 1;
|
||
let msg;
|
||
try { msg = JSON.parse(payload.toString()); } catch { return; }
|
||
const p = msg && msg.print;
|
||
if (!p) return;
|
||
if (p.gcode_state) seen.printer = { ...(seen.printer || {}), state: p.gcode_state };
|
||
if (p.mc_percent != null) seen.printer = { ...(seen.printer || {}), progress: Number(p.mc_percent) };
|
||
if (p.ams && Array.isArray(p.ams.ams)) {
|
||
seen.ams = describeAms(p.ams.ams);
|
||
if (debug) {
|
||
const unit = p.ams.ams[0] || {};
|
||
seen.raw = {
|
||
amsKeys: Object.keys(p.ams),
|
||
unitKeys: Object.keys(unit),
|
||
unitInfo: { info: unit.info, humidity: unit.humidity, temp: unit.temp },
|
||
tray: (unit.tray || []).find((t) => t && t.tray_type) || null,
|
||
};
|
||
}
|
||
}
|
||
if (p.vt_tray) seen.external = describeTray(p.vt_tray, null);
|
||
});
|
||
client.on('error', (err) => {
|
||
seen.error = friendlyError(err);
|
||
finish();
|
||
});
|
||
client.on('close', () => { if (!seen.connected) finish(); });
|
||
});
|
||
}
|
||
|
||
module.exports = { probe, describeAms, describeTray, friendlyError, productLine };
|