// The chamber camera on a P1 or A1 series printer. // // The printer serves still frames on port 6000 over TLS: an 80-byte hello with // the same "bblp" username and access code as the status connection, then a // stream of JPEGs, each behind a 16-byte header giving its size. Roughly one // frame a second — stills, not video. Read-only, like everything else here. // // X1 printers don't do this; they serve RTSP on port 322, which a browser // can't show without transcoding. const tls = require('tls'); const PORT = 6000; const HEADER = 16; const MAX_FRAME = 12 * 1024 * 1024; // a frame is well under this; a bigger one means we've lost our place function hello(accessCode) { const buf = Buffer.alloc(80); buf.writeUInt32LE(0x40, 0); // payload length buf.writeUInt32LE(0x3000, 4); // protocol marker buf.write('bblp', 16, 32, 'ascii'); buf.write(String(accessCode).slice(0, 31), 48, 32, 'ascii'); return buf; } // The picture's size, from the JPEG's own frame header — a cheap sanity check // that what arrived really is an image. function jpegSize(buf) { let i = 2; while (i + 9 <= buf.length) { if (buf[i] !== 0xff) { i += 1; continue; } const marker = buf[i + 1]; // SOF0…SOF3, SOF5…SOF7, SOF9…SOF11, SOF13…SOF15 carry the dimensions. if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) { return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) }; } i += 2 + buf.readUInt16BE(i + 2); } return null; } // Connects and calls onFrame for each JPEG until stop() is called. // onError is called once, with a plain-language reason. function watch({ host, accessCode, onFrame, onError = () => {}, connectTimeoutMs = 8000 }) { let closed = false; let pending = Buffer.alloc(0); let expecting = null; // size of the frame being gathered const socket = tls.connect({ host, port: PORT, rejectUnauthorized: false, // the printer's certificate is self-signed timeout: connectTimeoutMs, }); const fail = (message) => { if (closed) return; closed = true; socket.destroy(); onError(message); }; socket.on('secureConnect', () => { socket.setTimeout(0); socket.write(hello(accessCode)); }); socket.on('timeout', () => fail('The printer didn’t answer on its camera port.')); socket.on('error', (err) => fail( /ECONNREFUSED/.test(err.message) ? 'The printer refused the camera connection — “LAN Mode Liveview” may be off.' : err.message)); socket.on('close', () => fail('The printer closed the camera connection — the access code may be wrong, or “LAN Mode Liveview” is off.')); socket.on('data', (chunk) => { pending = pending.length ? Buffer.concat([pending, chunk]) : chunk; for (;;) { if (expecting === null) { if (pending.length < HEADER) return; expecting = pending.readUInt32LE(0); pending = pending.subarray(HEADER); if (!expecting || expecting > MAX_FRAME) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; } } if (pending.length < expecting) return; const frame = pending.subarray(0, expecting); pending = pending.subarray(expecting); expecting = null; // A frame always starts with the JPEG marker; anything else means trouble. if (frame[0] !== 0xff || frame[1] !== 0xd8) { fail('The printer sent something Printhive couldn’t read as a picture.'); return; } onFrame(frame); } }); return () => { if (closed) return; closed = true; socket.destroy(); }; } // One connection per printer, however many people are watching, closed as soon // as the last of them looks away. Frames aren't stored beyond the latest one, // which new viewers get straight away instead of waiting for the next. const feeds = new Map(); function subscribe(id, { host, accessCode }, onFrame, onError = () => {}) { let feed = feeds.get(id); if (!feed) { feed = { viewers: new Set(), latest: null, stop: () => {} }; feeds.set(id, feed); feed.stop = watch({ host, accessCode, onFrame: (frame) => { feed.latest = frame; for (const v of feed.viewers) v.onFrame(frame); }, onError: (error) => { feeds.delete(id); for (const v of feed.viewers) v.onError(error); feed.viewers.clear(); }, }); } const viewer = { onFrame, onError }; feed.viewers.add(viewer); if (feed.latest) onFrame(feed.latest); return () => { feed.viewers.delete(viewer); if (!feed.viewers.size) { feed.stop(); feeds.delete(id); } }; } // Connects, waits for one frame, and says what came back. Used to check a // printer's camera without opening a viewer. function testCamera({ host, accessCode, waitMs = 10000 }) { return new Promise((resolve) => { const started = Date.now(); let stop = () => {}; let done = false; const finish = (result) => { if (done) return; done = true; clearTimeout(timer); stop(); resolve({ ...result, ms: Date.now() - started }); }; const timer = setTimeout(() => finish({ ok: false, error: 'No picture arrived within ten seconds.' }), waitMs); stop = watch({ host, accessCode, onFrame: (frame) => finish({ ok: true, bytes: frame.length, ...(jpegSize(frame) || {}) }), onError: (error) => finish({ ok: false, error }), }); }); } module.exports = { watch, subscribe, testCamera, jpegSize, hello, PORT };