Public Access
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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user