Honest 16-bit row height + wider concentration range (both backward compatible, verified on real A40)#26
Conversation
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
Peripage A40 Bluetooth protocol: analysis of a real HCI trace from the official appDate: 2026-07-14/15 Printer: a real Peripage A40 ( How to read this: every claim is tagged with a confidence level — confirmed (seen Later (2026-07-15) the app itself ( 1. Capture overview
2. Per-print-action command sequenceReconstructed from all 3 print actions — byte-identical across all three except where noted as
The library's own image opcode ( 3. Image data transfer (opcode
|
| 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
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.
1b4a10— confirmed: this is this library's already-knownprintBreakopcode
(1b4a+ a size byte), here with size0x10=16. The official app genuinely uses the exact
sameprintBreakas this library — byte-for-byte identical.10fffe45— confirmed by code (see §2 step 7, §7): methodY(). 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
code0x02(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) |
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) |
|
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
- 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. - 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 berow_bytes. Now confirmed by the decompiled code:row_bytes = ceil(width/8), formula and byte positions read directly fromw(). The number 1664 stays
correct, it's just no longer "a guess from one byte." - 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 pathC()). Worth explicitly sending10ff1000+
03/04past 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.) - Now implementable, previously wasn't: the entire
0x1fopcode 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.) - 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.) - 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:
com.ileadtek.peripage-312.xapk— version 6.10.6 (build 312).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 encodesrow_bytes/heightas
big-endian (§3.1). The legacy (0x1d7630) protocol (methodC()) 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 writes0x00at that header position (1d763000—
the fourth byte is always zero). The official app instead writes a
concentration/mode parameter there, clamped to0..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, theheightfield 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 to10fffe01; in the app's code,reset
(U()) is exactly 4 bytes, with no zero bytes following (§2 step 5, §7.2M()).
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.
What
Two small, backward-compatible changes to
Printer, both verified live on a real Peripage A40:printRowBytesList(): honest 16-bit row height.row_bytes/heightare now proper2-byte little-endian fields instead of 1-byte-plus-a-hardcoded-zero-padding-byte. For every
currently supported model (
row_bytesis always ≤ 255) this produces byte-for-byteidentical 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 multiple0xff-row chunks (each getting its ownreset()), a tall image now goes out as a single continuous request — matching this method'sown 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.
setConcentration(): removed the hardcoded clamp to{0, 1, 2}. Values0/1/2stillproduce the exact same request bytes as before. Values above
2are no longer silentlyclamped 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.peripageAndroid app (with a security checkfirst — 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 legacy one closest to this library's
1d7630, and a newer one). The 255-row limit herereally is just this method's own 1-byte encoding, not something the firmware enforces.
real A40 accepts values
3/4without erroring (no visible density improvement over2onmy specific unit, for what it's worth — so treat anything past
2ashardware-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, firmwareV1.4.5_SD) viaPrinter.printImage()directly (i.e. through theunmodified public API, not some bypass):
at the old 255-row boundary.
case.
setConcentration(3)andsetConcentration(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.