diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 06:56:58 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 06:56:58 +0200 |
| commit | f455246fde086dda0cb61b92c84a5146a831a2bf (patch) | |
| tree | 09b5e97ee5f84e495bbd0646e1ad0e3e2fbfb9af /tools | |
| parent | 2df98d1ba129f95a10c6b81665671d3b12560c79 (diff) | |
test(codes): compare QR with segno, decode QR dumps and the QR screenshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/barcode_ref.py | 13 | ||||
| -rw-r--r-- | tools/check_codes.py | 11 | ||||
| -rw-r--r-- | tools/gen_code_vectors.py | 110 | ||||
| -rw-r--r-- | tools/gen_qr_tables.py | 75 | ||||
| -rw-r--r-- | tools/tests/test_barcode_tools.py | 22 | ||||
| -rw-r--r-- | tools/ui_flow.py | 18 |
6 files changed, 247 insertions, 2 deletions
diff --git a/tools/barcode_ref.py b/tools/barcode_ref.py index 2e58b5e..f56a7c3 100644 --- a/tools/barcode_ref.py +++ b/tools/barcode_ref.py @@ -16,6 +16,7 @@ from PIL import Image QR, CODE128, CODE39, EAN13, EAN8, UPCA, UPCE, ITF, CODABAR = range(9) FORMATS = { + QR: zxingcpp.BarcodeFormat.QRCode, CODE128: zxingcpp.BarcodeFormat.Code128, CODE39: zxingcpp.BarcodeFormat.Code39, EAN13: zxingcpp.BarcodeFormat.EAN13, @@ -54,6 +55,18 @@ def render_linear(modules: str, scale: int, height: int = 40) -> Image.Image: READ_FORMATS = {**FORMATS, CODE39: zxingcpp.BarcodeFormat.Code39Std, UPCA: zxingcpp.BarcodeFormat.EAN13} +def render_matrix(packed: bytes, size: int, scale: int, quiet: int) -> Image.Image: + """A square matrix (row-major bits, 8 per byte) as black modules on white with a quiet zone.""" + side = (size + 2 * quiet) * scale + image = Image.new("L", (side, side), 255) + for i in range(size * size): + if packed[i // 8] & (0x80 >> (i % 8)): + x, y = i % size, i // size + left, top = (quiet + x) * scale, (quiet + y) * scale + image.paste(0, (left, top, left + scale, top + scale)) + return image + + def decode(image: Image.Image, symbology: int | None = None) -> list[str]: """Texts zxing-cpp reads in image (only as `symbology` when given).""" if symbology is None: diff --git a/tools/check_codes.py b/tools/check_codes.py index 22d3033..53e30f0 100644 --- a/tools/check_codes.py +++ b/tools/check_codes.py @@ -19,7 +19,9 @@ from pathlib import Path import barcode_ref as ref DUMP = re.compile(r"CODEDUMP (\d+) ([0-9a-f]*) ([01]+)") +QR_DUMP = re.compile(r"QRDUMP (\d+) ([0-9a-f]*) ([0-9a-f]+)") SCALES = (1, 2, 3) +QR_SCALES = (2, 3) # README 12.2: 2D codes use at least 2 px per module def check_log(path: Path) -> tuple[int, list[str]]: @@ -36,6 +38,15 @@ def check_log(path: Path) -> tuple[int, list[str]]: if expected not in got: failures.append(f"symbology {symbology} '{text}' at {scale} px/module: read {got}") checked += 1 + for match in QR_DUMP.finditer(path.read_text(encoding="utf-8", errors="replace")): + size = int(match.group(1)) + text = bytes.fromhex(match.group(2)).decode("utf-8") + packed = bytes.fromhex(match.group(3)) + for scale in QR_SCALES: + got = ref.decode(ref.render_matrix(packed, size, scale, quiet=4), ref.QR) + if text not in got: + failures.append(f"QR size {size} '{text[:30]}' at {scale} px/module: read {got}") + checked += 1 return checked, failures diff --git a/tools/gen_code_vectors.py b/tools/gen_code_vectors.py index 970ca06..a87b0c9 100644 --- a/tools/gen_code_vectors.py +++ b/tools/gen_code_vectors.py @@ -15,10 +15,13 @@ from __future__ import annotations import argparse import random +import re import string from pathlib import Path +from typing import Any, cast import barcode_ref as ref +import segno CHECK_ADD, CHECK_FIX, CHECK_OFF = 0, 1, 2 TEXT_ON = 0x10 @@ -92,6 +95,94 @@ def cases(rng: random.Random, count: int) -> list[tuple[int, str, int, str]]: return out +QR_LEVELS = "LMQH" +LEVEL_CONSTS = [ + segno.consts.ERROR_LEVEL_L, + segno.consts.ERROR_LEVEL_M, + segno.consts.ERROR_LEVEL_Q, + segno.consts.ERROR_LEVEL_H, +] + + +def qr_cases(rng: random.Random, count: int) -> list[tuple[str, int, int, int]]: + """(payload, level 0-3, version 0 = auto, mask -1 = auto) for QR. + + Single-mode payloads only (numeric, alphanumeric or bytes), so segno's + one-segment encoding and the watch's optimal segmentation agree: a digit + run inside other text would be a cheaper numeric segment on the watch. + Mixed content is covered by the decoding check instead. + """ + fixed = [ + ("01234567", 1, 1, 2), + ("HELLO WORLD", 2, 1, 6), + ("hello, world", 0, 2, 0), + ("Zürich ✓ fake", 1, 5, 3), + ("https://example.com/member/abc", 1, 0, -1), + ("314159265358979323846264338327950288419716939937510", 3, 0, -1), + ("THE QUICK BROWN FOX 0123456789 $%*+-./:", 2, 7, 4), + ("v" * 120, 1, 10, 5), + ("Z" * 300, 0, 0, -1), + ("9" * 700, 1, 0, -1), + ("byte data " * 40, 3, 0, -1), + ] + out = list(fixed) + for _ in range(count): + kind = rng.randrange(3) + n = rng.randint(1, 60) + if kind == 0: + payload = digits(rng, n) + elif kind == 1: + payload = "".join(rng.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 $%*+-./:") for _ in range(n)) + if payload.isdigit(): + payload += "A" + else: + payload = "".join(rng.choice(string.ascii_lowercase + "!?,;") for _ in range(n)) + if not payload.isdigit() and re.search(r"\d{4,}", payload): + continue + out.append((payload, rng.randrange(4), 0, -1)) + return out + + +def single_mode_bits(payload: str, version: int) -> int: + """Bits of payload as one numeric, alphanumeric or byte segment.""" + c = 0 if version <= 9 else (1 if version <= 26 else 2) + if payload.isdigit(): + n = len(payload) + return 4 + (10, 12, 14)[c] + 10 * (n // 3) + (0, 4, 7)[n % 3] + if all(ch in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" for ch in payload): + n = len(payload) + return 4 + (9, 11, 13)[c] + 11 * (n // 2) + 6 * (n % 2) + return 4 + (8, 16, 16)[c] + 8 * len(payload.encode()) + + +def segno_pads_extra_byte(payload: str, level: int, version: int) -> bool: + """True when segno would insert an extra zero byte: after the terminator + its write_padding_bits adds 8 - (length % 8) bits even when the stream + already ends on a codeword boundary (ISO/IEC 18004 7.4.10 adds none). + The watch follows the standard, so such payloads make no exact reference.""" + groups = cast(dict[int, dict[int, tuple[Any, ...]]], segno.consts.ECC)[version][LEVEL_CONSTS[level]] + capacity = 8 * sum(g.num_blocks * g.num_data for g in groups) + bits = single_mode_bits(payload, version) + terminated = bits + min(4, capacity - bits) + return bool(terminated < capacity and terminated % 8 == 0) + + +def qr_reference(payload: str, level: int, version: int, mask: int) -> tuple[int, int, str]: + """segno's (version, mask, matrix as hex of row-major bits) for the case.""" + qr = segno.make_qr( + payload, + error=QR_LEVELS[level], + version=version or None, + mask=None if mask < 0 else mask, + boost_error=False, + encoding="utf-8", + ) + bits = "".join(str(b) for row in qr.matrix for b in row) + bits += "0" * (-len(bits) % 8) + packed = bytes(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8)) + return int(qr.designator.split("-")[0]), qr.mask, packed.hex() + + def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: lines = [] for symbology, payload, opts, text in rows: @@ -99,12 +190,29 @@ def render(rows: list[tuple[int, str, int, str]], seed: int) -> str: escaped = payload.replace("\\", "\\\\").replace('"', '\\"') lines.append(f' [{symbology}, "{escaped}", {opts}, "{modules}"]') body = ",\n".join(lines) + qr_lines = [] + for case in qr_cases(random.Random(seed + 1), 20): + payload, level, version, mask = case + ref_version, ref_mask, hexbits = qr_reference(payload, level, version, mask) + while segno_pads_extra_byte(payload, level, ref_version): + # Lengthen by one character of the same kind until segno's padding + # quirk doesn't apply. + payload += payload[-1] + ref_version, ref_mask, hexbits = qr_reference(payload, level, version, mask) + escaped = payload.replace("\\", "\\\\").replace('"', '\\"') + qr_lines.append(f' ["{escaped}", {level}, {version}, {mask}, {ref_version}, {ref_mask}, "{hexbits}"]') + qr_body = ",\n".join(qr_lines) return ( "// Author: cirakg & claude ai\n" "// Summary: zxing-cpp reference module rows for the linear barcode encoders (generated; do not edit).\n" f"//\n// Generated by tools/gen_code_vectors.py, seed {seed}. Rows: [symbology, payload, options,\n" "// expected modules without quiet zones]. Fake payloads only.\n\n" - "import Toybox.Lang;\n\n(:test)\nmodule CodeVectors {\n const LINEAR = [\n" + body + "\n ];\n}\n" + "import Toybox.Lang;\n\n(:test)\nmodule CodeVectors {\n const LINEAR = [\n" + + body + + "\n ];\n\n // [payload, level, version (0 auto), mask (-1 auto), segno version, segno mask,\n" + + " // segno matrix as hex of row-major bits]\n const QR = [\n" + + qr_body + + "\n ];\n}\n" ) diff --git a/tools/gen_qr_tables.py b/tools/gen_qr_tables.py new file mode 100644 index 0000000..68e6355 --- /dev/null +++ b/tools/gen_qr_tables.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# Author: cirakg & claude ai +# Summary: Generate source/codes/QrTables.mc (QR error-correction block structure) from segno's published constants. +"""QR block structure for versions 1-40 and levels L, M, Q, H (ISO/IEC 18004 +table 9), taken from segno so it isn't typed by hand. Each entry is +[ec codewords per block, blocks in group 1, data codewords per group-1 +block, blocks in group 2, data codewords per group-2 block]. + + gen_qr_tables.py [--out source/codes/QrTables.mc] +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any, cast + +import segno.consts as c + +LEVELS = [c.ERROR_LEVEL_L, c.ERROR_LEVEL_M, c.ERROR_LEVEL_Q, c.ERROR_LEVEL_H] +# segno.consts.ECC: version -> level -> tuple of EC(num_blocks, num_total, num_data); untyped in segno. +ECC = cast(dict[int, dict[int, tuple[Any, ...]]], c.ECC) + + +def table() -> list[list[int]]: + rows = [] + for version in range(1, 41): + for level in LEVELS: + groups = ECC[version][level] + ec = groups[0].num_total - groups[0].num_data + g1 = groups[0] + g2 = groups[1] if len(groups) > 1 else None + assert all(g.num_total - g.num_data == ec for g in groups) + rows.append([ec, g1.num_blocks, g1.num_data, g2.num_blocks if g2 else 0, g2.num_data if g2 else 0]) + return rows + + +def alignment() -> list[list[int]]: + """Alignment pattern centre coordinates per version (ISO/IEC 18004 annex E).""" + # segno starts at version 2; version 1 has no alignment pattern. + return [[]] + [list(c.ALIGNMENT_POS[v - 2]) for v in range(2, 41)] + + +def render(rows: list[list[int]]) -> str: + lines = [] + for version in range(1, 41): + cells = ", ".join(str(n) for level in range(4) for n in rows[(version - 1) * 4 + level]) + lines.append(f" {cells}{'' if version == 40 else ','} // version {version}: L, M, Q, H") + return ( + "// Author: cirakg & claude ai\n" + "// Summary: QR error-correction block structure per version and level (generated from segno; do not edit).\n" + "//\n" + "// Generated by tools/gen_qr_tables.py. For version v (1-40) and level e (0 L, 1 M, 2 Q, 3 H), the five\n" + "// numbers at index ((v - 1) * 4 + e) * 5 are: EC codewords per block, group-1 blocks, data codewords\n" + "// per group-1 block, group-2 blocks, data codewords per group-2 block (ISO/IEC 18004 table 9).\n\n" + "import Toybox.Lang;\n\nmodule QrTables {\n const BLOCKS = [\n" + + "\n".join(lines) + + "\n ];\n\n // Alignment pattern centres (row and column coordinates) per version, from version 1.\n" + + " const ALIGNMENT = [\n" + + ",\n".join(f" {pos}" for pos in alignment()) + + "\n ];\n}\n" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, default=Path("source/codes/QrTables.mc")) + args = parser.parse_args(argv) + args.out.write_text(render(table()), encoding="utf-8") + print(f"wrote the QR block table to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_barcode_tools.py b/tools/tests/test_barcode_tools.py index 7be1f18..0ee3cc4 100644 --- a/tools/tests/test_barcode_tools.py +++ b/tools/tests/test_barcode_tools.py @@ -8,6 +8,7 @@ from pathlib import Path import barcode_ref as ref import check_codes import gen_code_vectors +import gen_qr_tables ROOT = Path(__file__).resolve().parents[2] @@ -63,3 +64,24 @@ def test_check_codes_reads_dump_lines(tmp_path: Path) -> None: empty = tmp_path / "empty.log" empty.write_text("nothing\n", encoding="utf-8") assert check_codes.main([str(empty)]) == 2 + + +def test_committed_qr_tables_match_segno() -> None: + committed = (ROOT / "source" / "codes" / "QrTables.mc").read_text(encoding="utf-8") + assert committed == gen_qr_tables.render(gen_qr_tables.table()) + rows = gen_qr_tables.table() + assert rows[0] == [7, 1, 19, 0, 0] # version 1-L: 7 EC codewords, one block of 19 + assert len(gen_qr_tables.alignment()) == 40 and gen_qr_tables.alignment()[0] == [] + + +def test_segno_padding_quirk_detection() -> None: + # Byte mode always ends on a codeword boundary after the terminator, so + # segno would add a zero byte unless the symbol is exactly full. + assert gen_code_vectors.segno_pads_extra_byte("hello", 0, 1) + assert not gen_code_vectors.segno_pads_extra_byte("01234567", 1, 1) + + +def test_rendered_qr_matrix_decodes() -> None: + _, _, hexbits = gen_code_vectors.qr_reference("HELLO WORLD", 1, 1, 2) + image = ref.render_matrix(bytes.fromhex(hexbits), 21, 2, quiet=4) + assert ref.decode(image, ref.QR) == ["HELLO WORLD"] diff --git a/tools/ui_flow.py b/tools/ui_flow.py index 82e5d65..5428509 100644 --- a/tools/ui_flow.py +++ b/tools/ui_flow.py @@ -60,6 +60,10 @@ WRONG_PIN = "000000" FAILURES_UNTIL_WAIT = 5 # source/storage/Lockout.mc: the 5th failure starts a delay SELECTOR_OK = 10 # source/ui/PinView.mc: selector positions 0-9, then OK GYM_CARD = "5901234123457" # source/app/Fixtures.mc: EAN-13 with its check digit +MEMBER_QR = ( # source/app/Fixtures.mc: a version 6 QR + "https://example.com/member/fake-0042?name=Alice%20Example&card=0000111122223333&valid=2027-12-31&level=gold" +) +QR_TIMEOUT_S = 30.0 # the QR is encoded in timer steps the first time class FlowError(Exception): @@ -426,7 +430,7 @@ def flow_lockout(f: Flow) -> None: def flow_code(f: Flow) -> None: - """The EAN-13 gym card (index 6): its screenshot must decode.""" + """The EAN-13 gym card (index 6) and the QR card (7): their screenshots must decode.""" f.act() f.back() # leave the waiting PIN screen f.expect("list") @@ -436,6 +440,18 @@ def flow_code(f: Flow) -> None: f.open_focused_row() f.expect("code") f.shot("code", expect_code=GYM_CARD) + # The QR card (index 7) is encoded on the watch as a job, then shown. + f.act() + f.back() + f.expect("list") + f.next() + f.act() + f.open_focused_row() + f.expect("code", QR_TIMEOUT_S) + encoded = f.log.latest(r"DIAG qr_encoded version=(\d+) ms=(\d+)") + if encoded is not None: + print(f"[{f.sim.device_dir.name}] QR version {encoded.group(1)} encoded in {encoded.group(2)} ms") + f.shot("code_qr", expect_code=MEMBER_QR) def run_flow(f: Flow) -> None: |
