printhive/lib/printer.js
dlawler489 1aca9a6c01 Show which filament is feeding each printer, with a tube from spool to toolhead
The live connection reads tray_now (unit × 4 + slot, 254 external, 255
none) and works out the unit by id or, failing that, by position — so the
A2L's AMS lite numbered 16 is handled. Cards say 'Using' (or 'Loaded' when
not printing) with the filament's name and slot, mark the slot in the list,
and draw a tube in its colour from that spool to the toolhead, with a
moving flow while printing. The external spool is drawn when it's in use.
Debug reports include tray_now and tray_tar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:27:57 +10:00

170 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
// Speed settings as the printer numbers them.
const SPEEDS = { 1: 'Silent', 2: 'Standard', 3: 'Sport', 4: 'Ludicrous' };
const num = (v) => (v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v));
// The status fields in one report. Printers often send only what changed, so
// anything a report leaves out is left out here too, for the caller to merge.
function readStatus(p) {
const out = {};
const set = (key, value) => { if (value !== undefined) out[key] = value; };
set('state', p.gcode_state || undefined);
set('progress', num(p.mc_percent));
set('remainingMin', num(p.mc_remaining_time));
set('file', p.subtask_name || p.gcode_file || undefined);
set('layer', num(p.layer_num));
set('totalLayers', num(p.total_layer_num));
set('nozzle', num(p.nozzle_temper));
set('nozzleTarget', num(p.nozzle_target_temper));
set('bed', num(p.bed_temper));
set('bedTarget', num(p.bed_target_temper));
set('chamber', num(p.chamber_temper));
if (p.spd_lvl !== undefined) set('speed', SPEEDS[Number(p.spd_lvl)] || null);
if (p.print_error !== undefined) {
const code = Number(p.print_error) || 0;
set('printError', code ? code.toString(16).toUpperCase().padStart(8, '0') : null);
}
if (Array.isArray(p.hms)) {
// Health messages: a module/severity word and a code, shown as Bambu writes them.
set('hms', p.hms.map((h) => {
const hex = (n) => (Number(n) >>> 0).toString(16).toUpperCase().padStart(8, '0');
const a = hex(h.attr);
const c = hex(h.code);
return `${a.slice(0, 4)}_${a.slice(4)}_${c.slice(0, 4)}_${c.slice(4)}`;
}));
}
return out;
}
// 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 },
trayNow: p.ams.tray_now, trayTar: p.ams.tray_tar,
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, readStatus, PORT };