Skip to content

Honest 16-bit row height + wider concentration range (both backward compatible, verified on real A40)#26

Open
RainManVays wants to merge 1 commit into
bitrate16:mainfrom
RainManVays:honest-16bit-height-and-wider-concentration
Open

Honest 16-bit row height + wider concentration range (both backward compatible, verified on real A40)#26
RainManVays wants to merge 1 commit into
bitrate16:mainfrom
RainManVays:honest-16bit-height-and-wider-concentration

Conversation

@RainManVays

Copy link
Copy Markdown

What

Two small, backward-compatible changes to Printer, both verified live on a real Peripage A40:

  1. printRowBytesList(): honest 16-bit row height. row_bytes/height are now proper
    2-byte little-endian fields instead of 1-byte-plus-a-hardcoded-zero-padding-byte. For every
    currently supported model (row_bytes is always ≤ 255) this produces byte-for-byte
    identical
    requests to before — the old padding byte was already implicitly the high byte of
    a 16-bit value, just never treated as one. The only real behavior change is for height > 0xff: instead of silently slicing into multiple 0xff-row chunks (each getting its own
    reset()), a tall image now goes out as a single continuous request — matching this method's
    own docstring, which already said "this printer supports pages up to 0xffff rows". The
    255-row limit was an artifact of this method's own encoding choice, not a firmware limit.

  2. setConcentration(): removed the hardcoded clamp to {0, 1, 2}. Values 0/1/2 still
    produce the exact same request bytes as before. Values above 2 are no longer silently
    clamped down to 2 — they're sent as-is (up to a byte, 0-255).

Why

I've been building an open-source Bluetooth printing app
(perripage-ferrum) on top of this library and
ran into real data loss printing anything taller than 255 rows in one job. Chasing it down, I
ended up decompiling the official com.ileadtek.peripage Android app (with a security check
first — no backdoor, wanted to make sure before trusting the APK) and cross-referencing it
against a live Bluetooth HCI capture of that app talking to the same printer. Full writeup is in
a comment below, but the two relevant findings:

  • The official app's equivalent height field is a genuine 16 bits in both its image protocols
    (the legacy one closest to this library's 1d7630, and a newer one). The 255-row limit here
    really is just this method's own 1-byte encoding, not something the firmware enforces.
  • The official app's concentration-setting call has no range check in its code at all, and a
    real A40 accepts values 3/4 without erroring (no visible density improvement over 2 on
    my specific unit, for what it's worth — so treat anything past 2 as
    hardware-dependent/experimental, not a guaranteed win. Just no longer artificially blocked).

Testing

No test suite in this repo to hook into, so this was verified by hand against a real Peripage
A40 (PPG_A40_34C4, firmware V1.4.5_SD) via Printer.printImage() directly (i.e. through the
unmodified public API, not some bypass):

  • A 300-row striped test image printed as one continuous request with no corruption/discontinuity
    at the old 255-row boundary.
  • A normal short (60-row) image printed identically to before — no regression for the common
    case.
  • setConcentration(3) and setConcentration(4) were both accepted without error.

Happy to share the exact test scripts if useful, or adjust anything about the approach — this is
my first time looking at this codebase in depth, so let me know if I've missed context on why
the original 1-byte/clamp-to-2 behavior was chosen.

Both changes are backward compatible (identical wire bytes for every
existing valid input) and verified live on a real Peripage A40.

printRowBytesList(): row_bytes/height are now both proper 2-byte
little-endian fields instead of 1-byte-plus-a-hardcoded-zero-padding-
byte. For every currently supported model (row_bytes always <= 255)
this produces byte-for-byte identical requests to before -- the old
padding byte was already implicitly the high byte of a 16-bit value,
just never treated as one. The only real behavior change is for
height > 0xff: instead of silently slicing into multiple 0xff-row
chunks (each getting its own reset()), a full image now goes out as
one continuous request, matching this method's own docstring, which
already said "this printer supports pages up to 0xffff rows" -- the
255-row limit was an artifact of this method's encoding, not a
firmware limit. Confirmed by decompiling the official Android app
(its equivalent height field is a full 16 bits) and by printing a
real 300-row striped test image through this patched printImage() in
one continuous request with no corruption at the old chunk boundary.

setConcentration(): removed the hardcoded clamp to {0, 1, 2}. Values
0/1/2 still produce the exact same request bytes as before. The
official app's decompiled code sends this same opcode with no range
check, and a real A40 accepts 3/4 without error (no visible density
difference observed on that specific unit above 2, so treat values
past 2 as hardware-dependent, not a guaranteed improvement -- just no
longer artificially blocked).

Both findings, plus a lot more protocol detail that didn't make it
into this small PR (a newer zlib-compressed 0x1f protocol variant,
async status packets the printer can push mid-print, paper-type
selection, etc.), came out of reverse engineering the official
com.ileadtek.peripage Android app for a downstream project. Full
writeup, including a byte-exact opcode table cross-referenced against
a live Bluetooth HCI capture:
https://github.com/RainManVays/perripage-ferrum/blob/main/docs/bluetooth-protocol-trace-analysis.md
@RainManVays

Copy link
Copy Markdown
Author

Peripage A40 Bluetooth protocol: analysis of a real HCI trace from the official app

Date: 2026-07-14/15
Method: adb shell settings put secure bluetooth_hci_log 1 → restart the Bluetooth stack →
print through the official com.ileadtek.peripage Android app on a tablet → adb bugreport
extract FS/data/misc/bluetooth/logs/btsnoop_hci.log → analyze with tshark (no root needed on
the device — adb bugreport exposes these logs through a separate system service; /data/misc
itself isn't accessible without root).

Printer: a real Peripage A40 (PPG_A40_34C4, firmware V1.4.5_SD).

How to read this: every claim is tagged with a confidence level — confirmed (seen
directly in the bytes/stats, or confirmed by decompiling the app's code), likely (consistent
with the data but not proven), or unknown (recorded as fact, meaning not deciphered).

Later (2026-07-15) the app itself (com.ileadtek.peripage, versions 312 and 319, the latter
pulled directly off a real device) was decompiled with jadx, and its native libraries
disassembled (capstone/pyelftools) — including a security review first (no backdoor found:
no dynamic code loading, no Runtime.exec/ProcessBuilder, no JS bridges, no hardcoded
IPs, permissions requested match what's actually used). That upgraded most of this document's
findings from "guessed from trace timing" to "confirmed by decompiled code" — noted inline
below wherever it applies.


1. Capture overview

  • Duration: 754.6 seconds, 3 separate print actions inside it (the same test image printed
    twice, plus one more distinct action in between).

  • Protocol hierarchy (tshark -q -z io,phs):

    bluetooth: 1158 frames
      hci_h4
        bthci_cmd: 258   (HCI controller commands, not part of the application protocol)
        bthci_evt: 579   (HCI controller events)
        bthci_acl: 321
          btl2cap: 321
            btsdp: 4       (Service Discovery — finding the SPP service)
            btrfcomm: 277
              btspp: 135   (the actual application-layer data — what we care about)
    
  • Of the 135 SPP frames: 116 → to the printer (15183 bytes), 19 ← from the printer
    (242 bytes).

  • Of the 116 frames to the printer: 41 short control frames (≤100 bytes) and 75 large
    frames
    (>100 bytes, forming 3 continuous bursts — the actual image data).


2. Per-print-action command sequence

Reconstructed from all 3 print actions — byte-identical across all three except where noted as
variable.

# Direction Bytes (hex) What it is
1 → printer 10 ff 20 ee <XX> Confirmed by code: method h(byte b10) in the protocol controller class (u0.h/v0.h depending on build) — fixed 4-byte prefix + a 1-byte parameter from the caller. The calling context (where the parameter's value comes from) wasn't found in the decompile — the "random request nonce" hypothesis for the value is unconfirmed, but the command shape (4+1 bytes, not a fixed 5) is confirmed.
1r ← printer 32 bytes, ASCII-hex, e.g. 66cbe2f0a1ecb1ea9bf7b623755a7ed1 Response to (1) — likely a session token/nonce (different every session, not tied to the device). Probably only used by the app for its own telemetry/tracking — likely not required for printing itself (this library prints fine without it).
2 → printer 10 ff 70 Confirmed by code: method e() — a fixed 3 bytes, no extra parameters. Looks like this library's getDeviceFull (10ff70f100) but 2 bytes shorter — the official app really does use the shorter variant.
2r ← printer 77 bytes → ASCII: PPG_A40_34C4|28:D4:1E:01:34:C4|C8:D4:1E:01:34:C4|V1.4.5_SD|A40237251101400|70 Confirmed: byte-for-byte matches this library's own Printer.getDeviceFull() docstring format (name|device_mac|client_mac|firmware|serial|battery%). That docstring explicitly warns of a side effect (image shift/corruption) for the 5-byte variant 10ff70f100 — the short 3-byte variant (2) might be a side-effect-free alternative, worth checking on real hardware.
3 → printer 10 ff 10 03 01 Confirmed and decoded by code: method B(int i10), whose own Log.e("choosePaperType", ...) call names it — this is paper type selection, not "enter print mode" as originally guessed from the trace alone. 01 is a paper-type code (see §7 below: folded-with-black-mark / continuous-roll / adhesive-gap / perforated).
3r ← printer 4f 4b = "OK" ASCII acknowledgment.
(~3-9s pause) Likely the user's own reaction time in the app, not a protocol-level delay.
4 → printer 12 zero bytes Confirmed by code: a separate, deliberate method M() (D(new byte[12])), not padding/garbage — but its semantics remain unknown. Note this is exactly 12 bytes, whereas this library's own reset() appends 13 zero bytes — see the next row.
5 → printer 10 ff fe 01 Confirmed and refined by code: method U() — the official app sends exactly these 4 bytes and nothing else. This library's reset() (10fffe01 + 13 zero bytes) adds 13 bytes of padding that aren't part of the real protocol — likely historically merged from two adjacent observed commands (10fffe01 + the neighboring M(), which is 12, not 13, zero bytes).
6 → printer 10 ff 10 00 04 Confirmed by code: method P(int i10) — the parameter is sent with no range check at all in the decompiled app code. This library clamps to 0-2! Separately, method C() (the legacy image protocol, see §3 below) encodes concentration directly in the image header and clamps it to 0..3 — so the app's own code independently confirms a range of at least 0..3, and setConcentration itself may allow even more.
7 → printer 10 ff fe 45 Confirmed by code: method Y() — a distinct command (not a "sub-command" of reset), called as part of a retry loop k() (U(); Q(); Y();) and standalone elsewhere. Appears here and again at the very end of every print burst (see §3.4) — from where it's called in the code, it looks like a general "finalize the current operation," used the same way in several unrelated places.
7r ← printer 4f 4b then (after ~70ms) aa (1 byte) "OK", then a separate single-byte ack 0xAA — a pattern this library has never implemented.
(~0.5-0.7s pause)
8 → printer 10 ff fe 01 Reset again (U()), right before the image data transfer begins.
9 → printer 1f b2 10 Confirmed and reinterpreted by code: method T() ({0x1f, 0xb2, 0x10}), called conditionally from U() (reset) only for a specific list of models/chipsets ("newer" models — A8/A9/A40/A3X/H2/P40/P9 families). Important correction: despite sharing the first byte 0x1f with the image-transfer opcode (§3), this is unrelated to it — just a coincidental shared first byte, not a "0x1f family." Better read as "part of an extended reset for newer chips," not a sub-protocol of image printing.
10 → printer 10 ff 80 01 Confirmed by code: method W() — a fixed command enabling an unknown mode (there's a paired X() = 10 ff 80 00, presumably disabling the same mode).
11 → printer 10 ff 81 50 Confirmed and nearly decoded by code: methods j(int,int)/v(int) — a parameterized "speed/heat" command, clamped in the app's code to a maximum of 80 or 120 depending on the model/chipset (120 for "newer" chips like A40, 80 for others). The captured value 0x50=80 matches the "older"/lower limit — worth checking whether this specific A40 accepts values above 80 despite the app being capable of sending up to 120 for some models.
12 → printer 12 zero bytes (again) Same as (4), method M().
13 → printer image data burst See §3 — now fully decoded.

The library's own image opcode (1d763000) never appears anywhere in the trace — the official
app prints images through a completely different protocol for this printer.


3. Image data transfer (opcode 0x1f)

Update 2026-07-15: this section originally came from the live trace alone and had three
"unknown"/"likely" items. After decompiling the official app (method w(Bitmap, int) in the
protocol controller class) every preamble field is now decoded exactly, and — most
importantly — a real data-compression mechanism was discovered (§3.2). The original
observations below are kept as-is, with confidence levels updated; numbers that previously
"didn't add up" now do.

3.1. Preamble — now fully decoded

First bytes of the first frame of each burst:

Session 0 (t=34.538):  1f 00 00 d0 01 16 00 00 10 54 ed 92 4f 6c e4 46 ...
Session 1 (t=740.561): 1f 00 00 d0 08 61 00 00 19 b4 ed d2 7d 8c 5c d7 ...
Session 2 (t=774.124): 1f 00 00 d0 01 16 00 00 10 54 ed 92 4f 6c e4 46 ...

(Sessions 0 and 2 print the exact same test image twice, hence identical bytes in both the
preamble and the start of the data.)

Preamble format, byte-exact per the decompiled code (all multi-byte fields are big-endian
important, see §7.4 on how this differs from the legacy protocol):

Bytes Field Value Confidence
[0] = 1f opcode Image-transfer command opcode confirmed by code
[1] = 00 reserved Always 0 — hardcoded by the app, not read as a variable confirmed by code
[2:4] big-endian row_bytes ceil(width_px / 8). Sessions 0/1/2 all give 00 d0=208 → this A40's printer row width = 208×8=1664px. Previously only a hypothesis, now read directly from the decompiled formula: i11 = width%8!=0 ? width/8+1 : width/8, written as bytes (i11/256, i11%256) (big-endian). confirmed by both code and trace
[4:6] big-endian height Image height in pixels (raster rows), not something derived. Sessions 0/2: 01 16=278. Session 1: 08 61=2145 — both values scale correctly with the real test images' sizes. confirmed by code (closes the old "unknown" — direct read of the same (height/256, height%256) formula)
[6:10] big-endian, 4 bytes payload_len Length of the compressed payload (§3.2), not raw raster data. For sessions 0/2: 00 00 10 54=4180 bytes of compressed data vs. 278×208=57,824 bytes raw — a ×13.8 compression ratio. This answers the old puzzle of "278×208 doesn't match the 4149 bytes actually transferred" — the mismatch wasn't an arithmetic error, we just didn't know about the compression yet. confirmed by code
[10:] payload Compressed raster data, see §3.2 confirmed by code

3.2. Payload compression — it's plain zlib deflate

The main new finding (2026-07-15). Method w(Bitmap, int) builds the raster with the same
function as the legacy protocol (see §7.4 — 1 bit per pixel, MSB-first, black = 1 when average
RGB < 190), then runs the result through a native call Code.code(byte[]). The library backing
this call, libCode.so, was disassembled (capstone/pyelftools, see §7.1) — it retains debug
symbols and copyright strings unambiguously identifying it as plain zlib 1.2.3 ("deflate 1.2.3 Copyright 1995-2005 Jean-loup Gailly", "inflate 1.2.3 Copyright 1995-2005 Mark Adler",
symbols deflate, inflateInit2_, compress2, adler32, crc32, zcfree, paths
.../jni/zlib/{deflate,inflate}.c).

Code.code() is a thin JNI wrapper around zlib's standard compress2() (or equivalent). The
Java code afterward explicitly strips the first 2 bytes of the result before sending
(System.arraycopy(bArrCode, 2, bArr2, 10, bArrCode.length - 2)) — exactly the 2-byte zlib
wrapper header (CMF+FLG, typically 78 9C/78 01/78 DA depending on compression level).
So what goes over the wire is not a full zlib stream, just its deflate body plus the
trailing 4-byte Adler-32 (the trailer itself is not stripped).

Practical takeaway for implementing this (details and code in
printer-protocol-implementation-plan.md): the stdlib zlib module is enough to replicate this
in Python:

raw = pack_1bpp_msb_first(bitmap)   # same packing already used for the legacy protocol
compressed = zlib.compress(raw, level)   # compression level doesn't affect decodability
payload = compressed[2:]                 # strip the 2-byte zlib header, as the app does

⚠️ Real-hardware update, added after posting this analysis: we implemented this and tested
it against a real Peripage A40. The printer hung/powered off on receipt of a payload
produced by Python's own zlib.compress() (fully recovered after a manual restart, no lasting
damage — confirmed via an immediate working print through the legacy 1d7630 path right
after). So this protocol is not simply "any standard deflate stream" as compatible as the
matching zlib version might suggest — something about our compressed byte stream specifically
isn't accepted by this firmware's decompressor, even though the algorithm is nominally the
same. Not root-caused; our leading guess is a deflate window-size mismatch (zlib.compress()
defaults to a 32KB window, wbits=15; an embedded decompressor may expect much less and not
fail gracefully on a larger one). If anyone picks this up: try zlib.compressobj(wbits=...)
with an explicitly smaller window, or capture another live trace of the official app on the
exact same input and byte-diff its compressed output against a Python-produced one rather than
assuming "same algorithm" implies "same accepted bytes."

3.3. Timing — the headline finding

Session Frames Bytes Duration Avg. inter-frame gap
0 21 4149 64ms 3.19ms
1 33 6549 140ms 4.38ms
2 21 4125 49ms 2.47ms

Confirmed: the official app inserts no manual delay whatsoever between
rows/frames — the observed gaps (2.5-4.4ms) are purely the natural pace of the Bluetooth
transport (bounded by the negotiated RFCOMM MTU), not a deliberate delay in the app's code. The
entire image goes out as one continuous stream in 49-140 milliseconds.

That's 15-30x faster than the delay=0.05 a downstream project (PeriPrint) had been using
before finding this trace (this library inserts time.sleep(delay) between every raster row
inside printRowBytesList()). This single finding was the fix that resolved silent data loss
on longer prints for that project — slow, evenly-paced delivery was desynchronizing the
printer's receive state, not protecting it.

Caveat: the 200-byte frame size is very likely just the negotiated RFCOMM/L2CAP packet size
(a transport-layer artifact), not a guarantee that "one frame = one raster row." Don't
confuse transport-level fragmentation with the protocol's logical structure — the timing
conclusion (no manual per-row delay) holds regardless, but "1 row = 208 bytes = 1 frame" does
not.

3.4. End of burst — another finding

The last frame of each of the 3 bursts ends identically:

Session 0: ...981b4a1010fffe45
Session 1: ...9a1b4a1010fffe45
Session 2: ...981b4a1010fffe45

Breaking down the trailing bytes: 1b 4a 10 + 10 ff fe 45.

  • 1b4a10confirmed: this is this library's already-known printBreak opcode
    (1b4a + a size byte), here with size 0x10=16. The official app genuinely uses the exact
    same printBreak as this library — byte-for-byte identical.
  • 10fffe45confirmed by code (see §2 step 7, §7): method Y(). Appears both before and
    after the image data — not phase-specific, more like a general "finalize/rearm state," called
    from several unrelated places in the app's code.

This confirms the image data and trailing commands are one continuous Python/Java-level
write()
that the OS simply cut into RFCOMM packets wherever convenient, with no regard for
logical command boundaries.


4. Unsolicited (unrequested) messages from the printer

During an idle gap between print session 1 and session 3 (~t=750-753s, no preceding app command
in this window at all):

t=750.67 ← fd01
t=751.29 ← aa
t=753.28 ← fd02

Decoded by code (2026-07-15) — the single most important finding of this update. These are
asynchronous control messages from the printer, and this library doesn't listen for them at
all. The official app's incoming-packet parser tags the first byte 0xFD (-3) and dispatches on
the second byte (b11) like this: b11==3 and b11==4 go to two empty handlers (no-ops in
the decompiled code — the app receives them but does nothing); every other value of b11
(including 1 and 2 — exactly what's seen in the trace) goes to onStartOrStopSend
, where:

  • b11 == 1 → logs "终止命令" ("terminate/abort command") → the app stops the current print
    (clears its "keep sending" flag, hides the progress bar) and publishes an event with code
    0x01.
  • b11 == 2 → logs "继续开始命令" ("continue/start again command") → publishes an event with
    code 0x02 (the decompiled handling stops there — what exactly "starts again" at the app
    level wasn't traced further).

In other words: fd 01 = the printer itself is asking to abort the print, fd 02 = the
printer is allowing it to resume/restart.
This isn't directly battery/button-related (though
the physical cause behind such a signal could be any of cover-open/overheat/button — those are
already covered by their own separate tags, see §7.3); specifically the 0xFD family is a
print-level flow-control protocol. This library and any client built on it currently don't
listen for incoming bytes at all
during printImage() — meaning if the printer sends fd 01
mid-print (e.g. the user opened the cover), a naive client just keeps writing into a socket the
printer may have already stopped processing.

(Update: a downstream project implemented a listener for this and confirmed it live — printing
a real job and physically opening the printer's cover mid-job produced a cover_open status
(0xFF 0x02, see §7.3 below) that was caught and correctly paused the print, with the physical
printer stopping at the exact same instant. Two distinct real triggers — abort_print/fd01
from this trace and cover_open/ff02 from that live test — both land in the same
"stop, something needs attention" bucket, worth knowing they're not the same event under two
names.)


5. Opcode summary: known vs. found in the trace

Updated 2026-07-15 from the decompiled code — see §7.2 for the full opcode-by-opcode table of
controller-class methods, including commands never seen in this particular trace.

Opcode Source Status
10fffe01 this library's reset(); app's method U() ✅ confirmed, also used by the app — but the app sends 4 bytes with no 13-byte padding (see §2 step 5, §7.4)
10ff1000 + 00/01/02 this library's setConcentration (0-2); app's method P(int) ⚠️ the app sends 10ff1000 + 04 with no range check in its code — this library's 0-2 clamp is needlessly narrow
1b4a + size this library's printBreak; app's method i(int) ✅ confirmed, also used by the app (size 0x10)
1d763000... this library's printImage/printRowBytesList; app's method C(Bitmap,int) never appears in the trace at all — the app prints via a different protocol for this model (see below); the header format also differs between the app and this library (little-endian, plus a concentration byte — see §7.4)
1f 00 00 d0 ... app's method w(Bitmap,int) fully decoded (§3.1-3.2): opcode for allow-listed printer models (including A40), payload is zlib deflate with the 2-byte header stripped
10ff20ee + byte app's method h(byte) ⚠️ format confirmed, parameter semantics not found (no calling code in the decompile)
10ff70 app's method e(), resembles this library's getDeviceFull (10ff70f100) ✅ confirmed, 2 bytes shorter — possibly a side-effect-free alternative to the buggy 5-byte variant
10ff1003 + 01 app's method B(int), log tag "choosePaperType" decoded: paper type selection, not "enter print mode"
10fffe45 app's method Y() ✅ confirmed as a distinct command, called in several contexts ("finalize")
1fb210 app's method T(), called conditionally from U()/reset for specific models ✅ confirmed and reinterpreted: unrelated to the image-transfer 0x1f, part of an extended reset
10ff8001 app's method W() (paired with X() = 10ff8000) ✅ confirmed as a toggle pair, mode purpose not found
10ff8150 app's methods j(int,int)/v(int) — "speed/heat" parameter, clamped to 80 or 120 by model ✅ confirmed and nearly decoded
fd01, fd02 (incoming) app's handler onStartOrStopSend decoded: fd01=abort print, fd02=continue/restart — an asynchronous command from the printer, see §4

6. Practical implications

  1. A downstream project (PeriPrint) already applied: removed the manual per-row delay
    (0.05s → 0.001s) and stopped artificially chunking short documents — both follow directly
    from the timing in §3.3, confirmed by printing on a real printer.
  2. Previously a workaround, now backed by an exact formula: a 1664px "safe content width"
    for this A40 (vs. this library's hardcoded 1728px) used to rest on a single observed byte
    (d0=208), just assumed to be row_bytes. Now confirmed by the decompiled code: row_bytes = ceil(width/8), formula and byte positions read directly from w(). The number 1664 stays
    correct, it's just no longer "a guess from one byte."
  3. Not applied, needs hardware verification: concentration 04 (§2 step 6, §5) — this
    library artificially clamps to 0-2; the app's code has no such clamp (a 0-3 clamp exists only
    in the separate, legacy image-header path C()). Worth explicitly sending 10ff1000 +
    03/04 past the clamp and checking whether print density actually improves.
    (Update: tried on a real A40 — accepted without error at 3/4, but no visible density
    difference on that specific unit.)
  4. Now implementable, previously wasn't: the entire 0x1f opcode is fully decoded (§3),
    including discovering the zlib compression — that was the only reason the numbers didn't add
    up before. (Update: implemented and tested — see the real-hardware warning in §3.2. Do not
    use as-is.)
  5. Decoded, needs immediate attention: the incoming fd01/fd02 (§4) isn't random
    telemetry, it's print-flow-control (abort/resume). No client built on this library currently
    listens for it at all — this is the single most actionable functional gap found in this
    document. (Update: implemented and confirmed live via a real cover-open test — see the note
    at the end of §4.)
  6. Partially decoded, reliably reproducible: 10ff20ee, 10ff70, 10ff1003, 10fffe45,
    1fb210, 10ff8001, 10ff8150 — all now tied to specific decompiled-code methods (see §7.2
    for the full table and §7.3 for the status-response semantics: low battery, cover open/
    closed, wrong paper type, overheat, cartridge/head "insufficient mileage").

7. Cross-reference with the official app's decompiled code (2026-07-15)

7.1. Method

Two instances of the official com.ileadtek.peripage Android app were analyzed:

  1. com.ileadtek.peripage-312.xapk — version 6.10.6 (build 312).
  2. peripage-base.apk + peripage-split_config.arm64_v8a.apk — version 6.10.9 (build 319),
    pulled directly off a real device (a more authoritative source than an arbitrarily-downloaded
    .xapk).

Both were decompiled with jadx 1.5.6 (Java/Kotlin from classes*.dex); native libraries
(.so, arm64-v8a) via readelf/nm/strings and a manual disassembler built on capstone +
pyelftools. Full methodology, including a security check for malicious behavior (none found),
is available on request.

Important methodological note: between builds 312 and 319, obfuscation (ProGuard/R8)
renames classes and fields (e.g. the protocol-command class was v0.h in build 312 and
became u0.h in build 319; a byte-level comparison confirmed identical logic, only the
generated names changed). String literals (log tags like "BluetoothPortPrint",
"choosePaperType", Chinese messages like "onOutPaper", "onOverHeat") survive obfuscation
and are more reliable to search by than class/package names if this needs to be repeated
against a future app version.

7.2. Full command table from the protocol controller class

The controller class (u0.h/v0.h depending on build) is the single point all low-level
commands go out from, over the same Bluetooth SPP transport (UUID
00001101-0000-1000-8000-00805F9B34FB — the same one this library uses). Below is every
command found in this class, not just the ones seen in the live trace. The "In trace?" column
distinguishes what was confirmed by a live capture from what's confirmed by code only.

Method Opcode (hex) In trace? Purpose
U() 10fffe01 (4 bytes, no padding) ✅ (§2.5) reset — required after connect
T() 1fb210 ✅ (§2.9) part of reset, only for a list of "newer" models (A8/A9/A40/H2/P40/P9/A3X families); called from inside U()
V() 10fffd01 not seen in the trace; a stylistic neighbor of U()/W(), purpose unknown
W() / X() 10ff8001 / 10ff8000 ✅ / ❌ toggle pair (§2.10), mode purpose unknown
Y() 10fffe45 ✅ (§2.7, §3.4) "finalize", called in several contexts, including from k()
B(int) 10ff1003 + byte ✅ (§2.3) paper type selection (log tag "choosePaperType"); values, by context of the n() handler (§7.3): 1=folded with black mark, 2=continuous roll, 3=adhesive-gap, 4=perforated
P(int) 10ff1000 + byte ✅ (§2.6) print concentration/density, no range check in the app's code
j(int,int) / v(int) 10ff81 + byte ✅ (§2.11) "speed/heat", clamped to 80 or 120 depending on model/chipset
i(int) 1b4a + byte ✅ (§3.4) printBreak — already known to this library
e() 10ff70 ✅ (§2.2) short variant of getDeviceFull (this library's is 10ff70f100, 5 bytes)
h(byte) 10ff20ee + byte ✅ (§2.1) format confirmed, parameter semantics not found
c() 10ff20f1 = this library's getDeviceFirmware()
d() 10ff20f2 = this library's getDeviceSerialNumber()
f() 10ff20f0 = this library's getDeviceIP()
a() 10ff3012 = this library's getDeviceMAC()
b() 10ff3010 = this library's getDeviceHardware()
b0() 10ff50f1 = this library's getDeviceBattery()
(anonymous, in i.java) 10ff50f2 a neighbor of getDeviceBattery by opcode, not documented by this library
K(int) 10ff12 + 2 bytes big-endian = this library's setPowerTimeout() — this match is itself a good sanity check that the constant-parsing approach below (POI/tar substitutions) was interpreted correctly
N(int) 10ffa000 + 4 bytes big-endian (int32) not documented by this library, purpose unknown
a0() 10ffa001 neighbor of N(), fixed parameter
S() 10ff40 sets an internal transport flag "expect a binary response"
Q() 1d0c called together with U()/Y() in the retry loop k(), purpose unknown
M() 12 zero bytes ✅ (§2.4/12) a separate deliberate command, semantics unknown
k(int,cb) retry wrapper: repeats U(); Q(); Y(); up to int times on failure
C(Bitmap,int) 1d7630 + 5-byte header + raster legacy image protocol (related to this library's 1d763000, but a different header format — see §7.4)
o(byte[],int,int) 1d7631 + 4-byte header, data sent separately a variant of the legacy protocol, third byte 0x31 instead of 0x30
w(Bitmap,int) 1f + 9-byte header + compressed raster ✅ (§3) new image protocol for allow-listed models (including A40)
l(Bitmap,int) dispatcher: picks w() (new protocol) or C() (legacy) by model name

Decompilation note: jadx substituted numeric byte literals with static constant names from
unrelated libraries (org.apache.poi, org.apache.commons.compress) that happen to share
the same value — e.g. UnionPtg.sid happens to equal 0x10, BoolPtg.sid = 0x1D,
NumberPtg.sid = 0x1F, UnaryPlusPtg.sid = 0x12, TarConstants.LF_NORMAL/LF_LINK = ASCII
'0'/'1' (0x30/0x31), Ptg.CLASS_ARRAY = 0x40. This is purely a decompiler artifact
(the substitution has nothing to do with Excel formulas or tar archives) — worth knowing if
anyone reopens these classes in jadx and finds these "random" imports confusing.

7.3. Printer status responses — full semantics

The status-message handler (interface implemented in the print-orchestration class) gives exact
semantics to every tag mentioned in §2/§4:

Tag (byte 0) byte 1 Handler method Meaning
0xFF 1 onOutPaper out of paper
0xFF 2 onOpenCover cover open
0xFF 3 onOverHeat overheating
0xFF 4 onLowVal low battery (sets battery=9%, shows a UI warning)
0xFF 5 onCloseCover cover closed
0xFF 6 onPrinterLowMileageAuto log "标签打印机_打印_里程不足" — "insufficient mileage" of a label printer's cartridge/head (auto mode) — an anti-counterfeit-consumables mechanism
0xFE any onPaperError paper type mismatch: 1="folded black-mark paper", 2="continuous roll paper", 3="adhesive-gap paper", 4="perforated paper" — uses the value chosen via choosePaperType/B() (§7.2) as the expected value
0xFD 3 (no-op) empty in the decompiled code
0xFD 4 (no-op) empty in the decompiled code
0xFD anything else (incl. 1,2) onStartOrStopSend 1="终止命令" (abort print), 2="继续开始命令" (continue/restart) — see §4
0xFC any (no-op in this part of the code) a handler exists but is empty at the UI-logic level

7.4. Differences between the legacy (0x1d7630) and new (0x1f) image protocols

Not just a different opcode — the header formats also differ structurally in ways that are
easy to miss when porting:

  • Different byte order. The new (0x1f) protocol encodes row_bytes/height as
    big-endian (§3.1). The legacy (0x1d7630) protocol (method C()) encodes the same values
    as little-endian ((byte)(i11%256), (byte)(i11/256) — low byte first).
  • The legacy header has a concentration byte this library doesn't use. This library's own
    printRow()/printRowBytesList() always writes 0x00 at that header position (1d763000
    the fourth byte is always zero). The official app instead writes a
    concentration/mode parameter there, clamped to 0..3 — meaning the protocol itself has an
    unused-by-this-library field: "density for this specific image."
  • The 255-row chunk limit is artificial, not protocol-level. This library encodes height
    as one byte (hence its own comment, "chunked... height limit of 0xff") — which is why
    printRowBytesList() has to slice any image taller than 255 rows into chunks. But in both the
    legacy and new app protocols, the height field is a genuine 16 bits. This means the
    255-row chunk limit in this library isn't a firmware limitation — it's a consequence of how
    this library encodes the header bytes; the firmware potentially accepts heights up to 65535
    rows in one command.
  • This library's reset() appends 13 zero bytes to 10fffe01; in the app's code, reset
    (U()) is exactly 4 bytes, with no zero bytes following (§2 step 5, §7.2 M()).

This writeup, and a companion implementation plan with concrete code for each of these
findings, came out of building an open-source printing app
(perripage-ferrum) on top of this library.
Posted here in case any of it is useful upstream — happy to answer questions about the
methodology or dig further into any of the still-undecoded opcodes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant