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 |
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? |
|---|---|---|---|
|
A6/A6+/A40/A40+ |
GPL-3.0-or-later (LICENSE file + per-file source headers + |
NO — do not read while coding |
|
same (fork of above) |
GPL-3.0 |
NO |
|
A40/A40+ |
No license = all rights reserved |
NO (worse than GPL — no grant at all) |
|
A6/A6+ only |
GPL-3.0 |
N/A (no A40) |
|
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
-
Whoever writes the Rust/Kotlin encoder must not read
bitrate16/peripage-python, its fork, orperipage-java. Treat them as forbidden source. -
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.
-
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).
-
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 |
DPI |
203 or 304 dpi depending on density variant ("300 dpi" in resellers = rounded 304) |
Confirmed (official); our unit’s value pinned via |
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
BluetoothSocketviadevice.createRfcommSocketToServiceRecord(UUID 00001101-0000-1000-8000-00805f9b34fb), thensocket.outputStream.write(stream). Inherits SPP flow-control (the backpressure the P83C spike found essential for large jobs). BT name looks likePeriPage_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 intauri-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×00reset 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) then10 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:
-
peripage-encoder— pure-Rust, no-Android crate (clean-room, prototyped in the separate workspace). Input:Pixmap(or&[u8]1-bpp rows) + model enum. Output: theVec<u8>command stream. Pure function ⇒ trivially unit-testable.-
Fixture test: assert byte-equality of our
GS v 0framing + 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.
-
-
tauri-plugin-peripage(Kotlin + Rust bus): scan/pair/connect RFCOMM ch.1, write the stream with SPP backpressure, surface battery/firmware status.PeripageController+PeripageOpsseam so a JVM unit test exercises orchestration with a fake (same pattern asWifiController). AddBLUETOOTH_CONNECT, auto-granted via Device Owner. -
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_pngat the A40’spixel_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)
-
Pixel polarity — does bit 1 burn black, or do we invert? (One small capture answers it.)
-
Exact dpi of our unit —
10 ff 20 f1→ 203 vs 304 → setspixel_per_pt. -
Reset cadence / inter-chunk delay — minimum that prints a full A4 page without buffer overrun (P83C needed ~human intervention; A40 buffer size is undocumented).
-
End-of-job tail — confirm the A6
1b 4a 40 10 ff fe 45tail applies to A40. -
Literal SPP UUID vs channel — confirm RFCOMM ch.1 /
…1101on 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:
-
Pair the phone with the PeriPage A40, print one Protokoll/test image from the official PeriPage app.
-
Pull the btsnoop HCI log (enable "Bluetooth HCI snoop log" in Developer Options →
adb bugreportor pull/sdcard/.../btsnoop_hci.log), so we capture the exact handshake +GS v 0framing on our device. -
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 addressE0: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 |
|
Interface |
class 7 (Printer), protocol 2 (Bidirectional) |
Endpoints |
Bulk OUT |
Linux |
|
Android |
USB host (OTG) via |
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)
-
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).
-
Rust
peripage-encoder= port of the prototype above (pure fn, fixture-tested). -
tauri-plugin-peripage=BluetoothSocketch.1 writer +BLUETOOTH_CONNECTperm. -
Thermal-mono Typst variant + dither option for signatures (P83C lessons carry over).