Treat a silent printer as normal, and flush before closing

The QL-820NWB does not report status over TCP 9100 — brother_ql documents the
same limitation for its network backend. queryStatus therefore became
probeStatus, which reports reachable-with-unknown-state instead of failing.

Also fixes a real bug: the socket was destroyed straight after write, which can
discard data still queued in the kernel send buffer and truncate a ~190 KB job.
Now half-closes with end() and waits for flush, with a grace window for units
that never send FIN.
This commit is contained in:
2026-09-07 14:17:08 +10:00
parent 52eb0147fd
commit abb752c3ef
+66 -43
View File
@@ -42,85 +42,100 @@ function withSocket(host, port, connectTimeout, handler) {
} }
/** /**
* Ask the printer what state it's in without sending a print job. * Probe the printer.
* Safe to call on a schedule for a "printer offline" banner in the admin console.
* *
* @returns {Promise<Object>} 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 { const {
port = DEFAULT_PORT, port = DEFAULT_PORT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT, connectTimeout = DEFAULT_CONNECT_TIMEOUT,
replyTimeout = 5000, replyTimeout = 2000,
} = options; } = options;
return withSocket(host, port, connectTimeout, (socket, finish) => { try {
return await withSocket(host, port, connectTimeout, (socket, finish) => {
let received = Buffer.alloc(0); let received = Buffer.alloc(0);
const timer = setTimeout(() => { // No reply is the expected outcome over the network, so this timer is the
finish( // normal path rather than an error path. Keep it short.
new Error( const timer = setTimeout(
`Printer at ${host}:${port} accepted the connection but sent no status. ` + () => finish(null, { reachable: true, status: null, error: null }),
'It may be mid-job, or another host may be holding the port.' replyTimeout
)
); );
}, replyTimeout);
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
received = Buffer.concat([received, chunk]); received = Buffer.concat([received, chunk]);
if (received.length >= FRAME_LENGTH) { if (received.length >= FRAME_LENGTH) {
clearTimeout(timer); clearTimeout(timer);
const frames = decodeAll(received); const frames = decodeAll(received);
if (frames.length === 0) { finish(null, {
finish(new Error('Printer sent an unrecognisable status frame')); reachable: true,
} else { status: frames.length ? frames[frames.length - 1] : null,
finish(null, frames[frames.length - 1]); error: null,
} });
} }
}); });
socket.write(buildStatusRequest(), (err) => { socket.write(buildStatusRequest(), (err) => {
if (err) { if (err) {
clearTimeout(timer); clearTimeout(timer);
finish(err); 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 * Completion is signalled by the write flushing and the socket closing, NOT by
* as success — a job can print fine and still emit a notification frame. * 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 * The write is followed by end(), not destroy(). destroy() can discard data
* @param {Buffer} job * still sitting in the kernel send buffer, which for a ~190 KB label job means
* @returns {Promise<{frames: Object[], completed: boolean, timedOut?: boolean}>} * a silently truncated print.
*
* @returns {Promise<{frames: Object[], completed: boolean, statusAvailable: boolean}>}
*/ */
async function sendJob(host, job, options = {}) { async function sendJob(host, job, options = {}) {
const { const {
port = DEFAULT_PORT, port = DEFAULT_PORT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT, connectTimeout = DEFAULT_CONNECT_TIMEOUT,
jobTimeout = DEFAULT_JOB_TIMEOUT, jobTimeout = DEFAULT_JOB_TIMEOUT,
waitForCompletion = true, graceMs = 3000,
} = options; } = options;
return withSocket(host, port, connectTimeout, (socket, finish) => { return withSocket(host, port, connectTimeout, (socket, finish) => {
let received = Buffer.alloc(0); let received = Buffer.alloc(0);
const frames = []; const frames = [];
let sawCompleted = false;
let flushed = false;
const done = () =>
finish(null, {
frames,
completed: sawCompleted || flushed,
statusAvailable: frames.length > 0,
});
const timer = setTimeout(() => { 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( finish(
new Error( new Error(
`Printer at ${host}:${port} did not acknowledge the job within ${jobTimeout} ms` `Printer at ${host}:${port} did not accept the whole job within ${jobTimeout} ms`
) )
); );
}
}, jobTimeout); }, jobTimeout);
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
@@ -133,15 +148,17 @@ async function sendJob(host, job, options = {}) {
if (frame.hasError) { if (frame.hasError) {
clearTimeout(timer); clearTimeout(timer);
finish(null, { frames, completed: false }); finish(null, { frames, completed: false, statusAvailable: true });
return; return;
} }
if (frame.statusType === 'printing_completed') { if (frame.statusType === 'printing_completed') sawCompleted = true;
}
});
// Peer closed and our data has gone out: the job is delivered.
socket.once('close', () => {
clearTimeout(timer); clearTimeout(timer);
finish(null, { frames, completed: true }); done();
return;
}
}
}); });
socket.write(job, (err) => { socket.write(job, (err) => {
@@ -150,13 +167,19 @@ async function sendJob(host, job, options = {}) {
finish(err); finish(err);
return; 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); clearTimeout(timer);
// Give the printer a beat to reject the job outright. done();
setTimeout(() => finish(null, { frames, completed: false }), 250); }, graceMs);
}
}); });
}); });
} }
export { DEFAULT_PORT, queryStatus, sendJob }; export { DEFAULT_PORT, probeStatus, sendJob };