Skip to content

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:

  1. 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.
  2. Inferred semantics. The ESC p command direction (0x30 vs 0x31), whether the trailing zero pad on ESC M is load-bearing vs tolerated, whether the LT-200B chassis prints all 32 rows vs clips the edges, and whether flags = 0xF0 is required vs the only observed value — these are best-guess reads.
  3. Status code enum (codes 1–7). Only code === 0 has been positively observed; values 1–7 below are carried over from ysfchn/dymo-bluetooth's Result.from_bytes and 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:

RoleUUID prefixProperties
Primary servicebe3dd650-…
printRequestUUID (TX)be3dd651-…write-without-response
printReplyUUID (RX)be3dd652-…notify
printShortCommandUUIDbe3dd653-…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 -1 reserves 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     reserved

The cassetteId field in this broadcast is the load-bearing source of "is there a cassette in the printer and is it the right size".

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 payload

All 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>
FieldBytesMeaning
Preamble10xFF
Flags10xF0 — fixed; meaning unspecified, every job uses it.
Magic20x12 0x34 — also appended after the final body chunk.
Length4 LEBody length in bytes (excludes header and chunk index bytes).
Checksum1(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.

OpcodeBytesLengthDescription
ESC #1B 23 N3Copy count — 1-byte unsigned N.
ESC A1B 412Request the result notification.
ESC C1B 43 …Print density — recognised by firmware, not observed on the wire.
ESC D1B 44 …12 + imageRaster bitmap.
ESC E1B 452Form feed — advance tape past the head.
ESC M1B 4D nn 00 00 006Set cassette type; three trailing zero pad bytes.
ESC p1B 70 nn3Cut (0x30) or suppress the cut (0x31).
ESC Q1B 512Close the job.
ESC s1B 73 9A 02 00 006Open 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 41

Always 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.

CodeSymbolMeaning
0SUCCESSPrint completed.
1SUCCESS (variant)Observed alongside 0; same semantics.
2FAILEDUnspecified failure.
3SUCCESS_LOW_BATTERYPrinted, but battery is low.
4CANCELLEDJob cancelled by the printer.
5FAILED (variant)Observed alongside 2; same semantics.
6BATTERY_TOO_LOWBattery too low to drive the head.
7CASSETTE_MISSINGDocumented; 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>
FieldBytesValue / meaning
bpp10x81 — fixed.
align10x02 — fixed.
width4 LEFeed-direction column count (= image.length / 4).
height4 LEAcross-head row count — always 32.
pixelsvar4 × 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 45

Documented 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 00

Six 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:

cassetteIdTape widthDYMO size name
16 mmSMALL
29 mmMEDIUM
312 mmLARGE
419 mmX_LARGE
524 mmXX_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>
commandMeaning
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 51

Mandatory 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 00

The 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 imageHead rows packedBit 7 (MSB) is rowBit 0 (LSB) is row
024..312431
116..231623
28..15815
30..707

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 00

Every 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 Q

23 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.