aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 06:56:58 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 06:56:58 +0200
commitf455246fde086dda0cb61b92c84a5146a831a2bf (patch)
tree09b5e97ee5f84e495bbd0646e1ad0e3e2fbfb9af
parent2df98d1ba129f95a10c6b81665671d3b12560c79 (diff)
test(codes): compare QR with segno, decode QR dumps and the QR screenshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--Makefile3
-rw-r--r--test/codes/CodeCacheTest.mc54
-rw-r--r--test/codes/QrTest.mc120
-rw-r--r--test/data/CodeVectors.mc36
-rw-r--r--test/golden/fenix7/about.pngbin4037 -> 4032 bytes
-rw-r--r--test/golden/fenix7/code.pngbin3294 -> 3339 bytes
-rw-r--r--test/golden/fenix7/code_qr.pngbin0 -> 3794 bytes
-rw-r--r--test/golden/fr255s/about.pngbin2385 -> 2382 bytes
-rw-r--r--test/golden/fr255s/code.pngbin2267 -> 2273 bytes
-rw-r--r--test/golden/fr255s/code_qr.pngbin0 -> 2604 bytes
-rw-r--r--test/golden/fr70/about.pngbin15606 -> 15572 bytes
-rw-r--r--test/golden/fr70/code.pngbin13889 -> 13887 bytes
-rw-r--r--test/golden/fr70/code_qr.pngbin0 -> 12968 bytes
-rw-r--r--test/golden/fr965/about.pngbin15984 -> 16027 bytes
-rw-r--r--test/golden/fr965/code.pngbin12507 -> 12467 bytes
-rw-r--r--test/golden/fr965/code_qr.pngbin0 -> 11727 bytes
-rw-r--r--test/golden/instinct3amoled45mm/about.pngbin13651 -> 13621 bytes
-rw-r--r--test/golden/instinct3amoled45mm/code.pngbin11749 -> 11824 bytes
-rw-r--r--test/golden/instinct3amoled45mm/code_qr.pngbin0 -> 12096 bytes
-rw-r--r--test/golden/venusq2/code.pngbin3037 -> 3040 bytes
-rw-r--r--test/golden/venusq2/code_qr.pngbin0 -> 3388 bytes
-rw-r--r--test/golden/venux1/code.pngbin11473 -> 11468 bytes
-rw-r--r--test/golden/venux1/code_qr.pngbin0 -> 10767 bytes
-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
29 files changed, 459 insertions, 3 deletions
diff --git a/Makefile b/Makefile
index 61a2b46..39acb0f 100644
--- a/Makefile
+++ b/Makefile
@@ -139,7 +139,8 @@ vectors: dirs need-image ## Regenerate test/data/Vectors.mc (cross-checked RFC/N
corpus: dirs need-image ## Regenerate test/data/FuzzCorpus.mc (seeded fuzz inputs for the import parsers)
$(DEV_OFFLINE) python3 tools/gen_fuzz_corpus.py
-codes: dirs need-image ## Regenerate test/data/CodeVectors.mc (zxing-cpp reference barcodes)
+codes: dirs need-image ## Regenerate the QR tables (from segno) and test/data/CodeVectors.mc (zxing-cpp and segno references)
+ $(DEV_OFFLINE) python3 tools/gen_qr_tables.py
$(DEV_OFFLINE) python3 tools/gen_code_vectors.py
spike: dirs need-image ## Build the M1 settings spike (debug .prg + beta .iq) into build/spike
diff --git a/test/codes/CodeCacheTest.mc b/test/codes/CodeCacheTest.mc
new file mode 100644
index 0000000..e09d902
--- /dev/null
+++ b/test/codes/CodeCacheTest.mc
@@ -0,0 +1,54 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the encrypted barcode matrix cache: round trip, keys of locked entries, invalidation and tampering.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+function codeCacheRoundTripsAndInvalidates(logger as Test.Logger) as Boolean {
+ var store = new MemoryBackend();
+ var vault = new Vault(store, new FakeTime());
+ var e = StorageFixtures.code("QR card");
+ e.type = Codes.QR;
+ var id = vault.add(e);
+ var qr = Qr.encode("cache me", 1, 0, -1) as Qr.MatrixCode;
+ var matrix = new Codes.Matrix(qr.size, qr.size, 4, qr.modules);
+ vault.saveCodeCache(id, matrix.toBytes());
+ var back = Codes.Matrix.fromBytes(vault.loadCodeCache(id) as ByteArray);
+ var same = back != null && back.width == qr.size && back.quiet == 4 && Bytes.equal(back.modules, qr.modules);
+ // The cache is sealed: tampering gives no cache rather than a wrong code.
+ var stored = store.raw("x." + id) as ByteArray;
+ stored[stored.size() - 1] = stored[stored.size() - 1] ^ 1;
+ var tampered = vault.loadCodeCache(id) == null;
+ vault.saveCodeCache(id, matrix.toBytes());
+ var opened = vault.open(id);
+ vault.update(opened);
+ opened.wipeSecrets();
+ var clearedByUpdate = store.getValue("x." + id) == null;
+ vault.saveCodeCache(id, matrix.toBytes());
+ vault.remove(id);
+ return same && tampered && clearedByUpdate && store.getValue("x." + id) == null;
+}
+
+// A locked entry's cache uses the master key: unreadable while locked.
+(:test)
+function codeCacheOfLockedEntriesNeedsTheSession(logger as Test.Logger) as Boolean {
+ var vault = new Vault(new MemoryBackend(), new FakeTime());
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ var e = StorageFixtures.code("Locked QR");
+ e.type = Codes.QR;
+ e.locked = true;
+ var id = vault.add(e);
+ vault.saveCodeCache(id, [3, 1, 4, 0xE0]b);
+ vault.lock();
+ var code = 0;
+ try {
+ vault.loadCodeCache(id);
+ } catch (err instanceof VaultError) {
+ code = err.code;
+ }
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ var back = vault.loadCodeCache(id);
+ return code == VaultError.LOCKED && back != null && Bytes.equal(back, [3, 1, 4, 0xE0]b)
+ && Codes.Matrix.fromBytes([3, 1, 4, 0xE0]b) != null && Codes.Matrix.fromBytes([3, 1, 4]b) == null;
+}
diff --git a/test/codes/QrTest.mc b/test/codes/QrTest.mc
new file mode 100644
index 0000000..50cb35a
--- /dev/null
+++ b/test/codes/QrTest.mc
@@ -0,0 +1,120 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the QR encoder: segno reference matrices (forced and automatic version and mask), decoding dumps, capacity, timing.
+
+import Toybox.Lang;
+import Toybox.System;
+import Toybox.Test;
+
+(:test)
+module QrFixtures {
+
+ // Row-major modules packed 8 per byte, as hex (the CodeVectors format).
+ function packedHex(code as Qr.MatrixCode) as String {
+ var m = code.modules;
+ var out = new [(m.size() + 7) / 8]b;
+ for (var i = 0; i < m.size(); i++) {
+ if (m[i] == 1) {
+ out[i / 8] = out[i / 8] | (0x80 >> (i % 8));
+ }
+ }
+ return Bytes.toHex(out);
+ }
+}
+
+// README 12.4 check 1 for QR: identical to segno with forced version and
+// mask, and the same automatic version and mask choice.
+(:test)
+function qrMatchesSegno(logger as Test.Logger) as Boolean {
+ var rows = CodeVectors.QR;
+ for (var i = 0; i < rows.size(); i++) {
+ var row = rows[i] as Array;
+ var payload = row[0] as String;
+ var result = Qr.encode(payload, row[1] as Number, row[2] as Number, row[3] as Number);
+ if (result instanceof Codes.CodeError) {
+ logger.error("row " + i + ": " + result.message);
+ return false;
+ }
+ var expectedVersion = row[4] as Number;
+ var expectedMask = row[5] as Number;
+ if (result.version != expectedVersion || result.mask != expectedMask) {
+ logger.error("row " + i + " '" + Text.clip(payload, 20) + "': version " + result.version + " mask " + result.mask
+ + ", segno " + expectedVersion + " / " + expectedMask);
+ return false;
+ }
+ var got = QrFixtures.packedHex(result);
+ var expected = row[6] as String;
+ if (!got.equals(expected)) {
+ logger.error("row " + i + " '" + Text.clip(payload, 20) + "' (version " + result.version + ") matrix differs");
+ logger.debug("QRGOT " + got);
+ return false;
+ }
+ }
+ logger.debug("CODES qr reference rows=" + rows.size());
+ return true;
+}
+
+// README 12.4 check 2: mixed-mode (segmented), Unicode and long payloads,
+// decoded by tools/check_codes.py with zxing-cpp.
+(:test)
+function qrDumpForDecoding(logger as Test.Logger) as Boolean {
+ var payloads = [
+ "ABC12345678901234xyz", "Membership 0042-7731-55 · Zürich", "12345678901234567890HELLO WORLD hello world",
+ "WIFI:T:WPA;S:FakeNet;P:not-a-real-password;;", "😀 emoji and Ünïcödé", "a"
+ ];
+ var seed = 777;
+ for (var i = 0; i < 24; i++) {
+ var text = "";
+ var length = 1 + i * 13 % 90;
+ for (var k = 0; k < length; k++) {
+ seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF;
+ var pool = (seed >> 16) % 3 == 0 ? "0123456789" : ((seed >> 16) % 3 == 1 ? "ABCXYZ $%*+-./:" : "abcxyz,!?é");
+ var at = (seed >> 4) % pool.length();
+ text += pool.substring(at, at + 1);
+ }
+ payloads.add(text);
+ }
+ for (var i = 0; i < payloads.size(); i++) {
+ var text = payloads[i] as String;
+ var result = Qr.encode(text, i % 4, 0, -1);
+ if (result instanceof Codes.CodeError) {
+ logger.error("'" + text + "': " + result.message);
+ return false;
+ }
+ logger.debug("QRDUMP " + result.size + " " + Bytes.toHex(Bytes.fromUtf8(text)) + " " + QrFixtures.packedHex(result));
+ }
+ return true;
+}
+
+(:test)
+function qrReportsCapacity(logger as Test.Logger) as Boolean {
+ var big = "";
+ for (var i = 0; i < 300; i++) {
+ big += "abcdefghij";
+ }
+ var tooLong = Qr.encode(big, 3, 0, -1);
+ var forced = Qr.encode("hello world, too long for version 1", 3, 1, -1);
+ var empty = Qr.encode("", 1, 0, -1);
+ return tooLong instanceof Codes.CodeError && (tooLong.message.find("too long") != null)
+ && forced instanceof Codes.CodeError && empty instanceof Codes.CodeError;
+}
+
+// Timing and step counts for PERFORMANCE.md (simulator).
+(:test)
+function qrBenchmark(logger as Test.Logger) as Boolean {
+ var text = "";
+ for (var i = 0; i < 20; i++) {
+ text += "abcdefghij";
+ }
+ var inputs = ["https://example.com/member/12345", text, text + text + text + text + text];
+ for (var i = 0; i < inputs.size(); i++) {
+ var job = new Qr.QrJob(inputs[i] as String, 1, 0, -1);
+ var steps = 0;
+ var start = System.getTimer();
+ while (!job.step(1)) {
+ steps++;
+ }
+ var code = job.result as Qr.MatrixCode;
+ logger.debug("BENCH qr_v" + code.version + " ms=" + (System.getTimer() - start) + " steps=" + steps);
+ }
+ return true;
+}
diff --git a/test/data/CodeVectors.mc b/test/data/CodeVectors.mc
index 64f8644..bb5f7bd 100644
--- a/test/data/CodeVectors.mc
+++ b/test/data/CodeVectors.mc
@@ -227,4 +227,40 @@ module CodeVectors {
[7, "5620356163", 25, "101011101000111000101010111010001000111011100011101000101010001110111010100010001110001110101011101"],
[8, "B+2C", 17, "1001001011010110110110101001011010100100110"]
];
+
+ // [payload, level, version (0 auto), mask (-1 auto), segno version, segno mask,
+ // segno matrix as hex of row-major bits]
+ const QR = [
+ ["01234567", 1, 1, 2, 1, 2, "fe5bfc13d06ea0bb7585dbabaec14507faafe01300be4be0ad4b08d53e1083c1f948005f33f9ac1057c5ba8965d6482ead2904036fef4a00"],
+ ["HELLO WORLD", 2, 1, 6, 1, 6, "fe13fc16506e96bb75f5dbad2ec12507faafe01b005eced5e87b8ac4c168b18dfeef8044a3f999f05497bad23dd5c52e908705ce6fe50100"],
+ ["hello, worlddddddddddddddddddddd", 0, 2, 0, 2, 0, "fe333fc13b906ea88bb74665dba332ec13b907faaafe01bb00efcce206e651e4822cff1bbd0e2ace1882671e5aa34e927b82a6ccf80066c5ffa26b705b91abaacf8dd3685eeaa66b05bb2afecda980"],
+ ["Zürich ✓ fakeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 1, 5, 3, 5, 3, "fef33d0bfc16bf1c506e936e38bb751f85e5dba16d0baec1049c7107faaaaaafe01e8c7200b720f43a590489e848a1e438e377518871c4f53ad42f0eb842e85cbf7db0e394ce96f1c6c37eb22f4064af185e9329b8e38f454995c71bb2efef42b8e4da5e872018c38e028236871ebec3fb42f6f88f8e85fe15e48e381d7a831c7a113fc2f4fa004185e447fad638aa705adc7318ba4af42f95d6e5e894eeb1b8e1f104e071c9cfea342e6780"],
+ ["https://example.com/member/abccccccccccccc", 1, 0, -1, 3, 7, "fe6373fc136b506e9af2bb746665dba67a2ec151e907faaaafe00b5900968e9507a426524ef4a47d51284b63614de5dcf43a03afd0b3ed3bcbaa3b506c1247763a68dd91a67165f739a6e9fa00726c5ff82aaa505e851fba02cf85d5c2f7ae95883b04c8b42feaa4bd00"],
+ ["314159265358979323846264338327950288419716939937510", 3, 0, -1, 3, 3, "fe2cabfc1323d06e886abb7404f5dbadbcaec10e0907faaaafe01f3f0033a49683e08632dff753a25a06ec28f034511c6c33df3c34c59e0b5f495f225eda6a0e43e4a006420982eb45a3a2f90049a473fb482b9048151bba2f0f85d64b802ebe3adb04656f0fe55fbc00"],
+ ["THE QUICK BROWN FOX 0123456789 $%*+-./::", 2, 7, 4, 7, 4, "fe3768f68bfc10c2fd0e906ea7f4be94bb742a9143b5dbad97f8f3aec167fc7f2107faaaaaaaafe006391d62004a9bdfc1ada0cdf238a7baddae1d271eb9ca11ad08d74a6e4bbb647bd639847d825cfeaa0366f81c27638c6ed9961afa49f47cc5e0c8e50a7daed1a5107c429fb5197348fc19fa64fa8c5cdc79f454ebba6b67ab871d2113ad1edfd0ffd8cf9e66a01f549df4f5d3d00a572b9b91d6281beea02a1a12029b79eb76246cdeb0f9db803319038f36cf50e89e5ebee2257e589dc2c4e4786a44f08eb9262bb9b6a8ff9afe007ed46e7c6bf8eaaa71ebf043a3128b17bad33fce0fb5d20349da0d6e8c2ff27c61058cb637419fe617679e3780"],
+ ["vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv", 1, 10, 5, 10, 5, "fe0e40b333333fc14ca23abfa8906ead43f3e53fcbb75c9de33333a5dba3093be85e92ec10b8e31bfab107faaaaaaaaaaafe01ecd7c7abfb00828a617f7a17672020def33333f3a6f686094f949854ac5d7bfabfbbd3ebdbe0ccccc489673d8e76276e7e3efa6894f949855b82c3eeeeefef6b0d71617a17251c421ce76276e7f79240d222224334947c7bfabfbbd621d21617a17250e037df33333f39dc1e2094f94984c8d757bfabfbbd4f23da0ccccc489a7220e76276e7ffe5f81fcf94be411a24346eeef1c2a95e12b7a17ad2473d0f1627647d7edeecfa2223f26bf85b6babfaedf792676f7a16cd0ce215dc33338fc1b8d91fcf949660af3576babfaecaa23ba02cccda19e835fc862769bc6b8959fcf949643335e33aeeeebcc7ed496f7a16cd1e8f7bc862769bdddd2544a2224d3f280276babfaed0f35196f7a16cd0c1206dc33338fa9b2159fcf94965f0a4076babfaed0288a23ecccdf8804743f1627647ff96148acf94ea504f71f46eeef1cba3ffb7f7a16fd5d0703ff6276af2e8ae24b2222337043a0f5fabf89cfed9fb457a17e500"],
+ ["ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", 0, 0, -1, 9, 4, "fefa22054563fc171897da59906ea1670caea4bb75aaa2a96955dba0409f9d722ec164d747525107faaaaaaaaaafe00f61f1c51500ce182bfea4b17bc23fee7a209ee1ab5d745d809de19044b177e82e7dcbdb689fdc36e202d05f74f6fc22a9256c36e508ad46e822c9fb975a36a4f22bcd51265bd05597ada373992435d23336190bea9e3bd79519b6f54a0d85381d5418ef3b3aaaebb6a79462b6f8a8f9ffd02afd451febc4bffc5a5845baa5456b8a2aa5f108831969d171ff408f9d74fda6e1c5c3525523afc4bec2efec8d1cacaf2b6a4e272f5d7f440cee6c1126744f6c63a02aa1d040e53327b762ec287fb540428bef431274b913527de2bc3d2f5217e9349350ab2b60cafbf83d337a05b582bfc64cdb8d77f24093f15372c0580f6cc91a6120f0bf83abfb006ab946a2846bf9f4d6b2d3ab305afe91575d12bacf5ffb4b0fc5d101418eba53ae83346256d34d0533df9c51416fed18a76a4a0000"],
+ ["9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999", 1, 0, -1, 13, 7, "fe4a90b14553917bfc135f70be759a0c106e9098f4f1e5b4f8bb745957b49ff75515dba6f193dfbe714eaec17daec7c471cf9107faaaaaaaaaaaaaafe0004fb471cb1d500096c7aff4ff382d8d01a1e4220b520004cd6584ebab973d811c739bd2391d5be53c71eca3364f4575555951702fba31c71c71473f1cc63d471d60d6aef33f7924dd9dddc78552fe3bd65b6db7e2c9e343b4f4bf94f5f77cf24dbd71d55525c03381ecc51f71c7947ffb665d1c71c75e57ea8e1e8757f77551168d100b40cfb6cb830b24b20f825924d7355a931b05c54444556264851514f1c73e507be1ffcc31e71cf94790046155546551015f34ad79834835c794603d57623d688354fc6abc7c95f5509154522bfd3ce3c73e51c37fa310a8ff1c358f88c78f8f64511444c502ac482bab24c71ab7719f481d1b641c51d6fe8478dff715ddf8f6858e3dc71cf9493ecffba5271c68d654fd94cd7ed555dddd45429018b7314dbe504c4edba9fcad9471076f95d4555fdd5777108966d7072871f614f2bcfa2f14f5c70c3bd4657cb7d9577f74c61eeb914264b6f9471fa470d38109248b1daab857c53044456d44ea2bdf1d61c71b7179c1f68c71c71c38f77b36eb56d55556131a7c65c98dd835872c227cdc021424929c4059a9681d09111515f2884d8c71d53e92c983ca3e31c69c73873960ef5057515564716bfe7d876d00d79c31007a8fc42b4f9c7dc9a54b51fff7ffddf980669839c714f9447bf81ee346bc71d66ab056ec6dd15459df15ba5c713ef95f3e5fc5d5ea8d416d7f7147ae83e8c6f5dbff745304340f621834d618efea45bbec71d60c7a00"],
+ ["byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data byte data ", 3, 0, -1, 21, 6, "fe363eb7c12901ec7db7eef3fc1287842a724204e3a17a94106ea71c78d0bb61bec46a57a4bb750aec25ba758b7775dff165dba10a7ff65c0f973cfe7976aec13dbdf1b583449597115a0907faaaaaaaaaaaaaaaaaaaaaafe004a6845543518319461702001b7fc73ff028fe9233f968f064c4fc7681d9654c2be57dddf135b744fed4c880e0ec5e85ae289bc721eb31a4e6952772379792a90b3399dbdf3502abbaacccae5e8365378fb678dd594d7cb65b9405d7936df2b9b91ed59e3ebff347b706ebed83f3133afd725ed9c814c3417165bbeb3f40436301d03b15dfe50453c7b8278fe3c5e40b3a2f1d8d8ccdaee000fd287c013e9d1381fa29af7c22aaf06665aab338a29e59caf5fd534b3d725c945d15f41b8e369010fde0170499a85ba293b937a90eb72547f8d7a179fb1ad03a19cc2d1176eb4bbd69d48a2bd2c0a79e6178f51ed1e373ebc9ef8e1cbf19fffdf85bfa51216e4755591b5bb4733911d4ac185ea26d0abb5eeb4de2ac544ea77144b046cf1b1594fc423f8406f8464feade9fc8c8bea2399b7149c590dfa98695a3143702736f6bd42f5b67bfb89bb82448aaa86e4a26a747d5774f36b6b4e7948028423d6a84ae4dc8eba4d2983277feaaba171db02635bb5523008c4f7cbdf3fca665997fcbeebba98dd3ef19f6f8b33a2d8897149ffa0ec5bc21a4af98a716e1ee7b871b3035b7fce9feb2bd4e14ac9f8f28842789570320bfc9931c71c5a8a9ea2dc24f6371196dbcc8d8db88eedda3ce43dd223a617833616c37b8b5e062cfda3ff26b2f703870d49903de4995f5f754d23e43972f2a2be3629eed5ef406235cde6f94c406ca89a1f9593ff5167f4245fb09fbecfbbf8fc550651adef4498d513c79c76abcd23aa9f62bc90eada81ea291d093c691771eb3147b1311d4fb4a87efb6cff262ff20e5fd12cdae3acee0d4cb735f1e5be26bf9066d5a5e37c4d50cd8f7f32861265b98abc0b39617a9463b7112ada65b26d0a20f4d6ae9477d07878a66707dde77d5fd3fa68567ac3696bdadfb81eeb8406e6b77d530386ca73897861e7d87bf7962e4a564f36aba2ce023f8c0e966090abe61c7347e98ebf9beb9372833f65ec4f7303a7f99197776e97bdf33116f5b75a0d8604590c86d3290669bab15479146bbb0b5357be7b405cd1dff3fa663f99d7c9cce5393587b28b4fab56917c17230432e75f5bd88c53e4730869fba82874f62f7ae6dc15fc755d7513f164efa222be3ca8fec5aff851f401c6b7c71b17ac72379190ab65e2a47e6a824febf6eba9b454d2919dcdc702171be71c54fe2033f9313fed94ff8e81ff72087ba55d28f768e77f3b32bf9fe1ef970899fd242ba299e90208ed5dab58a937ef127367540d96b9ff855417fc0d30d8cc8a890319fd8a962b89b4a37205c438c16b28c7f608262967dbe68440b2cef3a3eba8ed354777da1ba10532605d4f6117680e96848cf63cea1e7cb7725ba9531b13d7728bd4dee9ceb9ffb2be52ccb28d5c386abf034914734f7ea0963679500b45937da84c9a0b111b7cccfe776037d33321d9fd184756edea830fb67df6d450ef48c07ec255ab57851ce0c26a0d9708d6620e48db48c8eab6ad137c1cb432c3a95e95a9f40b46733e5c2ffeb6b3fbfcffb8064ba910f00c4806f1f755c73fb73c7afa872ba570ab85aaad042fac44f63d136e247a9791bbaa55dbfbd0af864fbefbf7fcdd57bb7aa26fef85bc61ce38b2e833baea3e454c11e81ec48f704c2fdcbc283b1c4d3b13b1fdfe388ff7aabd2534b9ffcb7b800"],
+ ["prektsmgqerpcriyjm!,!hkfankqtsrqopz!vmhnotiroq;r!?ekcipppppppppppppppppppppppp", 0, 0, -1, 4, 2, "fe729f3fc164b6106e9249ebb754fa75dba72ff2ec164a3507faaaaafe00921a00fba7cbd50eb94fb3d2b24a08ccb923dbf12c7f2dcfc096f91719a582f46bd23a6e1687d2c972294f925afa591bc69124fad67a7f3986e795c93e7b26aa5521d21dacaa8673fc00796f45ffba716bd04d03b1dba874af9dd795dc72ea26229d059008fcfe86409500"],
+ [",fveff,ysr!us?zzjcyozbjtpnh?aps;rigvz;okfkwirejqj!qa;;;;;;;;;;", 1, 0, -1, 4, 5, "fe1824bfc14272106ea8c56bb753ce85dba32112ec12c77107faaaaafe0192450082ce4b6706b2b8af21897b9c4a91734fd063629b1d8deae9855c6fa0bcdad356cd0ff6c2598868afb3c8fcecd52029c01ef4fb2fa48da6cca872faee207101f643ddefa690f80079ec47ff915b6b904c6311eba2ae2f85d1d8a27ee92dee37049e609cfeda53a500"],
+ ["197834199444734599047796", 1, 0, -1, 1, 6, "febbfc15506eb4bb74d5dbadaec10107faafe000009fb4bb0ceedba68fb853c8f3d600444ffa48d05cb8baa2fdd7e12e9667049fdfeec100"],
+ ["17300540039481501468148583841710577909639375835732099", 3, 0, -1, 3, 1, "fe10fbfc1451106ea93ebb7522d5dba88a2ec168f507faaaafe00da80027a04df14188fda3a7c7c9a0a4d6dfe5ec070e702f449aa2cdb820a4401a768b29aac52038cfc7d2235452eda7fbfe005eb463fb4caad0516915ba79efadd1af462eacc4370461642fe760bf80"],
+ ["156713373123807826012956072920985024575793509150", 3, 0, -1, 3, 3, "fe5283fc11ab106e8514bb74f845dba878aec1233d07faaaafe01ab200338c2682a46b59a5d21e1fd8f652bb72ad11da250b4a7fd592d78d3121572959824bc78862ec28ac7a055145e44fff0050c46ffa1d2bd0451310ba6f2fedd549712eaf160b0460c3cfe5b84400"],
+ ["97058709262", 0, 0, -1, 1, 0, "fe5bfc13906eb6bb74a5dba2aec10507faafe01b00eff6258213d8ba3981c4aab0a9004a97fbdcd057b3baf76dd019aeba2b05460fe8b480"],
+ ["34961507286771922", 3, 0, -1, 1, 3, "fe43fc13506e9cbb7485dba9aec12507faafe0160033de85ed18aa9c64638c4530f70070dffa5c104bacba29ddd451aea8a904687fe3c500"],
+ ["159255232356897556335229745653445256127057", 1, 0, -1, 2, 1, "febe3fc12d906eafebb74415dba722ec173d07faaafe009b00a30592f65033ddedf18c994bf32c7bd9ab359f7ac4384a494afa3cfb0044c53fb3aad047511ba66f95d039d2eac2df04cb42fefdd380"],
+ ["/-1.VZIP", 2, 0, -1, 1, 2, "fe9bfc11506e90bb7435dba82ec16507faafe002007f598ea8c92a972b09933fee48806faffbcd5057ecba8995d7432ead5905831fe75e00"],
+ ["qx?p!r!igvzrwoqv?ikkkkkkkkkkkkkk", 0, 0, -1, 2, 2, "fe14bfc140d06e9eabb757d5dba142ec142907faaafe00f500fbbfd51aca69afc17eadaf621dedfea48aa6aa9c965742b51b9a7efe806ac6bfa12a7043511badffadd6a762ea915b053569fede0780"],
+ ["-0", 0, 0, -1, 1, 2, "fe4bfc14906e90bb7525dba3aec17507faafe00700fbcd57454997ed2df203792d4a004f2ffb2c504bcabac925d5492ebd3905035fef4e00"],
+ ["v,qtunyigbqrjuio?b!kfeqlhyqbtayc,cbttttttt", 1, 0, -1, 3, 6, "fee443fc1761906eb546bb74d895dbac8b2ec1216107faaaafe002e4009fabd4bdc5cb8fb6fa79c90893e3bfe998a19a4ee3dfac1eee210541ddbed4080eab9e8732d35f57d8be195cb73dfa8064944bfb46eb90541f18bab61fd5d73c226e9f436704e16adfed90a800"],
+ ["xntf!iv,mrluxuvoxe;zys,k,rsyatpmhhjgr;ykennnnnnnnnnnn", 0, 0, -1, 3, 2, "fe12f3fc156c506e942cbb757a95dba16faec156b507faaaafe00a4000fbbe7d5160b7ded7aa41e998a66b266de592760a4dc43f35a31822662b9f3e582dea849de2c2fa1138271da9ade4ff805a6467fabcea904a2310babfcfa5d4a50b6ea3cbe505e337afe9fd3200"],
+ ["-*$5XFB2%88", 2, 0, -1, 1, 4, "fe03fc10106ebebb7435dbaeaec14507faafe00b004ad5a78476d68c67c2b2a3ec72806ce3f86c7049e2badfcdd373ee934905309fe33b80"],
+ ["850902690648268841680344282300334174509686325772", 3, 0, -1, 3, 5, "fecadbfc1369506eaff0bb7408c5dbaf462ec11fe107faaaafe01aef00066982ae27b71e35c82d6d03ae302d65fc0cf0b4b4710a16008d2e0dd7ebb9f3568d0e01f8f5264779b3084b31a6fc8054944bf9daeaf0556514ba35af8dd346432e942d9d042e54bfe0fe0d00"],
+ ["476546", 2, 0, -1, 1, 5, "fee3fc14106e82bb7435dba42ec10d07faafe00b0043941c606c5492ce48ee2ffdfe00700bfa32b04f47ba4ca5d1fa2e8f7705beafe49200"],
+ ["18814959338", 2, 0, -1, 1, 3, "fe1bfc16906ebabb74f5dba4aec10d07faafe00d007630374ab109fc1d092798e396806107f964b05c3dba678dd694aeb2d905883fe05100"],
+ ["gahjdgfqhu,;cweecqopj?fr;wehvpsqomenwqrxyezoe;kmyx;vyvqdee", 3, 0, -1, 6, 3, "fe45120dbfc1370302106e975d7ecbb74d9c3195dbad9b03b2ec1201d34907faaaaaaafe0163e20a0033cae5d7686c35b8e4fe5baaaf6209140ec41f2f38bd5597692780f22cfb3faa57a395e6d879c0f7aa53d1992a583ede83d94a22e7bb5f3192bf26a1a3ebfa6824f7bc4adc374fdeca2183391438e85205f01f0b657783a8cdc41c9170868f114679ac7b703a9362eb4b219b463115b6aafea9c24a7829fbf11bed6252bdcefb805aac84c4ffa346f36bd04c3f31512ba2116e4fa5d599f3c2ceeaf52c0a4b047c3d57dafe5b8e48b900"],
+ ["rlcekuvw?gmdkyn!tuemukewpdsxhwdc!nnnnnnnnnnnnn", 2, 0, -1, 4, 2, "fed2fb3fc107a5106e92cb6bb74c5875dbac6792ec14cb2507faaaaafe00193e007f655b98ba173cb266c612b6d5851580cb76ab2d86095dc93678970b2c28d5facde685918c708e3cb1d09a02815d00958cf2fbf638944030e91e7aee93d509e53efdaacb13fd805e6d47ffa8aaebd05ebdb1ebac962f95d4f4ea46ea191e89052bf81cfe4d5bd900"],
+ ["9413149993340443910325583", 1, 0, -1, 1, 3, "fedbfc16906e8ebb75d5dba52ec10d07faafe01300b77a5ae2f39f84955268f8a0a080693bfb75905c79ba6a7dd536aea3f104e33febcd00"]
+ ];
}
diff --git a/test/golden/fenix7/about.png b/test/golden/fenix7/about.png
index 3f74946..1e4799d 100644
--- a/test/golden/fenix7/about.png
+++ b/test/golden/fenix7/about.png
Binary files differ
diff --git a/test/golden/fenix7/code.png b/test/golden/fenix7/code.png
index d42686b..11d13d0 100644
--- a/test/golden/fenix7/code.png
+++ b/test/golden/fenix7/code.png
Binary files differ
diff --git a/test/golden/fenix7/code_qr.png b/test/golden/fenix7/code_qr.png
new file mode 100644
index 0000000..32ccc0a
--- /dev/null
+++ b/test/golden/fenix7/code_qr.png
Binary files differ
diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png
index b2f2548..70134e0 100644
--- a/test/golden/fr255s/about.png
+++ b/test/golden/fr255s/about.png
Binary files differ
diff --git a/test/golden/fr255s/code.png b/test/golden/fr255s/code.png
index fc6989a..7b112ec 100644
--- a/test/golden/fr255s/code.png
+++ b/test/golden/fr255s/code.png
Binary files differ
diff --git a/test/golden/fr255s/code_qr.png b/test/golden/fr255s/code_qr.png
new file mode 100644
index 0000000..96e1792
--- /dev/null
+++ b/test/golden/fr255s/code_qr.png
Binary files differ
diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png
index d7d18a0..8980df8 100644
--- a/test/golden/fr70/about.png
+++ b/test/golden/fr70/about.png
Binary files differ
diff --git a/test/golden/fr70/code.png b/test/golden/fr70/code.png
index 4353f5b..a0a2453 100644
--- a/test/golden/fr70/code.png
+++ b/test/golden/fr70/code.png
Binary files differ
diff --git a/test/golden/fr70/code_qr.png b/test/golden/fr70/code_qr.png
new file mode 100644
index 0000000..628b094
--- /dev/null
+++ b/test/golden/fr70/code_qr.png
Binary files differ
diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png
index 65e3902..8e7d950 100644
--- a/test/golden/fr965/about.png
+++ b/test/golden/fr965/about.png
Binary files differ
diff --git a/test/golden/fr965/code.png b/test/golden/fr965/code.png
index 35d817e..dca4ce3 100644
--- a/test/golden/fr965/code.png
+++ b/test/golden/fr965/code.png
Binary files differ
diff --git a/test/golden/fr965/code_qr.png b/test/golden/fr965/code_qr.png
new file mode 100644
index 0000000..97db8bd
--- /dev/null
+++ b/test/golden/fr965/code_qr.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png
index 75ad5de..89f3826 100644
--- a/test/golden/instinct3amoled45mm/about.png
+++ b/test/golden/instinct3amoled45mm/about.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/code.png b/test/golden/instinct3amoled45mm/code.png
index 6cabbac..f393183 100644
--- a/test/golden/instinct3amoled45mm/code.png
+++ b/test/golden/instinct3amoled45mm/code.png
Binary files differ
diff --git a/test/golden/instinct3amoled45mm/code_qr.png b/test/golden/instinct3amoled45mm/code_qr.png
new file mode 100644
index 0000000..a0e427a
--- /dev/null
+++ b/test/golden/instinct3amoled45mm/code_qr.png
Binary files differ
diff --git a/test/golden/venusq2/code.png b/test/golden/venusq2/code.png
index 42c16a7..8ef5016 100644
--- a/test/golden/venusq2/code.png
+++ b/test/golden/venusq2/code.png
Binary files differ
diff --git a/test/golden/venusq2/code_qr.png b/test/golden/venusq2/code_qr.png
new file mode 100644
index 0000000..63bc766
--- /dev/null
+++ b/test/golden/venusq2/code_qr.png
Binary files differ
diff --git a/test/golden/venux1/code.png b/test/golden/venux1/code.png
index bcfd63e..a80362d 100644
--- a/test/golden/venux1/code.png
+++ b/test/golden/venux1/code.png
Binary files differ
diff --git a/test/golden/venux1/code_qr.png b/test/golden/venux1/code_qr.png
new file mode 100644
index 0000000..8d34f4c
--- /dev/null
+++ b/test/golden/venux1/code_qr.png
Binary files differ
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: