From de99bfe9c692068cc9262c8773101b4e01d8aa44 Mon Sep 17 00:00:00 2001 From: odudex Date: Fri, 24 Nov 2023 15:29:07 -0300 Subject: [PATCH] QR code frame count calculated instead of iterated won't try to encode huge QRs and blow RAM --- pyproject.toml | 2 +- src/krux/display.py | 2 + src/krux/metadata.py | 2 +- src/krux/qr.py | 114 +++++++++++++++++++++++++++++++------------ tests/test_qr.py | 25 +++++++--- 5 files changed, 107 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 585611163..10d42fe9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ [tool.poetry] name = "krux" -version = "24.04.beta8" +version = "24.04.beta9" description = "Open-source signing device firmware for Bitcoin" authors = ["Jeff S "] diff --git a/src/krux/display.py b/src/krux/display.py index 46683d4f5..3aa945d30 100644 --- a/src/krux/display.py +++ b/src/krux/display.py @@ -160,6 +160,8 @@ def qr_data_width(self): """ if self.width() > 300: return self.width() // 6 # reduce density even more on larger screens + if self.width() > 200: + return self.width() // 5 return self.width() // 4 def to_landscape(self): diff --git a/src/krux/metadata.py b/src/krux/metadata.py index e26e26c3f..f1f12149e 100644 --- a/src/krux/metadata.py +++ b/src/krux/metadata.py @@ -19,5 +19,5 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -VERSION = "24.04.beta8" +VERSION = "24.04.beta9" SIGNER_PUBKEY = "03339e883157e45891e61ca9df4cd3bb895ef32d475b8e793559ea10a36766689b" diff --git a/src/krux/qr.py b/src/krux/qr.py index bb972758d..bac6053c3 100644 --- a/src/krux/qr.py +++ b/src/krux/qr.py @@ -23,7 +23,6 @@ import io import math import qrcode -from ur.ur_encoder import UREncoder from ur.ur_decoder import URDecoder from ur.ur import UR @@ -31,6 +30,36 @@ FORMAT_PMOFN = 1 FORMAT_UR = 2 +PMOFN_PREFIX_LENGTH_1D = 6 +PMOFN_PREFIX_LENGTH_2D = 8 +UR_GENERIC_PREFIX_LENGTH = 22 +UR_CHECKSUM_SIZE = 32 +UR_MIN_FRAGMENT_LENGTH = 10 +# List of capacities, based on versions +# Version 1(index 0)=21x21px = 17 bytes, version 2=25x25px = 32 bytes ... +QR_CAPACITY = [ + 17, + 32, + 53, + 78, + 106, + 134, + 154, + 192, + 230, + 271, + 321, + 367, + 425, + 458, + 520, + 586, + 644, + 718, + 792, + 858, +] + class QRPartParser: """Responsible for parsing either a singular or animated series of QR codes @@ -110,22 +139,26 @@ def to_qr_codes(data, max_width, qr_format): code = qrcode.encode(data) yield (code, 1) else: - num_parts = find_min_num_parts(data, max_width, qr_format) - while math.ceil(data_len(data) / num_parts) > 128: - num_parts += 1 - part_size = math.ceil(data_len(data) / num_parts) - + num_parts, part_size = find_min_num_parts(data, max_width, qr_format) if qr_format == FORMAT_PMOFN: - for i in range(num_parts): - part_number = "p%dof%d " % (i + 1, num_parts) + part_index = 0 + while True: + part_number = "p%dof%d " % (part_index + 1, num_parts) part = None - if i == num_parts - 1: - part = part_number + data[i * part_size :] + if part_index == num_parts - 1: + part = part_number + data[part_index * part_size :] + part_index = 0 else: - part = part_number + data[i * part_size : i * part_size + part_size] + part = ( + part_number + + data[part_index * part_size : (part_index + 1) * part_size] + ) + part_index += 1 code = qrcode.encode(part) yield (code, num_parts) elif qr_format == FORMAT_UR: + from ur.ur_encoder import UREncoder + encoder = UREncoder(data, part_size, 0) while True: part = encoder.next_part() @@ -146,29 +179,50 @@ def data_len(data): return len(data) +def max_qr_bytes(max_width): + """Calculates the maximum length, in bytes, a QR code of a given size can store""" + # Given qr_size = 17 + 4 * version + 2 * frame_size + max_width -= 2 # Subtract frame width + qr_version = (max_width - 17) // 4 + try: + capacity = QR_CAPACITY[qr_version - 1] + except: + capacity = QR_CAPACITY[-1] + return capacity + + def find_min_num_parts(data, max_width, qr_format): """Finds the minimum number of QR parts necessary to encode the data in the specified format within the max_width constraint """ - num_parts = 1 - part_size = math.ceil(data_len(data) / num_parts) - while True: - part = "" - if qr_format == FORMAT_PMOFN: - part_number = "p1of%d " % num_parts - part = part_number + data[0:part_size] - elif qr_format == FORMAT_UR: - encoder = UREncoder(data, part_size, 0) - part = encoder.next_part() - # The worst-case number of bytes needed to store one QR Code, up to and including - # version 40. This value equals 3918, which is just under 4 kilobytes. - if len(part) < 3918: - code = qrcode.encode(part) - if get_size(code) <= max_width: - break - num_parts += 1 - part_size = math.ceil(data_len(data) / num_parts) - return num_parts + qr_capacity = max_qr_bytes(max_width) + if qr_format == FORMAT_PMOFN: + data_length = len(data) + part_size = qr_capacity - PMOFN_PREFIX_LENGTH_1D + # where prefix = "pXofY " where Y < 9 + num_parts = (data_length + part_size - 1) // part_size + if num_parts > 9: # Prefix has 2 digits numbers, so re-calculate + part_size = qr_capacity - PMOFN_PREFIX_LENGTH_2D + # where prefix = "pXXofYY " where max YY = 99 + num_parts = (data_length + part_size - 1) // part_size + part_size = (data_length + num_parts - 1) // num_parts + elif qr_format == FORMAT_UR: + qr_capacity -= ( + UR_GENERIC_PREFIX_LENGTH # index: ~ "ur:crypto-psbt/xxx-xx/"UR index grows + ) + data_length = len(data.cbor) + data_length += UR_CHECKSUM_SIZE # UR 32 bits Checksum + # This help make UR QRs huge: + data_length *= 2 # UR will Bytewords.encode, which is 2 chars per byte + num_parts = (data_length + qr_capacity - 1) // qr_capacity + # For UR, part size will be the input for "max_fragment_len" + part_size = len(data.cbor) // num_parts + part_size = max( + part_size, UR_MIN_FRAGMENT_LENGTH + ) + else: + raise ValueError("Invalid format type") + return num_parts, part_size def parse_pmofn_qr_part(data): diff --git a/tests/test_qr.py b/tests/test_qr.py index 212aa4961..aa8117a17 100644 --- a/tests/test_qr.py +++ b/tests/test_qr.py @@ -120,19 +120,31 @@ def test_parser(mocker, m5stickv, tdata): def test_to_qr_codes(mocker, m5stickv, tdata): from krux.qr import to_qr_codes, FORMAT_NONE, FORMAT_PMOFN, FORMAT_UR + from krux.display import Display cases = [ - (FORMAT_NONE, tdata.TEST_DATA_B58, 1), - (FORMAT_PMOFN, tdata.TEST_DATA_B58, 3), - (FORMAT_UR, tdata.TEST_DATA_UR, 3), + # Test 135 pixels wide display + (FORMAT_NONE, tdata.TEST_DATA_B58, 135, 1), + (FORMAT_PMOFN, tdata.TEST_DATA_B58, 135, 9), + (FORMAT_UR, tdata.TEST_DATA_UR, 135, 22), + # Test 320 pixels wide display + (FORMAT_NONE, tdata.TEST_DATA_B58, 320, 1), + (FORMAT_PMOFN, tdata.TEST_DATA_B58, 320, 3), + (FORMAT_UR, tdata.TEST_DATA_UR, 320, 5), ] for case in cases: + mocker.patch( + "krux.display.lcd", + new=mocker.MagicMock(width=mocker.MagicMock(return_value=case[2])) + ) + display = Display() + qr_data_width = display.qr_data_width() fmt = case[0] data = case[1] - expected_parts = case[2] + expected_parts = case[3] codes = [] - code_generator = to_qr_codes(data, 135, fmt) + code_generator = to_qr_codes(data, qr_data_width, fmt) i = 0 while True: try: @@ -141,7 +153,8 @@ def test_to_qr_codes(mocker, m5stickv, tdata): assert total == expected_parts if i == total - 1: break - except: + except Exception as e: + print("Error:",e) break i += 1 assert len(codes) == expected_parts