printhive/lib/printerCamera.js
dlawler489 a91c7b4bf7 Show the chamber camera on the printer card
A Camera button on each card opens the printer's chamber view, served
through Printhive so the access code stays on the server. One connection
per printer however many people are watching, dropped as soon as the last
viewer closes it; the latest frame goes to a new viewer straight away.
Nothing is recorded.

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

159 lines
5.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.

// 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 };