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.
* Safe to call on a schedule for a "printer offline" banner in the admin console.
* Probe the printer.
*
* @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 {
port = DEFAULT_PORT,
connectTimeout = DEFAULT_CONNECT_TIMEOUT,
replyTimeout = 5000,
replyTimeout = 2000,
} = options;
return withSocket(host, port, connectTimeout, (socket, finish) => {
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]);
}
finish(null, {
reachable: true,
status: frames.length ? frames[frames.length - 1] : null,
error: null,
});
}
});
socket.write(buildStatusRequest(), (err) => {
if (err) {
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
* 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`
`Printer at ${host}:${port} did not accept the whole job within ${jobTimeout} ms`
)
);
}
}, jobTimeout);
socket.on('data', (chunk) => {
@@ -133,15 +148,17 @@ async function sendJob(host, job, options = {}) {
if (frame.hasError) {
clearTimeout(timer);
finish(null, { frames, completed: false });
finish(null, { frames, completed: false, statusAvailable: true });
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);
finish(null, { frames, completed: true });
return;
}
}
done();
});
socket.write(job, (err) => {
@@ -150,13 +167,19 @@ async function sendJob(host, job, options = {}) {
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 };