From f455246fde086dda0cb61b92c84a5146a831a2bf Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 06:56:58 +0200 Subject: test(codes): compare QR with segno, decode QR dumps and the QR screenshot Co-Authored-By: Claude Opus 5.5 --- Makefile | 3 +- test/codes/CodeCacheTest.mc | 54 +++++++++++++ test/codes/QrTest.mc | 120 ++++++++++++++++++++++++++++ test/data/CodeVectors.mc | 36 +++++++++ test/golden/fenix7/about.png | Bin 4037 -> 4032 bytes test/golden/fenix7/code.png | Bin 3294 -> 3339 bytes test/golden/fenix7/code_qr.png | Bin 0 -> 3794 bytes test/golden/fr255s/about.png | Bin 2385 -> 2382 bytes test/golden/fr255s/code.png | Bin 2267 -> 2273 bytes test/golden/fr255s/code_qr.png | Bin 0 -> 2604 bytes test/golden/fr70/about.png | Bin 15606 -> 15572 bytes test/golden/fr70/code.png | Bin 13889 -> 13887 bytes test/golden/fr70/code_qr.png | Bin 0 -> 12968 bytes test/golden/fr965/about.png | Bin 15984 -> 16027 bytes test/golden/fr965/code.png | Bin 12507 -> 12467 bytes test/golden/fr965/code_qr.png | Bin 0 -> 11727 bytes test/golden/instinct3amoled45mm/about.png | Bin 13651 -> 13621 bytes test/golden/instinct3amoled45mm/code.png | Bin 11749 -> 11824 bytes test/golden/instinct3amoled45mm/code_qr.png | Bin 0 -> 12096 bytes test/golden/venusq2/code.png | Bin 3037 -> 3040 bytes test/golden/venusq2/code_qr.png | Bin 0 -> 3388 bytes test/golden/venux1/code.png | Bin 11473 -> 11468 bytes test/golden/venux1/code_qr.png | Bin 0 -> 10767 bytes tools/barcode_ref.py | 13 +++ tools/check_codes.py | 11 +++ tools/gen_code_vectors.py | 110 ++++++++++++++++++++++++- tools/gen_qr_tables.py | 75 +++++++++++++++++ tools/tests/test_barcode_tools.py | 22 +++++ tools/ui_flow.py | 18 ++++- 29 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 test/codes/CodeCacheTest.mc create mode 100644 test/codes/QrTest.mc create mode 100644 test/golden/fenix7/code_qr.png create mode 100644 test/golden/fr255s/code_qr.png create mode 100644 test/golden/fr70/code_qr.png create mode 100644 test/golden/fr965/code_qr.png create mode 100644 test/golden/instinct3amoled45mm/code_qr.png create mode 100644 test/golden/venusq2/code_qr.png create mode 100644 test/golden/venux1/code_qr.png create mode 100644 tools/gen_qr_tables.py 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 Binary files a/test/golden/fenix7/about.png and b/test/golden/fenix7/about.png differ diff --git a/test/golden/fenix7/code.png b/test/golden/fenix7/code.png index d42686b..11d13d0 100644 Binary files a/test/golden/fenix7/code.png and b/test/golden/fenix7/code.png differ diff --git a/test/golden/fenix7/code_qr.png b/test/golden/fenix7/code_qr.png new file mode 100644 index 0000000..32ccc0a Binary files /dev/null and b/test/golden/fenix7/code_qr.png differ diff --git a/test/golden/fr255s/about.png b/test/golden/fr255s/about.png index b2f2548..70134e0 100644 Binary files a/test/golden/fr255s/about.png and b/test/golden/fr255s/about.png differ diff --git a/test/golden/fr255s/code.png b/test/golden/fr255s/code.png index fc6989a..7b112ec 100644 Binary files a/test/golden/fr255s/code.png and b/test/golden/fr255s/code.png differ diff --git a/test/golden/fr255s/code_qr.png b/test/golden/fr255s/code_qr.png new file mode 100644 index 0000000..96e1792 Binary files /dev/null and b/test/golden/fr255s/code_qr.png differ diff --git a/test/golden/fr70/about.png b/test/golden/fr70/about.png index d7d18a0..8980df8 100644 Binary files a/test/golden/fr70/about.png and b/test/golden/fr70/about.png differ diff --git a/test/golden/fr70/code.png b/test/golden/fr70/code.png index 4353f5b..a0a2453 100644 Binary files a/test/golden/fr70/code.png and b/test/golden/fr70/code.png differ diff --git a/test/golden/fr70/code_qr.png b/test/golden/fr70/code_qr.png new file mode 100644 index 0000000..628b094 Binary files /dev/null and b/test/golden/fr70/code_qr.png differ diff --git a/test/golden/fr965/about.png b/test/golden/fr965/about.png index 65e3902..8e7d950 100644 Binary files a/test/golden/fr965/about.png and b/test/golden/fr965/about.png differ diff --git a/test/golden/fr965/code.png b/test/golden/fr965/code.png index 35d817e..dca4ce3 100644 Binary files a/test/golden/fr965/code.png and b/test/golden/fr965/code.png differ diff --git a/test/golden/fr965/code_qr.png b/test/golden/fr965/code_qr.png new file mode 100644 index 0000000..97db8bd Binary files /dev/null and b/test/golden/fr965/code_qr.png differ diff --git a/test/golden/instinct3amoled45mm/about.png b/test/golden/instinct3amoled45mm/about.png index 75ad5de..89f3826 100644 Binary files a/test/golden/instinct3amoled45mm/about.png and b/test/golden/instinct3amoled45mm/about.png differ diff --git a/test/golden/instinct3amoled45mm/code.png b/test/golden/instinct3amoled45mm/code.png index 6cabbac..f393183 100644 Binary files a/test/golden/instinct3amoled45mm/code.png and b/test/golden/instinct3amoled45mm/code.png differ diff --git a/test/golden/instinct3amoled45mm/code_qr.png b/test/golden/instinct3amoled45mm/code_qr.png new file mode 100644 index 0000000..a0e427a Binary files /dev/null and b/test/golden/instinct3amoled45mm/code_qr.png differ diff --git a/test/golden/venusq2/code.png b/test/golden/venusq2/code.png index 42c16a7..8ef5016 100644 Binary files a/test/golden/venusq2/code.png and b/test/golden/venusq2/code.png differ diff --git a/test/golden/venusq2/code_qr.png b/test/golden/venusq2/code_qr.png new file mode 100644 index 0000000..63bc766 Binary files /dev/null and b/test/golden/venusq2/code_qr.png differ diff --git a/test/golden/venux1/code.png b/test/golden/venux1/code.png index bcfd63e..a80362d 100644 Binary files a/test/golden/venux1/code.png and b/test/golden/venux1/code.png differ diff --git a/test/golden/venux1/code_qr.png b/test/golden/venux1/code_qr.png new file mode 100644 index 0000000..8d34f4c Binary files /dev/null and b/test/golden/venux1/code_qr.png 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: -- cgit v1.2.3