LetraTag BT Protocol
The wire protocol of DYMO's LetraTag LT-200B — a BLE-only handheld label printer. Print jobs travel as packet-framed payloads over a write-without-response GATT characteristic; status replies arrive as notifications on a second characteristic and as continuous broadcasts in BLE advertising data. There is no USB, serial, or TCP path on this chassis.
DYMO does not publish a technical reference for this protocol. The byte sequences below are anchored on prior public reverse-engineering work (ysfchn/dymo-bluetooth, alexhorn/lt200b) and on-the-wire observation between a paired LT-200B and a host.
Status of facts — hardware-unverified in places
Concrete byte-level claims (header layout, checksum, opcode table, chunking, advertising-data bit layout, GATT topology, prefix-matching strategy) are high-confidence. Three classes of claim deserve explicit caveats:
- Bit packing. The worked examples follow MSB-first per-byte packing plus per-rasterline byte reversal. They have not been byte-traced on a real print on every supported substrate.
- Inferred semantics. The
ESC pcommand direction (0x30vs0x31), whether the trailing zero pad onESC Mis load-bearing vs tolerated, whether the LT-200B chassis prints all 32 rows vs clips the edges, and whetherflags = 0xF0is required vs the only observed value — these are best-guess reads. - Status code enum (codes 1–7). Only
code === 0has been positively observed; values 1–7 below are carried over fromysfchn/dymo-bluetooth'sResult.from_bytesand have not been confirmed by direct observation. Do not treat them as canonical.
Frame geometry
The protocol's ESC D height field is fixed at 32 head rows. Labels shorter than 32 rows are centred within the 32-row frame by padding with zero-rasterlines at the top and bottom — there is no fixed printable-row count baked into the wire format. Whether all 32 rows physically print is a property of the print head, not the protocol.
The protocol vocabulary supports five tape widths (see the cassette-ID table). The LT-200B chassis only accepts 12 mm cassettes, but the wire format does not distinguish.
BLE topology
The printer advertises a single primary GATT service. The service UUID and three characteristics share the same 28-character tail; the first 8 hex digits of the service UUID are stable across firmware revisions, the remaining body may differ. The canonical advertised UUIDs are:
| Role | UUID prefix | Properties |
|---|---|---|
| Primary service | be3dd650-… | — |
printRequestUUID (TX) | be3dd651-… | write-without-response |
printReplyUUID (RX) | be3dd652-… | notify |
printShortCommandUUID | be3dd653-… | write-without-response |
The advertised device name is Letratag <12-hex-MAC-suffix> — e.g. Letratag 10B41D8220FE. Prior public reverse-engineering work (ysfchn / alexhorn) recorded the prefix as DYMO LT-200B; firmware revisions observed in 2026 advertise the Letratag prefix (with a trailing space) instead. Implementations targeting the broadest fleet should accept both.
The printShortCommandUUID characteristic carries out-of-band commands that don't go through the chunked-print pipeline — most notably the stand-alone ESC s → ESC M → ESC Q payload that records the loaded cassette in the printer's session state.
UUID body is variable, prefix is stable
The full UUID body observed on the wire may differ across firmware revisions or device units. Only the first 8 hex digits (be3dd650- / be3dd651- / be3dd652- / be3dd653-) are stable; the primary service and its three characteristics always share the same 28-character tail at runtime.
MTU and chunking
Two ceilings apply to every TX write:
- The protocol chunk size is 500 body bytes (501 on the wire once the 1-byte chunk-index prefix is added). This is the upper bound documented in the prior public reverse-engineering work; the firmware rejects larger bodies per chunk.
- The BLE link MTU is whatever the OS / browser negotiates. On the LT-200B's BLE 4.2 stack, ATT MTU 247 (244-byte payload) is the modern conservative default; older stacks negotiate as low as 23. Writes that exceed the negotiated link MTU fail at the BLE layer on the first chunk of a multi-chunk job — the firmware never sees them. The effective per-chunk ceiling is therefore
min(500, mtu - 1); the-1reserves one byte for the chunk-index prefix.
Every write to TX uses write-without-response, so the host does not receive ack frames between writes; ordering is preserved by the sequence-index byte that prefixes each chunk.
Advertising data
The printer continuously broadcasts a 3-byte payload in its BLE advertising packets' manufacturer data. No connection is required to read this — a passive scan exposes cassette presence, tape size, battery level, charging status, and four error flags. The byte layout is observable on the wire with any passive scan tool (e.g. btmon on Linux or Android's HCI snoop log):
byte 0 bits 4-7 revision (protocol version)
bits 0-3 reserved
byte 1 bits 0-3 cassetteId (1..5; see ESC M)
bit 4 carbonType
bit 5 busyLocked (job in progress)
bits 6-7 spare
byte 2 bit 0 TAPE_JAM (error)
bit 1 CUTTER_JAM (error)
bit 2 BATTERY_TOO_LOW (error — won't print)
bit 3 BATTERY_LOW (warning — prints anyway)
bits 4-5 batteryLevel (0..3; four levels)
bit 6 chargingIndicator
bit 7 reservedThe cassetteId field in this broadcast is the load-bearing source of "is there a cassette in the printer and is it the right size".
Print job structure
Every job — including a one-line text label — follows this exact shape:
HEADER[9] preamble + length + checksum
[chunks of body, prefixed with 1-byte index, ≤500 bytes payload each]
body =
ESC s opens the job (job ID fixed)
ESC # <N> always emitted, default N=1
ESC D <bpp> <align> <w> <h> <pixels>
ESC p <command> 0x30 = cut now, 0x31 = suppress
ESC A requests the result notification
ESC Q closes the job
final chunk has MAGIC (12 34) appended after its payloadAll multi-byte integers are little-endian.
The header is sent first, by itself, as the very first TX write. Body chunks follow as separate writes in order. Total TX writes for a job = 1 + ⌈len(body) / 500⌉.
Tiny-print alternation quirk (firmware state-toggle)
With identical short content sent back-to-back and insufficient total feed-column count, post-print status codes alternate perfectly between success and silent rejection — success → silent reject (head never engaged) → success → silent reject — repeating for 8+ consecutive prints. The threshold is empirical (LT-200B clears the alternation with ~30+ total feed columns per job, with feed columns counting whether they come from bitmap content, leading zeros, or trailing zeros). The firmware appears to require a minimum number of head-cycles per job to clear an internal state-toggle.
Header (9 bytes)
FF F0 12 34 <length0..3> <checksum>| Field | Bytes | Meaning |
|---|---|---|
| Preamble | 1 | 0xFF |
| Flags | 1 | 0xF0 — fixed; meaning unspecified, every job uses it. |
| Magic | 2 | 0x12 0x34 — also appended after the final body chunk. |
| Length | 4 LE | Body length in bytes (excludes header and chunk index bytes). |
| Checksum | 1 | (sum of preceding 8 bytes) & 0xFF |
Opcode vocabulary
Every opcode is a 0x1B (ESC) prefix followed by one character byte. Nine opcodes are defined; six are emitted in the normal print flow, two are auxiliary, and one (ESC C) is recognised by the firmware but has not been observed on the wire.
| Opcode | Bytes | Length | Description |
|---|---|---|---|
ESC # | 1B 23 N | 3 | Copy count — 1-byte unsigned N. |
ESC A | 1B 41 | 2 | Request the result notification. |
ESC C | 1B 43 … | — | Print density — recognised by firmware, not observed on the wire. |
ESC D | 1B 44 … | 12 + image | Raster bitmap. |
ESC E | 1B 45 | 2 | Form feed — advance tape past the head. |
ESC M | 1B 4D nn 00 00 00 | 6 | Set cassette type; three trailing zero pad bytes. |
ESC p | 1B 70 nn | 3 | Cut (0x30) or suppress the cut (0x31). |
ESC Q | 1B 51 | 2 | Close the job. |
ESC s | 1B 73 9A 02 00 00 | 6 | Open a print job; the job ID is a fixed constant. |
The printer's status reply uses one further ESC-prefixed sequence, ESC R (1B 52 <code>), but it travels printer→host only and is documented under ESC A.
ESC # — copy count
1B 23 <N>A 1-byte unsigned copy count, default 1. Always emitted, even for a single-copy job. Position is immediately after ESC s, before ESC D.
ESC A — request job status
1B 41Always present in a print job, between ESC p and ESC Q. Schedules a 3-byte notification on the printReplyUUID characteristic when the job completes (or fails). The notification format is:
1B 52 <code>0x1B 0x52 is a fixed prefix (ESC R); <code> is the result.
| Code | Symbol | Meaning |
|---|---|---|
| 0 | SUCCESS | Print completed. |
| 1 | SUCCESS (variant) | Observed alongside 0; same semantics. |
| 2 | FAILED | Unspecified failure. |
| 3 | SUCCESS_LOW_BATTERY | Printed, but battery is low. |
| 4 | CANCELLED | Job cancelled by the printer. |
| 5 | FAILED (variant) | Observed alongside 2; same semantics. |
| 6 | BATTERY_TOO_LOW | Battery too low to drive the head. |
| 7 | CASSETTE_MISSING | Documented; not observed in practice. |
Codes 1–7 are sourced from ysfchn/dymo-bluetooth's Result.from_bytes enum and have not been confirmed by direct observation; only code 0 has been positively observed in bench captures.
The same characteristic may be polled at ~500 ms intervals during printing to drive a progress UI; the final notification on job completion arrives on the same channel.
ESC C — print density
1B 43 …Recognised by the printer firmware (carried over from earlier LetraTag-family vocabulary) but has not been observed on the wire on any LT-200B job. Length and payload are unknown on this chassis.
ESC D — raster bitmap
1B 44 <bpp> <align> <width0..3> <height0..3> <pixel bytes>| Field | Bytes | Value / meaning |
|---|---|---|
bpp | 1 | 0x81 — fixed. |
align | 1 | 0x02 — fixed. |
width | 4 LE | Feed-direction column count (= image.length / 4). |
height | 4 LE | Across-head row count — always 32. |
| pixels | var | 4 × width bytes; column-major (one 4-byte head column per feed step). |
The image bytes encode the head columns in the order the feed mechanism advances. Each 4-byte column carries 32 bits — the bit packing is described in Image encoding.
ESC E — form feed
1B 45Documented in the opcode vocabulary; not emitted on the LT-200B (Avatar) path. Sibling LetraTag-family chassis that lack a cutter substitute ESC E for ESC p to advance the tape past the head at end-of-job; the LT-200B reaches the same physical effect by sending ESC p 0x30.
ESC M — set cassette type
1B 4D <cassetteId> 00 00 00Six bytes (the trailing three zeros are part of the wire format). Records the cassette type the printer should expect in its session state. The printer prints correctly on every observed substrate without this opcode — it is optional in the print flow and is typically issued out-of-band on the short-command characteristic via the stand-alone set-cassette-type payload.
The 1-byte <cassetteId> and the 4-bit cassetteId field in advertising data share the same enum:
cassetteId | Tape width | DYMO size name |
|---|---|---|
| 1 | 6 mm | SMALL |
| 2 | 9 mm | MEDIUM |
| 3 | 12 mm | LARGE |
| 4 | 19 mm | X_LARGE |
| 5 | 24 mm | XX_LARGE |
LT-200B hardware accepts only 12 mm cassettes and broadcasts cassetteId = 3 when one is loaded. The wider widths are reserved for sibling LetraTag-family chassis that share this protocol.
ESC p — cut
1B 70 <command>command | Meaning |
|---|---|
0x30 ('0') | Cut at the trailing edge of this copy. Used when copies = 1 or auto-cut is enabled. |
0x31 ('1') | Suppress the cut. Used between copies in a multi-copy job. |
ESC p takes the place of ESC E in the LT-200B (Avatar) flow. Sibling LetraTag-family chassis that lack a cutter substitute ESC E here instead.
ESC Q — close job
1B 51Mandatory trailer. Without it, the printer holds the job in its buffer and the next write to TX appends rather than starting a new job.
ESC s — open job
1B 73 9A 02 00 00The 9A 02 00 00 tail is the printer's expected job ID — a fixed constant on every observed job. It is not a queue handle or a generation counter; emit it verbatim.
Image encoding
Each feed column packs 32 head rows into 4 bytes. The packing is MSB-first within each byte, then the four bytes of each column are emitted with byte 0 = head rows 24..31 and byte 3 = head rows 0..7. That is:
| Byte index in image | Head rows packed | Bit 7 (MSB) is row | Bit 0 (LSB) is row |
|---|---|---|---|
| 0 | 24..31 | 24 | 31 |
| 1 | 16..23 | 16 | 23 |
| 2 | 8..15 | 8 | 15 |
| 3 | 0..7 | 0 | 7 |
For a user pixel at feed column x (0..feed_count - 1) and head row y (0..31):
byte_index = 3 - floor(y / 8)
bit_index = 7 - (y % 8)Cross-check by single-pixel column:
pixel at (x=0, y=0) → 00 00 00 80 (byte 3, bit 7)
pixel at (x=0, y=7) → 00 00 00 01 (byte 3, bit 0)
pixel at (x=0, y=24) → 80 00 00 00 (byte 0, bit 7)
pixel at (x=0, y=31) → 01 00 00 00 (byte 0, bit 0)
full-black column → FF FF FF FF
empty column → 00 00 00 00Every protocol row is addressable — there is no y + 1 skip. Labels shorter than 32 head rows are centred within the 32-row protocol frame by padding with zero-rasterlines at the top and bottom (top = floor((32 - h) / 2), bottom = 32 - h - top); the firmware does not branch on content extent.
On centering and "printable rows"
The protocol does not distinguish "printable" from "non-printable" head positions; all 32 rows go on the wire and all 32 are imaged by the head. User-facing media descriptors that report a printable-row count (e.g. 30) describe a chassis-mechanical reality (the top and bottom rows are clipped by the cassette geometry on certain substrates), not a wire-format constraint.
Chunking
The body (ESC s + ESC # + ESC D + ESC p + ESC A + ESC Q) is sliced into ≤500-byte windows. Each window is written as one BLE TX write:
<index> <slice...> — for non-final chunks
<index> <slice...> 12 34 — for the final chunk only<index> is a single byte sequence number. The first chunk has index = 0; subsequent chunks increment by 1 — except that the chunk that would receive index = 27 is given index = 28 instead, and every chunk after it is shifted by one (i.e. for a zero-based chunk position i, the emitted index is i + 1 when i >= 27) (per ysfchn/dymo-bluetooth).
This skip appears on the wire on every observed job, and the firmware tolerates (or relies on) it. Realistic LT labels never reach 27 chunks (= 13.5 KiB body), so the quirk is dormant on every typical print.
The 12 34 magic appended to the final chunk is the same two bytes that appear inside the header. It marks end-of-body.
Index byte at 256 chunks
The 1-byte index implies a hard limit of 256 chunks (= ~128 KiB body). LT labels are nowhere near that. Behaviour at wraparound has not been observed.
Stand-alone set-cassette-type payload
A separate, single-write payload tells the printer which cassette is loaded. It uses the printShortCommandUUID characteristic, not TX:
HEADER[9] + ESC s + ESC M <cassetteId> + ESC Q23 bytes total, no chunking — header + body are written as a single non-print write to the short-command characteristic. This is the observed mechanism for setting the printer's known cassette state outside a print job.
Recovery
There is no soft-reset directive on the wire. If the printer is left in a partial-job state (host disconnected mid-stream, or a chunk failed to write), the firmware discards the partial body on GATT disconnect; subsequent jobs print cleanly after a fresh connection. The hardware power button is a separate hard reset.
References
ysfchn/dymo-bluetooth— Python reverse-engineering of the LetraTag BT protocol. Source for the opcode vocabulary, header format, chunking skip at index 27, and the result-code enum (codes 1–7 unverified).alexhorn/lt200b— earlier reverse-engineering effort; first to document the GATT topology and the advertised name prefix (DYMO LT-200B).- LetraTag 200B User Guide (Sanford / Newell, 2023) — end-user documentation. Establishes the cassette family ("DYMO LT label cassettes") and the electrical envelope (4×AA, 2400–2483.5 MHz, < 10 dBm). No protocol details.