Settings → Printer takes the printer's address, serial number and access code (the code stored as a secret, like the other tokens). "Save & test connection" connects to the printer's own MQTT broker on port 8883, subscribes to its reports, asks once for a full report, and listens for a few seconds — then says what it heard: its state, and the filament in each AMS slot or on the external spool holder, with how much is left where the printer knows. Nothing sent to the printer changes what it is doing; the only thing published is the request for a full report. If the printer is in cloud mode and treats even that as a write, the test says it received nothing rather than failing silently. The access code never leaves the server. Adds mqtt as a runtime dependency — server.js now loads it at startup, so the lockfile has to travel with this change. Not verified against a real printer yet: the printer's ports answer on the network, but no connection has been made with its credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
4.1 KiB
JavaScript
99 lines
4.1 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;
|
||
|
||
// One tray, as the printer describes it. `remain` is a percentage; the printer
|
||
// sends -1 when it can't tell, which is mostly the case for non-Bambu spools.
|
||
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,
|
||
};
|
||
}
|
||
|
||
// 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.
|
||
function probe({ host, serial, accessCode, waitMs = 8000 }) {
|
||
return new Promise((resolve) => {
|
||
const started = Date.now();
|
||
const seen = { connected: false, reports: 0, printer: null, ams: null, external: null, error: 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 (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 };
|