diff --git a/src/printing/ql-transport.js b/src/printing/ql-transport.js index 1dacac8..31e020f 100644 --- a/src/printing/ql-transport.js +++ b/src/printing/ql-transport.js @@ -42,85 +42,100 @@ function withSocket(host, port, connectTimeout, handler) { } /** - * 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. + * Probe the printer. * - * @returns {Promise} decoded status frame + * Never throws for the "connected but silent" case, because that is the normal + * result on this hardware: the QL-820NWB accepts jobs on port 9100 but does not + * report state back over TCP — status frames only come back over USB. brother_ql + * documents the same limitation for its network backend. A silent printer is + * therefore reachable-and-probably-fine, not broken, and callers must treat a + * null status as "unknown" rather than "bad". + * + * @returns {Promise<{reachable: boolean, status: Object|null, error: string|null}>} */ -async function queryStatus(host, options = {}) { +async function probeStatus(host, options = {}) { const { port = DEFAULT_PORT, connectTimeout = DEFAULT_CONNECT_TIMEOUT, - replyTimeout = 5000, + replyTimeout = 2000, } = options; - return withSocket(host, port, connectTimeout, (socket, finish) => { - let received = Buffer.alloc(0); + try { + return await 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.' - ) + // No reply is the expected outcome over the network, so this timer is the + // normal path rather than an error path. Keep it short. + const timer = setTimeout( + () => finish(null, { reachable: true, status: null, error: null }), + replyTimeout ); - }, 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.on('data', (chunk) => { + received = Buffer.concat([received, chunk]); + if (received.length >= FRAME_LENGTH) { + clearTimeout(timer); + const frames = decodeAll(received); + finish(null, { + reachable: true, + status: frames.length ? frames[frames.length - 1] : null, + error: null, + }); } - } - }); + }); - socket.write(buildStatusRequest(), (err) => { - if (err) { - clearTimeout(timer); - finish(err); - } + socket.write(buildStatusRequest(), (err) => { + if (err) { + clearTimeout(timer); + finish(null, { reachable: false, status: null, error: err.message }); + } + }); }); - }); + } catch (err) { + return { reachable: false, status: null, error: err.message }; + } } /** - * Send a prepared job and wait for the printer to report completion. + * Send a prepared job. * - * Resolves with every status frame received. The caller decides what counts - * as success — a job can print fine and still emit a notification frame. + * Completion is signalled by the write flushing and the socket closing, NOT by + * a status frame — see probeStatus. If the printer does happen to answer, the + * frames come back as a bonus and a reported error is treated as authoritative. * - * @param {string} host - * @param {Buffer} job - * @returns {Promise<{frames: Object[], completed: boolean, timedOut?: boolean}>} + * The write is followed by end(), not destroy(). destroy() can discard data + * still sitting in the kernel send buffer, which for a ~190 KB label job means + * a silently truncated print. + * + * @returns {Promise<{frames: Object[], completed: boolean, statusAvailable: boolean}>} */ async function sendJob(host, job, options = {}) { const { port = DEFAULT_PORT, connectTimeout = DEFAULT_CONNECT_TIMEOUT, jobTimeout = DEFAULT_JOB_TIMEOUT, - waitForCompletion = true, + graceMs = 3000, } = options; return withSocket(host, port, connectTimeout, (socket, finish) => { let received = Buffer.alloc(0); const frames = []; + let sawCompleted = false; + let flushed = false; + + const done = () => + finish(null, { + frames, + completed: sawCompleted || flushed, + statusAvailable: frames.length > 0, + }); 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` - ) - ); - } + finish( + new Error( + `Printer at ${host}:${port} did not accept the whole job within ${jobTimeout} ms` + ) + ); }, jobTimeout); socket.on('data', (chunk) => { @@ -133,30 +148,38 @@ async function sendJob(host, job, options = {}) { if (frame.hasError) { clearTimeout(timer); - finish(null, { frames, completed: false }); - return; - } - if (frame.statusType === 'printing_completed') { - clearTimeout(timer); - finish(null, { frames, completed: true }); + finish(null, { frames, completed: false, statusAvailable: true }); return; } + if (frame.statusType === 'printing_completed') sawCompleted = true; } }); + // Peer closed and our data has gone out: the job is delivered. + socket.once('close', () => { + clearTimeout(timer); + done(); + }); + socket.write(job, (err) => { if (err) { clearTimeout(timer); finish(err); return; } - if (!waitForCompletion) { + flushed = true; + // Half-close: flushes everything queued, then sends FIN. + socket.end(); + + // Some units never close their half of the connection. Once our data is + // out the job is delivered, so don't hold the queue open waiting on a FIN + // that may never arrive. + setTimeout(() => { clearTimeout(timer); - // Give the printer a beat to reject the job outright. - setTimeout(() => finish(null, { frames, completed: false }), 250); - } + done(); + }, graceMs); }); }); } -export { DEFAULT_PORT, queryStatus, sendJob }; +export { DEFAULT_PORT, probeStatus, sendJob };