From 26701d0d3bd0fcdbf0591d553bef9a25dc1e2b6c Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 7 Sep 2026 14:01:12 +1000 Subject: [PATCH] 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. --- src/printing/ql-status.js | 170 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/printing/ql-status.js diff --git a/src/printing/ql-status.js b/src/printing/ql-status.js new file mode 100644 index 0000000..2085d82 --- /dev/null +++ b/src/printing/ql-status.js @@ -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, +};