@thermal-label/brother-ql-core 0.6.1 → 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 (48) hide show
  1. package/data/devices.json +4 -0
  2. package/dist/__tests__/devices.test.js +12 -20
  3. package/dist/__tests__/devices.test.js.map +1 -1
  4. package/dist/__tests__/network-status.test.d.ts +2 -0
  5. package/dist/__tests__/network-status.test.d.ts.map +1 -0
  6. package/dist/__tests__/network-status.test.js +132 -0
  7. package/dist/__tests__/network-status.test.js.map +1 -0
  8. package/dist/__tests__/protocol.test.js +103 -0
  9. package/dist/__tests__/protocol.test.js.map +1 -1
  10. package/dist/__tests__/status.test.js +13 -1
  11. package/dist/__tests__/status.test.js.map +1 -1
  12. package/dist/devices.d.ts +1 -1
  13. package/dist/devices.d.ts.map +1 -1
  14. package/dist/devices.generated.d.ts +1 -0
  15. package/dist/devices.generated.d.ts.map +1 -1
  16. package/dist/devices.generated.js +4 -0
  17. package/dist/devices.generated.js.map +1 -1
  18. package/dist/devices.js +0 -12
  19. package/dist/devices.js.map +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/network-status.d.ts +45 -0
  25. package/dist/network-status.d.ts.map +1 -0
  26. package/dist/network-status.js +140 -0
  27. package/dist/network-status.js.map +1 -0
  28. package/dist/protocol.d.ts.map +1 -1
  29. package/dist/protocol.js +34 -6
  30. package/dist/protocol.js.map +1 -1
  31. package/dist/status.d.ts +5 -3
  32. package/dist/status.d.ts.map +1 -1
  33. package/dist/status.js +21 -6
  34. package/dist/status.js.map +1 -1
  35. package/dist/types.d.ts +5 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/__tests__/devices.test.ts +13 -23
  39. package/src/__tests__/network-status.test.ts +165 -0
  40. package/src/__tests__/protocol.test.ts +129 -2
  41. package/src/__tests__/status.test.ts +15 -1
  42. package/src/devices.generated.ts +4 -0
  43. package/src/devices.ts +0 -16
  44. package/src/index.ts +3 -1
  45. package/src/network-status.ts +173 -0
  46. package/src/protocol.ts +41 -7
  47. package/src/status.ts +26 -6
  48. package/src/types.ts +5 -1
package/dist/types.d.ts CHANGED
@@ -98,7 +98,11 @@ export interface BrotherQLMedia extends MediaDescriptor {
98
98
  printableDots?: number;
99
99
  leftMarginPins?: number;
100
100
  rightMarginPins?: number;
101
- /** Die-cut masked area in dots (registration windows). */
101
+ /**
102
+ * Die-cut page length in dots at 300 dpi: the reference's "print area
103
+ * length" (§2.3.2(b) column 4). The encoder sends exactly this many
104
+ * rows for die-cut media; see DECISIONS D20.
105
+ */
102
106
  dieCutMaskedAreaDots?: number;
103
107
  }
104
108
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,uBAAuB,EACvB,YAAY,EACZ,aAAa,EACd,MAAM,0BAA0B,CAAC;AAElC,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AACjD,MAAM,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,CAAC;AACnC,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE/C;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,KAAK,GAAG,UAAU,GAAG,UAAU,CAAC;AAEhE;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,eAAe,GAAG,WAAW,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,cAAe,SAAQ,eAAe;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,CAAC;IAChB;;;;OAIG;IACH,UAAU,EAAE,UAAU,CAAC;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,IAAI,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;IAC1D,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0DAA0D;IAC1D,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,CAAC;AAE5C,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAsB,SAAQ,YAAY;IACzD,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,GAAG,GAAG,CAAC;IACrC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,uBAAuB,EACvB,YAAY,EACZ,aAAa,EACd,MAAM,0BAA0B,CAAC;AAElC,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AACjD,MAAM,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,CAAC;AACnC,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE/C;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,KAAK,GAAG,UAAU,GAAG,UAAU,CAAC;AAEhE;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,eAAe,GAAG,WAAW,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,cAAe,SAAQ,eAAe;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,CAAC;IAChB;;;;OAIG;IACH,UAAU,EAAE,UAAU,CAAC;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAAC,IAAI,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;IAC1D,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,CAAC;AAE5C,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAsB,SAAQ,YAAY;IACzD,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,GAAG,GAAG,GAAG,CAAC;IACrC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thermal-label/brother-ql-core",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Protocol encoding, device registry, and media registry for Brother QL label printers",
5
5
  "keywords": [
6
6
  "brother",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@mbtech-nl/bitmap": "^1.3.0",
59
- "@thermal-label/contracts": "^0.6.0"
59
+ "@thermal-label/contracts": "^0.6.2"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@mbtech-nl/tsconfig": "^1.1.0",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { DEVICES, findDevice, getUsbIds, isMassStorageMode } from '../devices.js';
2
+ import { DEVICES, findDevice } from '../devices.js';
3
3
 
4
4
  describe('findDevice', () => {
5
5
  it('returns correct entry for QL-820NWBc (PID shared with QL-820NWB)', () => {
@@ -33,27 +33,6 @@ describe('findDevice', () => {
33
33
  });
34
34
  });
35
35
 
36
- describe('isMassStorageMode', () => {
37
- it('returns true for 0x20a9 (QL-1100 mass storage)', () => {
38
- expect(isMassStorageMode(0x20a9)).toBe(true);
39
- });
40
-
41
- it('returns true for 0x20aa (QL-1110NWB mass storage)', () => {
42
- expect(isMassStorageMode(0x20aa)).toBe(true);
43
- });
44
-
45
- it('returns true for 0x20ac (QL-1115NWB mass storage)', () => {
46
- expect(isMassStorageMode(0x20ac)).toBe(true);
47
- });
48
-
49
- it('returns false for all printer-class PIDs', () => {
50
- for (const dev of Object.values(DEVICES)) {
51
- const ids = getUsbIds(dev);
52
- if (ids) expect(isMassStorageMode(ids.pid)).toBe(false);
53
- }
54
- });
55
- });
56
-
57
36
  describe('Device registry invariants', () => {
58
37
  it('every two-color device has a 720-dot engine', () => {
59
38
  for (const dev of Object.values(DEVICES)) {
@@ -77,6 +56,18 @@ describe('Device registry invariants', () => {
77
56
  }
78
57
  });
79
58
 
59
+ it('modelNames, where set, includes the marketing name and only distinct wire names', () => {
60
+ for (const dev of Object.values(DEVICES)) {
61
+ if (dev.modelNames === undefined) continue;
62
+ expect(dev.modelNames).toContain(dev.name);
63
+ expect(new Set(dev.modelNames).size).toBe(dev.modelNames.length);
64
+ }
65
+ });
66
+
67
+ it('QL-820NWBc reports itself as QL-820NWB on the wire', () => {
68
+ expect(DEVICES.QL_820NWBc.modelNames).toEqual(['QL-820NWB', 'QL-820NWBc']);
69
+ });
70
+
80
71
  it('every device declares a USB transport with hex-string vid+pid', () => {
81
72
  for (const dev of Object.values(DEVICES)) {
82
73
  expect(dev.transports.usb).toBeDefined();
@@ -164,7 +155,6 @@ describe('PT-* device entries', () => {
164
155
  const dev = DEVICES.PT_P750W;
165
156
  expect(dev.transports.usb?.pid).toBe('0x2062');
166
157
  expect(dev.capabilities?.massStoragePid).toBe('0x2065');
167
- expect(isMassStorageMode(0x2065)).toBe(true);
168
158
  });
169
159
 
170
160
  it('PT-P910BT declares bluetooth-spp transport', () => {
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMediaName, statusFromPrinterMib, type PrinterMibStatus } from '../network-status.js';
3
+
4
+ /** Bench values, QL-820NWBc 2026-09-15 (plan 17 Context). */
5
+ const DK_11201_NAME = '29mm x 90mm / 1.1" x 3.5"';
6
+ const DK_22205_NAME = '62mm / 2.4"';
7
+
8
+ function mib(overrides: Partial<PrinterMibStatus> = {}): PrinterMibStatus {
9
+ return {
10
+ printerStatus: 3,
11
+ errorState: new Uint8Array([0x00]),
12
+ mediaName: DK_22205_NAME,
13
+ feedDir: -1,
14
+ xFeedDir: -1,
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ /** Octets with the given RFC 2790 bit numbers set (bit 0 = MSB of octet 0). */
20
+ function errorBits(...bits: number[]): Uint8Array {
21
+ const octets = new Uint8Array(2);
22
+ for (const bit of bits) octets[bit >> 3] = (octets[bit >> 3] ?? 0) | (0x80 >> (bit & 7));
23
+ return octets;
24
+ }
25
+
26
+ describe('parseMediaName', () => {
27
+ it.each([
28
+ [DK_11201_NAME, { widthMm: 29, heightMm: 90 }],
29
+ [DK_22205_NAME, { widthMm: 62 }],
30
+ ['17mm x 54mm / 0.66" x 2.1"', { widthMm: 17, heightMm: 54 }],
31
+ ['62mm', { widthMm: 62 }],
32
+ ['24mm Dia / 0.9" Dia', { widthMm: 24, heightMm: 24 }],
33
+ ['29 mm × 90 mm', { widthMm: 29, heightMm: 90 }],
34
+ ])('%s', (name, expected) => {
35
+ expect(parseMediaName(name)).toEqual(expected);
36
+ });
37
+
38
+ it.each(['', 'garbage', '2.4"', 'mm', '62 / 2.4"', 'Roll 62'])('rejects %j', name => {
39
+ expect(parseMediaName(name)).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe('statusFromPrinterMib', () => {
44
+ it('idle, no errors, 62 mm continuous → ready with DK-22205 and the two-colour warning', () => {
45
+ const status = statusFromPrinterMib(mib());
46
+ expect(status.ready).toBe(true);
47
+ expect(status.errors).toEqual([]);
48
+ expect(status.mediaLoaded).toBe(true);
49
+ expect(status.detectedMedia?.id).toBe(259);
50
+ expect(status.rawBytes).toHaveLength(0);
51
+ expect(status.details).toContainEqual({
52
+ label: 'Two-colour',
53
+ value: 'not detectable over network',
54
+ severity: 'warn',
55
+ });
56
+ expect(status.details?.find(d => d.label === 'Printer state')?.value).toBe('idle');
57
+ });
58
+
59
+ it('29×90 die-cut → DK-11201, no two-colour row', () => {
60
+ const status = statusFromPrinterMib(mib({ mediaName: DK_11201_NAME }));
61
+ expect(status.detectedMedia?.id).toBe(271);
62
+ expect(status.details?.some(d => d.label === 'Two-colour')).toBe(false);
63
+ });
64
+
65
+ it('printing counts as ready; warmup, other and unknown do not', () => {
66
+ expect(statusFromPrinterMib(mib({ printerStatus: 4 })).ready).toBe(true);
67
+ expect(statusFromPrinterMib(mib({ printerStatus: 5 })).ready).toBe(false);
68
+ expect(statusFromPrinterMib(mib({ printerStatus: 1 })).ready).toBe(false);
69
+ expect(statusFromPrinterMib(mib({ printerStatus: 99 })).ready).toBe(false);
70
+ expect(
71
+ statusFromPrinterMib(mib({ printerStatus: 99 })).details?.find(
72
+ d => d.label === 'Printer state',
73
+ )?.value,
74
+ ).toBe('unknown');
75
+ });
76
+
77
+ it.each([
78
+ [1, 'no_media', 'No media'],
79
+ [4, 'cover_open', 'Cover open'],
80
+ [5, 'cutter_jam', 'Jammed'],
81
+ [6, 'not_ready', 'Offline'],
82
+ [7, 'system_error', 'Service requested'],
83
+ [13, 'no_media', 'Input tray empty'],
84
+ [8, 'system_error', 'inputTrayMissing'],
85
+ [14, 'system_error', 'overduePreventMaint'],
86
+ ])('error-state bit %i → %s', (bit, code, message) => {
87
+ const status = statusFromPrinterMib(mib({ errorState: errorBits(bit) }));
88
+ expect(status.errors).toEqual([{ code, message }]);
89
+ expect(status.ready).toBe(false);
90
+ });
91
+
92
+ it('lowPaper is a warning row, not an error', () => {
93
+ const status = statusFromPrinterMib(mib({ errorState: errorBits(0) }));
94
+ expect(status.errors).toEqual([]);
95
+ expect(status.ready).toBe(true);
96
+ expect(status.details).toContainEqual({ label: 'Media', value: 'Media low', severity: 'warn' });
97
+ });
98
+
99
+ it('reads error state from 0, 1 and 2 octets', () => {
100
+ expect(statusFromPrinterMib(mib({ errorState: new Uint8Array(0) })).errors).toEqual([]);
101
+ expect(statusFromPrinterMib(mib({ errorState: new Uint8Array([0x08]) })).errors).toEqual([
102
+ { code: 'cover_open', message: 'Cover open' },
103
+ ]);
104
+ expect(statusFromPrinterMib(mib({ errorState: new Uint8Array([0x00, 0x04]) })).errors).toEqual([
105
+ { code: 'no_media', message: 'Input tray empty' },
106
+ ]);
107
+ });
108
+
109
+ it('the name wins over Dim OIDs (bench: unit 4 claimed, 1/100 mm sent)', () => {
110
+ const status = statusFromPrinterMib(
111
+ mib({ mediaName: DK_11201_NAME, xFeedDir: 2900, feedDir: 9000, dimUnit: 4 }),
112
+ );
113
+ expect(status.detectedMedia?.id).toBe(271);
114
+ });
115
+
116
+ it('falls back to Dim OIDs in ten-thousandths of an inch (unit 3)', () => {
117
+ // 29 mm = 11417, 90 mm = 35433 ten-thousandths of an inch.
118
+ const status = statusFromPrinterMib(
119
+ mib({ mediaName: 'nonsense', xFeedDir: 11417, feedDir: 35433, dimUnit: 3 }),
120
+ );
121
+ expect(status.detectedMedia?.id).toBe(271);
122
+ });
123
+
124
+ it('falls back to Dim OIDs in micrometres (unit 4)', () => {
125
+ const status = statusFromPrinterMib(
126
+ mib({ mediaName: 'nonsense', xFeedDir: 62000, feedDir: -1, dimUnit: 4 }),
127
+ );
128
+ expect(status.detectedMedia?.id).toBe(259);
129
+ });
130
+
131
+ it('no media when the name is unparseable and the Dim OIDs are negative or unitless', () => {
132
+ expect(
133
+ statusFromPrinterMib(mib({ mediaName: 'nonsense', xFeedDir: -1, feedDir: -1, dimUnit: 3 }))
134
+ .detectedMedia,
135
+ ).toBeUndefined();
136
+ expect(
137
+ statusFromPrinterMib(mib({ mediaName: 'nonsense', xFeedDir: 11417, feedDir: 35433 }))
138
+ .detectedMedia,
139
+ ).toBeUndefined();
140
+ // RFC 3805 only defines 3 and 4; anything else is not converted.
141
+ expect(
142
+ statusFromPrinterMib(
143
+ mib({ mediaName: 'nonsense', xFeedDir: 11417, feedDir: 35433, dimUnit: 1 }),
144
+ ).detectedMedia,
145
+ ).toBeUndefined();
146
+ });
147
+
148
+ it('unrecognised media name → no media, warn row with the raw string', () => {
149
+ const status = statusFromPrinterMib(mib({ mediaName: 'Roll 62' }));
150
+ expect(status.mediaLoaded).toBe(false);
151
+ expect(status.detectedMedia).toBeUndefined();
152
+ expect(status.details).toContainEqual({
153
+ label: 'Media',
154
+ value: 'not recognised: "Roll 62"',
155
+ severity: 'warn',
156
+ });
157
+ expect(status.ready).toBe(true);
158
+ });
159
+
160
+ it('a recognised size with no registry entry → warn row, no media', () => {
161
+ const status = statusFromPrinterMib(mib({ mediaName: '99mm x 99mm / x' }));
162
+ expect(status.detectedMedia).toBeUndefined();
163
+ expect(status.details?.some(d => d.value.startsWith('not recognised'))).toBe(true);
164
+ });
165
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createBitmap } from '@mbtech-nl/bitmap';
2
+ import { createBitmap, type LabelBitmap } from '@mbtech-nl/bitmap';
3
3
  import {
4
4
  buildInvalidate,
5
5
  buildInitialize,
@@ -16,7 +16,7 @@ import {
16
16
  encodeJobForEngine,
17
17
  type EncoderEngine,
18
18
  } from '../protocol.js';
19
- import type { PageData } from '../types.js';
19
+ import type { BrotherQLMedia, PageData } from '../types.js';
20
20
  import { MEDIA } from '../media.js';
21
21
 
22
22
  describe('buildInvalidate', () => {
@@ -494,3 +494,130 @@ describe('encodeJob legacy entry point — back compat', () => {
494
494
  expect(out[204]).toBe(0x1b);
495
495
  });
496
496
  });
497
+
498
+ describe('die-cut page length (reference §2.3.2(b), §2.3.3, §2.3.4)', () => {
499
+ const DK_29X90 = MEDIA[271]!; // print area length 991 dots
500
+ const DK_62MM = MEDIA[259]!;
501
+ const QL_ENGINE: EncoderEngine = {
502
+ protocol: 'ql-raster',
503
+ headDots: 720,
504
+ capabilities: { autocut: true, mediaDetection: true },
505
+ };
506
+
507
+ /** Bitmap with every pixel of one row set, so the row is recognisable on the wire. */
508
+ function bitmapWithBlackRow(widthPx: number, heightPx: number, row: number): LabelBitmap {
509
+ const bitmap = createBitmap(widthPx, heightPx);
510
+ const bytesPerRow = Math.ceil(widthPx / 8);
511
+ bitmap.data.fill(0xff, row * bytesPerRow, (row + 1) * bytesPerRow);
512
+ return bitmap;
513
+ }
514
+
515
+ function findCmd(buf: Uint8Array, third: number): number {
516
+ for (let i = 0; i < buf.length - 4; i++) {
517
+ if (buf[i] === 0x1b && buf[i + 1] === 0x69 && buf[i + 2] === third) return i;
518
+ }
519
+ return -1;
520
+ }
521
+ const rasterNumber = (buf: Uint8Array): number => {
522
+ const i = findCmd(buf, 0x7a);
523
+ return (buf[i + 7] ?? 0) | ((buf[i + 8] ?? 0) << 8);
524
+ };
525
+ const marginDots = (buf: Uint8Array): number => {
526
+ const i = findCmd(buf, 0x64);
527
+ return (buf[i + 3] ?? 0) | ((buf[i + 4] ?? 0) << 8);
528
+ };
529
+ /** Raster rows as [opcode, colour, payload] triples, in wire order. */
530
+ function rasterRows(buf: Uint8Array): { op: number; color: number; payload: Uint8Array }[] {
531
+ const rows = [];
532
+ // Rows start after ESC i d (5 bytes) and run until the print command.
533
+ let i = findCmd(buf, 0x64) + 5;
534
+ while (i < buf.length && (buf[i] === 0x67 || buf[i] === 0x77)) {
535
+ const len = buf[i + 2] ?? 0;
536
+ rows.push({ op: buf[i]!, color: buf[i + 1]!, payload: buf.subarray(i + 3, i + 3 + len) });
537
+ i += 3 + len;
538
+ }
539
+ return rows;
540
+ }
541
+
542
+ it('sends the print-area length as the raster number and margin 0 on DK-11201', () => {
543
+ const bitmap = createBitmap(306, 608);
544
+ const out = encodeJobForEngine([{ bitmap, media: DK_29X90 }], {}, QL_ENGINE);
545
+ expect(rasterNumber(out)).toBe(991);
546
+ expect(marginDots(out)).toBe(0);
547
+ expect(rasterRows(out)).toHaveLength(991);
548
+ });
549
+
550
+ it('ignores marginDots on die-cut media', () => {
551
+ const bitmap = createBitmap(306, 10);
552
+ const out = encodeJobForEngine(
553
+ [{ bitmap, media: DK_29X90, options: { marginDots: 100 } }],
554
+ {},
555
+ QL_ENGINE,
556
+ );
557
+ expect(marginDots(out)).toBe(0);
558
+ });
559
+
560
+ it('centres the bitmap in the page', () => {
561
+ const bitmap = bitmapWithBlackRow(306, 3, 1);
562
+ const out = encodeJobForEngine([{ bitmap, media: DK_29X90 }], {}, QL_ENGINE);
563
+ const rows = rasterRows(out);
564
+ const black = rows.map((r, i) => (r.payload.some(b => b !== 0) ? i : -1)).filter(i => i >= 0);
565
+ // padTop = floor((991 - 3) / 2) = 494; the black row is bitmap row 1.
566
+ expect(black).toEqual([495]);
567
+ });
568
+
569
+ it('doubles the page length when QL high-res rows are supplied', () => {
570
+ const bitmap = createBitmap(306, 100);
571
+ const out = encodeJob([{ bitmap, media: DK_29X90, options: { highResolution: true } }]);
572
+ expect(rasterNumber(out)).toBe(1982);
573
+ expect(rasterRows(out)).toHaveLength(1982);
574
+ expect(marginDots(out)).toBe(0);
575
+ });
576
+
577
+ it('rejects a bitmap taller than the page, naming both numbers', () => {
578
+ const bitmap = createBitmap(306, 1200);
579
+ expect(() => encodeJobForEngine([{ bitmap, media: DK_29X90 }], {}, QL_ENGINE)).toThrow(
580
+ /1200 rows.*exactly 991/,
581
+ );
582
+ });
583
+
584
+ it('pads both planes alike on two-colour die-cut', () => {
585
+ const bitmap = bitmapWithBlackRow(306, 3, 1);
586
+ const redBitmap = bitmapWithBlackRow(306, 3, 1);
587
+ const out = encodeJobForEngine([{ bitmap, redBitmap, media: DK_29X90 }], {}, QL_ENGINE);
588
+ const rows = rasterRows(out);
589
+ expect(rows).toHaveLength(991 * 2);
590
+ const inked = rows
591
+ .map((r, i) => (r.payload.some(b => b !== 0) ? { i, color: r.color } : undefined))
592
+ .filter(r => r !== undefined);
593
+ expect(inked).toEqual([
594
+ { i: 495 * 2, color: 0x01 },
595
+ { i: 495 * 2 + 1, color: 0x02 },
596
+ ]);
597
+ });
598
+
599
+ it('throws when a die-cut entry lacks dieCutMaskedAreaDots', () => {
600
+ const media: BrotherQLMedia = { ...DK_29X90 };
601
+ delete media.dieCutMaskedAreaDots;
602
+ const bitmap = createBitmap(306, 10);
603
+ expect(() => encodeJobForEngine([{ bitmap, media }], {}, QL_ENGINE)).toThrow(
604
+ /dieCutMaskedAreaDots/,
605
+ );
606
+ });
607
+
608
+ it('leaves the continuous path byte-identical', async () => {
609
+ const { createHash } = await import('node:crypto');
610
+ const bitmap = bitmapWithBlackRow(696, 40, 7);
611
+ const out = encodeJobForEngine(
612
+ [{ bitmap, media: DK_62MM, options: { marginDots: 50 } }],
613
+ { copies: 2 },
614
+ QL_ENGINE,
615
+ );
616
+ expect(rasterNumber(out)).toBe(40);
617
+ expect(marginDots(out)).toBe(50);
618
+ // Digest recorded from the encoder before die-cut padding existed.
619
+ expect(createHash('sha256').update(out).digest('hex')).toBe(
620
+ 'ebc2b5f467faca133f0f72c6f3123671d4e4dc130f2dd8cad8d9a69fa50e95fc',
621
+ );
622
+ });
623
+ });
@@ -18,7 +18,8 @@ function makeStatusBytes(
18
18
  bytes[0] = 0x80;
19
19
  bytes[1] = 0x20;
20
20
  bytes[2] = 0x42;
21
- bytes[3] = 0x30;
21
+ bytes[3] = 0x34;
22
+ bytes[4] = 0x41;
22
23
  bytes[8] = overrides?.errInfo1 ?? 0;
23
24
  bytes[9] = overrides?.errInfo2 ?? 0;
24
25
  bytes[10] = overrides?.mediaWidthMm ?? 62;
@@ -150,6 +151,18 @@ describe('parseStatus', () => {
150
151
  });
151
152
 
152
153
  describe('parseStatus — details[]', () => {
154
+ it('model-code row renders bytes 3/4 as ASCII (4A = QL-820NWB capture)', () => {
155
+ const status = parseStatus(CAPTURE_DK_22205);
156
+ expect(status.details?.[0]).toEqual({ label: 'Model code', value: '4A' });
157
+ });
158
+
159
+ it('model-code row falls back to hex for unprintable bytes', () => {
160
+ const bytes = makeStatusBytes();
161
+ bytes[3] = 0x00;
162
+ bytes[4] = 0x41;
163
+ expect(parseStatus(bytes).details?.[0]?.value).toBe('0x00 0x41');
164
+ });
165
+
153
166
  it('always emits a Print phase row', () => {
154
167
  const status = parseStatus(makeStatusBytes());
155
168
  expect(status.details).toBeDefined();
@@ -196,6 +209,7 @@ describe('parseStatus — details[]', () => {
196
209
  it('details carries both phase and cooling rows while printing + cooling', () => {
197
210
  const status = parseStatus(makeStatusBytes({ phaseType: 0x01, notification: 0x03 }));
198
211
  expect(status.details).toEqual([
212
+ { label: 'Model code', value: '4A' },
199
213
  { label: 'Print phase', value: 'printing' },
200
214
  { label: 'Head cooling', value: 'cooling started', severity: 'warn' },
201
215
  ]);
@@ -838,6 +838,10 @@ export const REGISTRY = {
838
838
  {
839
839
  "key": "QL_820NWBc",
840
840
  "name": "QL-820NWBc",
841
+ "modelNames": [
842
+ "QL-820NWB",
843
+ "QL-820NWBc"
844
+ ],
841
845
  "family": "brother-ql",
842
846
  "transports": {
843
847
  "usb": {
package/src/devices.ts CHANGED
@@ -6,18 +6,6 @@ import { DEVICES, REGISTRY } from './devices.generated.js';
6
6
  // labelwriter-aligned `REGISTRY` symbol.
7
7
  export const DEVICE_REGISTRY = REGISTRY;
8
8
 
9
- // Mass-storage PIDs aligned to Linux usb-ids (gowdy.us / linux-usb.org).
10
- // Each large-format printer with Editor Lite enumerates as USB Mass
11
- // Storage Class on this alternate PID until the user holds the Editor
12
- // Lite button to switch back to printer-class. Aggregated from each
13
- // device entry's `capabilities.massStoragePid`.
14
- const MASS_STORAGE_PIDS = new Set<number>(
15
- Object.values(DEVICES).flatMap(d => {
16
- const pid = d.capabilities?.massStoragePid;
17
- return typeof pid === 'string' ? [parseInt(pid, 16)] : [];
18
- }),
19
- );
20
-
21
9
  export { DEVICES };
22
10
 
23
11
  /**
@@ -39,7 +27,3 @@ export function findDevice(vid: number, pid: number): BrotherQLDevice | undefine
39
27
  return ids?.vid === vid && ids.pid === pid;
40
28
  });
41
29
  }
42
-
43
- export function isMassStorageMode(pid: number): boolean {
44
- return MASS_STORAGE_PIDS.has(pid);
45
- }
package/src/index.ts CHANGED
@@ -33,7 +33,7 @@ export {
33
33
  UnsupportedOperationError,
34
34
  } from '@thermal-label/contracts';
35
35
 
36
- export { DEVICE_REGISTRY, DEVICES, findDevice, getUsbIds, isMassStorageMode } from './devices.js';
36
+ export { DEVICE_REGISTRY, DEVICES, findDevice, getUsbIds } from './devices.js';
37
37
 
38
38
  /**
39
39
  * Protocols this core's encoder produces correct wire bytes for.
@@ -63,6 +63,8 @@ export {
63
63
  } from './protocol.js';
64
64
  export type { EncoderEngine, RasterProtocolConfig } from './protocol.js';
65
65
  export { parseStatus, STATUS_REQUEST } from './status.js';
66
+ export { hasTwoColourSibling, parseMediaName, statusFromPrinterMib } from './network-status.js';
67
+ export type { PrinterMibStatus } from './network-status.js';
66
68
  export { createPreviewOffline } from './preview.js';
67
69
 
68
70
  export type {
@@ -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
+ }