printhive/lib/printerLive.js
dlawler489 13b5e550a3 Show live printer status over a standing read-only connection
Each printer now has one MQTT connection that stays open, merging the
partial reports printers send while printing. Cards poll every 3 seconds
(paused while the tab is hidden) and show state, file, progress with time
left and finish time, layer, nozzle/bed/chamber temperatures, speed
setting, and print error and HMS codes. A spool change syncs the Filament
page and the card's drawing within seconds; a full report is requested
every 10 minutes as a backstop, replacing the 10-minute probe loop.

"Test & sync" uses the open connection rather than making a second one
(?debug=1 still makes a short separate one). Connections are reconciled
when printers are added, edited or removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:30:13 +10:00

210 lines
6.9 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] : [])];
}
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;
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);
if (p.vt_tray) c.external = describeTray(p.vt_tray, null);
if ((c.ams || c.external) && traysKey(allTrays(c)) !== c.syncedKey) {
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,
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,
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 };