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>
123 lines
4.4 KiB
JavaScript
123 lines
4.4 KiB
JavaScript
// 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 };
|