@thermal-label/brother-ql-core 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +3 -3
  2. package/data/devices.json +250 -24
  3. package/data/media.json +12 -12
  4. package/dist/__tests__/devices.test.js +12 -20
  5. package/dist/__tests__/devices.test.js.map +1 -1
  6. package/dist/__tests__/network-status.test.d.ts +2 -0
  7. package/dist/__tests__/network-status.test.d.ts.map +1 -0
  8. package/dist/__tests__/network-status.test.js +132 -0
  9. package/dist/__tests__/network-status.test.js.map +1 -0
  10. package/dist/__tests__/protocol.test.js +103 -0
  11. package/dist/__tests__/protocol.test.js.map +1 -1
  12. package/dist/__tests__/status.test.js +13 -1
  13. package/dist/__tests__/status.test.js.map +1 -1
  14. package/dist/devices.d.ts +31 -5
  15. package/dist/devices.d.ts.map +1 -1
  16. package/dist/devices.generated.d.ts +31 -4
  17. package/dist/devices.generated.d.ts.map +1 -1
  18. package/dist/devices.generated.js +38 -4
  19. package/dist/devices.generated.js.map +1 -1
  20. package/dist/devices.js +0 -12
  21. package/dist/devices.js.map +1 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/media.generated.js +24 -24
  27. package/dist/media.generated.js.map +1 -1
  28. package/dist/network-status.d.ts +45 -0
  29. package/dist/network-status.d.ts.map +1 -0
  30. package/dist/network-status.js +140 -0
  31. package/dist/network-status.js.map +1 -0
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/protocol.js +34 -6
  34. package/dist/protocol.js.map +1 -1
  35. package/dist/status.d.ts +5 -3
  36. package/dist/status.d.ts.map +1 -1
  37. package/dist/status.js +21 -6
  38. package/dist/status.js.map +1 -1
  39. package/dist/types.d.ts +5 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/__tests__/devices.test.ts +13 -23
  43. package/src/__tests__/network-status.test.ts +165 -0
  44. package/src/__tests__/protocol.test.ts +129 -2
  45. package/src/__tests__/status.test.ts +15 -1
  46. package/src/devices.generated.ts +38 -4
  47. package/src/devices.ts +0 -16
  48. package/src/index.ts +3 -1
  49. package/src/media.generated.ts +24 -24
  50. package/src/network-status.ts +173 -0
  51. package/src/protocol.ts +41 -7
  52. package/src/status.ts +26 -6
  53. package/src/types.ts +5 -1
@@ -0,0 +1,173 @@
1
+ import type { PrintEngine, PrinterError, StatusDetail } from '@thermal-label/contracts';
2
+ import type { BrotherQLMedia, BrotherQLStatus } from './types.js';
3
+ import { findMediaByDimensions, MEDIA } from './media.js';
4
+
5
+ /**
6
+ * Values read from the standard Printer-MIB / Host-Resources-MIB
7
+ * (RFC 3805 / RFC 2790) of a network print server. Port 9100 carries
8
+ * no status, so this is the only status source on TCP; see plan 17.
9
+ */
10
+ export interface PrinterMibStatus {
11
+ /** hrPrinterStatus: 1 other, 2 unknown, 3 idle, 4 printing, 5 warmup. */
12
+ printerStatus: number;
13
+ /** hrPrinterDetectedErrorState: 0–2 octets, bit 0 = MSB of octet 0. */
14
+ errorState: Uint8Array;
15
+ /** prtInputMediaName, e.g. `29mm x 90mm / 1.1" x 3.5"` or `62mm / 2.4"`. */
16
+ mediaName: string;
17
+ /** prtInputMediaDimFeedDir (length) / XFeedDir (width) in `dimUnit`; negative = unknown. */
18
+ feedDir?: number;
19
+ xFeedDir?: number;
20
+ /** prtInputDimUnit: 3 = ten-thousandths of an inch, 4 = micrometres. */
21
+ dimUnit?: number;
22
+ }
23
+
24
+ const PRINTER_STATE: Record<number, string> = {
25
+ 1: 'other',
26
+ 2: 'unknown',
27
+ 3: 'idle',
28
+ 4: 'printing',
29
+ 5: 'warmup',
30
+ };
31
+
32
+ /** RFC 2790 hrPrinterDetectedErrorState bit names, index = bit. */
33
+ const ERROR_STATE_BITS = [
34
+ 'lowPaper',
35
+ 'noPaper',
36
+ 'lowToner',
37
+ 'noToner',
38
+ 'doorOpen',
39
+ 'jammed',
40
+ 'offline',
41
+ 'serviceRequested',
42
+ 'inputTrayMissing',
43
+ 'outputTrayMissing',
44
+ 'markerSupplyMissing',
45
+ 'outputNearFull',
46
+ 'outputFull',
47
+ 'inputTrayEmpty',
48
+ 'overduePreventMaint',
49
+ ] as const;
50
+
51
+ /** Bit → contracts error code + message; unlisted set bits become `system_error`. */
52
+ const ERROR_STATE_CODES: Record<number, { code: string; message: string }> = {
53
+ 1: { code: 'no_media', message: 'No media' },
54
+ 4: { code: 'cover_open', message: 'Cover open' },
55
+ 5: { code: 'cutter_jam', message: 'Jammed' },
56
+ 6: { code: 'not_ready', message: 'Offline' },
57
+ 7: { code: 'system_error', message: 'Service requested' },
58
+ 13: { code: 'no_media', message: 'Input tray empty' },
59
+ };
60
+
61
+ /** Warning-only bits: reported as a details row, never an error. */
62
+ const ERROR_STATE_WARNINGS: Record<number, string> = {
63
+ 0: 'Media low',
64
+ };
65
+
66
+ function errorStateBit(errorState: Uint8Array, bit: number): boolean {
67
+ const octet = errorState[bit >> 3];
68
+ return octet !== undefined && (octet & (0x80 >> (bit & 7))) !== 0;
69
+ }
70
+
71
+ /**
72
+ * Parse the metric half of a Brother `prtInputMediaName`.
73
+ * `"29mm x 90mm / …"` → 29×90 die-cut, `"62mm / …"` → 62 continuous.
74
+ * The `Dia` form is the reference's naming for round labels and is
75
+ * unverified on the wire. Anything else → `undefined`.
76
+ */
77
+ export function parseMediaName(name: string): { widthMm: number; heightMm?: number } | undefined {
78
+ const metric = name.split('/')[0] ?? '';
79
+ const m = /^\s*(\d+(?:\.\d+)?)\s*mm(?:\s*(?:[x×]\s*(\d+(?:\.\d+)?)\s*mm|(dia)))?\s*$/i.exec(
80
+ metric,
81
+ );
82
+ if (!m?.[1]) return undefined;
83
+ const widthMm = Number(m[1]);
84
+ if (m[3]) return { widthMm, heightMm: widthMm };
85
+ return m[2] === undefined ? { widthMm } : { widthMm, heightMm: Number(m[2]) };
86
+ }
87
+
88
+ function dimToMm(value: number, unit: number): number | undefined {
89
+ if (unit === 3) return Math.round(value * 0.00254);
90
+ if (unit === 4) return Math.round(value / 1000);
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * The name string wins; the Dim OIDs are the fallback. Bench
96
+ * (QL-820NWB, DK-11201): `prtInputDimUnit` = 4 (micrometres) with
97
+ * `2900 × 9000`, which is 1/100 mm, so trusting the unit yields a
98
+ * 3 × 9 mm label. The name is the only value measured to be right.
99
+ */
100
+ function resolveDimensions(
101
+ input: PrinterMibStatus,
102
+ ): { widthMm: number; heightMm: number } | undefined {
103
+ const parsed = parseMediaName(input.mediaName);
104
+ if (parsed) return { widthMm: parsed.widthMm, heightMm: parsed.heightMm ?? 0 };
105
+ const { xFeedDir, feedDir, dimUnit } = input;
106
+ if (xFeedDir === undefined || xFeedDir < 0 || dimUnit === undefined) return undefined;
107
+ const widthMm = dimToMm(xFeedDir, dimUnit);
108
+ if (widthMm === undefined) return undefined;
109
+ const heightMm = feedDir !== undefined && feedDir >= 0 ? dimToMm(feedDir, dimUnit) : 0;
110
+ return { widthMm, heightMm: heightMm ?? 0 };
111
+ }
112
+
113
+ /**
114
+ * Whether a roll of this width and type also exists as a two-colour
115
+ * variant (62 mm continuous: DK-22205 vs DK-22251). Those are
116
+ * indistinguishable over the network, so callers warn.
117
+ */
118
+ export function hasTwoColourSibling(media: BrotherQLMedia): boolean {
119
+ return Object.values(MEDIA).some(
120
+ m => m.palette !== undefined && m.type === media.type && m.widthMm === media.widthMm,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Map Printer-MIB values onto the driver's status. `ready` requires
126
+ * an idle or printing state and no error bits. Two-colour rolls are
127
+ * invisible on every network surface (DK-22251 reads as 62 mm
128
+ * continuous), so the single-colour sibling is resolved and a `warn`
129
+ * row says so.
130
+ */
131
+ export function statusFromPrinterMib(
132
+ input: PrinterMibStatus,
133
+ engine?: Pick<PrintEngine, 'headDots' | 'mediaCompatibility'>,
134
+ ): BrotherQLStatus {
135
+ const errors: PrinterError[] = [];
136
+ const details: StatusDetail[] = [
137
+ { label: 'Printer state', value: PRINTER_STATE[input.printerStatus] ?? 'unknown' },
138
+ ];
139
+
140
+ for (const [bit, name] of ERROR_STATE_BITS.entries()) {
141
+ if (!errorStateBit(input.errorState, bit)) continue;
142
+ const warning = ERROR_STATE_WARNINGS[bit];
143
+ if (warning !== undefined) {
144
+ details.push({ label: 'Media', value: warning, severity: 'warn' });
145
+ continue;
146
+ }
147
+ errors.push(ERROR_STATE_CODES[bit] ?? { code: 'system_error', message: name });
148
+ }
149
+
150
+ const dims = resolveDimensions(input);
151
+ const detected = dims
152
+ ? findMediaByDimensions(dims.widthMm, dims.heightMm, false, engine)
153
+ : undefined;
154
+ if (detected === undefined) {
155
+ details.push({
156
+ label: 'Media',
157
+ value: `not recognised: ${JSON.stringify(input.mediaName)}`,
158
+ severity: 'warn',
159
+ });
160
+ } else if (hasTwoColourSibling(detected)) {
161
+ details.push({ label: 'Two-colour', value: 'not detectable over network', severity: 'warn' });
162
+ }
163
+
164
+ const state = input.printerStatus;
165
+ return {
166
+ ready: (state === 3 || state === 4) && errors.length === 0,
167
+ mediaLoaded: detected !== undefined,
168
+ ...(detected === undefined ? {} : { detectedMedia: detected }),
169
+ errors,
170
+ details,
171
+ rawBytes: new Uint8Array(0),
172
+ };
173
+ }
package/src/protocol.ts CHANGED
@@ -249,6 +249,26 @@ function resolveEncoderGeometry(
249
249
  return resolveTapeGeometry(media, engine);
250
250
  }
251
251
 
252
+ /**
253
+ * Page length for die-cut media: `dieCutMaskedAreaDots` is the
254
+ * reference's "print area length" (§2.3.2(b) column 4) at 300 dpi;
255
+ * `doubleRows` is set when the caller supplied 600 dpi rows (QL
256
+ * high-res, where the encoder does not duplicate lines).
257
+ */
258
+ function resolveDieCutRows(
259
+ media: BrotherQLMedia,
260
+ doubleRows: boolean,
261
+ ): { rows: number; highRes: boolean } | undefined {
262
+ if (media.type !== 'die-cut') return undefined;
263
+ const area = media.dieCutMaskedAreaDots;
264
+ if (typeof area !== 'number') {
265
+ throw new Error(
266
+ `die-cut media ${media.id.toString()} (${media.name}) has no dieCutMaskedAreaDots; page length unknown`,
267
+ );
268
+ }
269
+ return { rows: doubleRows ? area * 2 : area, highRes: doubleRows };
270
+ }
271
+
252
272
  interface EncodeContext {
253
273
  config: RasterProtocolConfig;
254
274
  engine?: EncoderEngine | undefined;
@@ -302,7 +322,21 @@ function encodeRasterJob(pages: PageData[], options: JobOptions, ctx: EncodeCont
302
322
  // Per §7.3: PT high-res doubles the feed margin and duplicates
303
323
  // each raster line. QL high-res leaves both untouched.
304
324
  const baseMargin = opts.marginDots ?? config.feedMarginDots;
305
- const marginDots = config.duplicateRasterLines && highRes ? baseMargin * 2 : baseMargin;
325
+ const feedMargin = config.duplicateRasterLines && highRes ? baseMargin * 2 : baseMargin;
326
+
327
+ // Die-cut pages are fixed-length (reference §2.3.3 / §2.3.4): the
328
+ // printer cuts at raster count + margin, not at the gap, so the page
329
+ // is always the print-area length with `ESC i d` 0 and the bitmap
330
+ // sits centred in it. Continuous pages are the bitmap's own height.
331
+ const dieCut = resolveDieCutRows(media, highRes && !config.duplicateRasterLines);
332
+ const rowCount = dieCut?.rows ?? bitmap.heightPx;
333
+ const marginDots = dieCut ? 0 : feedMargin;
334
+ if (dieCut && bitmap.heightPx > rowCount) {
335
+ throw new Error(
336
+ `Bitmap is ${bitmap.heightPx.toString()} rows but ${media.name} prints exactly ${rowCount.toString()}${dieCut.highRes ? ' (high-res)' : ''}; resize the image`,
337
+ );
338
+ }
339
+ const padTop = dieCut ? Math.floor((rowCount - bitmap.heightPx) / 2) : 0;
306
340
 
307
341
  // Multi-ink media (e.g. DK-22251) requires two-color mode even for black-only jobs.
308
342
  // Auto-create an empty red plane when the tape demands it but caller didn't supply one.
@@ -317,8 +351,6 @@ function encodeRasterJob(pages: PageData[], options: JobOptions, ctx: EncodeCont
317
351
  }
318
352
  }
319
353
 
320
- const rowCount = bitmap.heightPx;
321
-
322
354
  chunks.push(buildRasterMode());
323
355
  chunks.push(buildStatusRequest());
324
356
  chunks.push(buildPrintInfo(media, rowCount, i));
@@ -343,18 +375,20 @@ function encodeRasterJob(pages: PageData[], options: JobOptions, ctx: EncodeCont
343
375
  // Per §7.3: PT high-res duplicates each raster line. QL doesn't.
344
376
  const duplicate = config.duplicateRasterLines && highRes;
345
377
  for (let r = 0; r < rowCount; r++) {
346
- const blackSrc = getRow(bitmap, r);
378
+ // Rows outside the bitmap (die-cut padding) stay blank.
379
+ const src = r - padTop;
380
+ const inBitmap = src >= 0 && src < bitmap.heightPx;
347
381
  const blackBytes = new Uint8Array(rowByteLen);
348
- placeBits(blackSrc, bitmap.widthPx, blackBytes, leftMarginPins);
382
+ if (inBitmap) placeBits(getRow(bitmap, src), bitmap.widthPx, blackBytes, leftMarginPins);
349
383
  const blackPayload = compress ? packBits(blackBytes) : blackBytes;
350
384
  const blackChunk = buildRasterRow(blackPayload, 'black', twoColor);
351
385
  chunks.push(blackChunk);
352
386
  if (duplicate) chunks.push(blackChunk);
353
387
 
354
388
  if (twoColor && redBitmap !== undefined) {
355
- const redSrc = getRow(redBitmap, r);
356
389
  const redBytes = new Uint8Array(rowByteLen);
357
- placeBits(redSrc, redBitmap.widthPx, redBytes, leftMarginPins);
390
+ if (inBitmap)
391
+ placeBits(getRow(redBitmap, src), redBitmap.widthPx, redBytes, leftMarginPins);
358
392
  const redPayload = compress ? packBits(redBytes) : redBytes;
359
393
  const redChunk = buildRasterRow(redPayload, 'red', twoColor);
360
394
  chunks.push(redChunk);
package/src/status.ts CHANGED
@@ -31,6 +31,8 @@ const ERROR_INFO_2: { bit: number; code: string; message: string }[] = [
31
31
  * Parse a Brother QL 32-byte status response.
32
32
  *
33
33
  * Fields:
34
+ * byte 3 — series code (`'4'` for QL-800/810W/820NWB)
35
+ * byte 4 — model code (`'8'` QL-800, `'9'` QL-810W, `'A'` QL-820NWB)
34
36
  * byte 8 — error info 1 (bit mask, see ERROR_INFO_1)
35
37
  * byte 9 — error info 2 (bit mask, see ERROR_INFO_2)
36
38
  * byte 10 — media width (mm)
@@ -46,9 +48,9 @@ const ERROR_INFO_2: { bit: number; code: string; message: string }[] = [
46
48
  * `findMediaByDimensions`.
47
49
  *
48
50
  * `details` carries the contracts-standard `StatusDetail[]` diagnostic
49
- * rows the harness renders verbatim: the print phase (always present)
50
- * and the head-cooling notification (only when the printer reports
51
- * one).
51
+ * rows the harness renders verbatim: the model code and the print
52
+ * phase (always present) and the head-cooling notification (only when
53
+ * the printer reports one).
52
54
  */
53
55
  export function parseStatus(
54
56
  bytes: Uint8Array,
@@ -59,6 +61,7 @@ export function parseStatus(
59
61
  }
60
62
 
61
63
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
64
+ const modelCode = formatModelCode(view.getUint8(3), view.getUint8(4));
62
65
  const errInfo1 = view.getUint8(8);
63
66
  const errInfo2 = view.getUint8(9);
64
67
  const mediaWidthMm = view.getUint8(10);
@@ -87,15 +90,28 @@ export function parseStatus(
87
90
  mediaLoaded,
88
91
  ...(detected === undefined ? {} : { detectedMedia: detected }),
89
92
  errors,
90
- details: buildStatusDetails(phaseType, notification),
93
+ details: buildStatusDetails(modelCode, phaseType, notification),
91
94
  rawBytes: bytes,
92
95
  };
93
96
  }
94
97
 
98
+ /**
99
+ * Bytes 3/4 as Brother prints them in the reference (ASCII, e.g. `4A`
100
+ * for the QL-820NWB); hex for anything unprintable. Informational
101
+ * only: the USB/serial model signal a serial-port identify can use.
102
+ */
103
+ function formatModelCode(series: number, model: number): string {
104
+ const printable = (b: number): boolean => b >= 0x21 && b <= 0x7e;
105
+ return printable(series) && printable(model)
106
+ ? String.fromCharCode(series, model)
107
+ : `0x${series.toString(16).padStart(2, '0')} 0x${model.toString(16).padStart(2, '0')}`;
108
+ }
109
+
95
110
  /**
96
111
  * Build the contracts-standard `StatusDetail[]` rows for a parsed
97
112
  * Brother QL status.
98
113
  *
114
+ * - The model-code row (bytes 3/4) is always emitted.
99
115
  * - The print-phase row (byte 19) is always emitted — it is the most
100
116
  * useful "is the device doing anything" signal for a stuck report.
101
117
  * - The head-cooling row (byte 22) is emitted only when the printer
@@ -103,8 +119,12 @@ export function parseStatus(
103
119
  * normal idle status stays uncluttered. "Cooling started" is a
104
120
  * `warn` (printing is paused), "cooling finished" is plain `info`.
105
121
  */
106
- function buildStatusDetails(phaseType: number, notification: number): StatusDetail[] {
107
- const details: StatusDetail[] = [];
122
+ function buildStatusDetails(
123
+ modelCode: string,
124
+ phaseType: number,
125
+ notification: number,
126
+ ): StatusDetail[] {
127
+ const details: StatusDetail[] = [{ label: 'Model code', value: modelCode }];
108
128
 
109
129
  details.push({
110
130
  label: 'Print phase',
package/src/types.ts CHANGED
@@ -107,7 +107,11 @@ export interface BrotherQLMedia extends MediaDescriptor {
107
107
  printableDots?: number;
108
108
  leftMarginPins?: number;
109
109
  rightMarginPins?: number;
110
- /** Die-cut masked area in dots (registration windows). */
110
+ /**
111
+ * Die-cut page length in dots at 300 dpi: the reference's "print area
112
+ * length" (§2.3.2(b) column 4). The encoder sends exactly this many
113
+ * rows for die-cut media; see DECISIONS D20.
114
+ */
111
115
  dieCutMaskedAreaDots?: number;
112
116
  }
113
117