Merge chamber camera connection and test
All checks were successful
build-and-push / docker (push) Successful in 17s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-18 15:04:32 +10:00
commit a8fd112257
3 changed files with 159 additions and 0 deletions

123
lib/printerCamera.js Normal file
View file

@ -0,0 +1,123 @@
// 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 };

View file

@ -35,6 +35,7 @@ const printers = require('./lib/printers');
const spoolSync = require('./lib/spoolSync');
const printerLive = require('./lib/printerLive');
const printerCamera = require('./lib/printerCamera');
// What a printer has loaded → its card and the Filament page.
async function syncTrays(printerId, trays) {
@ -376,6 +377,17 @@ app.delete('/api/printers/:id', async (req, res, next) => {
} catch (e) { next(e); }
});
// Tries the printer's chamber camera and says whether a picture arrived.
// P1 and A1 series only, and the printer needs "LAN Mode Liveview" switched on.
app.post('/api/printers/:id/camera/test', async (req, res, next) => {
try {
const p = await printers.get(Number(req.params.id), { withCode: true });
if (!p) return res.status(404).json({ error: 'No such printer' });
if (!p.host || !p.access_code) return res.json({ ok: false, error: 'Fill in the printer’s address and access code first.' });
res.json(await printerCamera.testCamera({ host: p.host, accessCode: p.access_code }));
} catch (e) { next(e); }
});
// What every printer is doing right now, from the live connections.
app.get('/api/printers/live', (req, res) => {
res.set('Cache-Control', 'no-store');

View file

@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { hello, jpegSize } = require('../lib/printerCamera');
test('the camera hello carries the username and code in the right places', () => {
const buf = hello('12345678');
assert.equal(buf.length, 80);
assert.equal(buf.readUInt32LE(0), 0x40);
assert.equal(buf.readUInt32LE(4), 0x3000);
assert.equal(buf.subarray(16, 20).toString('ascii'), 'bblp');
assert.equal(buf.subarray(16, 48).toString('ascii').replace(/\0+$/, ''), 'bblp');
assert.equal(buf.subarray(48, 80).toString('ascii').replace(/\0+$/, ''), '12345678');
});
test('a frame’s size is read from the JPEG itself', () => {
// A JPEG header: start marker, a comment segment, then the frame header.
const jpeg = Buffer.concat([
Buffer.from([0xff, 0xd8]),
Buffer.from([0xff, 0xfe, 0x00, 0x04, 0x61, 0x62]), // comment, skipped
Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08, 0x04, 0x38, 0x07, 0x80]), // 1080 × 1920
]);
assert.deepEqual(jpegSize(jpeg), { height: 1080, width: 1920 });
assert.equal(jpegSize(Buffer.from([0xff, 0xd8, 0x00])), null);
});