printhive/lib/printerLive.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

235 lines
8 KiB
JavaScript
Raw 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.

// Live, read-only connections to your printers.
//
// One connection per printer stays open, so status arrives as the printer sends
// it (every second or two while printing) instead of on a timer. The printer is
// only ever asked for a full report — never sent anything that changes what it's
// doing. Access codes are held in memory for the connection and never leave
// this module.
const mqtt = require('mqtt');
const { describeAms, describeTray, friendlyError, readStatus, PORT } = require('./printer');
const RECONNECT_MS = 15 * 1000;
const FULL_REPORT_MS = 10 * 60 * 1000; // a fresh full report now and then, in case a change was missed
const TRAYS_SETTLE_MS = 5 * 1000; // let a spool swap finish reporting before syncing it
const connections = new Map(); // printer id → connection
let onTrays = async () => null;
let connect = (url, options) => mqtt.connect(url, options); // replaceable in tests
// What's loaded, reduced to what decides whether spools need syncing.
const traysKey = (trays) => JSON.stringify((trays || [])
.map((t) => [t.unit, t.slot, t.empty, t.material, t.colour, t.uuid]));
function allTrays(c) {
return [...(c.ams || []), ...(c.external ? [c.external] : [])];
}
// Which tray is feeding the toolhead. The printer sends `tray_now` as a number:
// 255 for none, 254 for the external spool, otherwise unit × 4 + slot — where
// "unit" is the AMS's id on most printers, or its position among the units.
function activeTray(c) {
const n = c.trayNow;
if (n == null || Number.isNaN(n) || n === 255) return null;
if (n === 254) return { external: true };
const ids = c.unitIds || [];
const byId = Math.floor(n / 4);
const unit = ids.includes(byId) ? byId : ids[byId];
return unit === undefined ? null : { unit, slot: n % 4 };
}
function requestFullReport(c) {
if (!c.client.connected) return;
c.client.publish(`device/${c.serial}/request`,
JSON.stringify({ pushing: { sequence_id: String(Date.now()), command: 'pushall' } }));
}
// Syncs spools with what's loaded now, and hands back what that did.
async function flushTrays(c) {
clearTimeout(c.traysTimer);
c.traysTimer = null;
c.pendingKey = null;
const trays = allTrays(c);
if (!c.ams && !c.external) return null;
c.syncedKey = traysKey(trays);
try {
c.lastSync = await onTrays(c.id, trays);
return c.lastSync;
} catch (err) {
console.warn(`Printer ${c.id} spool sync failed:`, err.message);
return null;
}
}
function handleMessage(c, payload) {
let msg;
try { msg = JSON.parse(payload.toString()); } catch { return; }
const p = msg && msg.print;
if (!p) return;
c.reports += 1;
c.updatedAt = new Date();
Object.assign(c.status, readStatus(p));
// AMS details come whole when they change; a report without them changes nothing.
const units = p.ams && p.ams.ams;
if (Array.isArray(units) && units.every((u) => Array.isArray(u.tray))) {
c.ams = describeAms(units);
c.unitIds = units.map((u) => Number(u.id));
}
if (p.ams && p.ams.tray_now !== undefined) c.trayNow = Number(p.ams.tray_now);
if (p.vt_tray) c.external = describeTray(p.vt_tray, null);
// A printer mid-print reports every second or so, so only a change in what's
// loaded restarts the wait — otherwise the sync would never come round.
const key = (c.ams || c.external) ? traysKey(allTrays(c)) : null;
if (key && key !== c.syncedKey && key !== c.pendingKey) {
c.pendingKey = key;
clearTimeout(c.traysTimer);
c.traysTimer = setTimeout(() => flushTrays(c), TRAYS_SETTLE_MS);
}
// Someone waiting for a full report is answered by one with the trays in it.
if (Array.isArray(units) || p.vt_tray) for (const resolve of c.waiters.splice(0)) resolve();
}
function open(p) {
const c = {
id: p.id,
serial: p.serial,
key: [p.host, p.serial, p.access_code].join('\n'),
connected: false,
error: null,
reports: 0,
updatedAt: null,
connectedAt: null,
status: {},
ams: null,
external: null,
syncedKey: null,
pendingKey: null,
trayNow: null,
unitIds: [],
lastSync: null,
traysTimer: null,
waiters: [],
};
c.client = connect(`mqtts://${p.host}:${PORT}`, {
username: 'bblp',
password: p.access_code,
rejectUnauthorized: false, // the printer's certificate is self-signed
connectTimeout: 10 * 1000,
reconnectPeriod: RECONNECT_MS,
keepalive: 30,
clientId: `printhive-${p.id}-${Math.random().toString(16).slice(2, 8)}`,
});
c.client.on('connect', () => {
c.connected = true;
c.error = null;
c.connectedAt = new Date();
c.client.subscribe(`device/${c.serial}/report`, (err) => {
if (err) { c.error = `The printer wouldn’t let Printhive listen: ${err.message}`; return; }
requestFullReport(c);
});
});
c.client.on('message', (_topic, payload) => handleMessage(c, payload));
c.client.on('error', (err) => { c.error = friendlyError(err); });
c.client.on('close', () => { c.connected = false; });
c.client.on('offline', () => { c.connected = false; });
c.fullReportTimer = setInterval(() => requestFullReport(c), FULL_REPORT_MS);
c.fullReportTimer.unref();
return c;
}
function close(c) {
clearInterval(c.fullReportTimer);
clearTimeout(c.traysTimer);
for (const resolve of c.waiters.splice(0)) resolve();
c.client.end(true);
}
// Brings the connections in line with the printers you have: new ones opened,
// removed ones closed, and any whose address, serial or code changed reopened.
function reconcile(printerRows) {
const wanted = new Map();
for (const p of printerRows) {
if (p.host && p.serial && p.access_code) wanted.set(p.id, p);
}
for (const [id, c] of connections) {
const p = wanted.get(id);
if (!p || [p.host, p.serial, p.access_code].join('\n') !== c.key) {
close(c);
connections.delete(id);
}
}
for (const [id, p] of wanted) {
if (!connections.has(id)) connections.set(id, open(p));
}
}
// What one printer is doing, as last reported. Nothing here identifies a spool
// or carries a credential.
function snapshot(id) {
const c = connections.get(id);
if (!c) return { live: false };
const loaded = allTrays(c).map((t) => ({
unit: t.unit, slot: t.slot, empty: t.empty, material: t.material, colour: t.colour, tagged: Boolean(t.uuid),
}));
return {
live: true,
connected: c.connected,
error: c.connected ? null : c.error,
updatedAt: c.updatedAt,
status: { ...c.status, active: activeTray(c) },
loaded: c.ams || c.external ? loaded : null,
};
}
function snapshots() {
const out = {};
for (const id of connections.keys()) out[id] = snapshot(id);
return out;
}
// Asks for a full report now, waits for it, and syncs spools from it — what
// "Test & sync" does. Uses the open connection rather than making another.
async function refresh(id, waitMs = 8000) {
const c = connections.get(id);
if (!c) return null;
const started = Date.now();
const before = c.reports;
if (!c.connected && !c.client.reconnecting) c.client.reconnect();
await new Promise((resolve) => {
const timer = setTimeout(resolve, waitMs);
const ask = () => {
if (c.connected) { requestFullReport(c); return; }
if (Date.now() - started < waitMs) setTimeout(ask, 250);
};
c.waiters.push(() => { clearTimeout(timer); setTimeout(resolve, 1000); }); // let the rest of the report arrive
ask();
});
const sync = c.reports > before ? await flushTrays(c) : null;
return {
connected: c.connected,
reports: c.reports - before,
printer: { state: c.status.state, progress: c.status.progress },
ams: c.ams,
external: c.external,
error: c.connected ? null : (c.error || 'Couldn’t reach the printer.'),
sync,
ms: Date.now() - started,
};
}
function setTraysHandler(fn) {
onTrays = fn;
}
function stopAll() {
for (const c of connections.values()) close(c);
connections.clear();
}
// For tests: connect with a stand-in instead of the network.
function setConnector(fn) {
connect = fn;
}
module.exports = { reconcile, snapshot, snapshots, refresh, setTraysHandler, stopAll, setConnector };