From 0c1ef935522409aed226f33c5599c51128ccd0b6 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 7 Sep 2026 14:01:33 +1000 Subject: [PATCH] Add TCP transport for the QL printer Connects to port 9100, sends the job and reads back status frames until the printer reports completion or an error. --- src/printing/ql-transport.js | 162 +++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/printing/ql-transport.js diff --git a/src/printing/ql-transport.js b/src/printing/ql-transport.js new file mode 100644 index 0000000..1dacac8 --- /dev/null +++ b/src/printing/ql-transport.js @@ -0,0 +1,162 @@ +import net from 'node:net'; + +import { decodeAll, FRAME_LENGTH } from './ql-status.js'; +import { buildStatusRequest } from './ql-raster.js'; + +const DEFAULT_PORT = 9100; +const DEFAULT_CONNECT_TIMEOUT = 5000; +const DEFAULT_JOB_TIMEOUT = 30000; + +/** + * Open a socket, run `handler`, and always clean up afterwards. + * The QL only accepts one connection at a time, so every helper here + * connects, does its work, and disconnects. + */ +function withSocket(host, port, connectTimeout, handler) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + let settled = false; + + const finish = (err, value) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + if (err) reject(err); + else resolve(value); + }; + + socket.setTimeout(connectTimeout); + socket.once('timeout', () => + finish(new Error(`Timed out connecting to ${host}:${port}`)) + ); + socket.once('error', (err) => + finish(new Error(`Cannot reach printer at ${host}:${port} — ${err.message}`)) + ); + + socket.connect(port, host, () => { + socket.setTimeout(0); + handler(socket, finish); + }); + }); +} + +/** + * Ask the printer what state it's in without sending a print job. + * Safe to call on a schedule for a "printer offline" banner in the admin console. + * + * @returns {Promise} decoded status frame + */ +async function queryStatus(host, options = {}) { + const { + port = DEFAULT_PORT, + connectTimeout = DEFAULT_CONNECT_TIMEOUT, + replyTimeout = 5000, + } = options; + + return withSocket(host, port, connectTimeout, (socket, finish) => { + let received = Buffer.alloc(0); + + const timer = setTimeout(() => { + finish( + new Error( + `Printer at ${host}:${port} accepted the connection but sent no status. ` + + 'It may be mid-job, or another host may be holding the port.' + ) + ); + }, replyTimeout); + + socket.on('data', (chunk) => { + received = Buffer.concat([received, chunk]); + if (received.length >= FRAME_LENGTH) { + clearTimeout(timer); + const frames = decodeAll(received); + if (frames.length === 0) { + finish(new Error('Printer sent an unrecognisable status frame')); + } else { + finish(null, frames[frames.length - 1]); + } + } + }); + + socket.write(buildStatusRequest(), (err) => { + if (err) { + clearTimeout(timer); + finish(err); + } + }); + }); +} + +/** + * Send a prepared job and wait for the printer to report completion. + * + * Resolves with every status frame received. The caller decides what counts + * as success — a job can print fine and still emit a notification frame. + * + * @param {string} host + * @param {Buffer} job + * @returns {Promise<{frames: Object[], completed: boolean, timedOut?: boolean}>} + */ +async function sendJob(host, job, options = {}) { + const { + port = DEFAULT_PORT, + connectTimeout = DEFAULT_CONNECT_TIMEOUT, + jobTimeout = DEFAULT_JOB_TIMEOUT, + waitForCompletion = true, + } = options; + + return withSocket(host, port, connectTimeout, (socket, finish) => { + let received = Buffer.alloc(0); + const frames = []; + + const timer = setTimeout(() => { + if (frames.length > 0) { + // We heard something; report what we know rather than a bare timeout. + finish(null, { frames, completed: false, timedOut: true }); + } else { + finish( + new Error( + `Printer at ${host}:${port} did not acknowledge the job within ${jobTimeout} ms` + ) + ); + } + }, jobTimeout); + + socket.on('data', (chunk) => { + received = Buffer.concat([received, chunk]); + while (received.length >= FRAME_LENGTH) { + const [frame] = decodeAll(received.subarray(0, FRAME_LENGTH)); + received = received.subarray(FRAME_LENGTH); + if (!frame) continue; + frames.push(frame); + + if (frame.hasError) { + clearTimeout(timer); + finish(null, { frames, completed: false }); + return; + } + if (frame.statusType === 'printing_completed') { + clearTimeout(timer); + finish(null, { frames, completed: true }); + return; + } + } + }); + + socket.write(job, (err) => { + if (err) { + clearTimeout(timer); + finish(err); + return; + } + if (!waitForCompletion) { + clearTimeout(timer); + // Give the printer a beat to reject the job outright. + setTimeout(() => finish(null, { frames, completed: false }), 250); + } + }); + }); +} + +export { DEFAULT_PORT, queryStatus, sendJob };