thermark 0.33.0

Local sticker printing for pocket thermal printers over BLE/USB — QR links, inventory tags, badges, photos; no vendor app
Documentation
# Hardware and protocol notes

Reference for printer connections, label rendering, protocol changes, and physical
print diagnosis. These notes preserve observations from the owned B1; they do not
establish hardware verification for other printers or transports.

## Commands

```bash
cargo build --locked --release
cargo build --locked --release --no-default-features --features ble
cargo test --locked
cargo test --locked --lib --no-default-features
cargo test --locked --test fixtures_readme   # sticker fixtures + boundary checks

# One-time setup (full BLE advertising name → config.json)
mkdir -p local
./target/release/thermark scan --save
./target/release/thermark identify --json > local/printer-identity.json
./target/release/thermark doctor --use-config
./target/release/thermark doctor --use-config --json > local/thermark-report.json

# Stickers (fixtures/ product demos; personal art → local/prints/)
./target/release/thermark print -i fixtures/sticker_wifi.png \
  --label 50x30 --no-fill --margin 0 -d 4
./target/release/thermark qr --url "https://example.com/o/1042" \
  --text $'ORDER #1042\nShip by Fri\nPriority' --font-name helvetica --label 50x30
./target/release/thermark text --text $'FRAGILE\nthis way up' --label 50x30
./target/release/thermark calibrate --label 50x30   # rings + safe-area box
```

Artwork demo: `cargo run --example bulldozer -- local/prints/bulldozer.png`

Check placement without a printer: `thermark print -i art.png --label 50x30 --preview out.png`

Fixtures locked by `tests/fixtures_readme.rs` (wifi, link, inventory, name, calibrate only).
Quit vendor apps before BLE connect. BLE `-a` is **exact** by default (`--fuzzy` optional).  
Experimental profile/task/connection paths need `--allow-experimental`.
The JSON doctor report is suitable for a public support issue: it keeps check
outcomes while omitting printer identifiers, RFID barcodes, serial numbers, and
local paths.

On macOS, Bluetooth Settings may show the printer as **Connected** while
CoreBluetooth cannot discover it. An exclusive-session conflict is one possible cause:
another client may own the printer's GATT connection. Also check whether the
printer is awake, in range, and advertising. `thermark doctor
--use-config` reports a matching `/dev/cu.…` endpoint as supporting evidence;
disconnect the printer in macOS Bluetooth settings, quit vendor apps, and
power-cycle or wake the printer before retrying BLE. A serial endpoint alone is
not proof that the printer accepts thermark's serial protocol.

## Protocol essentials

- BLE service `e7810a71-73ae-499d-8c15-faa9aef0c3f2`
- Characteristic `bef8d6c9-9c21-4c9e-b632-bd58c1009f9f`
- Write without response; notifications for replies
- B1: 7-byte PrintStart; 6-byte SetPageSize (rows, cols, copies)
- rows = image height (feed); cols = width ≤ 384
- `0xDB` first byte = `PrinterFault` (0x01 cover, 0x02 no paper, …)
- Info response cmd = `0x40 + key`

Protocol notes: see the command table in `src/protocol.rs`.

---

## Geometry & fonts

- **Owned B1 profile:** 8 px/mm; max width **384**. Other registered profiles
  use their own DPI and printhead width from `PrinterProfile`.
- Printable area on a **charged** B1 is the **whole canvas** — measured with
  `calibrate --boundary`, the last bar (rows 232-239) prints. `SafeArea::B1` is
  1 mm top/bottom as registration margin only. A low battery truncates dense
  pages and looks exactly like a printable-area limit; charge before measuring
- Always use `--label` for full-size media
- Prefer system fonts (`--font-name helvetica|times|arial`) over bitmap fallback
- Default no decorative border; QR is square beside text column
- `--font-size N` for fixed small/large type; omit for auto-fit

---

## Diagnosing a bad print

Read this before changing any layout constant. Most of the effort spent on this
project went into a printable-area limit that did not exist.

### Check the battery first

`thermark info` → `battery: N/4`. At level 1 a dense page sags the supply and
the printer **stops mid-page**. That is indistinguishable from a clipped layout
in a single sample, and it moved the apparent "printable area" by 7 mm between
a flat and a charged battery.

**Inconsistency is a reason to check power first.** Low battery caused varying
cutoff positions on the owned B1. It does not rule out transport or firmware
faults. Charge the printer and compare repeated runs of the same bitmap before
changing geometry; use status and transport evidence if variation persists.

### Then decide what kind of question you have

| Question | How to answer it | Costs a label? |
|---|---|---|
| What exactly will the printer receive? | `thermark print --preview out.png` | no |
| Did output change when it should not have? | `scripts/compare-render.sh <ref>` | no |
| Did a renderer change unexpectedly? | `cargo test --locked --test golden` | no |
| Does content land inside the printable area? | `cargo test --locked --test label_placement` | no |
| Where does this printer actually stop? | `thermark calibrate --boundary` | yes, one |
| Is a deliberate visual change right? | print one label | yes, one |

Only the last two need hardware. Everything above them used to be answered by
printing and photographing, which is slow, ambiguous, and burns media.

### Measure, do not infer

- Photographs of a **curled** label are unreliable: estimating a scale from one
  produced two contradictory measurements of the same printer, out by 5 mm.
  Lay it flat, or read a printed numeral instead of estimating.
- `calibrate --boundary` prints one numbered bar per millimetre, each at its own
  horizontal position. Read the highest complete bar — no counting, no scale
  estimation. If the **last** bar prints, there is no unprintable band at all.
- Quote numbers from the artifact (ink row extents, byte counts), not from
  reading the code.

### Do not let a test encode a theory

A test asserting `safe.bottom > safe.top` locked in a "the feed edge is
unreachable" belief. When the correct value arrived, the test failed and
argued for the wrong number. Pin observable behaviour — ink stays inside the
printable area — not conclusions that have not been verified on hardware.

---

## Protocol behaviour and deliberate omissions

Reference points that are optional, deliberately omitted, already implemented,
or easy to misinterpret. Keep each status explicit. Row-repeat coalescing is
implemented; the encoder splits long runs at the one-byte repeat limit of 255.

1. **`PrinterCheckLine` (0x86)**, payload `[line: u16, 0x01]`, reply `0xd3`.
   Conventionally slotted every 200 rows (`row % 200 == 199`), but it is
   optional and commonly left disabled — it is not required for reliable
   printing, on long pages or otherwise. Closing this "gap" is optional; treat
   earlier notes calling it the clearest gap as superseded.

2. **`PrintBitmapRowIndexed` (0x83)** for sparse rows — used when a row has
   **≤ 6 black pixels**, sending 2-byte pixel indices instead of a bitmap. This
   threshold is a firmware quirk rather than a size optimisation: above 6 black
   pixels the indexed form is reportedly unsafe, and clients refuse to build it.
   Our `Cmd` enum names it but nothing emits it, and no misbehaviour has been
   seen here. The rows that would land under the threshold are hairlines: 1 px
   rules, thin borders, the boundary probe's lettering.

3. **Black-pixel counts can be computed**, not zeroed. The bitmap row packet has
   a three-byte count field, computed against the printhead width in either a
   split (three chunks) or total form; thermark sends three zero bytes. Zeros
   are widely reported to work, and ours do print, so this is likely optional —
   but it is a deliberate deviation, not an accident.

4. **Print direction is per-model.** B-series images top-down; the D11/D110
   family images left-to-right, so clients for those rotate the canvas 90°
   clockwise during encoding and take the column count from the canvas *height*.
   thermark does not rotate; a D110 label is authored narrow-side-first
   (`--label 12x40`) and the wire bytes come out the same. Same output,
   different authoring convention — do not "fix" this by adding a rotation.

5. **`PrintStatus` (0xa3) has a payload worth reading**, and thermark now
   reads it: `[page: i16, pagePrintProgress: u8, pageFeedProgress: u8]`, and in
   the **10-byte form only**, a fault code at offset 6. That fault arrives
   inside a *successful* 0xb3 reply, so the framing layer never sees it — it is
   only catchable by parsing. The progress pair is also the direct answer to
   "how far did it get?", which is the question the battery episode was really
   asking. Do not read offset 6 at other lengths; it is a different field.

6. **`printEnd` returning 0 means refused, not failed.** Polling `printEnd`
   until it returns 1 is a valid completion signal in its own right — thermark
   already retries on `Ok(false)`, which is the same idea.

7. **`labelPositioningCalibration` ejects ~15 cm of paper on B1** when sent 1 or
   2. Deliberately not exposed; there is no way to make that non-destructive to
   a roll.

8. **RFID tells you the consumable, not its size** — see
   [Label size and RFID]../README.md#label-size-and-rfid. `consumablesType` could
   auto-select the label type instead of thermark's hardcoded
   `set_label_type(1)`, which is the one place a wrong default costs a mis-feed
   on continuous stock. Not implemented; needs a roll of continuous paper to
   verify.

No label-height limit exists in the protocol — no preset table, no clamp, no
per-model maximum. Page height is bounded only by the `u16` row count. 50x80
media (384×640 px, ~38 KB worst case) is a supported size, not an edge case; it
is simply the one most likely to expose a weak battery.

Also confirmed: row data is written unacknowledged with a fixed inter-packet
interval (**10 ms** is the common figure), which is why thermark paces by bytes
to roughly the same total. Acknowledged writes were tried here and made no
measurable difference.

---

## Pitfalls

1. BLE address match is **exact** by default (full advertising name or id). Short selectors no longer substring-match; use full name from `scan`, or pass `--fuzzy` only if intentional  
2. Require real printer GATT UUID; no random characteristic fallback  
3. Stuck CLI holds BLE lock — mostly addressed: Ctrl-C aborts the local job and
   `BleTransport::drop` blocks until disconnect on a multi-threaded runtime.
   It does not send the protocol's cancel-print command. `SIGKILL` still leaves
   the link held until the printer times out
4. Tiny prints = missing `--label` / wrong canvas, not “broken printer”  
5. Bitmap 5×7 font had mirrored text bugs — do not use for user labels