From a442d31dc7e60e479872943ab0dcac0b1bb0de8c Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Fri, 18 Sep 2026 11:47:09 +1000 Subject: [PATCH] Add a chamber camera connection and a test for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/printerCamera.js | 123 +++++++++++++++++++++++++++++++++++++ server.js | 12 ++++ test/printerCamera.test.js | 24 ++++++++ 3 files changed, 159 insertions(+) create mode 100644 lib/printerCamera.js create mode 100644 test/printerCamera.test.js diff --git a/lib/printerCamera.js b/lib/printerCamera.js new file mode 100644 index 0000000..9337960 --- /dev/null +++ b/lib/printerCamera.js @@ -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 }; diff --git a/server.js b/server.js index 91fe9ca..951aaf0 100644 --- a/server.js +++ b/server.js @@ -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'); diff --git a/test/printerCamera.test.js b/test/printerCamera.test.js new file mode 100644 index 0000000..cba15f5 --- /dev/null +++ b/test/printerCamera.test.js @@ -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); +});