Add QL status frame decoder

Decodes the 32-byte status frames so cover open, roll empty, cutter jam and
wrong roll width are reported from the printer itself rather than inferred
from brother_ql stderr.
This commit is contained in:
2026-09-07 14:01:12 +10:00
parent 1b4b9d662a
commit 26701d0d3b
+170
View File
@@ -0,0 +1,170 @@
/**
* Decoder for the 32-byte status frame the QL series sends back.
*
* This is what the Python brother_ql path could never give us: real printer
* state rather than a parsed traceback. Cover open, roll empty and wrong roll
* width can all be reported before a label is wasted.
*
* Layout (confirmed against Brother's reference and live QL-8xx captures):
* 0 print head mark (0x80)
* 1 size (0x20)
* 2 fixed 'B' (0x42)
* 3-4 device dependent (model code)
* 5 fixed '0' (0x30)
* 6-7 fixed
* 8 error information 1
* 9 error information 2
* 10 media width in mm
* 11 media type
* 12-13 fixed
* 14 reserved
* 15 mode
* 16 fixed
* 17 media length in mm
* 18 status type
* 19 phase type
* 20-21 phase number (big endian)
* 22 notification number
* 23-31 reserved
*/
const FRAME_LENGTH = 32;
const ERROR_BITS_1 = [
[0x01, 'No media loaded'],
[0x02, 'End of media reached'],
[0x04, 'Cutter jam'],
[0x10, 'Printer busy'],
[0x20, 'Printer turned off'],
[0x40, 'High-voltage adapter fault'],
[0x80, 'Fan motor fault'],
];
const ERROR_BITS_2 = [
[0x01, 'Wrong media for this job'],
[0x02, 'Expansion buffer full'],
[0x04, 'Transmission or communication error'],
[0x08, 'Communication buffer full'],
[0x10, 'Cover is open'],
[0x20, 'Cancelled at the printer'],
[0x40, 'Media cannot be fed'],
[0x80, 'System error'],
];
const STATUS_TYPES = {
0x00: 'reply',
0x01: 'printing_completed',
0x02: 'error',
0x04: 'turned_off',
0x05: 'notification',
0x06: 'phase_change',
};
const PHASE_TYPES = {
0x00: 'waiting_to_receive',
0x01: 'printing',
};
const MEDIA_TYPES = {
0x00: 'none',
0x0a: 'continuous',
0x0b: 'die_cut',
0x4a: 'continuous',
0x4b: 'die_cut',
0xff: 'incompatible',
};
/**
* Decode a status frame.
* @param {Buffer} buf
* @returns {Object|null} null if the buffer isn't a recognisable frame.
*/
function decodeStatus(buf) {
if (!buf || buf.length < FRAME_LENGTH) return null;
if (buf[0] !== 0x80 || buf[1] !== 0x20) return null;
const errors = [];
for (const [bit, message] of ERROR_BITS_1) {
if (buf[8] & bit) errors.push(message);
}
for (const [bit, message] of ERROR_BITS_2) {
if (buf[9] & bit) errors.push(message);
}
const statusType = STATUS_TYPES[buf[18]] || `unknown_0x${buf[18].toString(16)}`;
return {
modelCode: buf.readUInt16BE(3),
errors,
hasError: errors.length > 0 || buf[18] === 0x02,
mediaWidthMm: buf[10],
mediaLengthMm: buf[17],
mediaType: MEDIA_TYPES[buf[11]] || `unknown_0x${buf[11].toString(16)}`,
mediaLoaded: buf[11] !== 0x00,
statusType,
phaseType: PHASE_TYPES[buf[19]] || `unknown_0x${buf[19].toString(16)}`,
phaseNumber: buf.readUInt16BE(20),
notification: buf[22],
raw: Buffer.from(buf.subarray(0, FRAME_LENGTH)),
};
}
/**
* Split a stream of concatenated frames. The printer often sends several
* (phase change, then printing completed) in one go.
* @param {Buffer} buf
* @returns {Object[]}
*/
function decodeAll(buf) {
const out = [];
for (let offset = 0; offset + FRAME_LENGTH <= buf.length; offset += FRAME_LENGTH) {
const frame = decodeStatus(buf.subarray(offset, offset + FRAME_LENGTH));
if (frame) out.push(frame);
}
return out;
}
/**
* Turn a decoded frame into something worth showing a receptionist.
* Returns null when nothing is wrong.
*/
function describeProblem(status) {
if (!status) return null;
if (status.errors.length > 0) return status.errors.join('; ');
if (status.statusType === 'error') return 'The printer reported an unspecified error';
if (status.statusType === 'turned_off') return 'The printer is turning off';
if (!status.mediaLoaded) return 'No label roll detected';
return null;
}
/**
* Check the loaded roll matches what the job expects.
*
* Worth doing before sending: a two-colour job on a plain roll is refused with
* "wrong roll type", which gives nobody a clue that a colour setting caused it.
*
* @returns {string|null} a problem description, or null if it matches.
*/
function checkMediaMatches(status, media) {
if (!status) return null;
if (!status.mediaLoaded) return 'No label roll is loaded';
if (status.mediaWidthMm !== media.widthMm) {
return `Wrong roll loaded: printer reports ${status.mediaWidthMm} mm, job needs ${media.widthMm} mm`;
}
const wantDieCut = Boolean(media.dieCut);
const isDieCut = status.mediaType === 'die_cut';
if (wantDieCut !== isDieCut) {
return wantDieCut
? 'Job needs die-cut labels but continuous tape is loaded'
: 'Job needs continuous tape but die-cut labels are loaded';
}
return null;
}
export {
FRAME_LENGTH,
decodeStatus,
decodeAll,
describeProblem,
checkMediaMatches,
};