From 3353c2304ae350dbc9d695646e0fd6b10c2e2c7a Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Tue, 19 May 2026 21:59:30 +0200 Subject: [PATCH 01/17] Fix ESC U SKU geometry: deci-mm not mm; correct MULTI_PURPOSE_MEDIUM parseSkuInfo read the ESC U geometry fields as whole millimetres per the 550 reference table, but an on-the-wire S0722540 capture (a 57x32 mm roll reporting 571/317) shows the NFC tag encodes deci-millimetres. All *Mm geometry fields now convert from deci-mm, so detectedMedia reports 57.1 x 31.7 instead of 571 x 317. The MULTI_PURPOSE_MEDIUM catalogue entry was stored transposed (32x57); corrected to 57x32 (widthMm = across-head, heightMm = feed) with lengthDots recomputed to 378. findMediaByDimensions is marked @deprecated (removal in 0.7.0): nothing maps detectedMedia onto a catalogue entry, and its exact dimension equality cannot match the deci-mm values parseSkuInfo now produces. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/sku-info-deci-mm.md | 21 ++++++++ packages/core/data/media.json5 | 10 ++-- packages/core/src/__tests__/media.test.ts | 12 +++++ .../core/src/__tests__/protocol-550.test.ts | 47 ++++++++++------- packages/core/src/index.ts | 6 ++- packages/core/src/media.ts | 7 +++ packages/core/src/protocol-550.ts | 52 +++++++++++++------ packages/node/src/__tests__/printer.test.ts | 22 +++++--- packages/web/src/__tests__/printer.test.ts | 40 +++++++++----- 9 files changed, 156 insertions(+), 61 deletions(-) create mode 100644 .changeset/sku-info-deci-mm.md diff --git a/.changeset/sku-info-deci-mm.md b/.changeset/sku-info-deci-mm.md new file mode 100644 index 0000000..3ca295f --- /dev/null +++ b/.changeset/sku-info-deci-mm.md @@ -0,0 +1,21 @@ +--- +'@thermal-label/labelwriter-core': patch +--- + +Fix `ESC U` SKU geometry: the NFC tag reports deci-millimetres, not mm. + +`parseSkuInfo` read the label / marker / offset / liner geometry fields as +whole millimetres, per the 550 reference table (`1...2^16 = length in mm`). An +on-the-wire capture of an S0722540 (57×32 mm) roll disproves that — it reports +571 / 317. All `*Mm` geometry fields are now converted from deci-mm, so +`detectedMedia` reports 57.1 × 31.7 mm instead of 571 × 317. Counts +(`totalLabelCount`, `counterMargin`) are left unscaled. + +The `MULTI_PURPOSE_MEDIUM` catalogue entry was stored transposed (32×57); it +is corrected to 57×32 — `widthMm` is the across-head dimension and `heightMm` +the feed length, matching the ESC U tag. + +`findMediaByDimensions` is now `@deprecated` — nothing maps `detectedMedia` +onto a catalogue entry, and its exact dimension equality cannot match the +deci-mm values `parseSkuInfo` now produces. Behaviour is unchanged; it is +scheduled for removal in 0.7.0. diff --git a/packages/core/data/media.json5 b/packages/core/data/media.json5 index 7de6391..bd2fe87 100644 --- a/packages/core/data/media.json5 +++ b/packages/core/data/media.json5 @@ -222,11 +222,15 @@ id: 'multi-purpose-medium', name: '57×32mm Medium', category: 'multi-purpose', - widthMm: 32, - heightMm: 57, + // widthMm is the across-head dimension — the 550 head is ~57 mm + // (672 dots @ 300 dpi), so 57 mm spans the full head — and heightMm + // is the feed length. The ESC U NFC tag reports this roll as + // 57.1 × 31.7; this entry was previously transposed (32 × 57). + widthMm: 57, + heightMm: 32, type: 'die-cut', cornerRadiusMm: 3, - lengthDots: 673, + lengthDots: 378, // 32 mm × 11.81 dots/mm @ 300 dpi skus: ['30334', '1933084'], targetModels: ['lw'], }, diff --git a/packages/core/src/__tests__/media.test.ts b/packages/core/src/__tests__/media.test.ts index 049a28d..702bb1c 100644 --- a/packages/core/src/__tests__/media.test.ts +++ b/packages/core/src/__tests__/media.test.ts @@ -1,3 +1,7 @@ +// This file exercises `findMediaByDimensions`, which is @deprecated +// pending removal in 0.7.0 — the no-deprecated rule is off for the file +// so its own coverage doesn't trip the gate. +/* eslint-disable @typescript-eslint/no-deprecated */ import { describe, expect, it } from 'vitest'; import { mediaCompatibleWith } from '@thermal-label/contracts'; import type { PrintEngine } from '@thermal-label/contracts'; @@ -35,6 +39,14 @@ describe('MEDIA registry', () => { expect((MEDIA.CONTINUOUS_57MM as LabelWriterMedia).heightMm).toBeUndefined(); }); + it('stores MULTI_PURPOSE_MEDIUM in across-head orientation (57×32, lengthDots 378)', () => { + // The ESC U tag reports widthMm = across-head, heightMm = feed; the + // entry was previously transposed (32×57 / lengthDots 673). + expect(MEDIA.MULTI_PURPOSE_MEDIUM.widthMm).toBe(57); + expect(MEDIA.MULTI_PURPOSE_MEDIUM.heightMm).toBe(32); + expect(MEDIA.MULTI_PURPOSE_MEDIUM.lengthDots).toBe(378); + }); + it('all ids are unique', () => { const ids = ALL_MEDIA.map(m => m.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/packages/core/src/__tests__/protocol-550.test.ts b/packages/core/src/__tests__/protocol-550.test.ts index 388bdf5..26efcf5 100644 --- a/packages/core/src/__tests__/protocol-550.test.ts +++ b/packages/core/src/__tests__/protocol-550.test.ts @@ -446,11 +446,12 @@ describe('parseSkuInfo', () => { buf[23] = 0x01; // labelType: die buf[24] = 0x01; // labelColor: white buf[25] = 0x00; // contentColor: black - // labelLengthMm at 40-41, labelWidthMm at 42-43 - buf[40] = 89; - buf[41] = 0; - buf[42] = 28; - buf[43] = 0; + // labelLengthMm at 40-41, labelWidthMm at 42-43 — deci-mm on the + // wire (S0722540 / 57×32 mm roll: 317 → 31.7, 571 → 57.1) + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 return buf; } @@ -470,10 +471,11 @@ describe('parseSkuInfo', () => { expect(sku.contentColor).toBe('black'); }); - it('decodes label dimensions from u16-LE bytes 40-43', () => { + it('decodes label dimensions from deci-mm u16-LE bytes 40-43', () => { const sku = parseSkuInfo(makeSku()); - expect(sku.labelLengthMm).toBe(89); - expect(sku.labelWidthMm).toBe(28); + // 317 / 571 deci-mm → 31.7 / 57.1 mm, not 317 / 571. + expect(sku.labelLengthMm).toBe(31.7); + expect(sku.labelWidthMm).toBe(57.1); }); it('falls back to "unknown" for out-of-range enum bytes', () => { @@ -509,20 +511,23 @@ describe('skuInfoToMedia', () => { const buf = new Uint8Array(63); new TextEncoder().encodeInto('30252 ', buf.subarray(8, 20)); buf[23] = 0x01; // labelType: die - buf[40] = 89; - buf[42] = 28; + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 const m = skuInfoToMedia(parseSkuInfo(buf)); expect(m.id).toBe('sku-30252'); expect(m.type).toBe('die-cut'); - expect(m.widthMm).toBe(28); - expect(m.heightMm).toBe(89); + expect(m.widthMm).toBe(57.1); + expect(m.heightMm).toBe(31.7); }); it('continuous SKU (labelType=0 OR labelLengthMm=0) → continuous, omits heightMm', () => { const buf = new Uint8Array(63); buf[23] = 0x00; // labelType: continuous buf[40] = 0; - buf[42] = 56; + buf[42] = 0x30; + buf[43] = 0x02; // labelWidthMm 560 → 56.0 const m = skuInfoToMedia(parseSkuInfo(buf)); expect(m.type).toBe('continuous'); expect(m.widthMm).toBe(56); @@ -538,8 +543,10 @@ describe('skuInfoDetails', () => { new TextEncoder().encodeInto('30252 ', buf.subarray(8, 20)); buf[22] = 0x03; // material: paper buf[23] = 0x01; // labelType: die - buf[40] = 89; // labelLengthMm - buf[42] = 28; // labelWidthMm + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) buf[50] = 0x20; // totalLabelCount = 0x0120 = 288 buf[51] = 0x01; buf[56] = 0x01; // counterStrategy: count-down @@ -582,8 +589,10 @@ describe('withDetectedMedia', () => { buf[1] = 0xca; new TextEncoder().encodeInto('30252 ', buf.subarray(8, 20)); buf[23] = 0x01; // labelType: die - buf[40] = 89; // labelLengthMm - buf[42] = 28; // labelWidthMm + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) return parseSkuInfo(buf); } @@ -596,8 +605,8 @@ describe('withDetectedMedia', () => { }; const decorated = withDetectedMedia(base, dieCutSku()); expect(decorated.detectedMedia).toBeDefined(); - expect(decorated.detectedMedia?.widthMm).toBe(28); - expect(decorated.detectedMedia?.heightMm).toBe(89); + expect(decorated.detectedMedia?.widthMm).toBe(57.1); + expect(decorated.detectedMedia?.heightMm).toBe(31.7); // The other status fields pass through unchanged. expect(decorated.ready).toBe(true); expect(decorated.mediaLoaded).toBe(true); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e50aa7c..128af02 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,7 +41,11 @@ export { DEVICES, REGISTRY_LW, findDevice } from './devices.js'; * this driver alone. */ export const PROTOCOLS: ReadonlySet = new Set(['lw-raster', 'lw5-raster', 'd1-tape']); -export { DEFAULT_MEDIA, MEDIA, findMediaByDimensions } from './media.js'; +export { DEFAULT_MEDIA, MEDIA } from './media.js'; +// `findMediaByDimensions` is @deprecated (removal in 0.7.0); re-exported +// for back-compat until then. +// eslint-disable-next-line @typescript-eslint/no-deprecated +export { findMediaByDimensions } from './media.js'; export { ROTATE_DIRECTION } from './orientation.js'; export { buildReset, diff --git a/packages/core/src/media.ts b/packages/core/src/media.ts index 2e9d3d5..44ad9ff 100644 --- a/packages/core/src/media.ts +++ b/packages/core/src/media.ts @@ -20,6 +20,13 @@ export const DEFAULT_MEDIA: LabelWriterMedia = MEDIA.ADDRESS_STANDARD; * the 550 doesn't have a tape head. Returns undefined for sizes * outside the registry; callers can still surface `rawBytes` for * unknown-roll diagnostics. + * + * @deprecated Unused — no caller maps `detectedMedia` onto a catalogue + * entry; the print/preview path consumes the SKU-derived descriptor + * directly. The exact dimension equality below also cannot match the + * deci-mm values `parseSkuInfo` now produces (e.g. `57.1`), so don't + * resurrect this without making it tolerant. Scheduled for removal in + * 0.7.0. */ export function findMediaByDimensions( widthMm: number, diff --git a/packages/core/src/protocol-550.ts b/packages/core/src/protocol-550.ts index 06c3ad3..c3de78d 100644 --- a/packages/core/src/protocol-550.ts +++ b/packages/core/src/protocol-550.ts @@ -464,8 +464,16 @@ export function parseEngineVersion(bytes: Uint8Array): EngineVersion { * Parsed `ESC U` response — the 63-byte NFC SKU dump. * * Field layout matches the spec table on p.16-19. All multi-byte - * integers are little-endian. Dimensions are in millimetres - * (per the spec `1...2^16 = length in mm`). + * integers are little-endian. + * + * Geometry fields (`label*Mm`, `marker*Mm`, the `*OffsetMm` pair, + * `linerWidthMm`, `totalLengthMm`) are **deci-millimetres** on the + * wire and converted to true mm here. The spec table calls them + * `1...2^16 = length in mm`, but that unit is an erratum — an + * S0722540 (57×32 mm) roll reports 571 / 317. The deci-mm reading is + * confirmed by on-the-wire capture, not the PDF; this is the same + * class of spec error already noted for the status frame's + * width/length in `support_550_devices.md` §2.3. */ export interface SkuInfo { /** Magic number `0xCAB6` — used to validate the response. */ @@ -502,9 +510,9 @@ export interface SkuInfo { marker2WidthMm: number; marker2OffsetMm: number; verticalOffsetMm: number; - /** Label length in mm (u16). 0 / 0xFFFF for continuous. */ + /** Label length in mm — one decimal; deci-mm on the wire. 0 for continuous. */ labelLengthMm: number; - /** Label width in mm (u16). */ + /** Label width in mm — one decimal; deci-mm on the wire. */ labelWidthMm: number; printableHorizontalOffsetMm: number; printableVerticalOffsetMm: number; @@ -543,6 +551,17 @@ function u16le(bytes: Uint8Array, offset: number): number { return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8); } +/** + * Read a u16-LE geometry field and convert deci-mm → mm. + * + * Keeps the single decimal the NFC tag actually carries (571 → 57.1) + * without inventing further precision. See the `SkuInfo` doc for why + * the spec's "length in mm" is an erratum. + */ +function u16DeciMm(bytes: Uint8Array, offset: number): number { + return u16le(bytes, offset) / 10; +} + export function parseSkuInfo(bytes: Uint8Array): SkuInfo { if (bytes.length < SKU_INFO_BYTE_COUNT) { throw new Error( @@ -578,19 +597,20 @@ export function parseSkuInfo(bytes: Uint8Array): SkuInfo { labelColor: LABEL_COLOR_TABLE[labelColorIdx] ?? 'unknown', contentColor: CONTENT_COLOR_TABLE[contentColorIdx] ?? 'unknown', markerType: bytes[26] ?? 0, - markerPitchMm: u16le(bytes, 28), - marker1WidthMm: u16le(bytes, 30), - marker1ToStartMm: u16le(bytes, 32), - marker2WidthMm: u16le(bytes, 34), - marker2OffsetMm: u16le(bytes, 36), - verticalOffsetMm: u16le(bytes, 38), - labelLengthMm: u16le(bytes, 40), - labelWidthMm: u16le(bytes, 42), - printableHorizontalOffsetMm: u16le(bytes, 44), - printableVerticalOffsetMm: u16le(bytes, 46), - linerWidthMm: u16le(bytes, 48), + markerPitchMm: u16DeciMm(bytes, 28), + marker1WidthMm: u16DeciMm(bytes, 30), + marker1ToStartMm: u16DeciMm(bytes, 32), + marker2WidthMm: u16DeciMm(bytes, 34), + marker2OffsetMm: u16DeciMm(bytes, 36), + verticalOffsetMm: u16DeciMm(bytes, 38), + labelLengthMm: u16DeciMm(bytes, 40), + labelWidthMm: u16DeciMm(bytes, 42), + printableHorizontalOffsetMm: u16DeciMm(bytes, 44), + printableVerticalOffsetMm: u16DeciMm(bytes, 46), + linerWidthMm: u16DeciMm(bytes, 48), + // totalLabelCount and counterMargin are counts, not lengths — no scaling. totalLabelCount: u16le(bytes, 50), - totalLengthMm: u16le(bytes, 52), + totalLengthMm: u16DeciMm(bytes, 52), counterMargin: u16le(bytes, 54), counterStrategy, productionDate: asciiTrim(bytes.subarray(60, 62)), diff --git a/packages/node/src/__tests__/printer.test.ts b/packages/node/src/__tests__/printer.test.ts index ef54e8b..e53d464 100644 --- a/packages/node/src/__tests__/printer.test.ts +++ b/packages/node/src/__tests__/printer.test.ts @@ -213,8 +213,10 @@ describe('LabelWriterPrinter', () => { new TextEncoder().encodeInto('30252 ', buf.subarray(8, 20)); buf[20] = 0x00; // brand DYMO buf[23] = 0x01; // labelType: die - buf[40] = 89; // labelLengthMm - buf[42] = 28; // labelWidthMm + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) return buf; } @@ -232,7 +234,7 @@ describe('LabelWriterPrinter', () => { expect(Array.from(firstWrite)).toEqual([0x1b, 0x55]); expect(vi.mocked(transport.read)).toHaveBeenCalledWith(63); expect(sku?.sku).toBe('30252'); - expect(sku?.labelWidthMm).toBe(28); + expect(sku?.labelWidthMm).toBe(57.1); }); it('550: returns undefined when magic is wrong (no media / counterfeit)', async () => { @@ -273,8 +275,10 @@ describe('LabelWriterPrinter', () => { buf[1] = 0xca; new TextEncoder().encodeInto('30252 ', buf.subarray(8, 20)); buf[23] = 0x01; - buf[40] = 89; - buf[42] = 28; + buf[40] = 0x3d; + buf[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + buf[42] = 0x3b; + buf[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) return buf; } @@ -609,8 +613,10 @@ describe('LabelWriterPrinter', () => { sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const status = new Uint8Array(32); status[10] = 8; status[30] = 1; @@ -622,7 +628,7 @@ describe('LabelWriterPrinter', () => { await printer.getMedia(); const after = await printer.getStatus(); expect(after.detectedMedia).toBeDefined(); - expect(after.detectedMedia?.widthMm).toBe(28); + expect(after.detectedMedia?.widthMm).toBe(57.1); }); it('print() rejects an unknown engine role with the available-roles hint', async () => { diff --git a/packages/web/src/__tests__/printer.test.ts b/packages/web/src/__tests__/printer.test.ts index 856db6c..77ecd39 100644 --- a/packages/web/src/__tests__/printer.test.ts +++ b/packages/web/src/__tests__/printer.test.ts @@ -250,8 +250,10 @@ describe('WebLabelWriterPrinter', () => { sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) // Two scripted replies: the ESC A status read, then the ESC U SKU read. const device = createMockUSBDevice(LW_550.vid, LW_550.pid, [status, sku]); const printer = await fromUSBDevice(device); @@ -286,8 +288,10 @@ describe('WebLabelWriterPrinter', () => { sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const device = createMockUSBDevice(LW_550.vid, LW_550.pid, sku); const printer = await fromUSBDevice(device); const before = device.__transfers.length; @@ -295,7 +299,7 @@ describe('WebLabelWriterPrinter', () => { const sent = device.__transfers[before]!.data; expect(Array.from(sent)).toEqual([0x1b, 0x55]); expect(result?.sku).toBe('30252'); - expect(result?.labelWidthMm).toBe(28); + expect(result?.labelWidthMm).toBe(57.1); }); it('getMedia() returns undefined when the SKU magic is wrong', async () => { @@ -310,8 +314,10 @@ describe('WebLabelWriterPrinter', () => { sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const status = new Uint8Array(32); status[10] = 8; status[30] = 1; @@ -342,8 +348,10 @@ describe('WebLabelWriterPrinter', () => { new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[22] = 0x03; // material: paper sku[23] = 0x01; // labelType: die - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const status = new Uint8Array(32); status[10] = 8; status[30] = 1; @@ -549,8 +557,10 @@ describe('WebLabelWriterPrinter — 550 lock health', () => { sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const status = new Uint8Array(32); status[10] = 8; status[30] = 1; @@ -621,8 +631,10 @@ describe('WebLabelWriterPrinter — createPreview with cached detected media', ( sku[1] = 0xca; new TextEncoder().encodeInto('30252 ', sku.subarray(8, 20)); sku[23] = 0x01; // die-cut - sku[40] = 89; - sku[42] = 28; + sku[40] = 0x3d; + sku[41] = 0x01; // labelLengthMm 317 → 31.7 (deci-mm) + sku[42] = 0x3b; + sku[43] = 0x02; // labelWidthMm 571 → 57.1 (deci-mm) const device = createMockUSBDevice(LW_550.vid, LW_550.pid, sku); const printer = await fromUSBDevice(device); await printer.getMedia(); @@ -630,7 +642,7 @@ describe('WebLabelWriterPrinter — createPreview with cached detected media', ( // detectedMedia rather than falling back to DEFAULT_MEDIA. const preview = await printer.createPreview(solidRgba(8, 8)); expect(preview.assumed).toBe(false); - expect(preview.media.widthMm).toBe(28); + expect(preview.media.widthMm).toBe(57.1); }); }); From 68986de2628123e08bb8a72fbe2781717419b37b Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Tue, 19 May 2026 22:56:48 +0200 Subject: [PATCH 02/17] ci: route prerelease tags to a dedicated npm dist-tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag with a prerelease identifier (e.g. v0.6.3-debug.0) now publishes under that identifier as an npm dist-tag and is marked as a GitHub prerelease, leaving the `latest` dist-tag untouched. Stable tags are unaffected — they still publish to `latest` with make_latest: true. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7637ce0..d0e9cc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,24 @@ jobs: done echo "✅ All versions match tag $TAG" + - name: Determine release channel + id: channel + run: | + VERSION="${GITHUB_REF_NAME#v}" + if [[ "${{ github.event_name }}" == "push" && "$VERSION" == *-* ]]; then + # Prerelease tag (e.g. v0.6.3-debug.0): publish under the + # prerelease identifier as an npm dist-tag, never to `latest`. + NPM_TAG="${VERSION#*-}" + NPM_TAG="${NPM_TAG%%.*}" + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "npm_tag=$NPM_TAG" >> "$GITHUB_OUTPUT" + echo "🔖 Prerelease $VERSION → npm dist-tag '$NPM_TAG'" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" + echo "🔖 Stable $VERSION → npm dist-tag 'latest'" + fi + - name: Build run: pnpm build @@ -52,7 +70,7 @@ jobs: run: pnpm test - name: Publish - run: pnpm publish -r --access public --no-git-checks --provenance + run: pnpm publish -r --access public --no-git-checks --provenance --tag ${{ steps.channel.outputs.npm_tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -61,4 +79,5 @@ jobs: uses: softprops/action-gh-release@v2 with: generate_release_notes: true - make_latest: true + make_latest: ${{ steps.channel.outputs.prerelease == 'false' }} + prerelease: ${{ steps.channel.outputs.prerelease }} From 0f17cdc1a1d5c6324aab7566916b32939f0355b2 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Tue, 19 May 2026 22:56:53 +0200 Subject: [PATCH 03/17] debug: console.debug tracing across the print flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a grep-able dbg() helper per package and console.debug calls at the crucial print-flow points — print start, 550 lock, media resolved, rotation/bitmap, encode, transport write — in the node + web doPrint() paths and the core encodeLabel/encodeDuoTapeLabel encoders. Temporary, debug/print-flow branch only. See DEBUG_RELEASE.md. Must not merge to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- DEBUG_RELEASE.md | 54 +++++++++++++++++++++++++++++++++++ packages/core/src/protocol.ts | 25 +++++++++++++++- packages/node/src/printer.ts | 24 ++++++++++++++++ packages/web/src/printer.ts | 26 +++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 DEBUG_RELEASE.md diff --git a/DEBUG_RELEASE.md b/DEBUG_RELEASE.md new file mode 100644 index 0000000..685da6c --- /dev/null +++ b/DEBUG_RELEASE.md @@ -0,0 +1,54 @@ +# Debug release — `0.6.3-debug.x` + +This branch (`debug/print-flow`) carries **temporary `console.debug` tracing +through the print flow**. It exists only to publish an instrumented build to +npm so a downstream CI can be debugged against a real package. It must never +merge to `main`. + +## What's instrumented + +`console.debug` calls (one `dbg()` helper per package, grep-able) at the +crucial print-flow points: + +- `packages/node/src/printer.ts` — `doPrint()`: start, 550 lock, media + resolved, rotation/bitmap, encoded bytes, write complete. +- `packages/web/src/printer.ts` — same points in its `doPrint()`. +- `packages/core/src/protocol.ts` — `encodeLabel()` entry + 450 byte count, + `encodeDuoTapeLabel()` entry. + +Log prefixes: `[lw-node]`, `[lw-web]`, `[lw-core]`. + +## How it reaches npm + +`.github/workflows/release.yml` on this branch detects a prerelease tag +(a `-` in the version) and publishes under that identifier as an npm +**dist-tag** instead of `latest` — so `latest` is untouched. This change +lives only on this branch; `main`'s `release.yml` is unchanged. + +Publish: + +```sh +git push origin debug/print-flow +git tag v0.6.3-debug.0 && git push origin v0.6.3-debug.0 +``` + +The Release workflow publishes all three packages at `0.6.3-debug.0` to the +`debug` dist-tag. + +## How a consumer uses it + +```jsonc +// CI / consumer package.json +"@thermal-label/labelwriter-node": "debug" // or pin "0.6.3-debug.0" +``` + +npm `^`/`~` ranges never resolve to a prerelease, and it is not on `latest`, +so no other consumer picks it up by accident. + +## Teardown — there is no revert release + +1. Consumer drops the `@debug` pin, back to `^0.6.3` (or whatever stable). +2. `npm dist-tag rm @thermal-label/labelwriter-core debug` (and `-node`, `-web`). +3. Delete this branch. + +Iterating? Bump to `0.6.3-debug.1`, re-tag. diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts index 6933832..1c8069c 100644 --- a/packages/core/src/protocol.ts +++ b/packages/core/src/protocol.ts @@ -337,6 +337,10 @@ export async function encodeDuoTapeLabel( media?: MediaDescriptor, ): Promise { const { engine } = resolveEngine(device, options.engine); + dbg( + `encodeDuoTapeLabel ${device.key} engine=${engine.role} ` + + `bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`, + ); if (engine.protocol !== 'd1-tape') { throw new UnsupportedOperationError( `encodeDuoTapeLabel on ${device.key} engine "${engine.role}"`, @@ -355,6 +359,16 @@ export async function encodeDuoTapeLabel( return buildDuoTapeStream(bitmap, engine, d1Options, tapeMedia); } +/** + * Print-flow debug tracing — ships ONLY on the `debug/print-flow` + * branch / `0.6.3-debug.x` prerelease line (npm dist-tag `debug`). + * Delete this helper and its call sites before merging to main. + */ +function dbg(msg: string): void { + // eslint-disable-next-line no-console + console.debug(`[lw-core] ${msg}`); +} + export function encodeLabel( device: DeviceEntry, bitmap: LabelBitmap, @@ -365,6 +379,10 @@ export function encodeLabel( const { engine, selectRollByte } = resolveEngine(device, options.engine); assertEncoderSupports(engine, device.key); + dbg( + `encodeLabel ${device.key} engine=${engine.role} protocol=${engine.protocol} ` + + `bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)} copies=${String(copies)}`, + ); // 550 family uses a fundamentally different job structure (job // header / per-label header / job trailer), so dispatch out before @@ -431,5 +449,10 @@ export function encodeLabel( parts.push(buildFormFeed()); } - return concat(...parts); + const wire = concat(...parts); + dbg( + `encodeLabel 450 ${device.key}: ${String(fitted.heightPx)} rows → ` + + `${String(wire.length)} bytes`, + ); + return wire; } diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 98b14c1..07c74e8 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -47,6 +47,16 @@ import { WriteSerializer, } from '@thermal-label/contracts'; +/** + * Print-flow debug tracing — ships ONLY on the `debug/print-flow` + * branch / `0.6.3-debug.x` prerelease line (npm dist-tag `debug`). + * Delete this helper and its call sites before merging to main. + */ +function dbg(msg: string): void { + // eslint-disable-next-line no-console + console.debug(`[lw-node] ${msg}`); +} + export interface LabelWriterPrinterOptions { /** * Per-engine transport overrides for multi-engine devices that need @@ -169,6 +179,12 @@ export class LabelWriterPrinter implements PrinterAdapter { options?: LabelWriterPrintOptions, ): Promise { const engine = resolveRequestedEngine(this.device, options?.engine); + dbg( + `print start: device=${this.device.key} engine=${engine.role} ` + + `protocol=${engine.protocol} image=${String(image.width)}x${String(image.height)} ` + + `media=${media ? 'explicit' : this.lastStatus?.detectedMedia ? 'cached' : 'unset'} ` + + `copies=${String(options?.copies ?? 1)}`, + ); const transport = this.transports[engine.role]; if (!transport) { throw new Error( @@ -191,6 +207,10 @@ export class LabelWriterPrinter implements PrinterAdapter { // open conditions before we waste cycles encoding the bitmap). if (engine.protocol === 'lw5-raster') { await this.acquire550Lock(transport); + dbg( + `550 lock acquired: ready=${String(this.lastStatus?.ready)} ` + + `errors=${String(this.lastStatus?.errors.length ?? 0)}`, + ); } let resolvedMedia = media ?? this.lastStatus?.detectedMedia; @@ -206,14 +226,18 @@ export class LabelWriterPrinter implements PrinterAdapter { if (!resolvedMedia) { throw new MediaNotSpecifiedError(); } + dbg(`media resolved: ${JSON.stringify(resolvedMedia)}`); const rotate = pickRotation(image, resolvedMedia, ROTATE_DIRECTION, options?.rotate); const bitmap = renderImage(image, { dither: true, rotate }); + dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); // Duo tape engine: dispatch through the async encoder that // lazy-loads d1-core. Raster engines stay on the sync path. const bytes = isDuoTapeEngine(engine) ? await encodeDuoTapeLabel(this.device, bitmap, options, resolvedMedia) : encodeLabel(this.device, bitmap, options, resolvedMedia); + dbg(`encoded ${String(bytes.length)} bytes — writing to transport`); await transport.write(bytes); + dbg(`print complete: ${String(bytes.length)} bytes written`); } /** diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index a5bb175..09749f1 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -52,6 +52,16 @@ import { } from '@thermal-label/contracts'; import { WebUsbTransport } from '@thermal-label/transport/web'; +/** + * Print-flow debug tracing — ships ONLY on the `debug/print-flow` + * branch / `0.6.3-debug.x` prerelease line (npm dist-tag `debug`). + * Delete this helper and its call sites before merging to main. + */ +function dbg(msg: string): void { + // eslint-disable-next-line no-console + console.debug(`[lw-web] ${msg}`); +} + const D1_STATUS_BYTE_COUNT = 1; /** @@ -194,11 +204,23 @@ export class WebLabelWriterPrinter implements PrinterAdapter { ); } + dbg( + `print start: device=${this.device.key} engine=${effectiveEngine.role} ` + + `protocol=${effectiveEngine.protocol} ` + + `image=${String(image.width)}x${String(image.height)} ` + + `media=${media ? 'explicit' : this.lastStatus?.detectedMedia ? 'cached' : 'unset'} ` + + `copies=${String(options?.copies ?? 1)}`, + ); + // 550 family: acquire the print lock and check printer health // before sending the job. See the node driver for the full // contract. Released by `ESC Q` in the job trailer. if (this.engine.protocol === 'lw5-raster') { await this.doAcquire550Lock(); + dbg( + `550 lock acquired: ready=${String(this.lastStatus?.ready)} ` + + `errors=${String(this.lastStatus?.errors.length ?? 0)}`, + ); } let resolvedMedia = (media ?? this.lastStatus?.detectedMedia) as LabelWriterMedia | undefined; @@ -218,8 +240,10 @@ export class WebLabelWriterPrinter implements PrinterAdapter { if (!resolvedMedia) { throw new MediaNotSpecifiedError(); } + dbg(`media resolved: ${JSON.stringify(resolvedMedia)}`); const rotate = pickRotation(image, resolvedMedia, ROTATE_DIRECTION, options?.rotate); const bitmap = renderImage(image, { dither: true, rotate }); + dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); // Force `engine` to this instance's role so the encoder dispatches // on the right protocol (lw-raster / lw5-raster / d1-tape). const encodeOptions: LabelWriterPrintOptions = { ...options, engine: this.engine.role }; @@ -228,7 +252,9 @@ export class WebLabelWriterPrinter implements PrinterAdapter { const bytes = isDuoTapeEngine(this.engine) ? await encodeDuoTapeLabel(this.device, bitmap, encodeOptions, resolvedMedia) : encodeLabel(this.device, bitmap, encodeOptions, resolvedMedia); + dbg(`encoded ${String(bytes.length)} bytes — writing to transport`); await this.transport.write(bytes); + dbg(`print complete: ${String(bytes.length)} bytes written`); } /** From 70c0dfbbe1b8a0bd791811b876386d49a717789d Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Tue, 19 May 2026 22:56:57 +0200 Subject: [PATCH 04/17] release: 0.6.3-debug.0 Instrumented prerelease of core/node/web. Publishes to the `debug` npm dist-tag via the prerelease-aware release workflow. Lockfile unchanged (version-only bump of workspace packages). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/package.json | 2 +- packages/node/package.json | 2 +- packages/web/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index e6b4c95..9c66ba3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-core", - "version": "0.6.2", + "version": "0.6.3-debug.0", "description": "Protocol encoding and device registry for Dymo LabelWriter printers", "keywords": [ "dymo", diff --git a/packages/node/package.json b/packages/node/package.json index 6b4de9f..8a0a04b 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-node", - "version": "0.6.2", + "version": "0.6.3-debug.0", "description": "Node.js USB and TCP driver for Dymo LabelWriter printers", "keywords": [ "dymo", diff --git a/packages/web/package.json b/packages/web/package.json index 8047a61..60c10cb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-web", - "version": "0.6.2", + "version": "0.6.3-debug.0", "description": "WebUSB browser driver for Dymo LabelWriter printers", "keywords": [ "dymo", From 0b20aac8d4b772e80eb201d49d32910b3014287d Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Tue, 19 May 2026 23:07:59 +0200 Subject: [PATCH 05/17] rebuild docs --- docs/api/core/src/README.md | 2 +- .../core/src/functions/findMediaByDimensions.md | 11 ++++++++++- docs/api/core/src/interfaces/SkuInfo.md | 16 ++++++++++++---- docs/api/core/src/variables/MEDIA.md | 6 +++--- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/api/core/src/README.md b/docs/api/core/src/README.md index db1c288..d52774b 100644 --- a/docs/api/core/src/README.md +++ b/docs/api/core/src/README.md @@ -85,7 +85,7 @@ - [encodeDuoTapeLabel](functions/encodeDuoTapeLabel.md) - [encodeLabel](functions/encodeLabel.md) - [findDevice](functions/findDevice.md) -- [findMediaByDimensions](functions/findMediaByDimensions.md) +- [~~findMediaByDimensions~~](functions/findMediaByDimensions.md) - [findTapeMediaByWidth](functions/findTapeMediaByWidth.md) - [findTapeMediaByWidthAll](functions/findTapeMediaByWidthAll.md) - [isDuoTapeEngine](functions/isDuoTapeEngine.md) diff --git a/docs/api/core/src/functions/findMediaByDimensions.md b/docs/api/core/src/functions/findMediaByDimensions.md index bc4b949..4da27be 100644 --- a/docs/api/core/src/functions/findMediaByDimensions.md +++ b/docs/api/core/src/functions/findMediaByDimensions.md @@ -4,7 +4,7 @@ [labelwriter](../../../README.md) / [core/src](../README.md) / findMediaByDimensions -# Function: findMediaByDimensions() +# ~~Function: findMediaByDimensions()~~ > **findMediaByDimensions**(`widthMm`, `heightMm`): [`LabelWriterMedia`](../interfaces/LabelWriterMedia.md) \| `undefined` @@ -30,3 +30,12 @@ unknown-roll diagnostics. ## Returns [`LabelWriterMedia`](../interfaces/LabelWriterMedia.md) \| `undefined` + +## Deprecated + +Unused — no caller maps `detectedMedia` onto a catalogue +entry; the print/preview path consumes the SKU-derived descriptor +directly. The exact dimension equality below also cannot match the +deci-mm values `parseSkuInfo` now produces (e.g. `57.1`), so don't +resurrect this without making it tolerant. Scheduled for removal in +0.7.0. diff --git a/docs/api/core/src/interfaces/SkuInfo.md b/docs/api/core/src/interfaces/SkuInfo.md index 57a140f..fed5784 100644 --- a/docs/api/core/src/interfaces/SkuInfo.md +++ b/docs/api/core/src/interfaces/SkuInfo.md @@ -9,8 +9,16 @@ Parsed `ESC U` response — the 63-byte NFC SKU dump. Field layout matches the spec table on p.16-19. All multi-byte -integers are little-endian. Dimensions are in millimetres -(per the spec `1...2^16 = length in mm`). +integers are little-endian. + +Geometry fields (`label*Mm`, `marker*Mm`, the `*OffsetMm` pair, +`linerWidthMm`, `totalLengthMm`) are **deci-millimetres** on the +wire and converted to true mm here. The spec table calls them +`1...2^16 = length in mm`, but that unit is an erratum — an +S0722540 (57×32 mm) roll reports 571 / 317. The deci-mm reading is +confirmed by on-the-wire capture, not the PDF; this is the same +class of spec error already noted for the status frame's +width/length in `support_550_devices.md` §2.3. ## Properties @@ -58,7 +66,7 @@ CRC over payload (u16 LE, bytes 4-5). > **labelLengthMm**: `number` -Label length in mm (u16). 0 / 0xFFFF for continuous. +Label length in mm — one decimal; deci-mm on the wire. 0 for continuous. *** @@ -72,7 +80,7 @@ Label length in mm (u16). 0 / 0xFFFF for continuous. > **labelWidthMm**: `number` -Label width in mm (u16). +Label width in mm — one decimal; deci-mm on the wire. *** diff --git a/docs/api/core/src/variables/MEDIA.md b/docs/api/core/src/variables/MEDIA.md index b4f45c3..ce73537 100644 --- a/docs/api/core/src/variables/MEDIA.md +++ b/docs/api/core/src/variables/MEDIA.md @@ -872,7 +872,7 @@ #### MULTI\_PURPOSE\_MEDIUM.heightMm -> `readonly` **heightMm**: `57` = `57` +> `readonly` **heightMm**: `32` = `32` #### MULTI\_PURPOSE\_MEDIUM.id @@ -880,7 +880,7 @@ #### MULTI\_PURPOSE\_MEDIUM.lengthDots -> `readonly` **lengthDots**: `673` = `673` +> `readonly` **lengthDots**: `378` = `378` #### MULTI\_PURPOSE\_MEDIUM.name @@ -900,7 +900,7 @@ #### MULTI\_PURPOSE\_MEDIUM.widthMm -> `readonly` **widthMm**: `32` = `32` +> `readonly` **widthMm**: `57` = `57` ### MULTI\_PURPOSE\_SMALL From e526e06b2b4cc920d859c1b5a686f0ea8c97886f Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 19:33:06 +0200 Subject: [PATCH 06/17] =?UTF-8?q?fix(550):=20interactive=20print=20handsha?= =?UTF-8?q?ke=20=E2=80=94=20drive=20ESC=20A=20status=20between=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 550 print job is an interactive half-duplex exchange, not a fire-and-forget blob. The firmware stops draining the bulk-OUT endpoint after each label's ESC G footer until the host issues ESC A and reads the 32-byte status reply — so the previous monolithic write hung mid-job, leaving the printer lock-stuck (powercycle) and print() unresolved (harness result section frozen). Prior art: minlux/dymon (Wireshark RE of the DYMO Wireless + 550 protocol). Two confirmed bugs vs the DYMO 550 Tech Ref + dymon: - core: encode550Label built a monolithic blob with no mid-job status read. Add compose550Job -> { preamble, labels[], finalize }; the driver writes preamble, then per label writes the segment + ESC A and drains the 32-byte status, then writes ESC E + ESC Q. ESC A lock byte is 0 for the last label (final query + lock release), 2 between labels (host defers that read). encode550Label now concatenates the segments — offline/test view only. - core: build550LabelIndex emitted ESC n as u32; spec + dymon + our own status parser (u16 echo) say u16. The 2 extra bytes desync the firmware command parser ahead of ESC D. - web + node: doPrint routes lw5-raster through the new interactive write550Job. The web handshake read is timed (15s) so a wedged firmware throws instead of hanging print() forever. ESC M (1B 4D 00x8, seen in dymon) deliberately NOT added — undocumented in the DYMO 550 Tech Ref and not stall-relevant. Diagnosed from prior art; not yet bench-confirmed — no LW 550 on the bench, external tester validates. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/__tests__/protocol-550.test.ts | 80 ++++++++--- packages/core/src/index.ts | 3 +- packages/core/src/protocol-550.ts | 124 +++++++++++++----- packages/node/src/__tests__/printer.test.ts | 21 +-- packages/node/src/printer.ts | 58 +++++++- packages/web/src/printer.ts | 83 +++++++++++- 6 files changed, 307 insertions(+), 62 deletions(-) diff --git a/packages/core/src/__tests__/protocol-550.test.ts b/packages/core/src/__tests__/protocol-550.test.ts index 26efcf5..f62ff57 100644 --- a/packages/core/src/__tests__/protocol-550.test.ts +++ b/packages/core/src/__tests__/protocol-550.test.ts @@ -21,6 +21,7 @@ import { build550SetLabelCount, density550Percent, PRINT_STATUS_LOCK_NOT_GRANTED, + compose550Job, encode550Label, parseEngineVersion, parseSkuInfo, @@ -64,11 +65,9 @@ describe('550 byte builders', () => { expect(Array.from(build550ContentType('high'))).toEqual([0x1b, 0x74, 0x20]); }); - it('build550LabelIndex: ESC n + u32LE index', () => { - expect(Array.from(build550LabelIndex(0))).toEqual([0x1b, 0x6e, 0, 0, 0, 0]); - expect(Array.from(build550LabelIndex(0x01020304))).toEqual([ - 0x1b, 0x6e, 0x04, 0x03, 0x02, 0x01, - ]); + it('build550LabelIndex: ESC n + u16LE index', () => { + expect(Array.from(build550LabelIndex(0))).toEqual([0x1b, 0x6e, 0, 0]); + expect(Array.from(build550LabelIndex(0x0304))).toEqual([0x1b, 0x6e, 0x04, 0x03]); }); it('build550LabelHeader: ESC D + bpp + align + u32LE width + u32LE height (12 bytes)', () => { @@ -228,8 +227,8 @@ describe('encode550Label', () => { let i = 11; expect(out[i]).toBe(0x1b); expect(out[i + 1]).toBe(0x6e); - // Skip 6 bytes of ESC n - i += 6; + // Skip 4 bytes of ESC n (ESC + n + u16 index) + i += 4; expect(out[i]).toBe(0x1b); expect(out[i + 1]).toBe(0x44); // BPP=1, align=2, width=8, height=672 @@ -240,24 +239,23 @@ describe('encode550Label', () => { it('raster block has no SYN/ETB framing — pure header + data', () => { const out = encode550Label(lw550, bm(672, 4)); - // 11 (job header) + 6 (ESC n) + 12 (ESC D) = 29 bytes preamble + // 11 (job header) + 4 (ESC n) + 12 (ESC D) = 27 bytes preamble // Followed by 4 raster lines × 84 bytes = 336 bytes of pure data - // Then ESC E + ESC Q = 4 bytes trailer - expect(out.length).toBe(29 + 4 * 84 + 2 + 2); + // Then ESC G + ESC E + ESC Q = 6 bytes trailer + expect(out.length).toBe(27 + 4 * 84 + 2 + 2 + 2); // The data block must not contain any SYN (0x16) framing — // verify the first raster byte is NOT preceded by 0x16 - const firstDataByte = 29; + const firstDataByte = 27; // Just confirm length math holds expect(out[firstDataByte]).toBe(0); // empty bitmap → all-zero rows }); - it('emits ESC G between copies and ESC E for the last copy', () => { + it('footers every copy with ESC G; ESC E once in the job trailer', () => { const out = encode550Label(lw550, bm(672, 4), { copies: 3 }); - // Find all label trailers const escGCount = countEsc(out, 0x47); const escECount = countEsc(out, 0x45); - expect(escGCount).toBe(2); // between copy 1→2 and 2→3 - expect(escECount).toBe(1); // after copy 3 + expect(escGCount).toBe(3); // ESC G after every label + expect(escECount).toBe(1); // ESC E once, in finalize }); it('exactly one ESC Q regardless of copy count', () => { @@ -277,13 +275,11 @@ describe('encode550Label', () => { it('label index increments per copy, starting at 0', () => { const out = encode550Label(lw550, bm(672, 1), { copies: 3 }); - // Find all ESC n occurrences + // Find all ESC n occurrences — index is u16LE. const indices: number[] = []; - for (let i = 0; i < out.length - 5; i++) { + for (let i = 0; i < out.length - 3; i++) { if (out[i] === 0x1b && out[i + 1] === 0x6e) { - indices.push( - (out[i + 2]! | (out[i + 3]! << 8) | (out[i + 4]! << 16) | (out[i + 5]! << 24)) >>> 0, - ); + indices.push(out[i + 2]! | (out[i + 3]! << 8)); } } expect(indices).toEqual([0, 1, 2]); @@ -376,6 +372,50 @@ describe('encode550Label', () => { }); }); +describe('compose550Job', () => { + const bm = (widthPx: number, heightPx: number): ReturnType => + createBitmap(widthPx, heightPx); + + it('preamble starts with ESC s and carries no label or trailer bytes', () => { + const job = compose550Job(DEVICES.LW_550, bm(672, 200)); + expect(job.preamble[0]).toBe(0x1b); + expect(job.preamble[1]).toBe(0x73); // ESC s + expect(findEscByte(job.preamble, 0x6e)).toBeUndefined(); // no ESC n + expect(findEscByte(job.preamble, 0x44)).toBeUndefined(); // no ESC D + expect(findEscByte(job.preamble, 0x51)).toBeUndefined(); // no ESC Q + }); + + it('emits one label segment per copy, each starting ESC n and ending ESC G', () => { + const job = compose550Job(DEVICES.LW_550, bm(672, 200), { copies: 3 }); + expect(job.labels).toHaveLength(3); + for (const label of job.labels) { + expect(label[0]).toBe(0x1b); + expect(label[1]).toBe(0x6e); // ESC n + expect(label.at(-2)).toBe(0x1b); + expect(label.at(-1)).toBe(0x47); // ESC G + } + }); + + it('finalize is exactly ESC E + ESC Q', () => { + const job = compose550Job(DEVICES.LW_550, bm(672, 200)); + expect(Array.from(job.finalize)).toEqual([0x1b, 0x45, 0x1b, 0x51]); + }); + + it('encode550Label equals preamble + labels + finalize concatenated', () => { + const job = compose550Job(DEVICES.LW_550, bm(672, 200), { copies: 2 }); + const flat = encode550Label(DEVICES.LW_550, bm(672, 200), { copies: 2 }); + const segLen = + job.preamble.length + + job.labels.reduce((n, l) => n + l.length, 0) + + job.finalize.length; + expect(flat.length).toBe(segLen); + }); + + it('throws when the device has no lw5-raster engine', () => { + expect(() => compose550Job(DEVICES.LW_450, bm(672, 200))).toThrow(/lw5-raster/); + }); +}); + describe('encodeLabel dispatch', () => { it('routes lw5-raster engines to encode550Label (no ESC @ reset, ends with ESC Q)', async () => { const { encodeLabel } = await import('../protocol.js'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 128af02..5e0d293 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,6 +97,7 @@ export { build550SetLabelCount, PRINT_STATUS_LOCK_NOT_GRANTED, density550Percent, + compose550Job, encode550Label, parseEngineVersion, parseSkuInfo, @@ -107,7 +108,7 @@ export { SKU_INFO_BYTE_COUNT, STATUS_BYTE_COUNT_550, } from './protocol-550.js'; -export type { EngineVersion, SkuInfo } from './protocol-550.js'; +export type { Composed550Job, EngineVersion, SkuInfo } from './protocol-550.js'; export { createPreviewOffline } from './preview.js'; export type { D1Material, diff --git a/packages/core/src/protocol-550.ts b/packages/core/src/protocol-550.ts index c3de78d..3d1a5dd 100644 --- a/packages/core/src/protocol-550.ts +++ b/packages/core/src/protocol-550.ts @@ -23,18 +23,22 @@ import type { LabelWriterPrintOptions, Density } from './types.js'; * a job stream that the 550 firmware cannot parse; this module is * the clean fork. * - * Wire layout this module emits: + * Wire layout — see `compose550Job` for the segmented form the driver + * actually writes. The 550 needs a status handshake between each + * label's footer and the job trailer, so a monolithic write hangs: * * ESC s (job header) * ESC h | ESC i (mode) * ESC C (density) * * per copy (label index 0..N-1): - * ESC n (label header) + * ESC n (label header) * ESC D (label header) * (no SYN prefix) - * ESC G (between copies) | ESC E (last copy) (label trailer) + * ESC G (label footer) + * [ driver: ESC A -> read 32-byte status ] (footer handshake) * + * ESC E (feed to tear) * ESC Q (job trailer) * * Spec ambiguities settled by deliberate choice: @@ -97,16 +101,18 @@ export function build550ContentType(speed: 'normal' | 'high'): Uint8Array { return new Uint8Array([ESC, 0x74, speed === 'high' ? 0x20 : 0x10]); } -/** `ESC n ` — Set Label Index. 4-byte u32, little-endian. */ +/** + * `ESC n ` — Set Label Index. 2-byte u16, little-endian. + * + * The 550 status frame echoes the label index back in a u16 field + * (status bytes 5-6), and minlux/dymon's Wireshark capture of the DYMO + * software shows a 2-byte field on the wire. An earlier revision + * emitted a u32 here — two bytes too wide — which left two stray `0x00` + * bytes in the job stream ahead of `ESC D` and can desync the + * firmware's command parser. + */ export function build550LabelIndex(index: number): Uint8Array { - return new Uint8Array([ - ESC, - 0x6e, - index & 0xff, - (index >> 8) & 0xff, - (index >> 16) & 0xff, - (index >> 24) & 0xff, - ]); + return new Uint8Array([ESC, 0x6e, index & 0xff, (index >> 8) & 0xff]); } /** @@ -341,23 +347,50 @@ function concat(...arrays: Uint8Array[]): Uint8Array { } /** - * Encode a complete 550-protocol print job for one or more copies. + * A 550 print job split into the segments an interactive print routine + * writes, with a status handshake between them. + * + * The 550 firmware stops draining the bulk-OUT endpoint after each + * label's `ESC G` footer until the host issues `ESC A` and reads the + * 32-byte status reply — a monolithic write of the whole job therefore + * hangs mid-stream. Confirmed against minlux/dymon's Wireshark capture + * (`dymon.cpp`) and the LW 550 Technical Reference. The driver must: + * write `preamble`, then for each `labels` segment write it, issue + * `ESC A` and drain the 32-byte status, then write `finalize`. + */ +export interface Composed550Job { + /** Job preamble — written once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. */ + preamble: Uint8Array; + /** + * One segment per copy: `ESC n` + `ESC D` + raster + `ESC G`. After + * writing each, the driver must issue `ESC A` and drain the 32-byte + * status reply before the next segment / the trailer. + */ + labels: Uint8Array[]; + /** Job trailer — written once after the last label's handshake: `ESC E` + `ESC Q`. */ + finalize: Uint8Array; +} + +/** + * Compose a 550 print job as interleavable segments — see + * `Composed550Job` for why the 550 can't take a monolithic write. * * The bitmap is fitted to the engine's `headDots` (right-padded if * narrower, cropped if wider) so each raster line is exactly * `headDots / 8` bytes. Copies share the same bitmap; each gets its - * own `ESC n` index and `ESC D` header. Inter-copy feed is `ESC G`; - * the final feed is `ESC E`. Job is closed with `ESC Q`. + * own `ESC n` index and `ESC D` header and ends with an `ESC G` + * footer. `ESC E` (feed to tear) + `ESC Q` (end job) close the job + * once, in `finalize`. * * `compress` is silently ignored — the 550 raster format does not * carry the 450's `SYN` / `ETB` framing and therefore cannot RLE. */ -export function encode550Label( +export function compose550Job( device: DeviceEntry, bitmap: LabelBitmap, options: LabelWriterPrintOptions = {}, media?: MediaDescriptor, -): Uint8Array { +): Composed550Job { const engine = device.engines.find(e => e.protocol === 'lw5-raster'); if (!engine) { throw new Error(`Device ${device.key} has no engine with protocol "lw5-raster".`); @@ -366,8 +399,6 @@ export function encode550Label( const headDots = engine.headDots; const bytesPerLine = headDots / 8; // Cross-feed-pad / leading-skip / trailing-skip per plan 08 §6. - // With empty `printableArea` (today's state) this is byte-identical - // to the previous `fitBitmapWidth` behaviour. const fitted = composeWireBitmap550(bitmap, engine, media); const widthLines = fitted.heightPx; @@ -390,23 +421,56 @@ export function encode550Label( ); } - const parts: Uint8Array[] = []; - parts.push(build550JobHeader(jobId)); - parts.push(build550Mode(mode)); - parts.push(build550Density(density550Percent(density))); + const preambleParts: Uint8Array[] = [ + build550JobHeader(jobId), + build550Mode(mode), + build550Density(density550Percent(density)), + ]; if (options.speed !== undefined) { - parts.push(build550ContentType(options.speed)); + preambleParts.push(build550ContentType(options.speed)); } + // Every label ends with `ESC G` — the 550 footer the driver follows + // with the `ESC A` status handshake. (The 450 family uses `ESC G` + // only between copies and `ESC E` as the last copy's trailer; the + // 550 footers every label and feeds-to-tear once, in `finalize`.) + const labels: Uint8Array[] = []; for (let c = 0; c < copies; c++) { - parts.push(build550LabelIndex(c)); - parts.push(build550LabelHeader(widthLines, headDots)); - parts.push(rasterBlock); - parts.push(c < copies - 1 ? build550ShortFormFeed() : build550FormFeed()); + labels.push( + concat( + build550LabelIndex(c), + build550LabelHeader(widthLines, headDots), + rasterBlock, + build550ShortFormFeed(), + ), + ); } - parts.push(build550EndJob()); - return concat(...parts); + return { + preamble: concat(...preambleParts), + labels, + finalize: concat(build550FormFeed(), build550EndJob()), + }; +} + +/** + * Encode a complete 550 print job as one contiguous byte array — + * `preamble` + every `labels` segment + `finalize` from + * `compose550Job`, with the inter-segment status handshakes omitted. + * + * This is the offline / test view of the job. **Real printing must go + * through `compose550Job` + the driver's interactive routine** — + * writing this blob in one shot hangs the 550 firmware (see + * `Composed550Job`). + */ +export function encode550Label( + device: DeviceEntry, + bitmap: LabelBitmap, + options: LabelWriterPrintOptions = {}, + media?: MediaDescriptor, +): Uint8Array { + const job = compose550Job(device, bitmap, options, media); + return concat(job.preamble, ...job.labels, job.finalize); } // ───────────────────────────────────────────────────────────────── diff --git a/packages/node/src/__tests__/printer.test.ts b/packages/node/src/__tests__/printer.test.ts index e53d464..77bded4 100644 --- a/packages/node/src/__tests__/printer.test.ts +++ b/packages/node/src/__tests__/printer.test.ts @@ -128,7 +128,7 @@ describe('LabelWriterPrinter', () => { expect(written[0]![1]).toBe(0x40); }); - it('prepends a job header for 550 devices (after the lock-acquire ESC A 1)', async () => { + it('runs the interactive 550 job (lock · preamble · label · handshake · finalize)', async () => { // Lock-acquire preamble reads 32 bytes; supply a healthy status // (bay=8 ok, voltage=1 ok) so acquire550Lock proceeds. const status = new Uint8Array(32); @@ -138,12 +138,15 @@ describe('LabelWriterPrinter', () => { const printer = new LabelWriterPrinter(device550, transport, 'usb'); await printer.print(solidRgba(672, 10), MEDIA.ADDRESS_STANDARD); - // First write is the lock-acquire ESC A 1; second write is the - // print job which starts with ESC s. - expect(written.length).toBe(2); + // Interactive 550 sequence: ESC A 1 (lock) · preamble (ESC s) · + // label segment (ESC n) · ESC A 0 (footer handshake) · finalize + // (ESC E + ESC Q). + expect(written.length).toBe(5); expect(Array.from(written[0]!)).toEqual([0x1b, 0x41, 0x01]); - expect(written[1]![0]).toBe(0x1b); - expect(written[1]![1]).toBe(0x73); + expect([written[1]![0], written[1]![1]]).toEqual([0x1b, 0x73]); // ESC s + expect([written[2]![0], written[2]![1]]).toEqual([0x1b, 0x6e]); // ESC n + expect(Array.from(written[3]!)).toEqual([0x1b, 0x41, 0x00]); // ESC A 0 + expect(Array.from(written[4]!)).toEqual([0x1b, 0x45, 0x1b, 0x51]); // ESC E + ESC Q }); it('550 print without explicit media throws (status carries no detectedMedia today)', async () => { @@ -259,12 +262,12 @@ describe('LabelWriterPrinter', () => { const printer = new LabelWriterPrinter(device550, transport, 'usb'); await printer.getMedia(); // No explicit media on print; should reuse cached SKU media - // (no extra ESC U fetch — cache hit). Two writes after getMedia: - // ESC A 1 (lock acquire) and the print job itself. + // (no extra ESC U fetch — cache hit). The interactive 550 job is + // 5 writes: ESC A 1 (lock) · preamble · label · ESC A 0 · finalize. const writesBefore = vi.mocked(transport.write).mock.calls.length; await printer.print(solidRgba(672, 4)); const writesAfter = vi.mocked(transport.write).mock.calls.length; - expect(writesAfter - writesBefore).toBe(2); + expect(writesAfter - writesBefore).toBe(5); }); }); diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 07c74e8..08b757b 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -7,6 +7,7 @@ import { build550GetVersion, build550Recovery, build550StatusRequest, + compose550Job, PRINT_STATUS_LOCK_NOT_GRANTED, STATUS_BYTE_COUNT_550, buildErrorRecovery, @@ -25,6 +26,7 @@ import { renderImage, skuInfoToMedia, statusByteCount, + type Composed550Job, type DeviceEntry, type EngineVersion, type LabelWriterEngineHandle, @@ -230,8 +232,21 @@ export class LabelWriterPrinter implements PrinterAdapter { const rotate = pickRotation(image, resolvedMedia, ROTATE_DIRECTION, options?.rotate); const bitmap = renderImage(image, { dither: true, rotate }); dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); + // 550 family: interactive half-duplex print — the firmware needs an + // `ESC A` + 32-byte status read after each label's `ESC G` footer or + // it stalls the bulk-OUT endpoint. See `write550Job`. + if (engine.protocol === 'lw5-raster') { + const job = compose550Job(this.device, bitmap, options, resolvedMedia); + dbg( + `composed 550 job: preamble=${String(job.preamble.length)}B ` + + `labels=${String(job.labels.length)} finalize=${String(job.finalize.length)}B`, + ); + await this.write550Job(transport, job); + return; + } + // Duo tape engine: dispatch through the async encoder that - // lazy-loads d1-core. Raster engines stay on the sync path. + // lazy-loads d1-core. lw-raster (450 family) stays on the sync path. const bytes = isDuoTapeEngine(engine) ? await encodeDuoTapeLabel(this.device, bitmap, options, resolvedMedia) : encodeLabel(this.device, bitmap, options, resolvedMedia); @@ -240,6 +255,47 @@ export class LabelWriterPrinter implements PrinterAdapter { dbg(`print complete: ${String(bytes.length)} bytes written`); } + /** + * Write a composed 550 job interactively — the 550 firmware needs an + * `ESC A` + 32-byte status read after each label's `ESC G` footer or + * it stalls the bulk-OUT endpoint. See the web printer's `write550Job` + * for the full rationale (minlux/dymon prior art). + * + * `ESC A` lock byte: `0` for the last label (final status query + + * lock release); `2` between labels (host does not block on the + * reply — it is drained on the next iteration). + */ + private async write550Job(transport: Transport, job: Composed550Job): Promise { + await transport.write(job.preamble); + // A deferred `ESC A 2` reply from the previous label, not yet drained. + let pendingHandshake = false; + for (const [i, segment] of job.labels.entries()) { + const isLast = i === job.labels.length - 1; + if (pendingHandshake) { + const prev = await transport.read(STATUS_BYTE_COUNT_550); + dbg( + `550 deferred handshake: status len=${String(prev.length)} ` + + `byte0=${String(prev[0] ?? -1)}`, + ); + pendingHandshake = false; + } + await transport.write(segment); + dbg(`550 label ${String(i + 1)}/${String(job.labels.length)} written — footer handshake`); + await transport.write(build550StatusRequest(isLast ? 0 : 2)); + if (isLast) { + const status = await transport.read(STATUS_BYTE_COUNT_550); + dbg( + `550 final handshake: status len=${String(status.length)} ` + + `byte0=${String(status[0] ?? -1)}`, + ); + } else { + pendingHandshake = true; + } + } + await transport.write(job.finalize); + dbg('550 interactive print complete — finalize written'); + } + /** * 550-only: send `ESC A 1` to acquire the print lock, parse the * 32-byte response, and refuse to proceed if (a) the lock is held diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index 09749f1..aec9027 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -8,6 +8,7 @@ import { build550GetVersion, build550Recovery, build550StatusRequest, + compose550Job, PRINT_STATUS_LOCK_NOT_GRANTED, STATUS_BYTE_COUNT_550, buildErrorRecovery, @@ -28,6 +29,7 @@ import { skuInfoDetails, skuInfoToMedia, statusByteCount, + type Composed550Job, type DeviceEntry, type EngineVersion, type LabelWriterEngineHandle, @@ -85,6 +87,17 @@ const D1_STATUS_BYTE_COUNT = 1; */ const STATUS_READ_TIMEOUT_MS = 2000; +/** + * Read deadline for the 550 print-footer status handshake — the + * `ESC A` reply the firmware expects the host to drain after every + * `ESC G` (see `write550Job`). Longer than `STATUS_READ_TIMEOUT_MS` + * because the firmware may answer only once the label has physically + * fed; a 300 dpi diagnostic label is a couple of seconds. The deadline + * still converts a wedged firmware into a thrown `TransportTimeoutError` + * the harness can surface, instead of an unbounded hang. + */ +const PRINT_HANDSHAKE_TIMEOUT_MS = 15000; + export interface RequestOptions { filters?: USBDeviceFilter[]; } @@ -247,8 +260,24 @@ export class WebLabelWriterPrinter implements PrinterAdapter { // Force `engine` to this instance's role so the encoder dispatches // on the right protocol (lw-raster / lw5-raster / d1-tape). const encodeOptions: LabelWriterPrintOptions = { ...options, engine: this.engine.role }; + + // 550 family: the print job is an interactive half-duplex exchange. + // The firmware stalls draining the bulk-OUT endpoint after each + // label's `ESC G` footer until the host issues `ESC A` and reads + // the 32-byte status reply — a monolithic write hangs. See + // `write550Job`. + if (this.engine.protocol === 'lw5-raster') { + const job = compose550Job(this.device, bitmap, encodeOptions, resolvedMedia); + dbg( + `composed 550 job: preamble=${String(job.preamble.length)}B ` + + `labels=${String(job.labels.length)} finalize=${String(job.finalize.length)}B`, + ); + await this.write550Job(job); + return; + } + // Duo tape engine: dispatch through the async encoder that - // lazy-loads d1-core. Raster engines stay on the sync path. + // lazy-loads d1-core. lw-raster (450 family) stays on the sync path. const bytes = isDuoTapeEngine(this.engine) ? await encodeDuoTapeLabel(this.device, bitmap, encodeOptions, resolvedMedia) : encodeLabel(this.device, bitmap, encodeOptions, resolvedMedia); @@ -257,6 +286,58 @@ export class WebLabelWriterPrinter implements PrinterAdapter { dbg(`print complete: ${String(bytes.length)} bytes written`); } + /** + * Write a composed 550 job interactively. + * + * The 550 firmware stops draining the bulk-OUT endpoint after each + * label's `ESC G` footer until the host issues `ESC A` and reads the + * 32-byte status reply (confirmed against minlux/dymon's Wireshark + * capture + the LW 550 Technical Reference). So: write the preamble, + * then per label write the segment + `ESC A`, drain the handshake + * status, then write `ESC E` + `ESC Q`. + * + * `ESC A` lock byte per the 550 spec: `0` for the LAST label — the + * final status query, which also drops the host lock; `2` between + * labels — the host does not block on that reply before streaming the + * next label, so the read is deferred to the next iteration. + * + * The handshake read is timed — a non-responsive firmware surfaces + * as a thrown `TransportTimeoutError` the caller can show, rather + * than an unbounded `print()` hang. + */ + private async write550Job(job: Composed550Job): Promise { + await this.transport.write(job.preamble); + // A deferred `ESC A 2` reply from the previous label, not yet drained. + let pendingHandshake = false; + for (const [i, segment] of job.labels.entries()) { + const isLast = i === job.labels.length - 1; + if (pendingHandshake) { + const prev = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); + dbg( + `550 deferred handshake: status len=${String(prev.length)} ` + + `byte0=${String(prev[0] ?? -1)}`, + ); + pendingHandshake = false; + } + await this.transport.write(segment); + dbg(`550 label ${String(i + 1)}/${String(job.labels.length)} written — footer handshake`); + await this.transport.write(build550StatusRequest(isLast ? 0 : 2)); + if (isLast) { + // `ESC A 0` — final status query; wait for the reply. + const status = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); + dbg( + `550 final handshake: status len=${String(status.length)} ` + + `byte0=${String(status[0] ?? -1)}`, + ); + } else { + // `ESC A 2` — host does not wait; drain on the next iteration. + pendingHandshake = true; + } + } + await this.transport.write(job.finalize); + dbg('550 interactive print complete — finalize written'); + } + /** * Acquire the 550 print lock + health check. Private — only called * from within `doPrint()`, which already holds the serializer, so From a3c3b5fd7afe1b1157b43a3ea799be64fc51747b Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 19:43:22 +0200 Subject: [PATCH 07/17] release: 0.6.3-debug.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 550 interactive print-handshake fix (e526e06) — drives ESC A status between labels so the firmware no longer stalls the bulk-OUT endpoint; ESC n label index corrected u32 -> u16. Publishes core/node/web to the `debug` npm dist-tag via the prerelease-aware release workflow. Lockfile unchanged (version-only bump of workspace packages). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/package.json | 2 +- packages/node/package.json | 2 +- packages/web/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 9c66ba3..ef1fd0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-core", - "version": "0.6.3-debug.0", + "version": "0.6.3-debug.1", "description": "Protocol encoding and device registry for Dymo LabelWriter printers", "keywords": [ "dymo", diff --git a/packages/node/package.json b/packages/node/package.json index 8a0a04b..80992c3 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-node", - "version": "0.6.3-debug.0", + "version": "0.6.3-debug.1", "description": "Node.js USB and TCP driver for Dymo LabelWriter printers", "keywords": [ "dymo", diff --git a/packages/web/package.json b/packages/web/package.json index 60c10cb..c4b5c01 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@thermal-label/labelwriter-web", - "version": "0.6.3-debug.0", + "version": "0.6.3-debug.1", "description": "WebUSB browser driver for Dymo LabelWriter printers", "keywords": [ "dymo", From a89a9392903509c7693f2d40abf85ea175f2f02d Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 19:45:07 +0200 Subject: [PATCH 08/17] Regenerate API docs --- docs/api/core/src/README.md | 2 + .../core/src/functions/build550LabelIndex.md | 9 +++- docs/api/core/src/functions/compose550Job.md | 44 +++++++++++++++++++ docs/api/core/src/functions/encode550Label.md | 18 ++++---- .../api/core/src/interfaces/Composed550Job.md | 44 +++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 docs/api/core/src/functions/compose550Job.md create mode 100644 docs/api/core/src/interfaces/Composed550Job.md diff --git a/docs/api/core/src/README.md b/docs/api/core/src/README.md index d52774b..5df9f93 100644 --- a/docs/api/core/src/README.md +++ b/docs/api/core/src/README.md @@ -12,6 +12,7 @@ ## Interfaces +- [Composed550Job](interfaces/Composed550Job.md) - [EngineVersion](interfaces/EngineVersion.md) - [LabelWriterEngineCapabilities](interfaces/LabelWriterEngineCapabilities.md) - [LabelWriterEngineHandle](interfaces/LabelWriterEngineHandle.md) @@ -78,6 +79,7 @@ - [buildSetLabelLength](functions/buildSetLabelLength.md) - [buildShortFormFeed](functions/buildShortFormFeed.md) - [buildStatusRequest](functions/buildStatusRequest.md) +- [compose550Job](functions/compose550Job.md) - [createPreviewOffline](functions/createPreviewOffline.md) - [density550Percent](functions/density550Percent.md) - [duoTapeStatusRequest](functions/duoTapeStatusRequest.md) diff --git a/docs/api/core/src/functions/build550LabelIndex.md b/docs/api/core/src/functions/build550LabelIndex.md index 7778d8b..96c54da 100644 --- a/docs/api/core/src/functions/build550LabelIndex.md +++ b/docs/api/core/src/functions/build550LabelIndex.md @@ -8,7 +8,14 @@ > **build550LabelIndex**(`index`): `Uint8Array` -`ESC n ` — Set Label Index. 4-byte u32, little-endian. +`ESC n ` — Set Label Index. 2-byte u16, little-endian. + +The 550 status frame echoes the label index back in a u16 field +(status bytes 5-6), and minlux/dymon's Wireshark capture of the DYMO +software shows a 2-byte field on the wire. An earlier revision +emitted a u32 here — two bytes too wide — which left two stray `0x00` +bytes in the job stream ahead of `ESC D` and can desync the +firmware's command parser. ## Parameters diff --git a/docs/api/core/src/functions/compose550Job.md b/docs/api/core/src/functions/compose550Job.md new file mode 100644 index 0000000..ce9470c --- /dev/null +++ b/docs/api/core/src/functions/compose550Job.md @@ -0,0 +1,44 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / compose550Job + +# Function: compose550Job() + +> **compose550Job**(`device`, `bitmap`, `options?`, `media?`): [`Composed550Job`](../interfaces/Composed550Job.md) + +Compose a 550 print job as interleavable segments — see +`Composed550Job` for why the 550 can't take a monolithic write. + +The bitmap is fitted to the engine's `headDots` (right-padded if +narrower, cropped if wider) so each raster line is exactly +`headDots / 8` bytes. Copies share the same bitmap; each gets its +own `ESC n` index and `ESC D` header and ends with an `ESC G` +footer. `ESC E` (feed to tear) + `ESC Q` (end job) close the job +once, in `finalize`. + +`compress` is silently ignored — the 550 raster format does not +carry the 450's `SYN` / `ETB` framing and therefore cannot RLE. + +## Parameters + +### device + +[`DeviceEntry`](/contracts/api/interfaces/DeviceEntry) + +### bitmap + +[`LabelBitmap`](/contracts/api/interfaces/LabelBitmap) + +### options? + +[`LabelWriterPrintOptions`](../interfaces/LabelWriterPrintOptions.md) = `{}` + +### media? + +[`MediaDescriptor`](/contracts/api/interfaces/MediaDescriptor) + +## Returns + +[`Composed550Job`](../interfaces/Composed550Job.md) diff --git a/docs/api/core/src/functions/encode550Label.md b/docs/api/core/src/functions/encode550Label.md index acb9f46..e7038fd 100644 --- a/docs/api/core/src/functions/encode550Label.md +++ b/docs/api/core/src/functions/encode550Label.md @@ -8,16 +8,14 @@ > **encode550Label**(`device`, `bitmap`, `options?`, `media?`): `Uint8Array` -Encode a complete 550-protocol print job for one or more copies. - -The bitmap is fitted to the engine's `headDots` (right-padded if -narrower, cropped if wider) so each raster line is exactly -`headDots / 8` bytes. Copies share the same bitmap; each gets its -own `ESC n` index and `ESC D` header. Inter-copy feed is `ESC G`; -the final feed is `ESC E`. Job is closed with `ESC Q`. - -`compress` is silently ignored — the 550 raster format does not -carry the 450's `SYN` / `ETB` framing and therefore cannot RLE. +Encode a complete 550 print job as one contiguous byte array — +`preamble` + every `labels` segment + `finalize` from +`compose550Job`, with the inter-segment status handshakes omitted. + +This is the offline / test view of the job. **Real printing must go +through `compose550Job` + the driver's interactive routine** — +writing this blob in one shot hangs the 550 firmware (see +`Composed550Job`). ## Parameters diff --git a/docs/api/core/src/interfaces/Composed550Job.md b/docs/api/core/src/interfaces/Composed550Job.md new file mode 100644 index 0000000..2bcae00 --- /dev/null +++ b/docs/api/core/src/interfaces/Composed550Job.md @@ -0,0 +1,44 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / Composed550Job + +# Interface: Composed550Job + +A 550 print job split into the segments an interactive print routine +writes, with a status handshake between them. + +The 550 firmware stops draining the bulk-OUT endpoint after each +label's `ESC G` footer until the host issues `ESC A` and reads the +32-byte status reply — a monolithic write of the whole job therefore +hangs mid-stream. Confirmed against minlux/dymon's Wireshark capture +(`dymon.cpp`) and the LW 550 Technical Reference. The driver must: +write `preamble`, then for each `labels` segment write it, issue +`ESC A` and drain the 32-byte status, then write `finalize`. + +## Properties + +### finalize + +> **finalize**: `Uint8Array` + +Job trailer — written once after the last label's handshake: `ESC E` + `ESC Q`. + +*** + +### labels + +> **labels**: `Uint8Array`[] + +One segment per copy: `ESC n` + `ESC D` + raster + `ESC G`. After +writing each, the driver must issue `ESC A` and drain the 32-byte +status reply before the next segment / the trailer. + +*** + +### preamble + +> **preamble**: `Uint8Array` + +Job preamble — written once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. From 8ddbb09db2fd23a927f7d7bf6a00dd1762a47051 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 19:47:56 +0200 Subject: [PATCH 09/17] pnpm format --- packages/core/src/__tests__/protocol-550.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/protocol-550.test.ts b/packages/core/src/__tests__/protocol-550.test.ts index f62ff57..064dfd2 100644 --- a/packages/core/src/__tests__/protocol-550.test.ts +++ b/packages/core/src/__tests__/protocol-550.test.ts @@ -405,9 +405,7 @@ describe('compose550Job', () => { const job = compose550Job(DEVICES.LW_550, bm(672, 200), { copies: 2 }); const flat = encode550Label(DEVICES.LW_550, bm(672, 200), { copies: 2 }); const segLen = - job.preamble.length + - job.labels.reduce((n, l) => n + l.length, 0) + - job.finalize.length; + job.preamble.length + job.labels.reduce((n, l) => n + l.length, 0) + job.finalize.length; expect(flat.length).toBe(segLen); }); From 740ca87f8da982096a765aa71b19e87a5917758b Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 20:00:07 +0200 Subject: [PATCH 10/17] =?UTF-8?q?test(550):=20cover=20write550Job=20multi-?= =?UTF-8?q?copy=20path=20=E2=80=94=20restore=2090%=20branch=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI flagged web branch coverage at 89.71% after the interactive 550 print rewrite (e526e06): write550Job's deferred-handshake path (ESC A 2 between labels) had no test. Add a copies:2 web print test that exercises it, and drop two unreachable `?? -1` branches in the handshake debug logging. web branch coverage 89.71% -> 92.04%. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/node/src/printer.ts | 4 ++-- packages/web/src/__tests__/printer.test.ts | 21 +++++++++++++++++++++ packages/web/src/printer.ts | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 08b757b..3339b98 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -275,7 +275,7 @@ export class LabelWriterPrinter implements PrinterAdapter { const prev = await transport.read(STATUS_BYTE_COUNT_550); dbg( `550 deferred handshake: status len=${String(prev.length)} ` + - `byte0=${String(prev[0] ?? -1)}`, + `byte0=${String(prev[0])}`, ); pendingHandshake = false; } @@ -286,7 +286,7 @@ export class LabelWriterPrinter implements PrinterAdapter { const status = await transport.read(STATUS_BYTE_COUNT_550); dbg( `550 final handshake: status len=${String(status.length)} ` + - `byte0=${String(status[0] ?? -1)}`, + `byte0=${String(status[0])}`, ); } else { pendingHandshake = true; diff --git a/packages/web/src/__tests__/printer.test.ts b/packages/web/src/__tests__/printer.test.ts index 77ecd39..4873319 100644 --- a/packages/web/src/__tests__/printer.test.ts +++ b/packages/web/src/__tests__/printer.test.ts @@ -233,6 +233,27 @@ describe('WebLabelWriterPrinter', () => { expect(writes[1]![1]).toBe(0x73); }); + it('print() runs the interactive multi-copy handshake on 550 (ESC A 2 → ESC A 0)', async () => { + const status = new Uint8Array(32); + status[10] = 8; // bay ok + status[30] = 1; // head voltage ok + const device = createMockUSBDevice(LW_550.vid, LW_550.pid, status); + const printer = await fromUSBDevice(device); + const before = device.__transfers.length; + await printer.print(solidRgba(672, 4), MEDIA.ADDRESS_STANDARD, { copies: 2 }); + const writes = device.__transfers.slice(before).map(t => t.data); + // ESC A 1 (lock) · preamble · label 0 · ESC A 2 (between labels) · + // label 1 · ESC A 0 (last label) · finalize (ESC E + ESC Q). + expect(writes).toHaveLength(7); + expect(Array.from(writes[0]!)).toEqual([0x1b, 0x41, 0x01]); + expect([writes[1]![0], writes[1]![1]]).toEqual([0x1b, 0x73]); // ESC s + expect([writes[2]![0], writes[2]![1]]).toEqual([0x1b, 0x6e]); // ESC n (copy 0) + expect(Array.from(writes[3]!)).toEqual([0x1b, 0x41, 0x02]); // ESC A 2 + expect([writes[4]![0], writes[4]![1]]).toEqual([0x1b, 0x6e]); // ESC n (copy 1) + expect(Array.from(writes[5]!)).toEqual([0x1b, 0x41, 0x00]); // ESC A 0 + expect(Array.from(writes[6]!)).toEqual([0x1b, 0x45, 0x1b, 0x51]); // ESC E + ESC Q + }); + it('print() throws when the 550 reports the lock is held by another host', async () => { const status = new Uint8Array(32); status[0] = 5; // PRINT_STATUS_LOCK_NOT_GRANTED diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index aec9027..53080cd 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -315,7 +315,7 @@ export class WebLabelWriterPrinter implements PrinterAdapter { const prev = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); dbg( `550 deferred handshake: status len=${String(prev.length)} ` + - `byte0=${String(prev[0] ?? -1)}`, + `byte0=${String(prev[0])}`, ); pendingHandshake = false; } @@ -327,7 +327,7 @@ export class WebLabelWriterPrinter implements PrinterAdapter { const status = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); dbg( `550 final handshake: status len=${String(status.length)} ` + - `byte0=${String(status[0] ?? -1)}`, + `byte0=${String(status[0])}`, ); } else { // `ESC A 2` — host does not wait; drain on the next iteration. From 63bb5466baa0b116112ad581c6886c6bd300f509 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Fri, 22 May 2026 20:06:15 +0200 Subject: [PATCH 11/17] pnpm format --- packages/node/src/printer.ts | 3 +-- packages/web/src/printer.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 3339b98..2e20e46 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -274,8 +274,7 @@ export class LabelWriterPrinter implements PrinterAdapter { if (pendingHandshake) { const prev = await transport.read(STATUS_BYTE_COUNT_550); dbg( - `550 deferred handshake: status len=${String(prev.length)} ` + - `byte0=${String(prev[0])}`, + `550 deferred handshake: status len=${String(prev.length)} ` + `byte0=${String(prev[0])}`, ); pendingHandshake = false; } diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index 53080cd..a1eef69 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -314,8 +314,7 @@ export class WebLabelWriterPrinter implements PrinterAdapter { if (pendingHandshake) { const prev = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); dbg( - `550 deferred handshake: status len=${String(prev.length)} ` + - `byte0=${String(prev[0])}`, + `550 deferred handshake: status len=${String(prev.length)} ` + `byte0=${String(prev[0])}`, ); pendingHandshake = false; } From 7c3926d646814f5f9fcbabf3e003c3f8cb3f79ca Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 01:13:51 +0200 Subject: [PATCH 12/17] refactor(core): drop dead-zone crop from encoder; expose getPrintableCanvasDots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-2026-05-23 encoder cropped `leading` mm of rows off the input bitmap so the head's mechanically unreachable band wouldn't eat authored content. That worked on rolls where the firmware didn't itself compensate (older LW 3xx/4xx), but on newer LW 550 rolls the NFC SKU dump's `marker1ToStart` already shifts the head past the deadzone — chassis pre-trim then double-counted and left ~6 mm blank on the trailing edge. The architectural fix is to move dead-zone awareness off the wire- encoding path and onto the authoring layer. Designer / harness code sizes its canvas to what the head can physically reach; the encoder trusts every row. * `composeWireBitmap` / `composeWireBitmap550` collapse to width-only fit (pad-right or crop-right to `headDots`, every row through). * New `getPrintableCanvasDots(engine, media)` helper exposes the dot-space deductions callers must subtract from the label length: `{widthDots, leadingDots, trailingDots, leftDots, rightDots}`. One place owns the mm→dot rounding. * Suspended plan-08 §6 tests deleted; replaced with assertions that the encoder ignores `printableArea` regardless of value, plus coverage on the new helper. The `printableArea` field stays on every device entry — its meaning shifts from "crop this much from the wire" to "the authoring layer must subtract this much from the canvas". Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/core/src/README.md | 2 + .../src/functions/getPrintableCanvasDots.md | 35 +++ .../src/interfaces/PrintableCanvasDots.md | 54 +++++ .../src/__tests__/printable-canvas.test.ts | 57 +++++ .../core/src/__tests__/protocol-550.test.ts | 63 ++--- packages/core/src/__tests__/protocol.test.ts | 221 ++++-------------- packages/core/src/index.ts | 2 + packages/core/src/printable-canvas.ts | 76 ++++++ packages/core/src/protocol-550.ts | 76 ++---- packages/core/src/protocol.ts | 117 +++------- 10 files changed, 334 insertions(+), 369 deletions(-) create mode 100644 docs/api/core/src/functions/getPrintableCanvasDots.md create mode 100644 docs/api/core/src/interfaces/PrintableCanvasDots.md create mode 100644 packages/core/src/__tests__/printable-canvas.test.ts create mode 100644 packages/core/src/printable-canvas.ts diff --git a/docs/api/core/src/README.md b/docs/api/core/src/README.md index 5df9f93..bf12eb7 100644 --- a/docs/api/core/src/README.md +++ b/docs/api/core/src/README.md @@ -19,6 +19,7 @@ - [LabelWriterMedia](interfaces/LabelWriterMedia.md) - [LabelWriterPrintOptions](interfaces/LabelWriterPrintOptions.md) - [LabelWriterTapeMedia](interfaces/LabelWriterTapeMedia.md) +- [PrintableCanvasDots](interfaces/PrintableCanvasDots.md) - [SkuInfo](interfaces/SkuInfo.md) ## Type Aliases @@ -90,6 +91,7 @@ - [~~findMediaByDimensions~~](functions/findMediaByDimensions.md) - [findTapeMediaByWidth](functions/findTapeMediaByWidth.md) - [findTapeMediaByWidthAll](functions/findTapeMediaByWidthAll.md) +- [getPrintableCanvasDots](functions/getPrintableCanvasDots.md) - [isDuoTapeEngine](functions/isDuoTapeEngine.md) - [isEngineDrivable](functions/isEngineDrivable.md) - [loadD1Core](functions/loadD1Core.md) diff --git a/docs/api/core/src/functions/getPrintableCanvasDots.md b/docs/api/core/src/functions/getPrintableCanvasDots.md new file mode 100644 index 0000000..19e6337 --- /dev/null +++ b/docs/api/core/src/functions/getPrintableCanvasDots.md @@ -0,0 +1,35 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / getPrintableCanvasDots + +# Function: getPrintableCanvasDots() + +> **getPrintableCanvasDots**(`engine`, `media?`): [`PrintableCanvasDots`](../interfaces/PrintableCanvasDots.md) + +Resolve the printable-canvas deductions for a given engine + media, +in dot space. Callers compose their authored bitmap at +`widthDots × (mediaLengthDots − leadingDots − trailingDots)` for +die-cut media (or any user-chosen height for continuous, minus the +dead zones). + +The encoder no longer reads these values — they are the authoring +layer's responsibility. When a caller authors a bitmap shorter than +the actual label length, it must also pass +`options.labelLengthDots = media.lengthDots` to `encodeLabel` so the +printer's form-feed pitch is correct. + +## Parameters + +### engine + +[`PrintEngine`](/contracts/api/interfaces/PrintEngine) + +### media? + +[`MediaDescriptor`](/contracts/api/interfaces/MediaDescriptor) + +## Returns + +[`PrintableCanvasDots`](../interfaces/PrintableCanvasDots.md) diff --git a/docs/api/core/src/interfaces/PrintableCanvasDots.md b/docs/api/core/src/interfaces/PrintableCanvasDots.md new file mode 100644 index 0000000..f3587c8 --- /dev/null +++ b/docs/api/core/src/interfaces/PrintableCanvasDots.md @@ -0,0 +1,54 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / PrintableCanvasDots + +# Interface: PrintableCanvasDots + +## Properties + +### leadingDots + +> **leadingDots**: `number` + +Feed-direction dots the authoring layer must subtract from the +label's physical length to get the printable height. With LW 3xx +/4xx/5xx engines this is `Math.round(6 mm * dpi / 25.4)` — 71 dots +at 300 dpi. + +*** + +### leftDots + +> **leftDots**: `number` + +Left-edge cross-feed dead zone. `0` everywhere on LW today. + +*** + +### rightDots + +> **rightDots**: `number` + +Right-edge cross-feed dead zone. `0` everywhere on LW today. + +*** + +### trailingDots + +> **trailingDots**: `number` + +Trailing-edge dead zone in feed direction. `0` everywhere on LW +today (the head reaches the trailing edge); plumbed through for +symmetry and future-proofing. + +*** + +### widthDots + +> **widthDots**: `number` + +Cross-feed dimension the authored bitmap should use. Equals +`engine.headDots − leftDots − rightDots`. With today's all-zero +`left`/`right` values across LW devices this is just `headDots`. diff --git a/packages/core/src/__tests__/printable-canvas.test.ts b/packages/core/src/__tests__/printable-canvas.test.ts new file mode 100644 index 0000000..1f26dea --- /dev/null +++ b/packages/core/src/__tests__/printable-canvas.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { PrintEngine } from '@thermal-label/contracts'; +import { getPrintableCanvasDots } from '../printable-canvas.js'; +import { DEVICES } from '../devices.js'; + +describe('getPrintableCanvasDots', () => { + it('absent printableArea reports headDots width and zero deductions', () => { + // Strip `printableArea` from a real engine — `getPrintableArea` + // falls back to ZERO_PRINTABLE_AREA when the field is missing. + const base = DEVICES.LW_450.engines[0]!; + const engine: PrintEngine = { ...base }; + delete (engine as { printableArea?: unknown }).printableArea; + const dots = getPrintableCanvasDots(engine); + expect(dots).toEqual({ + widthDots: engine.headDots, + leadingDots: 0, + trailingDots: 0, + leftDots: 0, + rightDots: 0, + }); + }); + + it('rounds mm→dots once and matches the encoder-era mmToDots formula', () => { + // Every LW 3xx/4xx/5xx ships `printableArea.leading = 6 mm`. + // 300 dpi → Math.round(6 * 300 / 25.4) = 71 dots. + const engine = DEVICES.LW_450.engines[0]!; + const dots = getPrintableCanvasDots(engine); + expect(dots.leadingDots).toBe(71); + expect(dots.widthDots).toBe(engine.headDots); + expect(dots.trailingDots).toBe(0); + }); + + it('synthesised non-zero edges round independently', () => { + // Build a synthetic engine with every edge populated to confirm + // the helper threads each value through mmToDots independently. + const base = DEVICES.LW_330_TURBO.engines[0]!; + const dpi = base.dpi; // 300 + // Choose mm values that back-solve to whole dot counts. + const synth: PrintEngine = { + ...base, + printableArea: { + leading: (70 * 25.4) / dpi, + trailing: (18 * 25.4) / dpi, + left: (18 * 25.4) / dpi, + right: (4 * 25.4) / dpi, + }, + }; + const dots = getPrintableCanvasDots(synth); + expect(dots).toEqual({ + widthDots: base.headDots - 18 - 4, + leadingDots: 70, + trailingDots: 18, + leftDots: 18, + rightDots: 4, + }); + }); +}); diff --git a/packages/core/src/__tests__/protocol-550.test.ts b/packages/core/src/__tests__/protocol-550.test.ts index 064dfd2..5c81b31 100644 --- a/packages/core/src/__tests__/protocol-550.test.ts +++ b/packages/core/src/__tests__/protocol-550.test.ts @@ -318,17 +318,16 @@ describe('encode550Label', () => { expect(findEscByte(out, 0x74)).toBeUndefined(); }); - // Plan 08 §6 (Labelwriter subsection): the 550 encoder mirrors the - // 450 dead-zone pipeline. With every DEVICES entry shipping - // `printableArea: undefined` today, resolved area is - // `ZERO_PRINTABLE_AREA` and the wire output stays byte-identical. - describe('printable-area integration (plan 08 §6)', () => { - it('field-absent: ESC D widthLines equals bitmap height', () => { + // Plan 08 §6 rolled back on 2026-05-23 (debug/print-flow): the 550 + // encoder no longer reads `printableArea`. ESC D widthLines now + // tracks `bitmap.heightPx` verbatim regardless of the device's + // configured leading. + describe('encoder ignores printableArea (round 2, debug/print-flow)', () => { + it('ESC D widthLines equals bitmap height for the field-absent case', () => { const heightPx = 200; const out = encode550Label(lw550, bm(672, heightPx)); const escD = findEscByte(out, 0x44); expect(escD).toBeDefined(); - // Width = u32LE at bytes 4..7 of ESC D = number of raster lines const width = (out[escD! + 4]! | (out[escD! + 5]! << 8) | @@ -340,24 +339,19 @@ describe('encode550Label', () => { function deviceWithPrintableArea(printableArea: PrintableArea): DeviceEntry { const baseEngine = lw550.engines[0]!; - return { - ...lw550, - engines: [{ ...baseEngine, printableArea }], - }; + return { ...lw550, engines: [{ ...baseEngine, printableArea }] }; } - it('populated fields: widthLines drops by leading + trailing dots', () => { + it('ESC D widthLines still tracks bitmap.heightPx with a non-zero leading', () => { const dpi = 300; const leadingMm = (70 * 25.4) / dpi; - const trailingMm = (18 * 25.4) / dpi; const dev = deviceWithPrintableArea({ leading: leadingMm, - trailing: trailingMm, + trailing: 0, left: 0, right: 0, }); const heightPx = 1051; - const expectedWireRows = heightPx - 70 - 18; const out = encode550Label(dev, bm(672, heightPx)); const escD = findEscByte(out, 0x44); expect(escD).toBeDefined(); @@ -367,7 +361,7 @@ describe('encode550Label', () => { (out[escD! + 6]! << 16) | (out[escD! + 7]! << 24)) >>> 0; - expect(width).toBe(expectedWireRows); + expect(width).toBe(heightPx); }); }); }); @@ -663,35 +657,13 @@ describe('withDetectedMedia', () => { }); }); -describe('encode550Label — printable-area edge cases', () => { +describe('encode550Label — edge cases', () => { function withArea(printableArea: PrintableArea): DeviceEntry { const baseEngine = DEVICES.LW_550.engines[0]!; return { ...DEVICES.LW_550, engines: [{ ...baseEngine, printableArea }] }; } - it('bitmap shorter than the leading dead-zone collapses to a zero-row wire bitmap', () => { - // leadingDots exceeds the bitmap height → wireRows clamps to 0 and - // the encoder emits no raster rows (just the job framing). - const dpi = 300; - const leadingMm = (100 * 25.4) / dpi; // 100 dots leading - const dev = withArea({ leading: leadingMm, trailing: 0, left: 0, right: 0 }); - const out = encode550Label(dev, createBitmap(672, 20)); // 20-row bitmap - // No SYN/raster lines — ESC D widthLines is 0. - const escD = findEscByte(out, 0x44); - expect(escD).toBeDefined(); - const width = - (out[escD! + 4]! | - (out[escD! + 5]! << 8) | - (out[escD! + 6]! << 16) | - (out[escD! + 7]! << 24)) >>> - 0; - expect(width).toBe(0); - }); - - it('zero printable-area with a head-width bitmap passes the bitmap straight through', () => { - // Zero dead-zone on every edge + an authored bitmap exactly headDots - // wide → the encoder's fast path returns the input bitmap unchanged - // (no crop, no pad). + it('a head-width bitmap passes straight through (no width-fit work)', () => { const dev = withArea({ leading: 0, trailing: 0, left: 0, right: 0 }); const headDots = dev.engines[0]!.headDots; const heightPx = 40; @@ -707,12 +679,12 @@ describe('encode550Label — printable-area edge cases', () => { expect(widthLines).toBe(heightPx); }); - it('left-only dead-zone with a head-width label crops then pads the wire bitmap', () => { - // `left` is non-zero so the encoder leaves the zero-area fast path - // and runs the crop + pad pipeline. Authoring a head-width bitmap - // keeps the surviving slice non-empty so `sourceColCount > 0`. + it('printableArea is ignored — non-zero left does not change widthLines', () => { + // The pre-2026-05-23 encoder ran a crop+pad pipeline whenever any + // dead-zone edge was non-zero; the round-2 encoder ignores + // printableArea entirely and ESC D widthLines tracks bitmap.heightPx. const dpi = 300; - const leftMm = (24 * 25.4) / dpi; // exactly 24 dots + const leftMm = (24 * 25.4) / dpi; const dev = withArea({ leading: 0, trailing: 0, left: leftMm, right: 0 }); const headDots = dev.engines[0]!.headDots; const heightPx = 12; @@ -725,7 +697,6 @@ describe('encode550Label — printable-area edge cases', () => { (out[escD! + 6]! << 16) | (out[escD! + 7]! << 24)) >>> 0; - // No leading/trailing skip — widthLines equals the bitmap height. expect(widthLines).toBe(heightPx); }); }); diff --git a/packages/core/src/__tests__/protocol.test.ts b/packages/core/src/__tests__/protocol.test.ts index 1e0e341..cf358ec 100644 --- a/packages/core/src/__tests__/protocol.test.ts +++ b/packages/core/src/__tests__/protocol.test.ts @@ -415,90 +415,38 @@ describe('encodeLabel', () => { } }); - // Plan 08 §6 (Labelwriter subsection): the encoder resolves - // `printableArea` and crops/pads the wire bitmap accordingly. As of - // 2026-05-08 every LW 3xx/4xx/5xx engine ships `printableArea.leading - // = 6 mm` (chassis-mechanical bench-validated value); the field-absent - // case is exercised here against a synthesised bare device so the - // skip-rows branch and its no-op path stay independently asserted. - describe('printable-area integration (plan 08 §6)', () => { - it('field-absent: row count equals bitmap height (skip-rows is a no-op at zero)', () => { - const headDots = device450Bare.engines[0]!.headDots; - const heightPx = 200; - const bm = makeBitmap(headDots, heightPx); - const out = encodeLabel(device450Bare, bm); - let rowCount = 0; - let i = 0; - while (i < out.length) { - if (out[i] === 0x16) { - rowCount++; - i += 1 + headDots / 8; - } else { - i++; - } - } - expect(rowCount).toBe(heightPx); - }); - - it('field-absent: ESC L label-length byte equals bitmap height', () => { - const headDots = device450Bare.engines[0]!.headDots; - const heightPx = 250; - const bm = makeBitmap(headDots, heightPx); - const out = encodeLabel(device450Bare, bm); - // ESC L emits little-endian u16; find the bytes directly. - let escL = -1; - for (let i = 0; i < out.length - 3; i++) { - if (out[i] === 0x1b && out[i + 1] === 0x4c) { - escL = i; - break; - } - } - expect(escL).toBeGreaterThan(-1); - const length = (out[escL + 2] ?? 0) | ((out[escL + 3] ?? 0) << 8); - expect(length).toBe(heightPx); - }); - + // Plan 08 §6 rolled back on 2026-05-23 (debug/print-flow): the + // encoder no longer reads `printableArea` — dead-zone offsets are + // applied by the authoring layer via `getPrintableCanvasDots`. These + // tests pin the encoder's new contract: every row of the input + // bitmap reaches the wire regardless of the device's leading value. + describe('encoder ignores printableArea (round 2, debug/print-flow)', () => { /** * Synthesize a single-engine LW device with a populated - * `printableArea`. Works against the LW 330 Turbo measured values - * (lever-arch, May 2026) the plan calls out, but the field stays - * absent on the registry entry until a follow-up data-population - * PR — the dead-zone-aware path is exercised by overriding here. + * `printableArea`. Used here to prove the encoder makes no + * decisions on it. */ function deviceWithPrintableArea(printableArea: PrintableArea): DeviceEntry { const base = DEVICES.LW_330_TURBO; const baseEngine = base.engines[0]!; - return { - ...base, - engines: [{ ...baseEngine, printableArea }], - }; + return { ...base, engines: [{ ...baseEngine, printableArea }] }; } - it('populated fields: wire row count drops by leading + trailing dots', () => { - // LW 330 Turbo: 300 dpi → 70 dots ≈ 5.93 mm leading, - // 18 dots ≈ 1.52 mm trailing, - // 18 dots ≈ 1.52 mm left, - // 0 dots right. + it('wire row count equals bitmap.heightPx for a non-zero leading', () => { const dpi = 300; - const leadingMm = (70 * 25.4) / dpi; // back-solves to exactly 70 dots - const trailingMm = (18 * 25.4) / dpi; - const leftMm = (18 * 25.4) / dpi; + const leadingMm = (70 * 25.4) / dpi; // 70 dots — would have been the old crop budget const dev = deviceWithPrintableArea({ leading: leadingMm, - trailing: trailingMm, - left: leftMm, + trailing: 0, + left: 0, right: 0, }); - const headDots = dev.engines[0]!.headDots; // 672 + const headDots = dev.engines[0]!.headDots; const bytesPerRow = headDots / 8; - - const heightPx = 1051; // ADDRESS_LARGE-style label rows at 300 dpi - const expectedWireRows = heightPx - 70 - 18; // 963 - + const heightPx = 200; const bm = makeBitmap(headDots, heightPx); const out = encodeLabel(dev, bm); - // Count `0x16` raster prefix bytes. let rowCount = 0; let i = 0; while (i < out.length) { @@ -509,131 +457,54 @@ describe('encodeLabel', () => { i++; } } - expect(rowCount).toBe(expectedWireRows); - - // ESC L label-length is the authored bitmap height (= label - // pitch), NOT the shorter wire row count. Plan 08 §6 footer: - // sending the wire-row count to ESC L makes the form-feed - // think the label is shorter than it really is, which compounds - // across consecutive prints. The encoder takes the input - // `bitmap.heightPx` (= label pitch) for ESC L and the post-skip - // wire count for the raster stream. - let escL = -1; - for (let j = 0; j < out.length - 3; j++) { - if (out[j] === 0x1b && out[j + 1] === 0x4c) { - escL = j; - break; - } - } - expect(escL).toBeGreaterThan(-1); - const length = (out[escL + 2] ?? 0) | ((out[escL + 3] ?? 0) << 8); - expect(length).toBe(heightPx); + expect(rowCount).toBe(heightPx); }); - it('populated fields: cross-feed pad places content at wire col `leftDots`', () => { + it('ESC L label-length still tracks the caller (bitmap.heightPx by default)', () => { const dpi = 300; - const leftMm = (18 * 25.4) / dpi; // exactly 18 dots + const leadingMm = (70 * 25.4) / dpi; const dev = deviceWithPrintableArea({ - leading: 0, + leading: leadingMm, trailing: 0, - left: leftMm, + left: 0, right: 0, }); const headDots = dev.engines[0]!.headDots; - const bytesPerRow = headDots / 8; - - // Label is narrower than the head: authored width 425 dots - // (typical 36 mm label @ 300 dpi). Fill it solid black so we can - // see exactly which wire columns the encoder fired. - const labelWidthDots = 425; - const bm = createBitmap(labelWidthDots, 4); - const stride = Math.ceil(labelWidthDots / 8); - // Fill the whole buffer with 0xff, then mask the trailing bits - // in each row's last byte (per LabelBitmap invariant: bits past - // `widthPx` stay zero). - bm.data.fill(0xff); - const trailingBits = labelWidthDots % 8; - if (trailingBits !== 0) { - const mask = (0xff << (8 - trailingBits)) & 0xff; - for (let y = 0; y < bm.heightPx; y++) { - const last = y * stride + (stride - 1); - bm.data[last] = (bm.data[last] ?? 0) & mask; - } - } - + const heightPx = 250; + const bm = makeBitmap(headDots, heightPx); const out = encodeLabel(dev, bm); - - // Pull the first raster row and inspect bit positions. - let firstRowOffset = -1; - for (let i = 0; i < out.length - 1; i++) { - if (out[i] === 0x16) { - firstRowOffset = i + 1; + let escL = -1; + for (let i = 0; i < out.length - 3; i++) { + if (out[i] === 0x1b && out[i + 1] === 0x4c) { + escL = i; break; } } - expect(firstRowOffset).toBeGreaterThan(-1); - const row = out.subarray(firstRowOffset, firstRowOffset + bytesPerRow); - - function bitAt(col: number): number { - const byte = row[col >> 3] ?? 0; - return (byte >> (7 - (col & 7))) & 1; - } - - // leftDots = 18 — cols 0..17 are the unprintable-left dead-zone - // and must be white. - for (let c = 0; c < 18; c++) { - expect(bitAt(c)).toBe(0); - } - // Authored content occupies cols 18..(18 + (425 - 18)) = 18..425. - // Right-shifted by leftDots — the authored col 18 lands at wire - // col 18 (sourceColStart = leftDots), and the slice ends at wire - // col 18 + (425 - 18) = 425. - for (let c = 18; c < 425; c++) { - expect(bitAt(c)).toBe(1); - } - // Past the label width (cols 425..671) the head fires harmlessly - // into air — wire cols stay zero. - for (let c = 425; c < headDots; c++) { - expect(bitAt(c)).toBe(0); - } + expect(escL).toBeGreaterThan(-1); + const length = (out[escL + 2] ?? 0) | ((out[escL + 3] ?? 0) << 8); + expect(length).toBe(heightPx); }); - /** - * Synthesize a single-engine LW device with a populated - * `printableArea` — same shape as `deviceWithPrintableArea` above - * but in this block's scope. - */ - function withArea(printableArea: PrintableArea): DeviceEntry { - const base = DEVICES.LW_330_TURBO; - const baseEngine = base.engines[0]!; - return { ...base, engines: [{ ...baseEngine, printableArea }] }; - } - - it('leading/trailing-only dead-zone with a head-width label: slice needs no cross-feed pad', () => { - // `left` and `right` are zero and the authored bitmap is exactly - // headDots wide, so `leftPad` and `rightPad` are both 0 — the - // encoder returns the cropped slice directly without padBitmap. - const dpi = 300; - const leadingMm = (10 * 25.4) / dpi; // exactly 10 dots - const trailingMm = (4 * 25.4) / dpi; // exactly 4 dots - const dev = withArea({ leading: leadingMm, trailing: trailingMm, left: 0, right: 0 }); + it('options.labelLengthDots is the supported override for short-authored bitmaps', () => { + // The authoring contract: when the harness authors a bitmap at + // the printable canvas height (< media.lengthDots), it MUST pass + // `options.labelLengthDots` so the printer's form-feed pitch + // stays correct. + const dev = DEVICES.LW_450; // any LW device const headDots = dev.engines[0]!.headDots; - const bytesPerRow = headDots / 8; - const heightPx = 60; - const bm = createBitmap(headDots, heightPx); - const out = encodeLabel(dev, bm); - let rowCount = 0; - let i = 0; - while (i < out.length) { - if (out[i] === 0x16) { - rowCount++; - i += 1 + bytesPerRow; - } else { - i++; + const printableHeight = 1051 - 71; // simulated 6 mm leading at 300 dpi + const bm = makeBitmap(headDots, printableHeight); + const out = encodeLabel(dev, bm, { labelLengthDots: 1051 }); + let escL = -1; + for (let i = 0; i < out.length - 3; i++) { + if (out[i] === 0x1b && out[i + 1] === 0x4c) { + escL = i; + break; } } - // Wire rows = heightPx - leadingDots - trailingDots = 60 - 10 - 4. - expect(rowCount).toBe(heightPx - 10 - 4); + expect(escL).toBeGreaterThan(-1); + const length = (out[escL + 2] ?? 0) | ((out[escL + 3] ?? 0) << 8); + expect(length).toBe(1051); }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e0d293..62ccd03 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,6 +125,8 @@ export type { } from './types.js'; export { isEngineDrivable, isDuoTapeEngine } from './protocol.js'; +export { getPrintableCanvasDots } from './printable-canvas.js'; +export type { PrintableCanvasDots } from './printable-canvas.js'; export { D1_TAPE_COLOR_HEX, diff --git a/packages/core/src/printable-canvas.ts b/packages/core/src/printable-canvas.ts new file mode 100644 index 0000000..e2a3455 --- /dev/null +++ b/packages/core/src/printable-canvas.ts @@ -0,0 +1,76 @@ +// Helper for callers (harness, drivers, designers) that need to size +// an authoring canvas to match what the head can physically print. +// +// The LabelWriter chassis parks the head a few mm past the label's +// leading edge after a form-feed; that band is mechanically +// unreachable. Authoring at the *full* label size and trusting the +// encoder to make it fit silently lost content (top-cropped or +// bottom-overrun, depending on the encoder version). The fix is to +// expose the dead zone here and have the authoring layer subtract it +// from the canvas before drawing — every authored dot is then a dot +// the head can reach. +// +// This module is the dot-space sibling of `getPrintableArea` (which +// returns mm). One place owns the mm→dot rounding so callers don't +// drift on rounding decisions. + +import type { MediaDescriptor, PrintEngine } from '@thermal-label/contracts'; +import { getPrintableArea } from '@thermal-label/contracts'; + +export interface PrintableCanvasDots { + /** + * Cross-feed dimension the authored bitmap should use. Equals + * `engine.headDots − leftDots − rightDots`. With today's all-zero + * `left`/`right` values across LW devices this is just `headDots`. + */ + widthDots: number; + /** + * Feed-direction dots the authoring layer must subtract from the + * label's physical length to get the printable height. With LW 3xx + * /4xx/5xx engines this is `Math.round(6 mm * dpi / 25.4)` — 71 dots + * at 300 dpi. + */ + leadingDots: number; + /** + * Trailing-edge dead zone in feed direction. `0` everywhere on LW + * today (the head reaches the trailing edge); plumbed through for + * symmetry and future-proofing. + */ + trailingDots: number; + /** Left-edge cross-feed dead zone. `0` everywhere on LW today. */ + leftDots: number; + /** Right-edge cross-feed dead zone. `0` everywhere on LW today. */ + rightDots: number; +} + +/** Round mm to whole dots at the given DPI. */ +function mmToDots(mm: number, dpi: number): number { + return Math.round((mm * dpi) / 25.4); +} + +/** + * Resolve the printable-canvas deductions for a given engine + media, + * in dot space. Callers compose their authored bitmap at + * `widthDots × (mediaLengthDots − leadingDots − trailingDots)` for + * die-cut media (or any user-chosen height for continuous, minus the + * dead zones). + * + * The encoder no longer reads these values — they are the authoring + * layer's responsibility. When a caller authors a bitmap shorter than + * the actual label length, it must also pass + * `options.labelLengthDots = media.lengthDots` to `encodeLabel` so the + * printer's form-feed pitch is correct. + */ +export function getPrintableCanvasDots( + engine: PrintEngine, + media?: MediaDescriptor, +): PrintableCanvasDots { + const area = getPrintableArea(engine, media); + const dpi = engine.dpi; + const leadingDots = mmToDots(area.leading, dpi); + const trailingDots = mmToDots(area.trailing, dpi); + const leftDots = mmToDots(area.left, dpi); + const rightDots = mmToDots(area.right, dpi); + const widthDots = Math.max(0, engine.headDots - leftDots - rightDots); + return { widthDots, leadingDots, trailingDots, leftDots, rightDots }; +} diff --git a/packages/core/src/protocol-550.ts b/packages/core/src/protocol-550.ts index 3d1a5dd..4994d66 100644 --- a/packages/core/src/protocol-550.ts +++ b/packages/core/src/protocol-550.ts @@ -1,4 +1,4 @@ -import { createBitmap, padBitmap, cropBitmap, getRow, type LabelBitmap } from '@mbtech-nl/bitmap'; +import { padBitmap, cropBitmap, getRow, type LabelBitmap } from '@mbtech-nl/bitmap'; import type { DeviceEntry, MediaDescriptor, @@ -7,7 +7,6 @@ import type { PrinterStatus, StatusDetail, } from '@thermal-label/contracts'; -import { getPrintableArea } from '@thermal-label/contracts'; import type { LabelWriterPrintOptions, Density } from './types.js'; /** @@ -276,63 +275,20 @@ export function density550Percent(density: Density): number { } } -/** Convert mm to dots at the given DPI, rounding to the nearest dot. */ -function mmToDots(mm: number, dpi: number): number { - return Math.round((mm * dpi) / 25.4); -} - /** - * Compose the wire bitmap for the LabelWriter 550 family per plan 08 - * §6 (Labelwriter subsection): **send fewer rows** for the leading / - * trailing dead zones, cross-feed pad with white columns inside - * `headDots`-wide rows. See `composeWireBitmap` in `protocol.ts` for - * the full rationale — this is the 550-shaped clone, kept here - * because the 550 encoder is a deliberate fork (different job header - * / `ESC D` block / trailer) and the two protocols don't share - * fitting logic. - * - * With empty `printableArea` (today's state) this is byte-identical - * to the previous `fitBitmapWidth` behaviour. + * Fit the authored bitmap to the engine's head width for the + * LabelWriter 550 family. Width-only — see `composeWireBitmap` in + * `protocol.ts` for the dead-zone rationale; the 5xx fork mirrors the + * same width-only contract so callers see identical behaviour across + * the LW lineup. */ -function composeWireBitmap550( - bitmap: LabelBitmap, - engine: PrintEngine, - media: MediaDescriptor | undefined, -): LabelBitmap { +function composeWireBitmap550(bitmap: LabelBitmap, engine: PrintEngine): LabelBitmap { const headDots = engine.headDots; - const dpi = engine.dpi; - const { leading, trailing, left, right } = getPrintableArea(engine, media); - const leadingDots = mmToDots(leading, dpi); - const trailingDots = mmToDots(trailing, dpi); - const leftDots = mmToDots(left, dpi); - const rightDots = mmToDots(right, dpi); - - const labelWidthDots = Math.min(bitmap.widthPx, headDots); - const wireRows = Math.max(0, bitmap.heightPx - leadingDots - trailingDots); - if (wireRows === 0) return { widthPx: headDots, heightPx: 0, data: new Uint8Array(0) }; - - const sourceColStart = Math.min(leftDots, labelWidthDots); - const sourceColEnd = Math.max(sourceColStart, labelWidthDots - rightDots); - const sourceColCount = sourceColEnd - sourceColStart; - - if (leadingDots === 0 && trailingDots === 0 && leftDots === 0 && rightDots === 0) { - if (bitmap.widthPx === headDots) return bitmap; - if (bitmap.widthPx < headDots) { - return padBitmap(bitmap, { right: headDots - bitmap.widthPx }); - } - return cropBitmap(bitmap, 0, 0, headDots, bitmap.heightPx); + if (bitmap.widthPx === headDots) return bitmap; + if (bitmap.widthPx < headDots) { + return padBitmap(bitmap, { right: headDots - bitmap.widthPx }); } - - const slice = - /* v8 ignore next 2 -- sourceColCount > 0 whenever any printable content survives; the createBitmap(0,…) arm is an unreachable degenerate-input guard (createBitmap rejects width 0 anyway) */ - sourceColCount > 0 - ? cropBitmap(bitmap, sourceColStart, leadingDots, sourceColCount, wireRows) - : createBitmap(0, wireRows); - - const leftPad = sourceColStart; - const rightPad = headDots - sourceColStart - sourceColCount; - if (leftPad === 0 && rightPad === 0) return slice; - return padBitmap(slice, { left: leftPad, right: rightPad }); + return cropBitmap(bitmap, 0, 0, headDots, bitmap.heightPx); } function concat(...arrays: Uint8Array[]): Uint8Array { @@ -391,6 +347,11 @@ export function compose550Job( options: LabelWriterPrintOptions = {}, media?: MediaDescriptor, ): Composed550Job { + // Media is no longer read by the 550 encoder — round-2 dead-zone + // work moved canvas-sizing to the caller. Parameter kept for API + // stability; the `void` keeps tsc + eslint quiet without renaming + // the public arg to `_media` (which would leak into typedoc output). + void media; const engine = device.engines.find(e => e.protocol === 'lw5-raster'); if (!engine) { throw new Error(`Device ${device.key} has no engine with protocol "lw5-raster".`); @@ -398,8 +359,9 @@ export function compose550Job( const headDots = engine.headDots; const bytesPerLine = headDots / 8; - // Cross-feed-pad / leading-skip / trailing-skip per plan 08 §6. - const fitted = composeWireBitmap550(bitmap, engine, media); + // Width-only fit. Dead-zone offsets live on the authoring canvas; + // see `getPrintableCanvasDots` for the helper the harness uses. + const fitted = composeWireBitmap550(bitmap, engine); const widthLines = fitted.heightPx; const density = options.density ?? 'normal'; diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts index 1c8069c..65d3dea 100644 --- a/packages/core/src/protocol.ts +++ b/packages/core/src/protocol.ts @@ -1,6 +1,6 @@ -import { createBitmap, padBitmap, cropBitmap, getRow, type LabelBitmap } from '@mbtech-nl/bitmap'; +import { padBitmap, cropBitmap, getRow, type LabelBitmap } from '@mbtech-nl/bitmap'; import type { DeviceEntry, MediaDescriptor, PrintEngine } from '@thermal-label/contracts'; -import { UnsupportedOperationError, getPrintableArea } from '@thermal-label/contracts'; +import { UnsupportedOperationError } from '@thermal-label/contracts'; import type { LabelWriterPrintOptions, LabelWriterTapeMedia, Density } from './types.js'; import { encode550Label } from './protocol-550.js'; import { buildDuoTapeStream } from './duo-tape-bridge.js'; @@ -157,96 +157,30 @@ export function buildRasterRow(rowBytes: Uint8Array, compress = false): Uint8Arr return new Uint8Array(rle); } -/** Convert mm to dots at the given DPI, rounding to the nearest dot. */ -function mmToDots(mm: number, dpi: number): number { - return Math.round((mm * dpi) / 25.4); -} - /** - * Compose the wire bitmap for the LabelWriter family per plan 08 §6 - * (Labelwriter subsection): **send fewer rows** for the leading / - * trailing dead zones (LW's head sits past the leading edge after - * form-feed and cannot reverse-feed); cross-feed pad with white - * columns inside `headDots`-wide rows. Result is `headDots` × - * `wireRows` where `wireRows = bitmap.heightPx − leadingDots − - * trailingDots`. LW labels are left-aligned, so the label's leftmost - * reachable dot lands at wire-bitmap col `leftDots`. + * Fit the authored bitmap to the engine's head width and emit it + * verbatim in feed direction. Pad right with white when narrower than + * `headDots`, crop right when wider. **No dead-zone handling** — + * leading/trailing/left/right offsets are a property of the *authoring + * canvas*, not the wire format. Callers (the harness, drivers) size + * their bitmaps via `getPrintableCanvasDots(engine, media)` so the + * authored region already matches the head's reachable area; the + * encoder then trusts every row. * - * When `getPrintableArea(engine, media)` returns all zeros (today's - * state — no `printableArea` populated on any DEVICES entry), this - * collapses to the previous `fitBitmapWidth` behaviour: pad to - * `headDots` width on the right when narrower, crop when wider, all - * rows passed through. Wire output is byte-identical to the - * pre-plan-08 encoder until someone populates real values. + * Historical context: an earlier iteration (plan 08 §6) cropped the + * top `leading` mm of rows here, on the assumption that the head sits + * past the leading edge after form-feed. That silently ate authored + * content. Bench testing on debug/print-flow (2026-05-23) confirmed + * the right fix is to expose the dead zones to the authoring layer, + * not to mutate the bitmap at encode time. */ -function composeWireBitmap( - bitmap: LabelBitmap, - engine: PrintEngine, - media: MediaDescriptor | undefined, -): LabelBitmap { +function composeWireBitmap(bitmap: LabelBitmap, engine: PrintEngine): LabelBitmap { const headDots = engine.headDots; - const dpi = engine.dpi; - // Chassis-mechanical dead zones come from the engine descriptor (with - // per-roll media-tag override applied by `getPrintableArea` — LW 5xx - // NFC tag exposes per-SKU offsets). Operator-facing per-call overrides - // are no longer plumbed here; drivers respect the registry-resolved - // values directly. - const { leading, trailing, left, right } = getPrintableArea(engine, media); - const leadingDots = mmToDots(leading, dpi); - const trailingDots = mmToDots(trailing, dpi); - const leftDots = mmToDots(left, dpi); - const rightDots = mmToDots(right, dpi); - - // Source label width is whatever fits in the head — wider authored - // bitmaps are cropped (preserves today's "wider than head crops to - // head" behaviour); narrower ones flow into the head's leftmost - // dots and the unreached pins stay zero. - const labelWidthDots = Math.min(bitmap.widthPx, headDots); - - // Feed direction: skip leading + trailing dead-zone rows. Wire - // bitmap is shorter than the authored bitmap by exactly the dead- - // zone budget. Clamp at zero in case a future caller hands in a - // bitmap shorter than the dead-zone budget — the encoder shouldn't - // explode on a degenerate input. - const wireRows = Math.max(0, bitmap.heightPx - leadingDots - trailingDots); - if (wireRows === 0) return { widthPx: headDots, heightPx: 0, data: new Uint8Array(0) }; - - // Cross-feed: copy authored cols [leftDots .. labelWidthDots − rightDots] - // (clamped at zero), preserving the left dead-zone as white columns. - const sourceColStart = Math.min(leftDots, labelWidthDots); - const sourceColEnd = Math.max(sourceColStart, labelWidthDots - rightDots); - const sourceColCount = sourceColEnd - sourceColStart; - - // Fast path — all-zero dead-zone is the only state that ships - // today, and we must emit byte-identical output. The crop+pad - // pipeline below would also be byte-identical in that case, but - // staying on the original code path keeps this commit's behavioural - // surface zero. - if (leadingDots === 0 && trailingDots === 0 && leftDots === 0 && rightDots === 0) { - if (bitmap.widthPx === headDots) return bitmap; - if (bitmap.widthPx < headDots) { - return padBitmap(bitmap, { right: headDots - bitmap.widthPx }); - } - return cropBitmap(bitmap, 0, 0, headDots, bitmap.heightPx); + if (bitmap.widthPx === headDots) return bitmap; + if (bitmap.widthPx < headDots) { + return padBitmap(bitmap, { right: headDots - bitmap.widthPx }); } - - // Source slice: the authored content that survives the dead-zone. - const slice = - /* v8 ignore next 2 -- sourceColCount > 0 whenever any printable content survives; the createBitmap(0,…) arm is an unreachable degenerate-input guard (createBitmap rejects width 0 anyway) */ - sourceColCount > 0 - ? cropBitmap(bitmap, sourceColStart, leadingDots, sourceColCount, wireRows) - : createBitmap(0, wireRows); - - // Cross-feed compose: position the slice into the head row. - // labelLeftEdgeDot is 0 for LW (left-aligned), so the slice sits at - // wire col `sourceColStart` (= leftDots, by construction). Right pad - // fills out to headDots; with non-zero rightDots the rightmost - // `rightDots` cols of the *label* stay white, and any head pins past - // labelWidthDots also stay white (head fires harmlessly into air). - const leftPad = sourceColStart; - const rightPad = headDots - sourceColStart - sourceColCount; - if (leftPad === 0 && rightPad === 0) return slice; - return padBitmap(slice, { left: leftPad, right: rightPad }); + return cropBitmap(bitmap, 0, 0, headDots, bitmap.heightPx); } function concat(...arrays: Uint8Array[]): Uint8Array { @@ -407,10 +341,11 @@ export function encodeLabel( const headDots = engine.headDots; const bytesPerRow = headDots / 8; - // Cross-feed-pad / leading-skip / trailing-skip per plan 08 §6. - // Resolved entirely from `engine.printableArea` (with per-roll media - // override applied internally by `getPrintableArea`). - const fitted = composeWireBitmap(bitmap, engine, media); + // Width-only fit: pad/crop to `headDots`, emit every row verbatim. + // Dead-zone offsets are an authoring-canvas property exposed via + // `getPrintableCanvasDots(engine, media)`; the encoder trusts whatever + // bitmap height the caller supplied. + const fitted = composeWireBitmap(bitmap, engine); const parts: Uint8Array[] = []; From 0e81e10a3472cf46da5f1a926fbeba303e966868 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 01:14:00 +0200 Subject: [PATCH 13/17] feat(web): auto-derive labelLengthDots from media.lengthDots Callers that author a bitmap at the printable-canvas size (label length minus leading + trailing dead zones) now author shorter than the physical label. `encodeLabel` defaults ESC L to `bitmap.heightPx`, which would shrink the form-feed pitch and compound across copies. `WebLabelWriterPrinter.print()` now back-fills `options.labelLengthDots = resolvedMedia.lengthDots` whenever: * the caller didn't supply one explicitly, AND * media exposes a `lengthDots` (i.e. die-cut), AND * the bitmap is shorter than that lengthDots. Explicit overrides still win; tape media (no lengthDots) falls through to the encoder's `bitmap.heightPx` default, unchanged. Pairs with the labelwriter-core change that exposes `getPrintableCanvasDots` for the authoring layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/printer.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index a1eef69..51b5de2 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -258,8 +258,20 @@ export class WebLabelWriterPrinter implements PrinterAdapter { const bitmap = renderImage(image, { dither: true, rotate }); dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); // Force `engine` to this instance's role so the encoder dispatches - // on the right protocol (lw-raster / lw5-raster / d1-tape). + // on the right protocol (lw-raster / lw5-raster / d1-tape). When + // the caller authored a short bitmap (printable-canvas-sized — see + // `getPrintableCanvasDots` in labelwriter-core), auto-supply + // `labelLengthDots = media.lengthDots` so ESC L still describes the + // full label feed pitch. Explicit `options.labelLengthDots` always + // wins; tape media has no fixed lengthDots and falls back to + // bitmap.heightPx inside the encoder. + const mediaLengthDots = (resolvedMedia as { lengthDots?: number }).lengthDots; const encodeOptions: LabelWriterPrintOptions = { ...options, engine: this.engine.role }; + if (encodeOptions.labelLengthDots === undefined) { + if (typeof mediaLengthDots === 'number' && mediaLengthDots > bitmap.heightPx) { + encodeOptions.labelLengthDots = mediaLengthDots; + } + } // 550 family: the print job is an interactive half-duplex exchange. // The firmware stalls draining the bulk-OUT endpoint after each From 0dda962c7040114ff36eca6a87be49dbf1a2a2bc Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 01:14:23 +0200 Subject: [PATCH 14/17] chore: mark move-printable-area-to-apps as implemented Plan stub for moving printable-area handling out of `labelwriter-core` has landed in core + web on this branch (commits 889491d, 105e7cc). Moves the doc from `plans/backlog/` to `plans/implemented/` and updates the status line. Bench confirmation on the LW 550 still pending. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../move-printable-area-to-apps.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 plans/implemented/move-printable-area-to-apps.md diff --git a/plans/implemented/move-printable-area-to-apps.md b/plans/implemented/move-printable-area-to-apps.md new file mode 100644 index 0000000..2477eb8 --- /dev/null +++ b/plans/implemented/move-printable-area-to-apps.md @@ -0,0 +1,148 @@ +# Move printable-area handling from core to apps + +Status: implemented in core+web on debug/print-flow (2026-05-24). +Bench confirmation on the LW 550 still pending — the print path now +sends every authored row verbatim and the harness sizes its canvas +via `getPrintableCanvasDots`. Surfaced from the LW 550 +`marker1ToStart` analysis on the bench S0722540 dump. + +## Problem + +`labelwriter-core` currently applies a per-engine chassis offset +(`printableArea.leading / trailing / left / right`) inside the encoders +(`composeWireBitmap`, `composeWireBitmap550`) — pre-trimming raster +rows off the input bitmap before emitting wire bytes. The LW 550 has +`printableArea.leading = 6 mm` to compensate for the head's leading +deadzone. + +That **double-counts on newer LW 550 rolls**. The NFC SKU dump's +`marker1ToStart` field already shifts the print head past the deadzone +before the firmware fires: + +- 2022-era roll (free-dmo S0722540 / 30334 dump): `marker1ToStart = 3.6 mm` + ≈ gap width + a sliver. Firmware does *not* compensate; chassis 6 mm + is load-bearing. +- 2024+ roll (this bench S0722540 dump): `marker1ToStart = 8.8 mm` + = 3 mm gap + 5.8 mm head-deadzone compensation. Firmware *is* + compensating; chassis 6 mm pre-trim now shifts a second time. + +Symptom on the newer roll: ~25.7 mm of a 31.7 mm label printed, with +a ~6 mm blank strip at the **trailing** edge. + +The bigger architectural point: cropping is the wrong layer. Designer +apps (the harness, burnmark.io, future tools) know the label they're +laying out onto; they can size content to the printable rectangle at +design time. Core's job is wire encoding — fit to head width, emit +raster bytes. Nothing more. + +See [[project_dead_zone_load_bearing]] — `leading` was tagged +load-bearing on the premise that *something* needed to compensate. +Newer rolls move that compensation into the firmware via NFC, so the +load shifts off `printableArea` onto the app+NFC pair. + +## Proposal + +1. **Core stops cropping leading / trailing / left / right.** + `encodeLabel` and `encode550Label` keep only the head-width fit + (right-pad if narrower than `headDots`, crop if wider). Whatever + rows the caller sends, core transmits. + +2. **`getPrintableArea` in `@thermal-label/contracts` becomes the + public query API for apps.** Signature gains optional `SkuInfo`: + + ```ts + getPrintableArea( + engine: PrintEngine, + media: MediaDescriptor, + sku?: SkuInfo, // ← new + ): PrintableArea + ``` + + Computes the NFC-aware leading reduction: + + ```ts + leadingMm = + sku?.markerType === 0 + ? Math.max( + 0, + engine.printableArea.leading + - Math.max(0, sku.marker1ToStartMm - sku.marker1WidthMm), + ) + : engine.printableArea.leading; + ``` + + (markerType 0 covers the bench roll. Other marker types may encode + the offset differently — see Open questions.) + +3. **Apps own printable-area layout.** Harness `buildDiagnosticImage` + and future designer apps call `getPrintableArea(engine, media, + sku?)` and size content to the result. What reaches `print(rgba)` + already fits the printable rectangle; core ships it verbatim. + +4. **`engine.printableArea` becomes informational.** Document as the + chassis offset *when the firmware doesn't compensate*; consumers + prefer `getPrintableArea(…, sku)` over reading the engine field + directly. + +## Implementation outline (do not start) + +1. **core**: split `composeWireBitmap` / `composeWireBitmap550` into + `fitToHeadWidth` (kept) and `applyChassisCrop` (dropped on the + encoder path; exported as an offline util if any consumer wants + the old behaviour). +2. **contracts**: extend `getPrintableArea(engine, media)` → + `getPrintableArea(engine, media, sku?)`. Compute the NFC-aware + reduction inside. +3. **harness**: `apps/harness-labelwriter/src/diagnostic-print.ts` + calls the SKU-aware `getPrintableArea` and sizes the diagnostic + image to the result. Smaller authoring canvas; same physical + print. Other harness-* apps + verify-cli: same shape. +4. **tests**: drop the `printable-area integration (plan 08 §6)` + tests in `protocol.test.ts` and `protocol-550.test.ts`; replace + with tests on `getPrintableArea`'s new SKU-aware branch in + `@thermal-label/contracts`. +5. **doc**: deprecate the load-bearing reading of + [[project_dead_zone_load_bearing]]; `leading` is now app-owned + for lw5-raster, defaulted-for-450. + +## Risks + +- **LW 450 family has no NFC.** Apps still need the chassis offset. + `getPrintableArea(engine, media)` (no `sku`) returns the chassis + value, so apps that go through the API get the right number; apps + that send a full-label-height bitmap blind to the API get a + trailing-edge misprint. Breaking change for the latter — flag in + the changeset. +- **Other-driver impact.** `getPrintableArea` lives in contracts and + is used by every driver (labelmanager, brother-ql, letratag, …). + Adding the optional `sku?` param is non-breaking, but each driver + needs to confirm it doesn't break their `composeWireBitmap` + equivalent when core stops auto-cropping. +- **Single bench sample.** The marker1ToStart threshold logic is + fitted to one S0722540 capture. Confirm against ≥1 more populated + capture before locking in — ideally a different-SKU die-cut and a + continuous-roll capture. + +## Open questions + +- **Continuous-label media** (non-die-cut): `markerPitch` is the + inter-tear distance, not a label-to-label gap. Does + `marker1ToStart` still apply, or is it zero? Need a continuous-roll + capture to verify. +- **`markerType ∈ {1, 2, 3}` semantics**: all free-dmo dumps and this + bench dump have `markerType = 0`. The other types are documented in + the spec but unobserved on the wire — generalising the formula to + cover them is a wait-for-capture exercise. +- **Older lw5-raster firmware**: 2022-era rolls had + `marker1ToStart ≈ 3.6 mm`. Firmware on a printer that's only ever + seen 2022 rolls might not handle the deadzone shift even if + presented with a 2024-roll's larger value — i.e. compensation could + be a firmware-version capability, not just a tag-version capability. + Verify with a firmware-version capture (`ESC V`) alongside the SKU + dump before declaring the rule universal. + +## Trigger + +Bench print of the LW 550 fix lands → if the symptom matches +(content shifted toward leading edge, ~6 mm blank at trailing) → +promote this plan from `backlog` to `implemented` queue. From 276217143a16d969755ef0ef076d2f7790a1ab5b Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 01:41:01 +0200 Subject: [PATCH 15/17] docs(protocol/lw5-raster): document interactive handshake; ESC n is u16; SKU lengths are deci-mm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three on-the-wire facts that bench / prior-art surfaced and the Tech Ref does not state clearly: * The 550 print job is interactive half-duplex. After every label's \`ESC G\` footer the firmware blocks bulk-OUT until the host reads a 32-byte \`ESC A\` reply. Adds an "Inter-label status handshake" subsection alongside the existing "Lock acquisition" subsection, and annotates the job-structure ASCII diagram with the handshake point. The host writes the job in segments, not one blob. * \`ESC n\` parameter is u16 (2 bytes), not u32 — the status reply echoes back as u16 and the wire field matches. Opcode table row and body section corrected; a u32 emission leaks two stray null bytes ahead of \`ESC D\` and desyncs the firmware command parser. * \`ESC U\` length fields are encoded as deci-mm on the wire even though the Tech Ref labels them "Length in mm". Bench-confirmed against an S0722540 capture (57.1 x 31.7 mm reports 571 / 317). Added a caveat block above the table and updated every length row to "deci-mm (/10 for mm)". Count, strategy and date fields are not affected. References section gains minlux/dymon (prior-art Wireshark RE of the DYMO 550 / Wireless protocol) alongside the DYMO Tech Ref. \`ESC G\`'s own body section is intentionally left unchanged — the handshake obligation is owned by the new subsection, not duplicated. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/protocol/lw5-raster.md | 91 +++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 28 deletions(-) diff --git a/docs/protocol/lw5-raster.md b/docs/protocol/lw5-raster.md index 3f5ddc8..117bf06 100644 --- a/docs/protocol/lw5-raster.md +++ b/docs/protocol/lw5-raster.md @@ -44,7 +44,7 @@ the 5XL. All chassis print at 300 dpi. | [`ESC h`](#esc-h-esc-i-—-output-mode) | `1B 68` | Select text output mode. | | [`ESC i`](#esc-h-esc-i-—-output-mode) | `1B 69` | Select graphics output mode. | | [`ESC L`](#esc-l-—-set-maximum-label-length) | `1B 4C …` | Set maximum label length (continuous stock). | -| [`ESC n`](#esc-n-—-set-label-index) | `1B 6E N N N N` | Set label index (u32 LE). | +| [`ESC n`](#esc-n-—-set-label-index) | `1B 6E N N` | Set label index (u16 LE). | | [`ESC o`](#esc-o-—-set-label-count) | `1B 6F nn` | Set label count. | | [`ESC Q`](#esc-q-—-end-of-print-job) | `1B 51` | End of print job (mandatory trailer). | | [`ESC s`](#esc-s-—-start-of-print-job) | `1B 73 N N N N` | Start of print job (u32 LE Job ID). | @@ -56,8 +56,12 @@ All multi-byte integers are little-endian. ## Print job structure -A complete job is a single byte stream on the OUT endpoint, framed -by a job header and trailer with one or more labels between them: +A complete job is a sequence of bulk-OUT writes framed by a job +header and trailer with one or more labels between them. The wire +**layout** is a single byte stream, but the host writes it in +segments: the firmware blocks the OUT endpoint after every label +until the host drains a 32-byte status reply (see +[Inter-label status handshake](#inter-label-status-handshake)). ``` [print job header] @@ -68,23 +72,26 @@ by a job header and trailer with one or more labels between them: ESC C — set print density [per label, index 0..N-1] - ESC n — label index + ESC n — label index ESC D — start of label print data (12-byte header) — width × ceil(height × bpp / 8) bytes - ESC G — feed to print head (between labels) - ESC E — feed to tear position (last label) + ESC G — feed to print head + [host: ESC A ← MANDATORY footer handshake; + read 32-byte status] firmware stalls bulk-OUT until drained +ESC E — feed to tear position (once, after last label) ESC Q — end of print job (mandatory) ``` `ESC s` and `ESC Q` are mandatory. The per-label structure (`ESC n` + -`ESC D` + print data + `ESC G` or `ESC E`) repeats for every label in -the job. Between labels, use `ESC G`; after the last label, use -`ESC E` so the printed label reaches the tear bar. The `ESC s` job -ID is echoed back in every status reply during the job so the host -can correlate. See _LabelWriter 550 Series Printers Technical -Reference Manual_, pp. 4–6, for the job-structure diagram. +`ESC D` + print data + `ESC G`) repeats for every label in the job; +every footer is followed by the inter-label `ESC A` handshake. After +the last label's handshake, `ESC E` feeds the printed label to the +tear bar and `ESC Q` closes the job. The `ESC s` job ID is echoed +back in every status reply during the job so the host can correlate. +See _LabelWriter 550 Series Printers Technical Reference Manual_, +pp. 4–6, for the job-structure diagram. The print data follows the `ESC D` header **directly** — no `SYN` prefix, no per-row framing, no length byte. Row width is fixed by @@ -99,6 +106,22 @@ another host holds it (`5`). A host that does not hold the lock can still issue `ESC A 0` heartbeats but cannot send a job. The lock releases on `ESC Q` (Tech Ref, p. 7). +### Inter-label status handshake + +After each label's `ESC G` footer the firmware stops draining the +bulk-OUT endpoint until the host issues `ESC A` and reads the 32-byte +reply. Streaming the whole job in one write hangs mid-job and leaves +the printer lock-held until power-cycle. + +| Position | `lock` byte | Reply timing | +| -------------- | ----------: | ------------------------------------------------------------------------------------- | +| Between labels | `2` | Host may defer the read until just before the next label's segment ships. | +| Last label | `0` | Host must drain the reply before sending `ESC E` + `ESC Q`. Also drops the host lock. | + +The `ESC s` job ID and `ESC n` label index are echoed in the reply +(bytes 1–4 and 5–6) so the host can correlate the handshake to the +label it just finished. + ## `ESC @` — restart print engine ``` @@ -298,13 +321,14 @@ needed in practice. ## `ESC n` — set label index ``` -1B 6E +1B 6E ``` -4-byte little-endian label index. Sent before each label's `ESC D` +2-byte little-endian label index. Sent before each label's `ESC D` block. The first label of a job is index `0`; subsequent labels increment. The current index is echoed back in status-reply bytes -5–6 so the host can track which label is being printed. +5–6 (also u16) so the host can track which label is being printed — +the wire field width matches. ## `ESC o` — set label count @@ -362,7 +386,14 @@ p. 8 — the LW 5XL does not support high speed at all). ``` Retrieves the NFC dump of the inserted consumable. The printer -replies with a **63-byte** structure (Tech Ref, pp. 16–19): +replies with a **63-byte** structure (Tech Ref, pp. 16–19). + +> **Unit caveat — length fields are deci-mm, not mm.** The Tech Ref +> labels every length field "Length in mm". On the wire the NFC tag +> encodes **tenths of a millimetre** — confirmed by an S0722540 bench +> capture (a 57.1 × 31.7 mm roll reports `571` / `317`). Divide by +> 10 for the millimetre value. Count, strategy, and date fields are +> not affected. | Offset | Field | Type | Notes | | -----: | -------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -379,19 +410,19 @@ replies with a **63-byte** structure (Tech Ref, pp. 16–19): | 25 | Content colour | u8 | `0x00` black · `0x01` red/black. | | 26 | Marker type | u8 | Marker / cut-edge geometry; values `0x00..0x03`. | | 27 | Reserved | u8 | | -| 28–29 | Marker pitch | u16 LE | Length in mm. | -| 30–31 | Marker 1 width | u16 LE | Length in mm. | -| 32–33 | Marker 1 to start of label | u16 LE | Length in mm. | -| 34–35 | Marker 2 width | u16 LE | Length in mm. | -| 36–37 | Marker 2 offset | u16 LE | Length in mm. | -| 38–39 | Vertical offset | u16 LE | Length in mm. | -| 40–41 | Label length | u16 LE | Length in mm. `0` / `0xFFFF` for continuous stock. | -| 42–43 | Label width | u16 LE | Length in mm. | -| 44–45 | Printable area H. offset | u16 LE | Length in mm. | -| 46–47 | Printable area V. offset | u16 LE | Length in mm. | -| 48–49 | Liner width | u16 LE | Length in mm. | +| 28–29 | Marker pitch | u16 LE | Length in deci-mm (÷10 for mm). | +| 30–31 | Marker 1 width | u16 LE | Length in deci-mm (÷10 for mm). | +| 32–33 | Marker 1 to start of label | u16 LE | Length in deci-mm (÷10 for mm). | +| 34–35 | Marker 2 width | u16 LE | Length in deci-mm (÷10 for mm). | +| 36–37 | Marker 2 offset | u16 LE | Length in deci-mm (÷10 for mm). | +| 38–39 | Vertical offset | u16 LE | Length in deci-mm (÷10 for mm). | +| 40–41 | Label length | u16 LE | Length in deci-mm (÷10 for mm). `0` / `0xFFFF` for continuous stock. | +| 42–43 | Label width | u16 LE | Length in deci-mm (÷10 for mm). | +| 44–45 | Printable area H. offset | u16 LE | Length in deci-mm (÷10 for mm). | +| 46–47 | Printable area V. offset | u16 LE | Length in deci-mm (÷10 for mm). | +| 48–49 | Liner width | u16 LE | Length in deci-mm (÷10 for mm). | | 50–51 | Total label count | u16 LE | Labels on a full roll. | -| 52–53 | Total length | u16 LE | Roll length in mm. | +| 52–53 | Total length | u16 LE | Roll length in deci-mm (÷10 for mm). | | 54–55 | Counter margin | u16 LE | Used by the printer to compute labels remaining. | | 56 | Counter strategy | u8 | `0x00` = count up from `0x0000`; `0x01` = count down from `0xFFFF - amount - margin`. | | 57–59 | Reserved | | | @@ -430,3 +461,7 @@ replies with **34 bytes** (Tech Ref, p. 20): for LabelWriter 550 / 550 Turbo / 5XL, Sanford L.P., 2021. The authoritative byte-level reference. Cited inline by page; not redistributed. +- **minlux/dymon** — open-source DYMO print tool; reverse-engineered + the LabelWriter Wireless / 550 protocol via Wireshark capture over + USB and TCP:9100. `protocol.md` + `src/dymon/dymon.cpp`. + . From 3662a167941186a7cd0d68847f495a9c5a5d183c Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 02:02:17 +0200 Subject: [PATCH 16/17] refactor(core): hoist write550Job out of node/web into one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive 550 print loop (write segment -> ESC A -> drain status, deferred between labels, sync on the last) was duplicated almost byte-for-byte in WebLabelWriterPrinter and NodeLabelWriterPrinter. Only real difference: web supplied a finite handshakeReadTimeoutMs because WebUSB has no implicit deadline; node passed nothing. It's pure protocol orchestration — no driver state, no transport- specific behaviour — so the home is labelwriter-core, alongside compose550Job (its natural pair). * Add write550Job(transport, job, opts?) + Write550JobOptions in protocol-550.ts. Loop body verbatim from the old copies. * Export from labelwriter-core's index. * Web/Node printers drop the private method and dispatch through core; web passes its 15 s handshake deadline, node leaves the read untimed (untouched behaviour for both). * Five new core tests pin the orchestration contract: op order, inter-label vs final ESC A lock byte, deferred-vs-sync read timing, and the timeout pass-through. Net effect on per-driver line count: web -47, node -36, core +83 (plus +140 test). Future bug fixes (mid-job error detection, parsing the drained status, etc.) land in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/core/src/README.md | 2 + docs/api/core/src/functions/write550Job.md | 45 ++++++ .../core/src/interfaces/Write550JobOptions.md | 20 +++ .../core/src/__tests__/write-550-job.test.ts | 146 ++++++++++++++++++ packages/core/src/index.ts | 3 +- packages/core/src/protocol-550.ts | 79 ++++++++++ packages/node/src/printer.ts | 49 +----- packages/web/src/printer.ts | 62 +------- 8 files changed, 307 insertions(+), 99 deletions(-) create mode 100644 docs/api/core/src/functions/write550Job.md create mode 100644 docs/api/core/src/interfaces/Write550JobOptions.md create mode 100644 packages/core/src/__tests__/write-550-job.test.ts diff --git a/docs/api/core/src/README.md b/docs/api/core/src/README.md index bf12eb7..7a241e4 100644 --- a/docs/api/core/src/README.md +++ b/docs/api/core/src/README.md @@ -21,6 +21,7 @@ - [LabelWriterTapeMedia](interfaces/LabelWriterTapeMedia.md) - [PrintableCanvasDots](interfaces/PrintableCanvasDots.md) - [SkuInfo](interfaces/SkuInfo.md) +- [Write550JobOptions](interfaces/Write550JobOptions.md) ## Type Aliases @@ -103,3 +104,4 @@ - [skuInfoToMedia](functions/skuInfoToMedia.md) - [statusByteCount](functions/statusByteCount.md) - [withDetectedMedia](functions/withDetectedMedia.md) +- [write550Job](functions/write550Job.md) diff --git a/docs/api/core/src/functions/write550Job.md b/docs/api/core/src/functions/write550Job.md new file mode 100644 index 0000000..f2934c0 --- /dev/null +++ b/docs/api/core/src/functions/write550Job.md @@ -0,0 +1,45 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / write550Job + +# Function: write550Job() + +> **write550Job**(`transport`, `job`, `options?`): `Promise`\<`void`\> + +Write a composed 550 job interactively over the given transport. + +The 550 firmware stops draining the bulk-OUT endpoint after each +label's `ESC G` footer until the host issues `ESC A` and reads the +32-byte status reply (confirmed against minlux/dymon's Wireshark +capture + the LW 550 Technical Reference). So: write the preamble, +then per label write the segment + `ESC A`, drain the handshake +status, then write `ESC E` + `ESC Q`. + +`ESC A` lock byte per the 550 spec: `0` for the LAST label — the +final status query, which also drops the host lock; `2` between +labels — the host does not block on that reply before streaming +the next label, so the read is deferred to the next iteration. + +Pure protocol orchestration — no driver state is touched; both the +node and web `LabelWriterPrinter`s dispatch through here so a bug +fix lands in one place. + +## Parameters + +### transport + +[`Transport`](/contracts/api/interfaces/Transport) + +### job + +[`Composed550Job`](../interfaces/Composed550Job.md) + +### options? + +[`Write550JobOptions`](../interfaces/Write550JobOptions.md) = `{}` + +## Returns + +`Promise`\<`void`\> diff --git a/docs/api/core/src/interfaces/Write550JobOptions.md b/docs/api/core/src/interfaces/Write550JobOptions.md new file mode 100644 index 0000000..b20ab8a --- /dev/null +++ b/docs/api/core/src/interfaces/Write550JobOptions.md @@ -0,0 +1,20 @@ +[**labelwriter**](../../../README.md) + +*** + +[labelwriter](../../../README.md) / [core/src](../README.md) / Write550JobOptions + +# Interface: Write550JobOptions + +## Properties + +### handshakeReadTimeoutMs? + +> `optional` **handshakeReadTimeoutMs?**: `number` + +Per-handshake read deadline, ms. The 550 may take a couple of +seconds to answer the post-`ESC G` `ESC A` while the label is +physically feeding. Omit (or pass `undefined`) to delegate the +deadline to the transport's own policy — the WebUSB transport +has no implicit timeout and an unresponsive firmware would hang +the read forever, so web callers should set a finite value. diff --git a/packages/core/src/__tests__/write-550-job.test.ts b/packages/core/src/__tests__/write-550-job.test.ts new file mode 100644 index 0000000..405828f --- /dev/null +++ b/packages/core/src/__tests__/write-550-job.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import type { Transport } from '@thermal-label/contracts'; +import { + build550StatusRequest, + compose550Job, + STATUS_BYTE_COUNT_550, + write550Job, + type Composed550Job, +} from '../protocol-550.js'; +import { DEVICES } from '../devices.js'; +import { createBitmap } from '@mbtech-nl/bitmap'; + +interface RecordedOp { + kind: 'write' | 'read'; + /** For writes: bytes sent. For reads: requested byte count. */ + data: Uint8Array | number; + /** For reads: the timeout argument the loop passed, if any. */ + timeout?: number; +} + +/** + * Minimal `Transport` double for orchestration assertions — records + * every call in order, satisfies reads with a canned 32-byte status + * frame, and never throws. + */ +function recordingTransport(): { transport: Transport; ops: RecordedOp[] } { + const ops: RecordedOp[] = []; + const transport: Transport = { + connected: true, + write(data) { + ops.push({ kind: 'write', data: Uint8Array.from(data) }); + return Promise.resolve(); + }, + read(length, timeout) { + const op: RecordedOp = { kind: 'read', data: length }; + if (timeout !== undefined) op.timeout = timeout; + ops.push(op); + // Canned 32-byte status: byte 0 = 0 (idle), rest zero. + return Promise.resolve(new Uint8Array(STATUS_BYTE_COUNT_550)); + }, + close() { + return Promise.resolve(); + }, + }; + return { transport, ops }; +} + +function lw550Job(copies: number): Composed550Job { + const dev = DEVICES.LW_550; + const headDots = dev.engines[0]!.headDots; + // Tiny bitmap — sized to the head so the encoder fast-paths without + // any pad/crop work. Content irrelevant for orchestration tests. + const bitmap = createBitmap(headDots, 4); + return compose550Job(dev, bitmap, { copies }); +} + +describe('write550Job', () => { + it('writes preamble first, then per-label segment + ESC A, then finalize', async () => { + const job = lw550Job(1); + const { transport, ops } = recordingTransport(); + await write550Job(transport, job); + + // 1 copy → preamble, segment, ESC A 0, read, finalize = 5 ops. + expect(ops).toHaveLength(5); + expect(ops[0]).toMatchObject({ kind: 'write', data: job.preamble }); + expect(ops[1]).toMatchObject({ kind: 'write', data: job.labels[0]! }); + expect(ops[2]).toMatchObject({ kind: 'write', data: build550StatusRequest(0) }); + expect(ops[3]).toMatchObject({ kind: 'read', data: STATUS_BYTE_COUNT_550 }); + expect(ops[4]).toMatchObject({ kind: 'write', data: job.finalize }); + }); + + it('multi-label: ESC A lock byte is 2 between labels and 0 on the last', async () => { + const job = lw550Job(3); + const { transport, ops } = recordingTransport(); + await write550Job(transport, job); + + const escAs = ops.filter( + (op): op is RecordedOp & { data: Uint8Array } => + op.kind === 'write' && + op.data instanceof Uint8Array && + op.data.length === 3 && + op.data[0] === 0x1b && + op.data[1] === 0x41, + ); + expect(escAs).toHaveLength(3); + expect(escAs[0]!.data[2]).toBe(2); // between + expect(escAs[1]!.data[2]).toBe(2); // between + expect(escAs[2]!.data[2]).toBe(0); // final + }); + + it('multi-label: between-label status reads are deferred to the next iteration', async () => { + // The loop writes ESC A 2 after each non-final label but DOES NOT + // block on its reply — the read happens once, just before the + // next label's segment ships. The final label's ESC A 0 is read + // synchronously after that segment's handshake write. + const job = lw550Job(3); + const { transport, ops } = recordingTransport(); + await write550Job(transport, job); + + // Pull the order of read operations relative to writes. + const order = ops.map(op => op.kind); + // Expected shape for 3 copies: + // write preamble + // write segment[0] + // write ESC A 2 + // read (deferred reply for label 0, drained before label 1) + // write segment[1] + // write ESC A 2 + // read (deferred reply for label 1, drained before label 2) + // write segment[2] + // write ESC A 0 + // read (final reply) + // write finalize + expect(order).toEqual([ + 'write', // preamble + 'write', // segment[0] + 'write', // ESC A 2 + 'read', // deferred drain for label 0 + 'write', // segment[1] + 'write', // ESC A 2 + 'read', // deferred drain for label 1 + 'write', // segment[2] + 'write', // ESC A 0 + 'read', // final + 'write', // finalize + ]); + }); + + it('passes handshakeReadTimeoutMs through to every transport.read', async () => { + const job = lw550Job(2); + const { transport, ops } = recordingTransport(); + await write550Job(transport, job, { handshakeReadTimeoutMs: 12345 }); + const reads = ops.filter(op => op.kind === 'read'); + expect(reads).toHaveLength(2); + expect(reads.every(op => op.timeout === 12345)).toBe(true); + }); + + it('omits the timeout when no option is supplied (transport-default deadline)', async () => { + const job = lw550Job(1); + const { transport, ops } = recordingTransport(); + await write550Job(transport, job); + const reads = ops.filter(op => op.kind === 'read'); + expect(reads).toHaveLength(1); + expect(reads[0]!.timeout).toBeUndefined(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62ccd03..72c5e90 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -99,6 +99,7 @@ export { density550Percent, compose550Job, encode550Label, + write550Job, parseEngineVersion, parseSkuInfo, skuInfoToMedia, @@ -108,7 +109,7 @@ export { SKU_INFO_BYTE_COUNT, STATUS_BYTE_COUNT_550, } from './protocol-550.js'; -export type { Composed550Job, EngineVersion, SkuInfo } from './protocol-550.js'; +export type { Composed550Job, EngineVersion, SkuInfo, Write550JobOptions } from './protocol-550.js'; export { createPreviewOffline } from './preview.js'; export type { D1Material, diff --git a/packages/core/src/protocol-550.ts b/packages/core/src/protocol-550.ts index 4994d66..e7cde97 100644 --- a/packages/core/src/protocol-550.ts +++ b/packages/core/src/protocol-550.ts @@ -6,9 +6,20 @@ import type { PrinterError, PrinterStatus, StatusDetail, + Transport, } from '@thermal-label/contracts'; import type { LabelWriterPrintOptions, Density } from './types.js'; +/** + * Print-flow debug tracing — ships ONLY on the `debug/print-flow` + * branch / `0.6.3-debug.x` prerelease line (npm dist-tag `debug`). + * Delete this helper and its call sites before merging to main. + */ +function dbg(msg: string): void { + // eslint-disable-next-line no-console + console.debug(`[lw-core] ${msg}`); +} + /** * Wire-protocol encoder for the LabelWriter 550 family * (LW 550 / 550 Turbo / 5XL). @@ -435,6 +446,74 @@ export function encode550Label( return concat(job.preamble, ...job.labels, job.finalize); } +export interface Write550JobOptions { + /** + * Per-handshake read deadline, ms. The 550 may take a couple of + * seconds to answer the post-`ESC G` `ESC A` while the label is + * physically feeding. Omit (or pass `undefined`) to delegate the + * deadline to the transport's own policy — the WebUSB transport + * has no implicit timeout and an unresponsive firmware would hang + * the read forever, so web callers should set a finite value. + */ + handshakeReadTimeoutMs?: number; +} + +/** + * Write a composed 550 job interactively over the given transport. + * + * The 550 firmware stops draining the bulk-OUT endpoint after each + * label's `ESC G` footer until the host issues `ESC A` and reads the + * 32-byte status reply (confirmed against minlux/dymon's Wireshark + * capture + the LW 550 Technical Reference). So: write the preamble, + * then per label write the segment + `ESC A`, drain the handshake + * status, then write `ESC E` + `ESC Q`. + * + * `ESC A` lock byte per the 550 spec: `0` for the LAST label — the + * final status query, which also drops the host lock; `2` between + * labels — the host does not block on that reply before streaming + * the next label, so the read is deferred to the next iteration. + * + * Pure protocol orchestration — no driver state is touched; both the + * node and web `LabelWriterPrinter`s dispatch through here so a bug + * fix lands in one place. + */ +export async function write550Job( + transport: Transport, + job: Composed550Job, + options: Write550JobOptions = {}, +): Promise { + const timeout = options.handshakeReadTimeoutMs; + await transport.write(job.preamble); + dbg(`550 preamble written: ${String(job.preamble.length)}B`); + // A deferred `ESC A 2` reply from the previous label, not yet drained. + let pendingHandshake = false; + for (const [i, segment] of job.labels.entries()) { + const isLast = i === job.labels.length - 1; + if (pendingHandshake) { + const prev = await transport.read(STATUS_BYTE_COUNT_550, timeout); + dbg( + `550 deferred handshake: status len=${String(prev.length)} byte0=${String(prev[0] ?? -1)}`, + ); + pendingHandshake = false; + } + await transport.write(segment); + dbg(`550 label ${String(i + 1)}/${String(job.labels.length)} written — footer handshake`); + await transport.write(build550StatusRequest(isLast ? 0 : 2)); + if (isLast) { + // `ESC A 0` — final status query; wait for the reply. + const status = await transport.read(STATUS_BYTE_COUNT_550, timeout); + dbg( + `550 final handshake: status len=${String(status.length)} byte0=${String(status[0] ?? -1)}`, + ); + } else { + // `ESC A 2` — host does not wait; drain on the next iteration. + pendingHandshake = true; + } + } + await transport.write(job.finalize); + dbg('550 interactive print complete — finalize written'); +} + // ───────────────────────────────────────────────────────────────── // Response parsers // ───────────────────────────────────────────────────────────────── diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 2e20e46..37f82fe 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -8,6 +8,7 @@ import { build550Recovery, build550StatusRequest, compose550Job, + write550Job, PRINT_STATUS_LOCK_NOT_GRANTED, STATUS_BYTE_COUNT_550, buildErrorRecovery, @@ -26,7 +27,6 @@ import { renderImage, skuInfoToMedia, statusByteCount, - type Composed550Job, type DeviceEntry, type EngineVersion, type LabelWriterEngineHandle, @@ -234,14 +234,17 @@ export class LabelWriterPrinter implements PrinterAdapter { dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); // 550 family: interactive half-duplex print — the firmware needs an // `ESC A` + 32-byte status read after each label's `ESC G` footer or - // it stalls the bulk-OUT endpoint. See `write550Job`. + // it stalls the bulk-OUT endpoint. The loop lives in + // labelwriter-core's `write550Job`. Node leaves the handshake reads + // untimed; the CLI surface is the deliberate place to address node- + // side hang behaviour. if (engine.protocol === 'lw5-raster') { const job = compose550Job(this.device, bitmap, options, resolvedMedia); dbg( `composed 550 job: preamble=${String(job.preamble.length)}B ` + `labels=${String(job.labels.length)} finalize=${String(job.finalize.length)}B`, ); - await this.write550Job(transport, job); + await write550Job(transport, job); return; } @@ -255,46 +258,6 @@ export class LabelWriterPrinter implements PrinterAdapter { dbg(`print complete: ${String(bytes.length)} bytes written`); } - /** - * Write a composed 550 job interactively — the 550 firmware needs an - * `ESC A` + 32-byte status read after each label's `ESC G` footer or - * it stalls the bulk-OUT endpoint. See the web printer's `write550Job` - * for the full rationale (minlux/dymon prior art). - * - * `ESC A` lock byte: `0` for the last label (final status query + - * lock release); `2` between labels (host does not block on the - * reply — it is drained on the next iteration). - */ - private async write550Job(transport: Transport, job: Composed550Job): Promise { - await transport.write(job.preamble); - // A deferred `ESC A 2` reply from the previous label, not yet drained. - let pendingHandshake = false; - for (const [i, segment] of job.labels.entries()) { - const isLast = i === job.labels.length - 1; - if (pendingHandshake) { - const prev = await transport.read(STATUS_BYTE_COUNT_550); - dbg( - `550 deferred handshake: status len=${String(prev.length)} ` + `byte0=${String(prev[0])}`, - ); - pendingHandshake = false; - } - await transport.write(segment); - dbg(`550 label ${String(i + 1)}/${String(job.labels.length)} written — footer handshake`); - await transport.write(build550StatusRequest(isLast ? 0 : 2)); - if (isLast) { - const status = await transport.read(STATUS_BYTE_COUNT_550); - dbg( - `550 final handshake: status len=${String(status.length)} ` + - `byte0=${String(status[0])}`, - ); - } else { - pendingHandshake = true; - } - } - await transport.write(job.finalize); - dbg('550 interactive print complete — finalize written'); - } - /** * 550-only: send `ESC A 1` to acquire the print lock, parse the * 32-byte response, and refuse to proceed if (a) the lock is held diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index 51b5de2..71e5df1 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -9,6 +9,7 @@ import { build550Recovery, build550StatusRequest, compose550Job, + write550Job, PRINT_STATUS_LOCK_NOT_GRANTED, STATUS_BYTE_COUNT_550, buildErrorRecovery, @@ -29,7 +30,6 @@ import { skuInfoDetails, skuInfoToMedia, statusByteCount, - type Composed550Job, type DeviceEntry, type EngineVersion, type LabelWriterEngineHandle, @@ -276,15 +276,18 @@ export class WebLabelWriterPrinter implements PrinterAdapter { // 550 family: the print job is an interactive half-duplex exchange. // The firmware stalls draining the bulk-OUT endpoint after each // label's `ESC G` footer until the host issues `ESC A` and reads - // the 32-byte status reply — a monolithic write hangs. See - // `write550Job`. + // the 32-byte status reply — a monolithic write hangs. The loop + // lives in labelwriter-core's `write550Job`; we pass a finite + // read deadline because WebUSB has no implicit timeout. if (this.engine.protocol === 'lw5-raster') { const job = compose550Job(this.device, bitmap, encodeOptions, resolvedMedia); dbg( `composed 550 job: preamble=${String(job.preamble.length)}B ` + `labels=${String(job.labels.length)} finalize=${String(job.finalize.length)}B`, ); - await this.write550Job(job); + await write550Job(this.transport, job, { + handshakeReadTimeoutMs: PRINT_HANDSHAKE_TIMEOUT_MS, + }); return; } @@ -298,57 +301,6 @@ export class WebLabelWriterPrinter implements PrinterAdapter { dbg(`print complete: ${String(bytes.length)} bytes written`); } - /** - * Write a composed 550 job interactively. - * - * The 550 firmware stops draining the bulk-OUT endpoint after each - * label's `ESC G` footer until the host issues `ESC A` and reads the - * 32-byte status reply (confirmed against minlux/dymon's Wireshark - * capture + the LW 550 Technical Reference). So: write the preamble, - * then per label write the segment + `ESC A`, drain the handshake - * status, then write `ESC E` + `ESC Q`. - * - * `ESC A` lock byte per the 550 spec: `0` for the LAST label — the - * final status query, which also drops the host lock; `2` between - * labels — the host does not block on that reply before streaming the - * next label, so the read is deferred to the next iteration. - * - * The handshake read is timed — a non-responsive firmware surfaces - * as a thrown `TransportTimeoutError` the caller can show, rather - * than an unbounded `print()` hang. - */ - private async write550Job(job: Composed550Job): Promise { - await this.transport.write(job.preamble); - // A deferred `ESC A 2` reply from the previous label, not yet drained. - let pendingHandshake = false; - for (const [i, segment] of job.labels.entries()) { - const isLast = i === job.labels.length - 1; - if (pendingHandshake) { - const prev = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); - dbg( - `550 deferred handshake: status len=${String(prev.length)} ` + `byte0=${String(prev[0])}`, - ); - pendingHandshake = false; - } - await this.transport.write(segment); - dbg(`550 label ${String(i + 1)}/${String(job.labels.length)} written — footer handshake`); - await this.transport.write(build550StatusRequest(isLast ? 0 : 2)); - if (isLast) { - // `ESC A 0` — final status query; wait for the reply. - const status = await this.transport.read(STATUS_BYTE_COUNT_550, PRINT_HANDSHAKE_TIMEOUT_MS); - dbg( - `550 final handshake: status len=${String(status.length)} ` + - `byte0=${String(status[0])}`, - ); - } else { - // `ESC A 2` — host does not wait; drain on the next iteration. - pendingHandshake = true; - } - } - await this.transport.write(job.finalize); - dbg('550 interactive print complete — finalize written'); - } - /** * Acquire the 550 print lock + health check. Private — only called * from within `doPrint()`, which already holds the serializer, so From f6133ee241647867dd9b25d8ea143d14ee7ae1a8 Mon Sep 17 00:00:00 2001 From: Mannes Brak Date: Sun, 24 May 2026 02:09:11 +0200 Subject: [PATCH 17/17] docs(550): thin out repeated protocol commentary at code sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk-OUT stall mechanic and the inter-label handshake contract now live in docs/protocol/lw5-raster.md. Code sites point to that doc instead of re-deriving it in JSDoc / call-site comments. * Composed550Job, compose550Job, write550Job JSDoc — keep purpose + pointer; drop the spec narrative. * Web and node dispatch comments — one-line "see write550Job" plus the local decision (web supplies a finite read deadline; node leaves the read untimed). No behaviour change. Typedoc output regenerated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/core/src/functions/compose550Job.md | 15 ++-- docs/api/core/src/functions/write550Job.md | 23 ++----- .../api/core/src/interfaces/Composed550Job.md | 22 ++---- .../core/src/interfaces/Write550JobOptions.md | 9 +-- packages/core/src/protocol-550.ts | 69 +++++-------------- packages/node/src/printer.ts | 8 +-- packages/web/src/printer.ts | 8 +-- 7 files changed, 43 insertions(+), 111 deletions(-) diff --git a/docs/api/core/src/functions/compose550Job.md b/docs/api/core/src/functions/compose550Job.md index ce9470c..b3f01a2 100644 --- a/docs/api/core/src/functions/compose550Job.md +++ b/docs/api/core/src/functions/compose550Job.md @@ -9,17 +9,10 @@ > **compose550Job**(`device`, `bitmap`, `options?`, `media?`): [`Composed550Job`](../interfaces/Composed550Job.md) Compose a 550 print job as interleavable segments — see -`Composed550Job` for why the 550 can't take a monolithic write. - -The bitmap is fitted to the engine's `headDots` (right-padded if -narrower, cropped if wider) so each raster line is exactly -`headDots / 8` bytes. Copies share the same bitmap; each gets its -own `ESC n` index and `ESC D` header and ends with an `ESC G` -footer. `ESC E` (feed to tear) + `ESC Q` (end job) close the job -once, in `finalize`. - -`compress` is silently ignored — the 550 raster format does not -carry the 450's `SYN` / `ETB` framing and therefore cannot RLE. +`Composed550Job`. The bitmap is fitted to `headDots` (right-pad +narrower, crop wider) so each raster line is `headDots / 8` bytes. +`compress` is ignored — the 550 raster format has no `SYN` / `ETB` +framing. ## Parameters diff --git a/docs/api/core/src/functions/write550Job.md b/docs/api/core/src/functions/write550Job.md index f2934c0..40e2c94 100644 --- a/docs/api/core/src/functions/write550Job.md +++ b/docs/api/core/src/functions/write550Job.md @@ -8,23 +8,12 @@ > **write550Job**(`transport`, `job`, `options?`): `Promise`\<`void`\> -Write a composed 550 job interactively over the given transport. - -The 550 firmware stops draining the bulk-OUT endpoint after each -label's `ESC G` footer until the host issues `ESC A` and reads the -32-byte status reply (confirmed against minlux/dymon's Wireshark -capture + the LW 550 Technical Reference). So: write the preamble, -then per label write the segment + `ESC A`, drain the handshake -status, then write `ESC E` + `ESC Q`. - -`ESC A` lock byte per the 550 spec: `0` for the LAST label — the -final status query, which also drops the host lock; `2` between -labels — the host does not block on that reply before streaming -the next label, so the read is deferred to the next iteration. - -Pure protocol orchestration — no driver state is touched; both the -node and web `LabelWriterPrinter`s dispatch through here so a bug -fix lands in one place. +Write a composed 550 job interactively. See the `lw5-raster` +protocol doc — "Inter-label status handshake" — for the wire +contract. + +Lock byte: `0` on the last label (final query + lock release), `2` +between labels (host defers the read to the next iteration). ## Parameters diff --git a/docs/api/core/src/interfaces/Composed550Job.md b/docs/api/core/src/interfaces/Composed550Job.md index 2bcae00..2e630b0 100644 --- a/docs/api/core/src/interfaces/Composed550Job.md +++ b/docs/api/core/src/interfaces/Composed550Job.md @@ -6,16 +6,10 @@ # Interface: Composed550Job -A 550 print job split into the segments an interactive print routine -writes, with a status handshake between them. - -The 550 firmware stops draining the bulk-OUT endpoint after each -label's `ESC G` footer until the host issues `ESC A` and reads the -32-byte status reply — a monolithic write of the whole job therefore -hangs mid-stream. Confirmed against minlux/dymon's Wireshark capture -(`dymon.cpp`) and the LW 550 Technical Reference. The driver must: -write `preamble`, then for each `labels` segment write it, issue -`ESC A` and drain the 32-byte status, then write `finalize`. +A 550 print job split into the segments an interactive write routine +interleaves with `ESC A` status reads. See the +`lw5-raster` protocol doc — "Inter-label status handshake" — for the +wire contract this shape encodes; `write550Job` is its driver. ## Properties @@ -23,7 +17,7 @@ write `preamble`, then for each `labels` segment write it, issue > **finalize**: `Uint8Array` -Job trailer — written once after the last label's handshake: `ESC E` + `ESC Q`. +Once, after the last label's handshake: `ESC E` + `ESC Q`. *** @@ -31,9 +25,7 @@ Job trailer — written once after the last label's handshake: `ESC E` + `ESC Q` > **labels**: `Uint8Array`[] -One segment per copy: `ESC n` + `ESC D` + raster + `ESC G`. After -writing each, the driver must issue `ESC A` and drain the 32-byte -status reply before the next segment / the trailer. +One per copy: `ESC n` + `ESC D` + raster + `ESC G`. *** @@ -41,4 +33,4 @@ status reply before the next segment / the trailer. > **preamble**: `Uint8Array` -Job preamble — written once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. +Once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. diff --git a/docs/api/core/src/interfaces/Write550JobOptions.md b/docs/api/core/src/interfaces/Write550JobOptions.md index b20ab8a..d11564a 100644 --- a/docs/api/core/src/interfaces/Write550JobOptions.md +++ b/docs/api/core/src/interfaces/Write550JobOptions.md @@ -12,9 +12,6 @@ > `optional` **handshakeReadTimeoutMs?**: `number` -Per-handshake read deadline, ms. The 550 may take a couple of -seconds to answer the post-`ESC G` `ESC A` while the label is -physically feeding. Omit (or pass `undefined`) to delegate the -deadline to the transport's own policy — the WebUSB transport -has no implicit timeout and an unresponsive firmware would hang -the read forever, so web callers should set a finite value. +Per-handshake read deadline, ms. Omit to delegate to the +transport's own policy — WebUSB has no implicit timeout, so web +callers should set a finite value. diff --git a/packages/core/src/protocol-550.ts b/packages/core/src/protocol-550.ts index e7cde97..7546e20 100644 --- a/packages/core/src/protocol-550.ts +++ b/packages/core/src/protocol-550.ts @@ -314,43 +314,26 @@ function concat(...arrays: Uint8Array[]): Uint8Array { } /** - * A 550 print job split into the segments an interactive print routine - * writes, with a status handshake between them. - * - * The 550 firmware stops draining the bulk-OUT endpoint after each - * label's `ESC G` footer until the host issues `ESC A` and reads the - * 32-byte status reply — a monolithic write of the whole job therefore - * hangs mid-stream. Confirmed against minlux/dymon's Wireshark capture - * (`dymon.cpp`) and the LW 550 Technical Reference. The driver must: - * write `preamble`, then for each `labels` segment write it, issue - * `ESC A` and drain the 32-byte status, then write `finalize`. + * A 550 print job split into the segments an interactive write routine + * interleaves with `ESC A` status reads. See the + * `lw5-raster` protocol doc — "Inter-label status handshake" — for the + * wire contract this shape encodes; `write550Job` is its driver. */ export interface Composed550Job { - /** Job preamble — written once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. */ + /** Once: `ESC s`, `ESC h`/`ESC i`, `ESC C`, optional `ESC T`. */ preamble: Uint8Array; - /** - * One segment per copy: `ESC n` + `ESC D` + raster + `ESC G`. After - * writing each, the driver must issue `ESC A` and drain the 32-byte - * status reply before the next segment / the trailer. - */ + /** One per copy: `ESC n` + `ESC D` + raster + `ESC G`. */ labels: Uint8Array[]; - /** Job trailer — written once after the last label's handshake: `ESC E` + `ESC Q`. */ + /** Once, after the last label's handshake: `ESC E` + `ESC Q`. */ finalize: Uint8Array; } /** * Compose a 550 print job as interleavable segments — see - * `Composed550Job` for why the 550 can't take a monolithic write. - * - * The bitmap is fitted to the engine's `headDots` (right-padded if - * narrower, cropped if wider) so each raster line is exactly - * `headDots / 8` bytes. Copies share the same bitmap; each gets its - * own `ESC n` index and `ESC D` header and ends with an `ESC G` - * footer. `ESC E` (feed to tear) + `ESC Q` (end job) close the job - * once, in `finalize`. - * - * `compress` is silently ignored — the 550 raster format does not - * carry the 450's `SYN` / `ETB` framing and therefore cannot RLE. + * `Composed550Job`. The bitmap is fitted to `headDots` (right-pad + * narrower, crop wider) so each raster line is `headDots / 8` bytes. + * `compress` is ignored — the 550 raster format has no `SYN` / `ETB` + * framing. */ export function compose550Job( device: DeviceEntry, @@ -448,34 +431,20 @@ export function encode550Label( export interface Write550JobOptions { /** - * Per-handshake read deadline, ms. The 550 may take a couple of - * seconds to answer the post-`ESC G` `ESC A` while the label is - * physically feeding. Omit (or pass `undefined`) to delegate the - * deadline to the transport's own policy — the WebUSB transport - * has no implicit timeout and an unresponsive firmware would hang - * the read forever, so web callers should set a finite value. + * Per-handshake read deadline, ms. Omit to delegate to the + * transport's own policy — WebUSB has no implicit timeout, so web + * callers should set a finite value. */ handshakeReadTimeoutMs?: number; } /** - * Write a composed 550 job interactively over the given transport. - * - * The 550 firmware stops draining the bulk-OUT endpoint after each - * label's `ESC G` footer until the host issues `ESC A` and reads the - * 32-byte status reply (confirmed against minlux/dymon's Wireshark - * capture + the LW 550 Technical Reference). So: write the preamble, - * then per label write the segment + `ESC A`, drain the handshake - * status, then write `ESC E` + `ESC Q`. - * - * `ESC A` lock byte per the 550 spec: `0` for the LAST label — the - * final status query, which also drops the host lock; `2` between - * labels — the host does not block on that reply before streaming - * the next label, so the read is deferred to the next iteration. + * Write a composed 550 job interactively. See the `lw5-raster` + * protocol doc — "Inter-label status handshake" — for the wire + * contract. * - * Pure protocol orchestration — no driver state is touched; both the - * node and web `LabelWriterPrinter`s dispatch through here so a bug - * fix lands in one place. + * Lock byte: `0` on the last label (final query + lock release), `2` + * between labels (host defers the read to the next iteration). */ export async function write550Job( transport: Transport, diff --git a/packages/node/src/printer.ts b/packages/node/src/printer.ts index 37f82fe..4b36720 100644 --- a/packages/node/src/printer.ts +++ b/packages/node/src/printer.ts @@ -232,12 +232,8 @@ export class LabelWriterPrinter implements PrinterAdapter { const rotate = pickRotation(image, resolvedMedia, ROTATE_DIRECTION, options?.rotate); const bitmap = renderImage(image, { dither: true, rotate }); dbg(`rotate=${String(rotate)} bitmap=${String(bitmap.widthPx)}x${String(bitmap.heightPx)}`); - // 550 family: interactive half-duplex print — the firmware needs an - // `ESC A` + 32-byte status read after each label's `ESC G` footer or - // it stalls the bulk-OUT endpoint. The loop lives in - // labelwriter-core's `write550Job`. Node leaves the handshake reads - // untimed; the CLI surface is the deliberate place to address node- - // side hang behaviour. + // 550 dispatch — see `write550Job`. Node leaves the handshake + // reads untimed (CLI-side timeout policy is a separate concern). if (engine.protocol === 'lw5-raster') { const job = compose550Job(this.device, bitmap, options, resolvedMedia); dbg( diff --git a/packages/web/src/printer.ts b/packages/web/src/printer.ts index 71e5df1..cb8a37b 100644 --- a/packages/web/src/printer.ts +++ b/packages/web/src/printer.ts @@ -273,12 +273,8 @@ export class WebLabelWriterPrinter implements PrinterAdapter { } } - // 550 family: the print job is an interactive half-duplex exchange. - // The firmware stalls draining the bulk-OUT endpoint after each - // label's `ESC G` footer until the host issues `ESC A` and reads - // the 32-byte status reply — a monolithic write hangs. The loop - // lives in labelwriter-core's `write550Job`; we pass a finite - // read deadline because WebUSB has no implicit timeout. + // 550 dispatch — see `write550Job`. Web supplies a finite read + // deadline; WebUSB has no implicit timeout. if (this.engine.protocol === 'lw5-raster') { const job = compose550Job(this.device, bitmap, encodeOptions, resolvedMedia); dbg(