Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions .changeset/initial-phase-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
with:
version: 9

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
with:
version: 9

Expand Down
40 changes: 20 additions & 20 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 2 additions & 16 deletions INTEROPERABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
34 changes: 6 additions & 28 deletions packages/core/data/media.json5
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
// 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 /
// plastic-clear / metallic / iron-on-fabric).
// - `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
Expand All @@ -34,8 +32,6 @@
material: 'paper',
text: 'black',
background: 'white',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / White ─────────────────────────────────────
Expand All @@ -52,8 +48,6 @@
material: 'plastic',
text: 'black',
background: 'white',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Pearl White (TBV) ────────────────────────
Expand All @@ -70,8 +64,6 @@
material: 'plastic',
text: 'black',
background: 'pearl-white',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Yellow ───────────────────────────────────
Expand All @@ -88,8 +80,6 @@
material: 'plastic',
text: 'black',
background: 'yellow',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Red ──────────────────────────────────────
Expand All @@ -106,8 +96,6 @@
material: 'plastic',
text: 'black',
background: 'red',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Green ────────────────────────────────────
Expand All @@ -124,8 +112,6 @@
material: 'plastic',
text: 'black',
background: 'green',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Blue ─────────────────────────────────────
Expand All @@ -142,8 +128,6 @@
material: 'plastic',
text: 'black',
background: 'blue',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Plastic / Clear ────────────────────────────────────
Expand All @@ -160,8 +144,6 @@
material: 'plastic-clear',
text: 'black',
background: 'clear',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Metallic / Silver ──────────────────────────────────
Expand All @@ -178,8 +160,6 @@
material: 'metallic',
text: 'black',
background: 'silver',
tapeWidthMm: 12,
printableDots: 30,
},

// ─── 12 mm — Iron-on Fabric / White ─────────────────────────────
Expand All @@ -196,7 +176,5 @@
material: 'iron-on-fabric',
text: 'black',
background: 'white',
tapeWidthMm: 12,
printableDots: 30,
},
]
4 changes: 1 addition & 3 deletions packages/core/src/__tests__/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
15 changes: 6 additions & 9 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
1 change: 0 additions & 1 deletion packages/web/src/__tests__/printer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 1 addition & 4 deletions scripts/compile-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'`);
}
Expand Down