printhive/lib/printerCamera.js
dlawler489 a442d31dc7 Add a chamber camera connection and a test for it
P1 and A1 series printers serve JPEG frames on port 6000 over TLS, behind
the same access code. This adds the connection, a frame reader, and
POST /api/printers/:id/camera/test, which waits for one picture and says
what arrived — enough to find out whether the camera answers before any
viewer is built.

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

123 lines
4.4 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.

// 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();
};
}
// 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, testCamera, jpegSize, hello, PORT };