The tray reader kept material, colour, profile and amount but dropped the spool's RFID fields, so the printer test couldn't have shown a filament's serial even if the printer sent one. It now keeps tray_uuid (the spool), tag_uid (the tag) and tray_id_name where the firmware provides it, and turns the all-zero values sent for untagged spools into "none" rather than a fake id. The printer test also takes ?debug=1, which returns one loaded tray exactly as the printer sent it, to see which fields a given printer and firmware provide before designing around them. It's filament data; no credentials are in it. Also notes that an AMS lite's "remain" is full-or-empty rather than a measurement, which the next change needs to account for per printer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
5.2 KiB
JavaScript
120 lines
5.2 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);
|
||
|
||
// 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
|
||
};
|
||
}
|
||
|
||
// 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 };
|