Spike: Native Bluetooth printing to the PeriPage A40 (thermal A4)

Date: 2026-06-26 Status: SOLVED & hardware-validated (clean-room). We printed our own raw GS v 0 raster to a physical A40 (PPG_A40_C2EA, fw V1.4.3_SD) from a Linux laptop over RFCOMM — no vendor app — confirming transport, polarity, density, orientation and width. The A40’s protocol is simpler than the Xlife P83C we already solved (no per-job nonce, no checksum, a standard ESC/POS raster opcode), the transport is the same Classic-Bluetooth SPP/RFCOMM path we already proved on the P83C, and the app’s Typst → pixmap rasterizer already exists (src-tauri/src/pdf/world.rs::render_pages_png). The only material risk is licensing: every public A40 implementation is GPL-3.0 or all-rights-reserved, and osd is proprietary — so we built clean-room from protocol facts + our own capture, never from their code. See §Hardware validation. Goal: Print the Protokoll directly from the Tauri Android app to a Bluetooth PeriPage A40 thermal printer, natively, without the vendor "PeriPage" app.

See the sibling spike Native Bluetooth printing to the Xlife P83C for the transport/encoder/quality template — most of that machinery is reused here.

Status update — shipped. This is no longer a proposal: the peripage-encoder crate and the tauri-plugin-peripage plugin are built and wired into osd. src-tauri/src/commands/printer.rs renders via render_protocol_luma / pack_luma and prints over RFCOMM. The "proposed implementation", "open questions", and "what I need from you" sections below are kept as the historical spike record. For how to set up and use printing today, see Printing (PeriPage A40).

TL;DR

protocol view → Typst render → tiny-skia Pixmap → 1-bpp threshold @ 216 B/row
              → PeriPage command stream (10 ff … reset · GS v 0 raster · ESC J feed)
              → Classic Bluetooth SPP / RFCOMM channel 1 (Android BluetoothSocket)

A dedicated tauri-plugin-peripage (cloned from tauri-plugin-kiosk's shape: Rust command bus → Kotlin @Commands → testable Ops seam) does the transport; a pure-Rust peripage-encoder crate (clean-room) does the bytes. No new heavy dependencies on the Rust side — Typst/tiny-skia are already in src-tauri. The only new platform capability is the BLUETOOTH_CONNECT Android permission + a Classic BluetoothSocket.


⚠️ Licensing — read first (this is the whole reason for "clean-room")

osd is distributed under a proprietary license (/LICENSE — "ES Dashboard — Proprietary License"). That makes the licensing constraint a hard blocker, not a nicety:

Library Models License Usable?

bitrate16/peripage-python (PRIMARY ref)

A6/A6+/A40/A40+

GPL-3.0-or-later (LICENSE file + per-file source headers + license='GPLv3'; the license='MIT' in setup.py is a stray template string — the authoritative artifacts are GPLv3)

NO — do not read while coding

jmsiefer/peripage-printer

same (fork of above)

GPL-3.0

NO

anthony-foulfoin/peripage-java

A40/A40+

No license = all rights reserved

NO (worse than GPL — no grant at all)

hvfrancesco/cups-peripage

A6/A6+ only

GPL-3.0

N/A (no A40)

Dibyakshu/peripage-kotlin-bluetooth-printer

A2 only

MIT

N/A (wrong model; only permissive one found)

There is no permissive (MIT/BSD/Apache) A40 implementation to vendor from. Incorporating or translating any GPL-3.0 code into a proprietary app violates the GPL; incorporating the unlicensed Java grants us nothing. So:

Clean-room rules for this work

  1. Whoever writes the Rust/Kotlin encoder must not read bitrate16/peripage-python, its fork, or peripage-java. Treat them as forbidden source.

  2. We may rely on protocol facts — command opcodes, geometry, bit order. Functional interface facts required to interoperate with a device are not copyrightable (idea/expression; interoperability). Those facts are recorded in §Protocol below.

  3. Provenance trail: confirm every fact against our own btsnoop HCI capture of the official PeriPage app printing to our A40. Documenting our own capture means the encoder is derived from observation of the wire, not from their expression — the strongest clean position. The capture log lives outside the proprietary repo (see §Workspace).

  4. Keep the clean-room encoder development in the separate workspace ~/Projects/spikes/thermalprint/ (mirrors how the P83C kept ~/xlife-spike/ outside the repo), and only copy the finished, capture-validated crate into osd.


The printer (A40) — confirmed geometry

Property Value Confidence

Model family

A40 is the wide A4/Letter model (no separate small "A40")

Confirmed

Print width

1728 dots = 216 bytes/row (A40+ = 1848 dots = 231 B/row)

Confirmed (2 independent repos + Java enum A40(1728))

DPI

203 or 304 dpi depending on density variant ("300 dpi" in resellers = rounded 304)

Confirmed (official); our unit’s value pinned via 10 ff 30 10 / 10 ff 20 f1

Color

Monochrome, 1-bpp, MSB-first, bit 1 = black (vendor pre-inverts)

Confirmed

Compression

None — raw rows, zero-padded/truncated to 216 B; no checksum

Confirmed

Paper widths

210 / 107 / 77 / 56 mm (A4 / 4" / 3" / 2") via adjustable clips; full A4 = 210×297 mm

Confirmed (official)

Buffer size

Unknown — stream row-chunked, don’t assume a full-page buffer

Unknown

At ~203 dpi the 1728-dot head ≈ 216 mm wide. For the Typst raster we want pixel_per_pt ≈ 203/72 ≈ 2.82 for a 203-dpi unit (or ≈ 4.22 for a 304-dpi unit) — pin after the firmware query. render_pages_png already takes pixel_per_pt.

Transport — Classic Bluetooth SPP / RFCOMM, channel 1 (NOT BLE)

Same conclusion the P83C landed on. The vendor libraries connect a Classic RFCOMM socket to channel 1 (bluetooth.BluetoothSocket(RFCOMM); connectmac, 1); the printer advertises the standard SPP service. No GATT/BLE characteristics apply to the A40 (the FF00/FF02 UUIDs from the P83C and the 0000aeXX/1f 11 idioms from "cat printers" are unrelated — do not use them).

  • Android: Classic BluetoothSocket via device.createRfcommSocketToServiceRecord(UUID 00001101-0000-1000-8000-00805f9b34fb), then socket.outputStream.write(stream). Inherits SPP flow-control (the backpressure the P83C spike found essential for large jobs). BT name looks like PeriPage_XXXX.

  • Permission: add android.permission.BLUETOOTH_CONNECT (API 31+) to the plugin manifest; as Device Owner we can auto-grant it silently (same path as the Wi-Fi perms in tauri-plugin-kiosk). Pairing can be pre-provisioned by the kiosk policy.

  • Linux desk validation (no Android needed): the P83C harness pattern works — socket.socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM); s.connectmac, 1; s.sendall(stream).

Protocol — 10 ff control + two ESC/POS opcodes (clean-room facts)

PeriPage is not an ESC/POS-language printer (no ESC @, no ESC ), but its raster path reuses two genuine ESC/POS opcodes wrapped in proprietary 10 ff control framing. *1-bpp, MSB-first, no checksum anywhere. Mental model: "10 ff wrapper around GS v 0 + ESC J."

Control commands (all 10 ff-prefixed)

| Function | Bytes | |--|--| | Reset / wake (required after connect; also re-sent before each raster chunk) | 10 ff fe 01 + twelve 00 | | Get firmware (pins dpi: V2.11_304dpi) | 10 ff 20 f1 | | Get hardware info (chip/date) | 10 ff 30 10 | | Get battery % (read resp[1]) | 10 ff 50 f1 | | Get device name | 10 ff 30 11 | | Set darkness / concentration (0/1/2) | 10 ff 10 00 0{0,1,2} | | Set power-off timeout (minutes, u16 BE) | 10 ff 12 + hi lo | | Feed paper (ESC/POS ESC J, 1 size byte) | 1b 4a + n |

⚠️ Do not send 10 ff 70 f1 00 (full-info query) before a print — it corrupts the next image. Avoid status queries interleaved with raster.

Raster command — ESC/POS GS v 0 = 1d 76 30

Main (chunked) form, per chunk of up to 0xff rows:

1d 76 30 00  <bytesPerRow=0xD8>  00  <chunkHeightRows>  00  <pixel data: rows × 216 B>
  • <bytesPerRow> = 0xD8 (216) for A40 (0xE7/231 for A40+).

  • Tall pages split into ≤255-row chunks; 10 ff fe 01+12×00 reset is sent before each chunk; insert a small inter-chunk delay (small printer buffer — the libs sleep).

  • End-of-job tail seen on A6: 1b 4a 40 (feed 64) then 10 ff fe 45.

Rasterization (1-bpp)

Threshold the tiny-skia Pixmap to 1-bpp, MSB-first, bit 1 = black, each row packed to exactly 216 bytes (pad right with 0x00). Polarity must be verified on hardware (the vendor inverts grayscale before convert('1'); we’ll confirm which way burns black). Reuse the P83C quality lessons: HalftoneType=None-style threshold for crisp tables/text, table rules ≥ 0.75—​1 pt so hairlines survive one dot; a Floyd—​Steinberg option for signatures.

Proposed implementation (pending design + dependency sign-off)

Two artifacts, mirroring the P83C plan and the existing tauri-plugin-kiosk shape:

  1. peripage-encoder — pure-Rust, no-Android crate (clean-room, prototyped in the separate workspace). Input: Pixmap (or &[u8] 1-bpp rows) + model enum. Output: the Vec<u8> command stream. Pure function ⇒ trivially unit-testable.

    • Fixture test: assert byte-equality of our GS v 0 framing + reset handshake against our own btsnoop capture (checked into the spike workspace, NOT the proprietary repo). Unlike the P83C there is no nonce to mask — the stream is deterministic, so a straight byte-diff works.

  2. tauri-plugin-peripage (Kotlin + Rust bus): scan/pair/connect RFCOMM ch.1, write the stream with SPP backpressure, surface battery/firmware status. PeripageController + PeripageOps seam so a JVM unit test exercises orchestration with a fake (same pattern as WifiController). Add BLUETOOTH_CONNECT, auto-granted via Device Owner.

  3. Thermal-mono Typst variant of the protocol PDF (carried over from the P83C verdict): pure B/W, rules ≥ 0.75 pt, status pills as black-on-white / reversed, rendered via the existing render_pages_png at the A40’s pixel_per_pt.

Dependencies: the Rust encoder needs nothing new (Typst/tiny-skia already present). The Kotlin side uses the platform android.bluetooth API — no new Gradle deps. ⇒ The human-in-the-loop dependency gate should be clean, but confirm before touching manifests.

Open questions — settle on hardware (needs the phone / a paired A40)

  1. Pixel polarity — does bit 1 burn black, or do we invert? (One small capture answers it.)

  2. Exact dpi of our unit — 10 ff 20 f1 → 203 vs 304 → sets pixel_per_pt.

  3. Reset cadence / inter-chunk delay — minimum that prints a full A4 page without buffer overrun (P83C needed ~human intervention; A40 buffer size is undocumented).

  4. End-of-job tail — confirm the A6 1b 4a 40 10 ff fe 45 tail applies to A40.

  5. Literal SPP UUID vs channel — confirm RFCOMM ch.1 / …1101 on our unit.

What I need from you (immediate next step — the capture)

To start the clean-room provenance trail and answer the open questions, the first concrete step uses your Android phone over USB debugging:

  1. Pair the phone with the PeriPage A40, print one Protokoll/test image from the official PeriPage app.

  2. Pull the btsnoop HCI log (enable "Bluetooth HCI snoop log" in Developer Options → adb bugreport or pull /sdcard/.../btsnoop_hci.log), so we capture the exact handshake + GS v 0 framing on our device.

  3. From that capture we (a) confirm polarity/dpi/tail, (b) write the encoder fixture test, and (c) prototype-print from this Linux laptop over RFCOMM before touching the Android plugin.

Blockers ruled out

  • ❌ GPL contamination — avoided by clean-room + own-capture provenance (this doc).

  • ❌ BLE/GATT complexity — A40 is Classic SPP; simpler than first feared.

  • ❌ Per-job nonce / checksum (the P83C headache) — none on PeriPage; deterministic stream.

  • ❌ Rasterizer gap — already solved (render_pages_png → tiny-skia Pixmap).

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


Hardware validation — 2026-06-26 (live printer)

Validated end-to-end against a physical A40 (PPG_A40_C2EA) from a Linux laptop over Classic Bluetooth SPP/RFCOMM, channel 1 — no vendor app, no Android. We sent our own clean-room raw-raster stream and it printed. Feasibility is no longer in question.

Device identity (read from our own btsnoop capture)

The vendor app’s info query (10 ff 70 / 10 ff 20 ee 54) returns a pipe-delimited string; ours decoded to:

PPG_A40_C2EA | 60:6E:41:45:C2:EA | C0:6E:41:45:C2:EA | V1.4.3_SD | A40233231104129 | 96
   name          classic MAC          (LE-ish MAC)       firmware       serial        battery%
  • Classic SPP address 60:6E:41:45:C2:EA (RFCOMM ch.1); a separate BLE address E0:6E:41:45:C2:EA (PPG_A40_C2EA_BLE) also advertises — ignore it, use Classic SPP.

  • Firmware V1.4.3_SD (dpi is not in this firmware string, unlike older units).

⚠️ The vendor app uses a COMPRESSED raster — we deliberately do NOT replicate it

Decoding our capture (captures/btsnoop-.log, reassembled tx.bin): the app’s image command is *1f 00 00 <ce 09 17 00 00>, sent in 3 bands, and the payload is a custom entropy-coded / compressed stream (~5.2× smaller than raw; flat autocorrelation, near-zero byte runs, not zlib/gzip/deflate/packbits). Cracking it would be a Huffman/ arithmetic-decoder slog with no payoff — because:

The A40 firmware also accepts the simple raw GS v 0 command, which we confirmed prints. That is our clean-room target. The compressed path is purely a vendor bandwidth optimisation; for an occasional clinical-protocol print, raw (~513 KB/A4 page ≈ ~11 s over SPP) is fine.

What we confirmed on hardware (raw GS v 0 path)

| Property | Result | |--|--| | Raw 1d 76 30 00 D8 00 yL yH <data> prints? | Yes (fw V1.4.3_SD) | | Polarity | bit 1 = black, 0xFF → solid dark; no inversion needed | | Density | default burn is grey; 10 ff 10 00 04 → solid black | | Width | 216 bytes = 1728 dots fills the paper edge-to-edge | | Orientation | image row → feed (top→bottom), byte/col → head width (left→right, byte0=left edge); no mirroring — pack the pixmap row-major, MSB-first, ship it | | Init / feed | 10 ff fe 01+12×00 init; 1b 4a <n> (ESC J) feed works |

Minimal working stream (clean-room prototype: ~/Projects/spikes/thermalprint/peripage_raw.py)

10 ff fe 01  00×12                         # init/reset
10 ff 10 00 04                             # darkness (level 4 = solid black)
1d 76 30 00  D8 00  yL yH                  # GS v 0: 216 B/row, yH..yL rows (LE)
<rows × 216 B, 1-bpp MSB-first, bit1=black>
1b 4a 50                                   # feed

Write over RFCOMM ch.1 (Python native AF_BLUETOOTH socket, or Android BluetoothSocket with SPP UUID 00001101-…), ~200 B chunks. No checksum, no nonce, deterministic.

USB-C direct transport — also VALIDATED (a second, simpler path)

Plugged into USB-C the A40 enumerates as a standard USB Printer-class device and prints the same GS v 0 stream — no Bluetooth, no pairing. Hardware-confirmed (a 3-square test page printed via /dev/usb/lp0).

USB descriptor Value

ID / strings

09c5:0200, iManufacturer "PeriPage Printer", iProduct "Printer"

Interface

class 7 (Printer), protocol 2 (Bidirectional)

Endpoints

Bulk OUT 0x02 (raster) · Bulk IN 0x81 (status) · 64-byte packets

Linux

usblp auto-binds → /dev/usb/lp0; just write() the stream (validated)

Android

USB host (OTG) via UsbManager + bulkTransfer() to EP 0x02; status readable from 0x81

The encoder is transport-agnostic — identical bytes go out BT or USB. Recommendation: Bluetooth = primary (field workflow: pick any nearby printer, no cable); USB-C = fallback (more reliable, no pairing/range/battery-drain — good for a fixed station or when BT misbehaves). Android USB caveats: tablet must be USB host (can’t charge through the same USB-C port while hosting without a powered OTG hub), and it tethers the printer to the tablet by cable.

Remaining (engineering only — feasibility done)

  1. Paper width = 210 mm (A4) confirmed on hardware. The 1728-dot head ≈216 mm, so ~6 mm clips off the sheet — render content ≤1680 dots wide + white-pad to 1728. A4 height calibrated: 2360 rows = 297 mm (≈202 dpi; our 2376-row test = 299 mm; vendor app’s "A4" = 291 mm/short). Full-A4 single 513 KB job prints with no stall (large-job risk cleared).

  2. Rust peripage-encoder = port of the prototype above (pure fn, fixture-tested).

  3. tauri-plugin-peripage = BluetoothSocket ch.1 writer + BLUETOOTH_CONNECT perm.

  4. Thermal-mono Typst variant + dither option for signatures (P83C lessons carry over).

Workspace / source artifacts

  • Clean-room prototype + own captures: ~/Projects/spikes/thermalprint/ (outside the proprietary repo, like ~/xlife-spike/ was for the P83C).

  • Reference-only (DO NOT copy code): the GPL/unlicensed repos in §Licensing, for protocol facts already transcribed above.