diff --git a/.changeset/initial-phase-1.md b/.changeset/initial-phase-1.md index 5d2b944..398467d 100644 --- a/.changeset/initial-phase-1.md +++ b/.changeset/initial-phase-1.md @@ -6,14 +6,13 @@ Initial Phase 1 release — DYMO LetraTag LT-200B driver, web only. - `letratag-core` — pure TypeScript encoder for the LT-200B BLE - print protocol. ysfchn axis order, column-major bit packing with - the y+1 skip, vendor-app chunk-index quirk. Internal - `__DebugEncoderOverrides` knobs (axisOrder / bitPacking / - chunkIndexQuirk / mediaTypeByte) reachable via the `./debug` - subpath but not on the public API. Status parser covers codes 0..7 - with the ysfchn 1↔0 / 5↔2 aliases. Media registry has all ten - active and discontinued LT cassette SKUs (US 91XXX + EU - S07XXXXX). + print protocol, following the observed wire format (column-major + bit packing, the 32-row print frame, the vendor chunk-index + quirk). A single internal `__DebugEncoderOverrides.mediaTypeByte` + knob is reachable via the `./debug` subpath but not on the public + API. Status parser covers codes 0..7 with the 1↔0 / 5↔2 aliases. + Media registry has all ten active and discontinued LT cassette + SKUs (US 91XXX + EU S07XXXXX). - `letratag-web` — `LetraTagPrinter implements PrinterAdapter` over Web Bluetooth. UUID-prefix matching on the `be3dd650-` service with TX / RX / aux UUIDs derived from the observed service tail. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fdc7a6..6b96631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@v6 with: version: 9 @@ -45,7 +45,7 @@ jobs: - name: Upload coverage to Codecov if: matrix.node == '24' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unittests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9942d05..b9923d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@v6 with: version: 9 diff --git a/DECISIONS.md b/DECISIONS.md index 21651a2..6ebb027 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -54,26 +54,26 @@ substituting the prefix on the matched service tail. The official app does the same via its `setDeviceUUID()` helper, which replaces a literal placeholder in each of the four UUIDs at connect time. -## D5 — Media detection is available via BLE advertising data - -The LT-200B continuously broadcasts a 3-byte payload in its BLE -advertising-data manufacturer field. The payload encodes -`cassetteId` (1=6mm, 2=9mm, 3=12mm, 4=19mm, 5=24mm), `busyLocked`, -`batteryLevel` (0..3), `chargingIndicator`, and four error flags -(tape jam, cutter jam, battery too low, battery low). - -This **revises** an earlier PLAN-1 working assumption that the -LT-200B has no media-detection signal. The driver: - -- Parses the advertising-data via `parseAdvertisingStatus()` in - `packages/core/src/status.ts`. -- Folds the most recent snapshot into `LetraTagPrinter.getStatus()`. -- The debug harness shows the full decoded state in a dedicated - panel and includes it in diagnostics exports. - -Status code 7 ("CASSETTE_MISSING") on the post-print RX -notification is still treated as unreliable — prefer the broadcast -`cassetteId` for cassette-presence checks. +## D5 — No BLE telemetry; the post-print notification is the only status source + +A PLAN-1-era revision assumed the LT-200B continuously broadcasts a +3-byte advertising-data manufacturer payload (`cassetteId`, +`batteryLevel`, charging, tape/cutter/battery error flags) that the +driver could parse via `parseAdvertisingStatus()` and fold into +`getStatus()`. **On-the-wire investigation found no such broadcast.** +The LT-200B exposes no battery, cassette, or live-status telemetry +over BLE — the advertising-status code was phantom and was removed. + +The driver's only status source is the 3-byte `[1B 52 code]` +notification the printer emits on its reply characteristic after +each print job (`packages/core/src/status.ts` → `parseStatus`). +`LetraTagPrinter.getStatus()` returns the last-known post-print +status (a default empty status before the first print); there is no +out-of-job status channel. + +Status code 7 ("CASSETTE_MISSING") on the post-print notification is +documented but never observed in practice; do not rely on it for +cassette-presence detection. ## D6 — Post-print status enum (codes 1–7) carried from ysfchn diff --git a/INTEROPERABILITY.md b/INTEROPERABILITY.md index f276709..da5f613 100644 --- a/INTEROPERABILITY.md +++ b/INTEROPERABILITY.md @@ -14,10 +14,8 @@ under [`docs/protocol/`](./docs/protocol/) describe: primary BLE service. The driver emits the same byte sequences the printer's own firmware consumes on the wire. - The 3-byte status notification the printer returns on its reply - characteristic, and the 3-byte advertising-data manufacturer - payload it broadcasts continuously (cassette ID, battery, error - flags) — both readable on the wire without any host-side - cooperation. + characteristic after each print job — readable on the wire without + any host-side cooperation. - The GATT topology (one primary service, three characteristics, UUID-prefix matching) used to locate the printer and its characteristics. @@ -35,22 +33,10 @@ The byte-level claims on the protocol pages are anchored on: number of details. The disagreements were resolved by on-the-wire observation, not by preferring one author over the other. -- **Interoperability analysis of the official LetraTag Connect - Android application** — limited to the byte sequences the - application emits over BLE and the layout of the advertising-data - it consumes. The application's source was not redistributed; only - the unprotectable wire-format facts (opcode bytes, header layout, - chunking strategy, bit-packing order, advertising-data bit layout) - are reproduced here. This corresponds to the use of - decompilation expressly authorised under EU Directive 2009/24/EC - Article 6 for the purpose of achieving interoperability of an - independently-created program. - **Observed wire output** captured between a host and a paired printer. BLE GATT writes and notifications on the LT-200B are unencrypted; capture is routine via Android's "HCI snoop log" developer option, `btmon`, or Wireshark with the BlueZ plugin. - Advertising-data broadcasts are observable with a passive scan - (no pairing required). The driver does **not** redistribute the printer's firmware, the mobile app, or any vendor binary. It does not include keys, diff --git a/PROGRESS.md b/PROGRESS.md index d739963..d3b4e83 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -169,7 +169,9 @@ Concrete code changes: DECISIONS.md updates: - **D3** rewritten — encoder follows the observed wire format, not ysfchn's encoder. -- **D5** added — media detection IS available via advertising data. +- **D5** rewritten — the assumed BLE advertising-data telemetry was + phantom; the post-print `[1B 52 code]` notification is the only + status source. - **D6** added — post-print status enum 1–7 carried from ysfchn, flagged as unconfirmed by direct observation pending hardware reports. @@ -178,7 +180,7 @@ DECISIONS.md updates: ## Implementation decisions / divergences -These are choices made by Claude during build-out where the plan was +These are choices made during build-out where the plan was ambiguous or where a small course-correction happened. Each entry explains the WHY so a human reviewer can flip it. diff --git a/packages/core/data/media.json5 b/packages/core/data/media.json5 index 730b481..153d4d0 100644 --- a/packages/core/data/media.json5 +++ b/packages/core/data/media.json5 @@ -1,10 +1,11 @@ // LT cassette registry for the LetraTag driver. // -// Every entry: 12 mm tape width, 30 printable dots across the head, -// single ink colour ("text"), single substrate colour -// ("background"). Per-cassette roll length varies (most ~4 m; -// iron-on is 2 m) but `heightMm` stays undefined per contracts — -// continuous tape has variable per-label length. +// Every entry: 12 mm tape width (`widthMm`), single ink colour +// ("text"), single substrate colour ("background"). Per-cassette +// roll length varies (most ~4 m; iron-on is 2 m) but `heightMm` +// stays undefined per contracts — continuous tape has variable +// per-label length. Printable head height (30 dots) is an engine +// fact (`headDots` / the `PRINTABLE_DOTS` const), not a media field. // // Driver-side fields beyond the contracts `MediaDescriptor` shape: // - `material` — LT substrate family (paper / plastic / @@ -12,9 +13,6 @@ // - `text` — printed colour, named (the only ink the // cartridge carries). // - `background` — substrate colour, named. -// - `tapeWidthMm` — 12 (literal-typed, mirrors labelmanager -// precedent for the encoder lookup). -// - `printableDots` — 30 (matches engine headDots). // // SKUs include every observed US (91XXX) and EU (S07XXXXX) part // number for the same physical tape; downstream consumers @@ -34,8 +32,6 @@ material: 'paper', text: 'black', background: 'white', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / White ───────────────────────────────────── @@ -52,8 +48,6 @@ material: 'plastic', text: 'black', background: 'white', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Pearl White (TBV) ──────────────────────── @@ -70,8 +64,6 @@ material: 'plastic', text: 'black', background: 'pearl-white', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Yellow ─────────────────────────────────── @@ -88,8 +80,6 @@ material: 'plastic', text: 'black', background: 'yellow', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Red ────────────────────────────────────── @@ -106,8 +96,6 @@ material: 'plastic', text: 'black', background: 'red', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Green ──────────────────────────────────── @@ -124,8 +112,6 @@ material: 'plastic', text: 'black', background: 'green', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Blue ───────────────────────────────────── @@ -142,8 +128,6 @@ material: 'plastic', text: 'black', background: 'blue', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Plastic / Clear ──────────────────────────────────── @@ -160,8 +144,6 @@ material: 'plastic-clear', text: 'black', background: 'clear', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Metallic / Silver ────────────────────────────────── @@ -178,8 +160,6 @@ material: 'metallic', text: 'black', background: 'silver', - tapeWidthMm: 12, - printableDots: 30, }, // ─── 12 mm — Iron-on Fabric / White ───────────────────────────── @@ -196,7 +176,5 @@ material: 'iron-on-fabric', text: 'black', background: 'white', - tapeWidthMm: 12, - printableDots: 30, }, ] diff --git a/packages/core/src/__tests__/media.test.ts b/packages/core/src/__tests__/media.test.ts index 67a945d..f128591 100644 --- a/packages/core/src/__tests__/media.test.ts +++ b/packages/core/src/__tests__/media.test.ts @@ -9,11 +9,9 @@ describe('media registry', () => { } }); - it('every entry is 12 mm tape with 30 printable dots', () => { + it('every entry is 12 mm tape', () => { for (const m of MEDIA_LIST) { expect(m.widthMm).toBe(12); - expect(m.tapeWidthMm).toBe(12); - expect(m.printableDots).toBe(30); expect(m.type).toBe('tape'); expect(m.category).toBe('cartridge'); expect(m.targetModels).toContain('letratag'); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 732b408..7aa472c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -22,19 +22,16 @@ export type LetraTagMaterial = * * Extends the contracts base `MediaDescriptor`. Tape is always * continuous — `heightMm` is omitted. Every LT cassette is 12 mm - * wide (the only width the LT-200B chassis accepts) and 30 dots - * printable; both literal-typed. + * wide; that width lives in the spec'd `widthMm` (the only width + * the LT-200B chassis accepts). * - * `printableDots: 30` is a chassis fact, not a wire-format fact — - * the protocol always frames 32 rows; the LT-200B's print head - * appears to image all 32, but prior public encoders reported that - * the top and bottom rows clip on certain substrates. Treat 30 as - * the safe authoring height for now and verify on hardware. + * Printable head height is a chassis fact, not a per-cassette one, + * so it lives on the engine (`PrintEngine.headDots`) and the + * `PRINTABLE_DOTS` constant in `./protocol.ts` — not on this media + * type. */ export interface LetraTagMedia extends MediaDescriptor { type: 'tape'; - tapeWidthMm: 12; - printableDots: 30; /** LT substrate family. */ material?: LetraTagMaterial; /** Printed ink colour, named (the only ink the cartridge carries). */ diff --git a/packages/web/src/__tests__/printer.test.ts b/packages/web/src/__tests__/printer.test.ts index e310a72..bfba1f3 100644 --- a/packages/web/src/__tests__/printer.test.ts +++ b/packages/web/src/__tests__/printer.test.ts @@ -154,7 +154,6 @@ describe('LetraTagPrinter (fake transport)', () => { id: 'LT-paper-12-white', name: 'White paper 12 mm', targetModels: ['LT_200B'], - tapeWidthMm: 12, material: 'paper', background: 'white', text: 'black', diff --git a/scripts/compile-data.mjs b/scripts/compile-data.mjs index df61ed2..471079f 100644 --- a/scripts/compile-data.mjs +++ b/scripts/compile-data.mjs @@ -20,8 +20,7 @@ // // - DRIVER = 'letratag', KNOWN_PROTOCOLS = {'letratag-bt'}. // - USB block validation removed; bluetooth-gatt block validated. -// - Per-media validation tightened to the LT 12 mm / 30 dot -// invariants. +// - Per-media validation tightened to the LT 12 mm invariant. import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; @@ -260,8 +259,6 @@ function loadMedia() { if (typeof m?.background !== 'string' || m.background.length === 0) { fail(`media[${i}]: background must be a non-empty string`); } - if (m?.tapeWidthMm !== 12) fail(`media[${i}]: tapeWidthMm must be 12`); - if (m?.printableDots !== 30) fail(`media[${i}]: printableDots must be 30`); if (!Array.isArray(m?.targetModels) || !m.targetModels.includes('letratag')) { fail(`media[${i}]: targetModels must include 'letratag'`); }