From 2b86308e4535c5bbdfcb5809e8edf20f7387d164 Mon Sep 17 00:00:00 2001 From: rainmanvays Date: Wed, 15 Jul 2026 13:01:32 +0300 Subject: [PATCH] Honest 16-bit row height + wider concentration range 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/__init__.py | 82 ++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/peripage/__init__.py b/peripage/__init__.py index 5237d7e..13a870b 100644 --- a/peripage/__init__.py +++ b/peripage/__init__.py @@ -482,15 +482,21 @@ def setConcentration(self, concentration: int, wait: bool=False) -> None: Request: `10ff1000+bytes[1]:big_endian`. Arguments: - * `concentration` - concentration value from range `(0, 1, 2)` - """ - - if concentration <= 0: - request = bytes.fromhex('10ff100000') - elif concentration == 1: - request = bytes.fromhex('10ff100001') - elif concentration >= 2: - request = bytes.fromhex('10ff100002') + * `concentration` - concentration value, byte range `(0-255)`. Values + `0`, `1`, `2` are the originally documented/tested levels and keep + producing the exact same bytes as before. The wire protocol itself + does not clamp to this range: the official Android app's + decompiled code sends this same opcode with no range check at all, + and on a real Peripage A40 values `3`/`4` are accepted without + error (no visible density difference was observed above `2` on + that specific unit, so treat anything past `2` as + hardware-dependent/experimental, not guaranteed to improve + anything). See the writeup this finding came from: + https://github.com/RainManVays/perripage-ferrum/blob/main/docs/bluetooth-protocol-trace-analysis.md + """ + + concentration = min(0xff, max(0x00, concentration)) + request = bytes.fromhex('10ff1000') + int.to_bytes(concentration, 1, 'big') if wait: return self.askPrinter(request) @@ -712,16 +718,27 @@ def printRowBytesList(self, rowbytes: typing.Iterable[bytes], delay: float=0.01) `Printer.getRowBytes()` constant, input is truncated. If size of input is under the `Printer.getRowBytes()`, it will be padded with zeros. - This printer supports pages up to `0xffff` rows, but current - implementation relies on chunked data with height limit of `0xff` and - automatically slices the input into chunks. + This printer supports pages up to `0xffff` rows in a single request — + `row_bytes`/`height` are both encoded as 2-byte little-endian fields + (see Request line below), which for every existing supported model + (`row_bytes` always <= 255) produces byte-for-byte identical wire + data to the previous one-byte-plus-zero-padding encoding. Only + `height` above `0xff` actually changes behavior: previously this was + silently sliced into multiple `0xff`-row chunks (each with its own + `reset()`), which was an artifact of this method's own encoding + choice, not a firmware limit -- confirmed by reverse engineering the + official Android app, whose equivalent height field is a full 16 + bits, and by printing a real 300-row image in one continuous + request on a Peripage A40 with no corruption at the old 255-row + boundary. See: + https://github.com/RainManVays/perripage-ferrum/blob/main/docs/bluetooth-protocol-trace-analysis.md Note: In case of A6+, preamble is `1d76300048000100` that can be viewed as `[ 1d7630, 0030, 0001 ]`, where `1d7630` is printing operation - request, `0030` is big endian bytes per row, `0001` is big endian input - height. + request, `0030` is little endian bytes per row, `0001` is little + endian input height. - Request: chunked `1d763000+bytes[1]:big_endian+00+bytes[1]:big_endian+00+bytes[Printer.getRowBytes()*chunk_height]`. + Request: `1d763000+bytes[2]:little_endian+bytes[2]:little_endian+bytes[Printer.getRowBytes()*height]`. Arguments: * `rowbytes` - list of bytes defining each row of the image. If row @@ -734,31 +751,28 @@ def printRowBytesList(self, rowbytes: typing.Iterable[bytes], delay: float=0.01) return expectedLen = self.getRowBytes() - chunks = [ rowbytes[i:i+0xff] for i in range(0, len(rowbytes), 0xff) ] - - for chunk in chunks: + height = len(rowbytes) - # Reset state before print - self.reset() + # Reset state before print + self.reset() - # 1d763000 30 00 01 00 - # Send preamble: `1d763000` + row_bytes:bytes[1] + `00` + chunk_size:bytes[1] + `00` - request = bytes.fromhex('1d763000') + int.to_bytes(self.getRowBytes(), 1, 'big') + bytes.fromhex('00') + int.to_bytes(len(chunk), 1, 'big') + bytes.fromhex('00') + # Send preamble: `1d763000` + row_bytes:bytes[2]:little_endian + height:bytes[2]:little_endian + request = bytes.fromhex('1d763000') + int.to_bytes(expectedLen, 2, 'little') + int.to_bytes(height, 2, 'little') - # Flush preamble - self.tellPrinter(request) + # Flush preamble + self.tellPrinter(request) - # Flush rows dith delay - for row in chunk: - # trunc/pad - if len(row) < expectedLen: - row = row.ljust(expectedLen, b'\0') - elif len(row) > expectedLen: - row = row[:expectedLen] + # Flush rows with delay + for row in rowbytes: + # trunc/pad + if len(row) < expectedLen: + row = row.ljust(expectedLen, b'\0') + elif len(row) > expectedLen: + row = row[:expectedLen] - self.tellPrinter(row) + self.tellPrinter(row) - time.sleep(delay) + time.sleep(delay) def printRowBytesIterator(self, rowiterator: typing.Iterable[bytes], delay: float=0.01) -> None: """