Spike: Native Bluetooth printing to the Xlife P83C (thermal A4)

Date: 2026-05-30 Status: SOLVED & hardware-validated. The full 3-page Protokoll prints reliably from Linux, no vendor app, over Classic Bluetooth SPP/RFCOMM (NOT BLE — see the transport correction). Good quality at HalftoneType=None. See §Hardware validation & handover for the transport, the HalftoneType quality knob, the lid caveat, and the Android-plugin path (Classic BluetoothSocket, SPP UUID …1101). Update 2026-05-30 (later): 0x43 descriptor fully reverse-engineered via an oracle harness — the suspected "checksum" turned out to be a random per-job nonce, not a checksum (see §The 0x43 descriptor — SOLVED). No CRC to reverse; the only remaining unknown is a hardware-side question (does the printer validate the nonce?), answerable only on a physical P83C. Goal: Print directly from the Tauri Android app to a Bluetooth Xlife P83C thermal printer, natively, without handing off to the vendor "XLife" app.

Superseded as the shipping printer. This spike is an accurate historical reverse-engineering record, but the printer osd actually ships against is now the PeriPage A40 (see the A40 spike, which reuses this transport/encoder work). The technical findings here (BLE-vs-SPP correction, the 0x43 nonce, HalftoneType) remain valid; only the "which printer are we shipping" implication is out of date.

TL;DR

Feasible, low—​moderate risk. The vendor Linux/Chrome drivers (on the supplied USB stick) hand us almost the entire protocol. The native path is fully self-contained — it needs no Android system PrintService and no XLife app at runtime:

protocol view → render 1-bpp raster @203dpi → PPLI command stream → BLE (FF00/FF02)

A dedicated Tauri plugin (tauri-plugin-xlife-ble, Kotlin BLE + PPLI encoder) does the work. Device Owner / ES Dashboard (epic 9) is still useful — but only to silently grant BLUETOOTH_CONNECT/SCAN and for kiosk lockdown, not as a print backend.

The printer

From the Chrome driver’s assets/printer-mapping.json (the P83C entry is explicit):

Property Value

Model

P83C

Resolution

203 DPI (note: plain P83 is 300 — different)

Color

Monochrome (1-bpp after dithering)

Compression

false (fixed-size raster)

Max width

216 mm (Letter); A4 default

Image prep

brightness +10, unsharp mask (filter-side)

CUPS PPD

Xlife-P83(203DPI).ppd, filter rastertosnailppli

Transport — BLE (not Classic SPP)

From the Chrome extension’s tabs/index.*.js (navigator.bluetooth / GATT):

Role UUID (16-bit)

Service

0xFF00

Write (PPLI out)

0xFF02 — writeValueWithoutResponse, 512-byte chunks

Notify (subscribed)

0xFF03 (also 0xFF01 exists)

Pairing filters on service 0xFF00. This is the standard FF00 BLE-serial profile, well-trodden on Android via BluetoothGatt.

Protocol — "PPLI"

We have two reference encoders from the stick:

  1. rastertosnailppli-x64 — runnable, non-stripped x86-64 CUPS filter (our byte oracle)

  2. The JS encoder inside the Chrome extension (readable)

How to regenerate ground truth (no vendor hardware needed)

The full CUPS chain is on a normal Linux box (gstoraster lives in /usr/lib/cups/filter/, gs has the cups device):

PPD="…/Xlife-P83(203DPI).ppd"
FILT="…/bin/rastertosnailppli-x64"
magick -density 203 -units pixelsperinch -size 64x16 xc:black black.pdf
PPD="$PPD" /usr/lib/cups/filter/gstoraster 1 u t 1 "" black.pdf > black.ras
PPD="$PPD" "$FILT" 1 u t 1 "" black.ras > black.ppli   # ← captured PPLI bytes

The filter also dumps intermediate bitmaps (/tmp/oriH*.bmp, /tmp/ditH*.bmp) per its mvimg.sh debug helper — handy for verifying our rasterization matches theirs.

Decoded structure (from white/black/top-half/left-half 64×16 captures)

  • 64-byte zero preamble, then commands each framed by prefix 9B 64 (0x9B = CSI).

  • Stream order: 0x41 init → 0x0E config (×2) → 0x43 image descriptor → four 0x03 raster bands → 0x00 → trailing 0x0E feed/finish.

  • Raster (0x03) command: carries 4 rows of bitmap (8 bytes/row × 4 = 32 bytes), emitted top→bottom.

    • 1-bpp, MSB-first, 1 = black, bytes left→right.

    • Verified: left (left 32 cols black) → each row FF FF FF FF 00 00 00 00; top (top 8 rows black) → black in first two 0x03 cmds only.

The 0x43 descriptor — SOLVED

0x43 payload is 11 bytes. The original 4 captures looked like a checksum:

        b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 b10
black:  ec 04 00 40 03 40 00 20 bb 5e 0a
white:  f4 04 00 40 03 40 00 4b ef 02 0a
top:    64 04 00 40 03 40 00 3a 65 98 0a
left:   a9 04 00 40 03 40 00 37 9c 02 0a

It is not a checksum. Proof via the oracle harness (~/xlife-spike/re/):

  1. Same bitmap → different bytes. Re-encoding a byte-identical raster (the CUPS .ras is SHA-stable run-to-run) yields a different b0/b7/b8/b9 on every run — three consecutive runs gave 7d…d0 20 c3, bd…3c 14 2d, 03…47 c4 97. A content checksum is deterministic; this isn’t.

  2. Only 4 bytes are non-deterministic. cmp -l of two identical-input streams differs at exactly offsets 0x6e, 0x75, 0x76, 0x77 = b0, b7, b8, b9. Nothing else in the 336-byte stream changes, so there is no transmitted nonce/seed elsewhere that they could be checksumming over — they are self-contained random values.

  3. The filter pulls randomness + time. objdump -T shows imports of time / localtime; strings shows std::random_device reading /dev/urandom.

Conclusion: b0, b7, b8, b9 are 4 random per-job nonce bytes. The native encoder does not need to compute any checksum — emit 4 random bytes (or test zeros on hardware).

The remaining 6 bytes are deterministic geometry, decoded by probing widths/heights:

Field Meaning

b1 b2 LE

0x0004 constant — top/y-offset (unchanged by height; format flag)

b3 b4 LE

x-offset = (1728 − width) / 2 — centers image on the 1728-px platen (216 mm @ 203 dpi)

b5 b6 LE

image width in px (64→40 00, 32→20 00, 128→80 00)

b10

0x0A terminator

Image height is not in the descriptor — it is implied by the number of 0x03 raster bands (4 rows per band).

Open question (hardware-only): does the P83C validate the nonce, or ignore it / treat it as an opaque job id? Settle on a physical printer by sending a stream with b0/b7/b8/b9 forced to 00 (or a fixed constant) and confirming it still prints. If it rejects, the nonce derivation (likely a plain random_device draw, possibly time-seeded) is the only thing left to match, and it would have to be observable to the printer — which the no-other-varying-bytes result says it is not. Strong prior: the printer ignores it.

Proposed implementation (pending design + dependency sign-off)

  1. tauri-plugin-xlife-ble (Kotlin): scan/connect FF00, subscribe FF03, write FF02 in 512-byte chunks. Runtime perms auto-granted via Device Owner.

  2. PPLI encoder (Rust or Kotlin): render the protocol print layout → 1-bpp raster at 203 dpi / 216 mm width → emit the 9B 64 command stream above.

  3. Fixture test: encode a known page, assert byte-equality against rastertosnailppli output (TDD; the filter is checked-in test data, not redistributed in the app). Must mask the 4 nonce offsets (0x6e, 0x75, 0x76, 0x77 within each 0x43 cmd) — the oracle randomizes them per run, so a naïve byte-diff will always fail there. Compare everything except those positions, and separately assert our encoder writes 4 bytes there (value irrelevant pending the hardware check).

Blockers ruled out

  • ❌ Android system PrintService from XLife — XLife is an in-app printer, not a system print backend; irrelevant on the direct-BLE path anyway.

  • ❌ Classic Bluetooth SPP — it’s BLE.

  • window.print() on Tauri Android — confirmed no-op (tauri#11933); a native plugin is required regardless.

Source artifacts (persisted — survives reboot)

All preserved in ~/xlife-spike/ (outside the repo, not committed — vendor binaries under EULA):

  • Linux/printer-driver-xlife_3.13.35_all.deb + .rpm — original vendor driver (source of truth)

  • Chrome/xlife-0-1-3-prod.zip — original Chrome app

  • extracted/bin/ — the runnable filter binaries, incl. rastertosnailppli-x64 (the byte oracle)

  • extracted/ppds/ — all Xlife PPDs (use Xlife-P83(203DPI).ppd)

  • extracted/chrome-app/ — unpacked Chrome extension (JS reference encoder + printer-mapping.json)

  • captures/ — the white/black/top/left .ppli decode samples + test PDFs + filter logs

Originals also on USB stick 9C9B-E5D5. The /tmp/ppli + /tmp/xlife-driver scratch dirs are wiped on reboot — regenerate via the commands above, pointing at ~/xlife-spike/extracted.


Hardware validation & handover (2026-05-30, live printer)

Validated end-to-end against a physical printer (branded Vretti, model P83C, A4 thermal) from this Linux laptop over BLE — no vendor app, no Android. Outcome: the native-print path works, including the full 3-page Protokoll and the full-width test page. Feasibility is no longer in question.

Device identity & transport (CONFIRMED on hardware)

The printer is dual-mode and advertises two addresses:

Address Role

60:B5:69:A5:04:CC

Classic BR/EDR — SPP (1101) on RFCOMM channel 1. THIS IS THE WORKING PRINT TRANSPORT (see correction below).

60:B5:69:A5:04:CD

BLE / LE = Classic address + 1. Service 0000ff00, write FF02. Prints small jobs but overflows on large jobs — bleak/BlueZ gives no write-without-response backpressure.

⚠️ TRANSPORT CORRECTION (the real answer): print over Classic Bluetooth SPP / RFCOMM, not BLE. Connect an RFCOMM socket to 60:B5:69:A5:04:CC channel 1 and write the PPLI stream — it is a reliable, flow-controlled stream, so the full 1.5 MB / 3-page Protokoll prints in ~33 s reliably and repeatably (verified multiple times). The earlier "RFCOMM swallows bytes" result was a lid-open artifact, not a transport failure. BLE/FF02 was a red herring for large jobs — it has no backpressure on Linux and the printer silently holds oversized jobs. Python: socket.socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM); s.connect"60:B5:69:A5:04:CC", 1; s.sendall(ppli). No rfcomm bind needed. For Android (the real target), the equivalent is a Classic BluetoothSocket with the SPP UUID 00001101-0000-1000-8000-00805f9b34fb — far simpler than the BLE GATT plugin first scoped, and it inherits SPP’s flow control.

GATT on the LE address:

Char Handle Props Use

0000ff02

8

write-without-response

write the PPLI byte stream here

0000ff03

10

notify

status/ack notifications

0000ff01

5

notify

status notifications (vendor’s read() uses this for command replies)

  • The OS holds the Classic link by default; you must bluetoothctl disconnect …:CC (and untrust to stop auto-reconnect) before the LE side is reachable. (Left untrusted at handover — re-trust …:CC if you want OS audio auto-connect back.)

  • BlueZ negotiates ATT MTU 497 → max write-without-response payload = 494 bytes. The vendor uses 512 (Chrome negotiates a bigger MTU); chunk size is transparent to the printer (PPLI is self-framing via 9B 64), so 494-byte chunks are fine — 512 throws org.bluez.Error.Failed on BlueZ.

What prints, and the operational quirks

  • Small pages print cleanly and correctly — rasterization + geometry model validated (e.g. the Protokoll’s 3 pages decode to width=1680, xoff=24 → centered on the 1728-px platen).

  • The full 3-page Protokoll AND the full-width card DID print. But large/buffered jobs needed human intervention at the printer: a power-cycle and a front-button press, and some jobs printed later, by themselves (buffered, then flushed). So the apparent "hang on jobs > ~11 KB" seen from the host is not a hard protocol size limit — it’s the printer waiting on a print-trigger / paper / cover condition that the front button + power state resolves.

  • The lid microswitch is flaky. Jobs received with the lid not fully latched buffer and print once it’s properly closed (this caused multiple "nothing printed" episodes and a later spontaneous double-print). Treat lid state as a first-class variable.

  • Recovery from a stuck/latched state = power-cycle the printer (clears buffer + any latched cover/not-ready error), then it accepts fresh jobs.

The status / flow-control layer (the real "next layer", partially RE’d)

Raw PPLI-dumping works for small jobs; robust large-job printing uses a command/status layer:

  • The printer emits PPLI-framed status notifications on FF01/FF03: 9B 64 F9 … and 9B 64 FF 00 1C <flag> …. The 0xFF status frame carries a completion flag (00 01 = in-progress/not-done, 00 00 = done) and a counter that advances per printed unit, plus it echoes the current page’s xoff+width (e.g. 18 00=24, 90 06=1680). A job that prints transitions 00 01 → 00 00; a stuck job stays 00 01.

  • Standalone 01 01 notifications arrive ~1 per 494-byte chunk — a per-chunk receipt ack (NOT required for flow control; the vendor doesn’t gate on it).

  • The vendor’s JS (extracted/chrome-app/tabs/index.7d315be3.js) has an AstraCommand class (CMD:AstraP per the PPD) with status(), beep(), density(), openDrawer(), etc., and a getStatus(){lackPaper, allowSendData}. allowSendData is the real backpressure signal; status replies are terminated by 0x9B (155). The vendor write(e) just blasts 512-byte writeValueWithoutResponse chunks and relies on Web-Bluetooth / L2CAP-credit backpressure — which bleak/BlueZ does not provide (fire-and-forget). On Android (the real target) the BLE stack provides this backpressure like Chrome, so the host-side overflow we hit on Linux is unlikely to reproduce there.

  • Still-open RE (for a production-grade encoder): decode the AstraCommand.status() request bytes and the 0xFF/0xF9 status-frame fields (lackPaper/allowSendData mapping), and confirm whether large pages need explicit segmentation or just proper backpressure. Not required to prove printing — required to make it robust & unattended.

RE harness (reusable — ~/xlife-spike/re/, outside the repo)

  • parse.py — parse a .ppli into commands / 0x43 descriptor / raster bytes.

  • oracle.sh <img> — image → gstorasterrastertosnailppli → print descriptor+raster (the byte oracle).

  • crack.py — checksum brute-forcer (used to prove the nonce is not a checksum).

  • ble_blast.py <ppli> [--zero-nonce] [--hold N] — vendor-faithful sequential 494 B blast, watches for the 00 00 done flag.

  • ble_fc.py <ppli> [--hold N] — strict 0101-gated send (window=1).

  • ble_print.py — earlier credit-gated sender (superseded; gating was unnecessary).

  • venv/ — Python venv with bleak (only new tool added; isolated, not a project dep).

  • Generated samples: testprint.ppli (512×160, prints), wide.ppli (1680×200), tall.ppli (512×660), proto.ppli (the 3-page Protokoll, 1.51 MB).

  • Regenerate the Protokoll PPLI: gstoraster 1 u t 1 "" Protokoll.pdf > p.ras then rastertosnailppli-x64 1 u t 1 "" p.ras > p.ppli (PPD env var = Xlife-P83(203DPI).ppd).

Print quality — halftone (CONFIRMED on hardware)

The printer is 1-bit (pure black/white dot) at 203 dpi. The PPD’s HalftoneType is the key quality knob, and its *default Stucki dithers everything — making text/table rules look ragged and colored fills come out as a weird grey dot-pattern.

  • HalftoneType=None (threshold) — recommended. Crisp text + table rules, solid fills; colored areas snap to solid black or white (no grey halftone). Trade-off: a freehand signature is thresholded too (loses smoothness) — acceptable for a clinical protocol.

  • FloydSteinberg — finer dither, better for photos/signatures, lines still slightly dithered.

  • Pass via the CUPS options string (5th arg) to both gstoraster and rastertosnailppli: … gstoraster 1 u t 1 "HalftoneType=None" file.pdf then the filter with the same option.

Biggest quality win is app-side: design a thermal-mono variant of the Typst protocol PDF — pure B/W, table rules ≥ 0.75—​1 pt (hairlines < 0.4 pt fall below one 203-dpi dot and drop out), colored status pills rendered as black-on-white / reversed white-on-black rather than colored fills. Render that with HalftoneType=None → crisp tables + text, no grey. Brightness and a slower PrintSpeed (default 7) darken the burn if rules look weak.

Verdict on "keep the printer?"

Keep it — SOLVED. The full 3-page Protokoll prints reliably and repeatably over Classic SPP/RFCOMM in ~33 s, with good quality at HalftoneType=None. Transport, encoding, and quality tuning are all understood. Remaining work is a bounded Tauri Android plugin using a Classic BluetoothSocket (SPP UUID …1101) — not the BLE GATT plugin first scoped — streaming the PPLI from the (already-understood) encoder, plus a thermal-mono PDF stylesheet. The flaky lid (jobs buffer until it’s latched; status bit handup) is a hardware/usage caveat for the field, not a software blocker.