aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/barcode_ref.py13
-rw-r--r--tools/check_codes.py11
-rw-r--r--tools/gen_code_vectors.py110
-rw-r--r--tools/gen_qr_tables.py75
-rw-r--r--tools/tests/test_barcode_tools.py22
-rw-r--r--tools/ui_flow.py18
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: