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 |
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 |
|
Resolution |
203 DPI (note: plain |
Color |
Monochrome (1-bpp after dithering) |
Compression |
|
Max width |
216 mm (Letter); A4 default |
Image prep |
brightness +10, unsharp mask (filter-side) |
CUPS PPD |
|
Transport — BLE (not Classic SPP)
From the Chrome extension’s tabs/index.*.js (navigator.bluetooth / GATT):
| Role | UUID (16-bit) |
|---|---|
Service |
|
Write (PPLI out) |
|
Notify (subscribed) |
|
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:
-
rastertosnailppli-x64— runnable, non-stripped x86-64 CUPS filter (our byte oracle) -
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:
0x41init →0x0Econfig (×2) →0x43image descriptor → four0x03raster bands →0x00→ trailing0x0Efeed/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 rowFF FF FF FF 00 00 00 00;top(top 8 rows black) → black in first two0x03cmds 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/):
-
Same bitmap → different bytes. Re-encoding a byte-identical raster (the CUPS
.rasis SHA-stable run-to-run) yields a differentb0/b7/b8/b9on every run — three consecutive runs gave7d…d0 20 c3,bd…3c 14 2d,03…47 c4 97. A content checksum is deterministic; this isn’t. -
Only 4 bytes are non-deterministic.
cmp -lof two identical-input streams differs at exactly offsets0x6e, 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. -
The filter pulls randomness + time.
objdump -Tshows imports oftime/localtime;stringsshowsstd::random_devicereading/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 |
|---|---|
|
|
|
x-offset = |
|
image width in px (64→ |
|
|
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)
-
tauri-plugin-xlife-ble(Kotlin): scan/connect FF00, subscribe FF03, write FF02 in 512-byte chunks. Runtime perms auto-granted via Device Owner. -
PPLI encoder (Rust or Kotlin): render the protocol print layout → 1-bpp raster at 203 dpi / 216 mm width → emit the
9B 64command stream above. -
Fixture test: encode a known page, assert byte-equality against
rastertosnailpplioutput (TDD; the filter is checked-in test data, not redistributed in the app). Must mask the 4 nonce offsets (0x6e, 0x75, 0x76, 0x77within each0x43cmd) — 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
PrintServicefrom 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 (useXlife-P83(203DPI).ppd) -
extracted/chrome-app/— unpacked Chrome extension (JS reference encoder +printer-mapping.json) -
captures/— thewhite/black/top/left.pplidecode 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 |
|---|---|
|
Classic BR/EDR — SPP ( |
|
BLE / LE = Classic address + 1. Service |
⚠️ TRANSPORT CORRECTION (the real answer): print over Classic Bluetooth SPP / RFCOMM, not BLE. Connect an RFCOMM socket to
60:B5:69:A5:04:CCchannel 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/FF02was 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). Norfcomm bindneeded. For Android (the real target), the equivalent is a ClassicBluetoothSocketwith the SPP UUID00001101-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 |
|---|---|---|---|
|
8 |
write-without-response |
write the PPLI byte stream here |
|
10 |
notify |
status/ack notifications |
|
5 |
notify |
status notifications (vendor’s |
-
The OS holds the Classic link by default; you must
bluetoothctl disconnect …:CC(anduntrustto stop auto-reconnect) before the LE side is reachable. (Left untrusted at handover — re-trust …:CCif 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 —512throwsorg.bluez.Error.Failedon 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 …and9B 64 FF 00 1C <flag> …. The0xFFstatus 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 transitions00 01 → 00 00; a stuck job stays00 01. -
Standalone
01 01notifications 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 anAstraCommandclass (CMD:AstraPper the PPD) withstatus(),beep(),density(),openDrawer(), etc., and agetStatus()→{lackPaper, allowSendData}.allowSendDatais the real backpressure signal; status replies are terminated by0x9B(155). The vendorwrite(e)just blasts 512-bytewriteValueWithoutResponsechunks 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 the0xFF/0xF9status-frame fields (lackPaper/allowSendDatamapping), 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.ppliinto commands /0x43descriptor / raster bytes. -
oracle.sh <img>— image →gstoraster→rastertosnailppli→ 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 the00 00done flag. -
ble_fc.py <ppli> [--hold N]— strict0101-gated send (window=1). -
ble_print.py— earlier credit-gated sender (superseded; gating was unnecessary). -
venv/— Python venv withbleak(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.rasthenrastertosnailppli-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
gstorasterandrastertosnailppli:… gstoraster 1 u t 1 "HalftoneType=None" file.pdfthen 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.