From 7b9767d326007f1f04f57ed7df1e1aed5fdbc1bf Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 17:43:38 +0200 Subject: [PATCH 001/132] fix: reset all solve globals --- src/state.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/state.rs b/src/state.rs index cc09981..eaec332 100644 --- a/src/state.rs +++ b/src/state.rs @@ -365,6 +365,11 @@ impl SignaledGlobalStateInner { pub async fn reset_solve_state(&mut self, save_nvs: Option<&Nvs>) { unsafe { GROUP_LIMIT = None; + CURRENT_TIME = None; + INSPECTION_TIME = None; + CURRENT_GROUP_ID = None; + CURRENT_COMPETITOR = None; + CURRENT_SESSION_ID = None; } self.solve_time = None; self.penalty = None; From 37b4fd158d167f1cd24c001ea30f06ff40d86be0 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 17:43:38 +0200 Subject: [PATCH 002/132] chore: update to esp-hal 1.2.2 --- Cargo.lock | 2063 ++++++++++++++++++++++++++++++--------------- Cargo.toml | 55 +- macros/Cargo.toml | 14 +- src/bluetooth.rs | 23 +- src/board.rs | 8 +- src/lcd_v4.rs | 29 +- src/main.rs | 4 +- src/rfid.rs | 10 +- src/ws.rs | 40 +- 9 files changed, 1482 insertions(+), 764 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a5b84e..d6e165d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,12 +20,12 @@ dependencies = [ [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array 0.14.7", + "crypto-common 0.2.2", + "inout 0.2.3", ] [[package]] @@ -35,22 +35,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] name = "aes-gcm" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.9.3", + "cipher 0.5.2", "ctr", + "ctutils", "ghash", - "subtle", ] [[package]] @@ -88,9 +99,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -101,7 +112,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ - "as-slice 0.2.1", + "as-slice", ] [[package]] @@ -127,15 +138,15 @@ checksum = "c583acf993cf4245c4acb0a2cc2ab1f9cc097de73411bb6d3647ff6af2b1013d" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" [[package]] name = "arg_enum_proc_macro" @@ -145,7 +156,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -156,21 +167,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.1.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" -dependencies = [ - "generic-array 0.12.4", - "generic-array 0.13.3", - "generic-array 0.14.7", - "stable_deref_trait", -] +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-slice" @@ -205,7 +204,7 @@ dependencies = [ "proc-macro2", "quote", "rustc-hash", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -280,10 +279,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "base64" -version = "0.13.1" +name = "base16ct" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64-simd" @@ -304,6 +303,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit_field" version = "0.10.3" @@ -312,22 +317,33 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitfield" -version = "0.19.4" +version = "0.19.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +checksum = "b45721c9db4c7a20899d05efb7ad9235f50b256e980db30ffb229abf732934c3" dependencies = [ "bitfield-macros", ] [[package]] name = "bitfield-macros" -version = "0.19.4" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cb6f3d4773a2107b94cbeccaa5b5f0b35a88389b5d522d13d659f64317b22d" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bitfield-struct" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +checksum = "8769c4854c5ada2852ddf6fd09d15cf43d4c2aaeccb4de6432f5402f08a6003b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -338,9 +354,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitstream-io" @@ -353,9 +369,9 @@ dependencies = [ [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -369,30 +385,40 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", ] [[package]] name = "bq27441" -version = "0.1.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "401a2308f1e83ceaaa9345f409759ac6e891259a4d909da8fb7332321ae428a4" +checksum = "3a233f95da3ce6692c34820c6b6f798212b005399fa35fab34dbd73a8f02fafd" dependencies = [ - "defmt 0.3.100", + "defmt 0.3.101", "device-driver", "device-driver-generation", + "embedded-batteries-async", "embedded-hal 1.0.0", "embedded-hal-async", "prettyplease", "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "bt-hci" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211713d2e9fb4793ce4360a712c0764264aff6be48932ccf02ca2a331c0436a9" +checksum = "8f3c66fd0a41a3b3a5906847b1bf5516048ce083e24fa9341a0fa31d3b4cd73d" dependencies = [ "btuuid", "embassy-sync 0.8.0", @@ -402,6 +428,16 @@ dependencies = [ "heapless 0.9.3", ] +[[package]] +name = "bt-hci-transport" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffb9819c69cf88877f5eebc8c5ade0ab50f2bf9475bbb3eb714d66c55b0cb64" +dependencies = [ + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", +] + [[package]] name = "btuuid" version = "0.1.1" @@ -450,9 +486,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -468,9 +504,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -483,9 +519,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -495,9 +531,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cipher" @@ -505,8 +541,19 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.3", ] [[package]] @@ -515,11 +562,17 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ - "cipher", + "cipher 0.4.4", "dbl", - "digest", + "digest 0.10.7", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -562,24 +615,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "const-str" -version = "0.3.2" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3" -dependencies = [ - "const-str-proc-macro", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "const-str-proc-macro" -version = "0.3.2" +name = "const-str" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" [[package]] name = "convert_case" @@ -601,29 +646,41 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" dependencies = [ "unicode-segmentation", ] [[package]] name = "cordyceps" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" dependencies = [ "loom", "tracing", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cow-utils" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -633,11 +690,20 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -650,9 +716,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -660,18 +726,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -685,61 +751,96 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.7", + "generic-array", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array 0.14.7", + "generic-array", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "cssparser" -version = "0.33.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.13.1", "smallvec", ] [[package]] name = "cssparser-color" -version = "0.1.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f" +checksum = "bbaa233e1dcd9c13a5d3e3a8a2c0f5a727bac380398345dbcb31db4597edc86b" dependencies = [ "cssparser", ] [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "cipher", + "cmov", + "subtle", ] [[package]] @@ -764,12 +865,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -783,7 +884,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -796,20 +897,20 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -820,7 +921,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -831,18 +932,18 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -860,9 +961,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-url" @@ -879,7 +980,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" dependencies = [ - "generic-array 0.14.7", + "generic-array", ] [[package]] @@ -895,18 +996,18 @@ dependencies = [ [[package]] name = "defmt" -version = "0.3.100" +version = "0.3.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53" dependencies = [ - "defmt 1.1.0", + "defmt 1.2.0", ] [[package]] name = "defmt" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -914,22 +1015,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror", ] @@ -942,7 +1042,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -951,17 +1051,40 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "der_derive", + "heapless 0.9.3", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "device-driver" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2e4547bd66511372d2a38ac3c1b2892c7ebf83cf0d5411c3406e496c85a1d96" dependencies = [ - "defmt 0.3.100", + "defmt 0.3.101", "device-driver-macros", "embedded-io 0.6.1", "embedded-io-async 0.6.1", @@ -982,7 +1105,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -993,7 +1116,7 @@ checksum = "176dbf719ece32afb641bf50d2d1881411ad1677aec6c8bf42011a7c279d97ff" dependencies = [ "device-driver-generation", "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1002,12 +1125,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "display-interface" version = "0.5.0" @@ -1025,6 +1160,17 @@ dependencies = [ "embedded-hal-async", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "dns-protocol" version = "0.1.2" @@ -1051,7 +1197,7 @@ checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1098,40 +1244,41 @@ checksum = "47fac87554ece38a037e0d53e750ee5129670b6f97e712bb0c411268181f3700" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", - "digest", - "elliptic-curve", + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", "rfc6979", - "signature", + "signature 3.0.0", + "zeroize", ] [[package]] name = "edge-dhcp" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e0b32c831ced877a78378312fe0b6f7cdd5759f3ba272578f582ff9bba5291d" +checksum = "f7a6f957c944e22c2c1904618863702e8af302953454ac738e6df0c7038c4d88" dependencies = [ "edge-raw", "heapless 0.9.3", "num_enum", - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] name = "edge-raw" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "466dfce9c2172a4e947b81b556f1f07a86029fbac679e323cfb66c738cc2faea" +checksum = "c87c23c2a56d22076013bbf039425369d05bdb9ac833e3249c48008a46e1f95c" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "elliptic-curve" @@ -1139,19 +1286,65 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array 0.14.7", - "group", - "hkdf", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "hkdf 0.12.4", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] +[[package]] +name = "embassy-crypto" +version = "0.1.0" +source = "git+https://github.com/embassy-rs/embassy?rev=822a76ea91f2c0885c6eb0c65fe91181e31133e1#822a76ea91f2c0885c6eb0c65fe91181e31133e1" +dependencies = [ + "unitrait", +] + +[[package]] +name = "embassy-crypto-rustcrypto" +version = "0.1.0" +source = "git+https://github.com/embassy-rs/embassy?rev=822a76ea91f2c0885c6eb0c65fe91181e31133e1#822a76ea91f2c0885c6eb0c65fe91181e31133e1" +dependencies = [ + "aead", + "aes 0.9.3", + "aes-gcm", + "cipher 0.5.2", + "digest 0.11.3", + "ecdsa", + "elliptic-curve 0.14.1", + "embassy-crypto", + "hmac 0.13.0", + "p256 0.14.0", + "sha2 0.11.1", +] + [[package]] name = "embassy-embedded-hal" version = "0.6.0" @@ -1191,7 +1384,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1315,6 +1508,31 @@ dependencies = [ "heapless 0.9.3", ] +[[package]] +name = "embedded-batteries" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40f975432b4e146342a1589c563cffab6b7a692024cb511bf87b6bfe78c84125" +dependencies = [ + "bitfield-struct", + "bitflags 2.13.2", + "defmt 0.3.101", + "embedded-hal 1.0.0", + "zerocopy", +] + +[[package]] +name = "embedded-batteries-async" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3bf0e4be67770cfc31f1cea8b73baf98c0baf2c57d6bd8c3a4c315acb1d8bd4" +dependencies = [ + "bitfield-struct", + "defmt 0.3.101", + "embedded-batteries", + "embedded-hal 1.0.0", +] + [[package]] name = "embedded-can" version = "0.4.1" @@ -1414,7 +1632,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" dependencies = [ - "defmt 0.3.100", + "defmt 0.3.101", ] [[package]] @@ -1423,15 +1641,6 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" -[[package]] -name = "embedded-io-adapters" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900a1c087f1f7d17cdc84a1290df91521cd90933efa76d68e568385d889f2f4" -dependencies = [ - "embedded-io 0.7.1", -] - [[package]] name = "embedded-io-async" version = "0.6.1" @@ -1468,7 +1677,7 @@ checksum = "4f6e621fe4c7e05b695274b722dc0a60bacd1c8696b58191baa0154713d52400" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1492,15 +1701,15 @@ dependencies = [ [[package]] name = "embedded-storage" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" +checksum = "c815b3ed4213d85d6cfd274b871f430c0681084e28dfd4a537877f47f844ec83" [[package]] name = "embedded-storage-async" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +checksum = "69aa7787b8ba0cf4e626c95839f811d853b75e24a0f7312a1eef324fedc13b21" dependencies = [ "embedded-storage", ] @@ -1519,42 +1728,37 @@ dependencies = [ [[package]] name = "embedded-tls" version = "0.19.0" -source = "git+https://github.com/filipton/embedded-tls#89e55935d088f323162360f2f4ae61b1b5c75a1e" +source = "git+https://github.com/embassy-rs/embedded-tls?rev=58126ee5c453147a5e00684075f001d7ef962ca3#58126ee5c453147a5e00684075f001d7ef962ca3" dependencies = [ - "aes-gcm", - "digest", - "ecdsa", + "der 0.8.2", + "embassy-crypto", "embedded-io 0.7.1", - "embedded-io-adapters", "embedded-io-async 0.7.0", - "generic-array 0.14.7", - "heapless 0.6.1", "heapless 0.9.3", - "hkdf", - "hmac", "log", - "p256", "portable-atomic", - "rand_core 0.6.4", - "sha2", - "signature", - "typenum", + "rsa", ] [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] name = "enumset" -version = "1.1.13" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" dependencies = [ "enumset_derive", ] @@ -1568,7 +1772,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1588,26 +1792,32 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "escape-simd" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "120339709b24832c07464b0838828361b735d793c409b8ca3147db0dae601542" [[package]] name = "esp-alloc" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46ced060d4085858283df950b80a4da2348e1707d7d07b1e966308582dae79f5" +checksum = "f73e8d355019c740f6a5a78429633040617d28a378c271464caadc52c78216e3" dependencies = [ "allocator-api2 0.3.1", - "cfg-if", "document-features", "enumset", "esp-config", + "esp-metadata-generated", "esp-sync", "linked_list_allocator", "rlsf", @@ -1615,11 +1825,10 @@ dependencies = [ [[package]] name = "esp-backtrace" -version = "0.19.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37950e24b2dfd98f1581102d1798281d4d9547af881e6bffc2c2b534c026ec8f" +checksum = "4fbfed00e884572d620a6958b13456fc2bb8ab75802b5adc8afc224f97cdebad" dependencies = [ - "cfg-if", "document-features", "esp-config", "esp-metadata-generated", @@ -1631,27 +1840,27 @@ dependencies = [ [[package]] name = "esp-bootloader-esp-idf" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35ffc117c3a9859835d89d0e90f5ee9886ce2264a71a849a7a22ab5308f6653c" +checksum = "ba0e487f6843419bb085d871f6e860da1bb8c267ce1c5db1051b83882e943054" dependencies = [ - "cfg-if", "document-features", - "embedded-storage", "esp-config", "esp-hal-procmacros", "esp-metadata-generated", "esp-rom-sys", + "esp-storage", "jiff", "log", - "strum 0.27.2", + "sha2 0.11.1", + "strum", ] [[package]] name = "esp-config" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9b92fd9cfb0b4f8f1b6219b9763269a335571e307b014903b8201619374b80" +checksum = "fcd577a122ec917691f6820a3df54388ecebc750f071b060f5e83707bc415512" dependencies = [ "document-features", "esp-metadata-generated", @@ -1662,17 +1871,16 @@ dependencies = [ [[package]] name = "esp-hal" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bfcf2a0842903717f4663f6a08512c32b0f6b2d7fb7db3c8a6895d2e6d49f72" +checksum = "c9e3dfd511fed638fe41fdb4595f8c7f0cbb488d0f0cf2bbc063d76d805057cf" dependencies = [ "bitfield", - "bitflags 2.13.0", "bytemuck", - "cfg-if", "critical-section", "delegate", - "digest", + "digest 0.10.7", + "digest 0.11.3", "document-features", "embassy-embedded-hal", "embassy-futures", @@ -1693,11 +1901,15 @@ dependencies = [ "esp-sync", "esp32", "esp32c2", - "esp32c3", + "esp32c3 0.33.0", + "esp32c5", "esp32c6", + "esp32c61", "esp32h2", + "esp32p4", "esp32s2", "esp32s3", + "esp32s31", "fugit", "instability", "nb 1.1.0", @@ -1707,7 +1919,9 @@ dependencies = [ "rand_core 0.6.4", "rand_core 0.9.5", "riscv", - "strum 0.27.2", + "somni-template", + "static_cell", + "strum", "ufmt-write", "xtensa-lx", "xtensa-lx-rt", @@ -1715,9 +1929,9 @@ dependencies = [ [[package]] name = "esp-hal-dhcp-server" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15dbfc886c70ad3f81ea6eaec936e41b95fff21279620371715f5635df85f5ff" +checksum = "cced67bda05f7109af8dba0d83c2625734a60c86319740b8bebdf83b1e2d20f1" dependencies = [ "edge-dhcp", "embassy-futures", @@ -1730,9 +1944,9 @@ dependencies = [ [[package]] name = "esp-hal-mdns" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2e7417c98ac86e107c76488f752a8872e04628a5981bd214bdce7c5268e9a94" +checksum = "1050471a978efb28602d6f3272398083195c2fc0a4105a8c9d50a53ad256a92d" dependencies = [ "dns-protocol", "heapless 0.9.3", @@ -1741,9 +1955,9 @@ dependencies = [ [[package]] name = "esp-hal-mfrc522" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60cb9b21a888cca9d719cde04541e6f8a0a383097e2986cf2ca99931e6f136d1" +checksum = "ab66d38140daa0dd2ff6b9768929679b7d355bc33cc847e6a650157ed9e49a1e" dependencies = [ "embassy-time", "embedded-hal-async", @@ -1753,38 +1967,40 @@ dependencies = [ [[package]] name = "esp-hal-ota" -version = "0.5.7" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b4ced6d9984fd29c88b586286634f44fa0d96b4ee5f4d68facdfb643336533" +checksum = "fddbb7a4963e2151368db46b3e736a6e6ae692a1dad9de1663adba0e6c78219e" dependencies = [ "embedded-storage", - "esp32c3", + "esp-rom-sys", + "esp32c3 0.33.0", "log", ] [[package]] name = "esp-hal-procmacros" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aebfabb2c21bec45e575e4f6cb6bb7aa8e1b33e7ac45b5dffa0f9d33ff59105" +checksum = "1a8733fdeadc349ba1e4a345c7bfb74d195d8c98fd3be9d2c15bfd78601cbc44" dependencies = [ "document-features", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "termcolor", ] [[package]] name = "esp-hal-wifimanager" -version = "0.3.0" -source = "git+https://github.com/filipton/esp-hal-wifimanager.git#9f92a342dec4903d2f4b97a182b58fe59ef4bdae" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a700c9cfd05e173ef91f3c5855323cfa71533d0ee88e44820cff8498278f6dc6" dependencies = [ "embassy-executor", "embassy-futures", "embassy-net", - "embassy-sync 0.7.2", + "embassy-sync 0.8.0", "embassy-time", "embedded-io-async 0.7.0", "embedded-storage", @@ -1795,12 +2011,10 @@ dependencies = [ "esp-radio", "esp-rtos", "esp-storage", - "getrandom 0.2.15", "heapless 0.9.3", "include-minifier", "log", "portable-atomic", - "rand_core 0.6.4", "serde", "serde_json", "static_cell", @@ -1809,29 +2023,27 @@ dependencies = [ [[package]] name = "esp-metadata-generated" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42c2ee95b945a4780796e4359e72c033aed3b45073880e8029458f538532db8a" +checksum = "911aadae41ec99104efa865bd0b6e486b4b735999f9b571963748a46d16495ab" [[package]] name = "esp-nvs" -version = "0.4.0" -source = "git+https://github.com/filipton/esp-nvs.git#b854077862bc2e666d386c7b98bf5a17d897e28a" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe94ff32323389591a43f90589698586d7375322d8d4e3ba3bf9efc54b3f6b76" dependencies = [ "embedded-storage", - "esp-hal", - "esp-storage", - "strum 0.28.0", + "strum", "thiserror", ] [[package]] name = "esp-phy" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c0a29815cd105ae1a02f3d0c6e7aafda9504a41effae17fac4c3f827719228" +checksum = "08a925ab42e72eaaa77167352bab7c2bd528ea962a0eb40501324f6db594f88c" dependencies = [ - "cfg-if", "document-features", "embassy-sync 0.8.0", "esp-config", @@ -1839,14 +2051,14 @@ dependencies = [ "esp-metadata-generated", "esp-sync", "esp-wifi-sys-esp32c3", - "esp32c3", + "esp32c3 0.34.0", ] [[package]] name = "esp-println" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42dee1e9ac7c3539bf6464db1707b0edd7557168f98278cf3c84fe70e63c6ce6" +checksum = "ebf772a41bc642b57399632aa06ab55391de35b2b8fcbb1b270d282fbd0fe5ff" dependencies = [ "document-features", "esp-metadata-generated", @@ -1857,13 +2069,13 @@ dependencies = [ [[package]] name = "esp-radio" -version = "1.0.0-beta.0" +version = "1.0.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f25cc4e3ce27476b42c4a68943f10a92f9dec3c24bb001269958f0318fef02c" +checksum = "50247f4e91000fc87661e77254126c74acc4535f822ee247f7778807202dc938" dependencies = [ "allocator-api2 0.3.1", "bt-hci", - "cfg-if", + "bt-hci-transport", "docsplay", "document-features", "embassy-net-driver", @@ -1883,31 +2095,31 @@ dependencies = [ "esp-rom-sys", "esp-sync", "esp-wifi-sys-esp32c3", - "esp32c3", + "esp32c3 0.34.0", "heapless 0.9.3", "instability", "num-derive", "num-traits", "portable-atomic", "portable_atomic_enum", + "xarxa-driver", ] [[package]] name = "esp-radio-rtos-driver" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd75cd9073a90ffaa53db0bf17df7dc14164f2407a6ff36c725d2d1f78ff494" +checksum = "27d3ad9d7ed96260566acd1a69a908ff805cfaa73a936ae445c1252e1ec983be" dependencies = [ - "cfg-if", "esp-sync", "portable-atomic", ] [[package]] name = "esp-riscv-rt" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a814ae91452de56a5e74f69aebfee40579511756837d3774a56fd24cf0ab79" +checksum = "626c6b1477224c46fb27919d53ac5bf4ef6f7f70344ee26a720373761aaef531" dependencies = [ "document-features", "riscv", @@ -1916,29 +2128,29 @@ dependencies = [ [[package]] name = "esp-rom-sys" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae852ccb08971155023d1371c96d5490cbc26860f06aee2d629ef73f1a890c3" +checksum = "e933ff78374b763b65998b64ad671b4890ed4ba13a80deff9f53afe95ede289d" dependencies = [ - "cfg-if", "document-features", "esp-metadata-generated", - "esp32c3", + "esp32c3 0.33.0", ] [[package]] name = "esp-rtos" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f90766e1527edaa0c91e8d559e9e2a60397b545e93357ac61fb31845e5712" +checksum = "2f8e99f512a839c78a77c2e55e1f0d208851cfa48935847d5e0b5e8b2c5a6289" dependencies = [ "allocator-api2 0.3.1", - "cfg-if", + "defmt 1.2.0", "document-features", "embassy-executor", "embassy-sync 0.8.0", "embassy-time-driver", "embassy-time-queue-utils", + "esp-alloc", "esp-config", "esp-hal", "esp-hal-procmacros", @@ -1953,9 +2165,9 @@ dependencies = [ [[package]] name = "esp-storage" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf2b3f00c9f94b27c62a4f5296b19470c3a083c687c8146e554a459f9bee596" +checksum = "6ef0e785f54e316f64a1e14a7a2f061a2ea0b038b284902ca71e1e1f5574cf47" dependencies = [ "document-features", "embedded-storage", @@ -1964,15 +2176,15 @@ dependencies = [ "esp-metadata-generated", "esp-rom-sys", "esp-sync", + "esp32c3 0.33.0", ] [[package]] name = "esp-sync" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4736bfbbb9e3f6353344e14fc61b6d18d3b877c3286914cf8c0a037be0ed224" +checksum = "064fd57eed12b5cbb669b3935e1f647fd6945b89938116d61153fb45fd703422" dependencies = [ - "cfg-if", "document-features", "embassy-sync 0.6.2", "embassy-sync 0.7.2", @@ -1984,83 +2196,132 @@ dependencies = [ [[package]] name = "esp-wifi-sys-esp32c3" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b290846b53db9a3965866964260220b67f2c41cc2dbc0b377e7136239fe168a7" +checksum = "c1345555a7856033077e8ca26572e4739a1b43f8d52daaa0fdf6a1497fe88845" [[package]] name = "esp32" -version = "0.40.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5726e07689249d1a2cb7c492077bc424837fb68a64f7eb5d46569325352e9428" +checksum = "c2f9714c79477940e5c7848b3832af89cd3bcd79abf4067e6938ae90de187a45" dependencies = [ "vcell", ] [[package]] name = "esp32c2" -version = "0.29.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef0b623533bbaa37e348c18b6b41cfd5b47c3cb64a4b9e44f0295941d62aa2e" +checksum = "496afd4a776ec7c96c90fb665894895a1a9f8a7fd5a9d75779fa09d63f9d40a1" dependencies = [ "vcell", ] [[package]] name = "esp32c3" -version = "0.32.2" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e89ed62cf6c043a6d29c520b02a13b359ec8a75d67b65d4330ed717d15fe97" +checksum = "ffb892f670ff857033c1624f99ecba1a3f3cafa05c45d734bc129388276715cf" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c3" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569e03d871eac998bc62ef95a9a6d31a91fc0aaef0e0017a6b5a6a9c4759768a" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c5" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97afa80d8110c7234f840252baa8ee8de28f78e8dc233d2d430c0ccfbe59a2b5" dependencies = [ "vcell", ] [[package]] name = "esp32c6" -version = "0.23.2" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207a5d6886ca89b4f220606dcf3779ca869803df192508314939186ae8de1ee" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32c61" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c58f34ff2633968c12125efc7f4f8f101078d5d34c7cb60eab82268db20986f9" +checksum = "f8690ba8b6292db301db513bb9b24263499c9a1ecd22b263dd4210ef12d47fe1" dependencies = [ "vcell", ] [[package]] name = "esp32h2" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5bab026020ed4606ce113b6fde598dbc48f7eefcc46e9469ece77cc2b1aa4be" +checksum = "083a75597f85acaabc0b05998f22455d78c33c4d23ff5f12df2b9590e46f571b" dependencies = [ "vcell", ] +[[package]] +name = "esp32p4" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4b1a8cb0d4831d710437f72a73246682919c16ef37a99788f1e26d9f49fe20" +dependencies = [ + "critical-section", + "vcell", +] + [[package]] name = "esp32s2" -version = "0.31.2" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0ad6f21cdf6ec7b06b7f7e0fbe51f0d975fd6a5fa67c3f8a5a910d3981af531" +checksum = "dbeb62af2f2ca94a85be9ab22f8ad97c23ba79b9b3d3e4e20f3e0957bc5f2b2d" dependencies = [ "vcell", ] [[package]] name = "esp32s3" -version = "0.35.2" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08eb1f516ba72b7eb272cdd88cd74899552292a294b831b2143a3e60f1666a" +dependencies = [ + "vcell", +] + +[[package]] +name = "esp32s31" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4b8c4e4d9f187553ecdb7173edec7b2deb2beea106eedefecdb1654b8ee25a" +checksum = "d8b4f101d37c233fe88b2b5e6d27fedab934a52f804e34f52b4b9d4d5ba2ea63" dependencies = [ + "critical-section", "vcell", ] [[package]] name = "exr" -version = "1.74.0" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", "rayon-core", "smallvec", "zune-inflate", @@ -2068,9 +2329,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -2097,11 +2358,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fkm-firmware" @@ -2116,10 +2387,12 @@ dependencies = [ "display-interface-i2c", "dotenvy", "dyn-smooth", + "embassy-crypto", + "embassy-crypto-rustcrypto", "embassy-executor", "embassy-futures", "embassy-net", - "embassy-sync 0.7.2", + "embassy-sync 0.8.0", "embassy-time", "embedded-graphics", "embedded-graphics-framebuf", @@ -2143,7 +2416,7 @@ dependencies = [ "esp-radio", "esp-rtos", "esp-storage", - "esp32c3", + "esp32c3 0.33.0", "getrandom 0.2.15", "heapless 0.9.3", "log", @@ -2153,9 +2426,9 @@ dependencies = [ "portable-atomic", "profont", "qrcodegen-no-heap", - "rand_core 0.6.4", "serde", "serde_json", + "spin", "static_cell", "trouble-host", "uuid", @@ -2164,12 +2437,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2187,11 +2461,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fugit" -version = "0.3.9" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" +checksum = "e88e2d6927cbdbbbdc85fc550cee539e63ae0f76445f13094833f63bb65b62f5" dependencies = [ "gcd", ] @@ -2204,9 +2484,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2218,9 +2498,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2228,9 +2508,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-intrusive" @@ -2244,27 +2524,27 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-sink", @@ -2280,9 +2560,9 @@ checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" [[package]] name = "generator" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" dependencies = [ "cc", "cfg-if", @@ -2293,24 +2573,6 @@ dependencies = [ "windows-result", ] -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - -[[package]] -name = "generic-array" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" -dependencies = [ - "typenum", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2341,17 +2603,27 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "ghash" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "opaque-debug", "polyval", ] @@ -2371,11 +2643,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -2387,15 +2670,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hash32" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" -dependencies = [ - "byteorder", -] - [[package]] name = "hash32" version = "0.3.1" @@ -2447,25 +2721,13 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heapless" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" -dependencies = [ - "as-slice 0.1.5", - "generic-array 0.14.7", - "hash32 0.1.1", - "stable_deref_trait", -] - [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32 0.3.1", + "hash32", "stable_deref_trait", ] @@ -2475,7 +2737,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" dependencies = [ - "hash32 0.3.1", + "hash32", "stable_deref_trait", ] @@ -2491,7 +2753,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2500,7 +2771,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2509,6 +2789,104 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "ident_case" version = "1.0.1" @@ -2551,9 +2929,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "include-minifier" @@ -2565,14 +2943,14 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2582,9 +2960,9 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" dependencies = [ "rustversion", ] @@ -2595,20 +2973,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array 0.14.7", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c0d789294fb457b791f7797a04478704788f30b407e5d0b01deaa846572d9f" +dependencies = [ + "hybrid-array", ] [[package]] name = "instability" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -2619,7 +3006,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2657,10 +3044,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ + "defmt 1.2.0", + "jiff-core", "jiff-static", "log", "portable-atomic", @@ -2668,38 +3057,55 @@ dependencies = [ "serde_core", ] +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt 1.2.0", + "log", +] + [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "json-escape-simd" -version = "3.0.2" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" +checksum = "6520172708e1b08b95f31bbf76cec064db4203a615cf2fb4e1128d6dd142b033" +dependencies = [ + "escape-simd", +] [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "lebe" @@ -2709,9 +3115,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libfuzzer-sys" @@ -2723,14 +3129,20 @@ dependencies = [ "cc", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "lightningcss" -version = "1.0.0-alpha.71" +version = "1.0.0-alpha.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937" +checksum = "d31b760f96e8fdfe1d0c295e4bf76c503d6f15d2d470d53bd8cd1f7aa8c7d934" dependencies = [ "ahash 0.8.12", - "bitflags 2.13.0", + "bitflags 2.13.2", "const-str", "cssparser", "cssparser-color", @@ -2769,11 +3181,17 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2786,9 +3204,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -2817,13 +3235,13 @@ name = "macros" version = "0.1.0" dependencies = [ "anyhow", - "convert_case 0.11.0", + "convert_case 0.12.0", "image", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -2859,9 +3277,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "micromath" @@ -2913,6 +3331,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2923,6 +3351,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nb" version = "0.1.3" @@ -2985,14 +3419,40 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3001,15 +3461,25 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ + "num-integer", "num-traits", ] @@ -3031,6 +3501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3051,7 +3522,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3066,7 +3537,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459f0aec254d6506e17928b6fee53bbf294bc37af9dbab75652fc74dc2c06b6c" dependencies = [ - "defmt 0.3.100", + "defmt 0.3.101", "display-interface", "embedded-graphics-core", "embedded-hal 1.0.0", @@ -3078,12 +3549,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "outref" version = "0.1.0" @@ -3098,9 +3563,9 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] name = "oxc-browserslist" @@ -3128,7 +3593,7 @@ dependencies = [ "textwrap", "thiserror", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -3139,7 +3604,7 @@ checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3161,7 +3626,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e78ea25d23521092edcd509198635dd0bd96df7fb71349bcd8087bb8f6a615d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "oxc_allocator", "oxc_ast_macros", "oxc_data_structures", @@ -3181,7 +3646,7 @@ dependencies = [ "phf 0.13.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3202,7 +3667,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "480bab938439c921d34750708abf15aacf253ea11e2f348e4b085cbf697f4ea2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cow-utils", "dragonbox_ecma", "itoa", @@ -3326,7 +3791,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb730ab93ff23bbc471ef7109f847afa709bb284dd52a5d3366283d724858158" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cow-utils", "memchr", "num-bigint", @@ -3349,7 +3814,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bafc3035e3b0fe555ae56f7bbc108a7ee520b0858250ba16d197e44ca83746" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "oxc_allocator", "oxc_ast_macros", "oxc_diagnostics", @@ -3412,7 +3877,7 @@ version = "0.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93c99e555ed497111004a71fb2aa6f8fb90b0d3e2733aceeb035e24701d69634" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cow-utils", "dragonbox_ecma", "nonmax", @@ -3449,20 +3914,31 @@ name = "p256" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve 0.13.8", + "primeorder 0.13.6", +] + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", ] [[package]] name = "parcel_selectors" -version = "0.28.2" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" +checksum = "d05f71e01edca03d245ab0a9f7ce13a974ceb79baaae8faf2ba0b11de6b90913" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cssparser", "log", "phf 0.11.3", @@ -3529,7 +4005,6 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -3539,7 +4014,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", + "phf_macros", "phf_shared 0.13.1", "serde", ] @@ -3561,7 +4036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -3574,19 +4049,6 @@ dependencies = [ "phf_shared 0.13.1", ] -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "phf_macros" version = "0.13.1" @@ -3597,7 +4059,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3624,42 +4086,62 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki", +] + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] name = "polyval" -version = "0.6.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", + "cpubits", + "cpufeatures 0.3.1", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -3682,7 +4164,7 @@ checksum = "a33fa6ec7f2047f572d49317cca19c87195de99c6e5b6ee492da701cfe02b053" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3697,6 +4179,17 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3719,54 +4212,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] -name = "primeorder" -version = "0.13.6" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "elliptic-curve", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "elliptic-curve 0.13.8", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "primeorder" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "proc-macro2", - "quote", + "elliptic-curve 0.14.1", + "primefield", + "wnaf", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3787,7 +4283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3819,11 +4315,34 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qoi" @@ -3848,9 +4367,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3861,6 +4380,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -3869,18 +4394,19 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3911,9 +4437,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] [[package]] name = "rand_core" @@ -3957,7 +4480,7 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", "thiserror", @@ -3980,6 +4503,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "rayon" version = "1.12.0" @@ -4000,20 +4532,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4022,9 +4560,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -4037,12 +4575,12 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "hmac", - "subtle", + "crypto-bigint 0.7.5", + "hmac 0.13.0", ] [[package]] @@ -4072,7 +4610,7 @@ checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4101,7 +4639,7 @@ checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4141,28 +4679,48 @@ dependencies = [ [[package]] name = "rlsf" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +checksum = "07393724337be2ee43a9d86164df4505746874a3fa65913374bc6d6a92314362" dependencies = [ "cfg-if", "const-default", "libc", "rustversion", - "svgbobdoc", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4194,18 +4752,32 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", - "generic-array 0.14.7", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", "subtle", "zeroize", ] [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "seq-macro" @@ -4215,9 +4787,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4234,30 +4806,31 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", "indexmap", "itoa", "memchr", @@ -4295,8 +4868,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -4320,10 +4904,20 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-abstraction" version = "0.7.1" @@ -4335,9 +4929,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_helpers" @@ -4356,21 +4950,21 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "smoltcp" @@ -4387,18 +4981,48 @@ dependencies = [ [[package]] name = "somni-expr" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" +checksum = "f024812d1302010ff9227b80650469e58a3ef129ec5fda36c7a0b6b14ce07371" dependencies = [ + "indexmap", "somni-parser", ] [[package]] name = "somni-parser" -version = "0.2.2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba0fb59df288988d1f80eab8a0a32b18222e87b48d07b065c2f69cfc54d946c" + +[[package]] +name = "somni-template" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f39bb07107650fb128f05f033a97212e946a3c0625cc729c97ed586bf7f226a3" +dependencies = [ + "somni-expr", + "somni-parser", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] [[package]] name = "stable_deref_trait" @@ -4433,16 +5057,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros 0.28.0", + "strum_macros", ] [[package]] @@ -4454,19 +5069,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4476,23 +5079,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "svgbobdoc" -version = "0.3.0" +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "base64", "proc-macro2", "quote", - "syn 1.0.109", - "unicode-width 0.1.14", + "unicode-ident", ] [[package]] name = "syn" -version = "1.0.109" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4501,15 +5102,26 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tap" version = "1.0.1" @@ -4527,40 +5139,40 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ + "icu_segmenter", "smawk", - "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4580,19 +5192,21 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.11.0" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "tinyvec_macros", + "displaydoc", + "serde_core", + "zerovec", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "tinyvec" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "toml" @@ -4618,9 +5232,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -4641,23 +5255,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4685,7 +5299,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4729,21 +5343,21 @@ dependencies = [ [[package]] name = "trouble-host" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df7817cead4b83dfbeeaa59736ecbc97c30b21dc3bfc0cc2ce8a6687f70b37a" +checksum = "bb9fe2be24000037431b036ba244cdb1d26f40e1719548553fb43c3fc62d496b" dependencies = [ - "aes", + "aes 0.8.4", "bt-hci", "cmac", "embassy-futures", - "embassy-sync 0.7.2", + "embassy-sync 0.8.0", "embassy-time", "embedded-io 0.7.1", "futures", "heapless 0.9.3", - "p256", - "rand 0.8.6", + "p256 0.13.2", + "rand 0.8.8", "rand_chacha 0.3.1", "rand_core 0.6.4", "static_cell", @@ -4753,15 +5367,15 @@ dependencies = [ [[package]] name = "trouble-host-macros" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d809ae05f510cf8f1d863749281eb28e049d9ae4ad52d92ea7a0ac068a0e17c" +checksum = "2562dc74f79ad12f7bf088da55c936d5eb1d022f2caac9849175326307a5eec1" dependencies = [ "convert_case 0.8.0", "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "uuid", ] @@ -4779,21 +5393,15 @@ checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" [[package]] name = "unicode-id-start" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -4801,26 +5409,38 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unitrait" +version = "2.0.0" +source = "git+https://github.com/embassy-rs/unitrait?rev=bb96601b3d293fa27d1ca2891f6ea148e534e433#bb96601b3d293fa27d1ca2891f6ea148e534e433" +dependencies = [ + "unitrait-macros", +] + +[[package]] +name = "unitrait-macros" +version = "2.0.0" +source = "git+https://github.com/embassy-rs/unitrait?rev=bb96601b3d293fa27d1ca2891f6ea148e534e433#bb96601b3d293fa27d1ca2891f6ea148e534e433" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -4829,6 +5449,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.12.1" @@ -4893,18 +5519,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4915,9 +5541,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4925,22 +5551,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -4995,9 +5621,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5008,6 +5634,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "ws-framer" version = "0.3.2" @@ -5029,7 +5673,7 @@ dependencies = [ "itertools 0.13.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5041,6 +5685,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xarxa-driver" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eb997638d080753b73e5335427d3a27568678aafa6c0e0564485257e90738" +dependencies = [ + "critical-section", + "document-features", +] + [[package]] name = "xtensa-lx" version = "0.13.0" @@ -5052,9 +5706,9 @@ dependencies = [ [[package]] name = "xtensa-lx-rt" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409a9b4629d429e995cde4dfbd9fe562ccae66f7624514e200733fc5d0ea8905" +checksum = "07933551193a68b5b0a94a7c428e9626adb559661e0f4b048ac5ec1fa4bed334" dependencies = [ "document-features", "xtensa-lx", @@ -5069,7 +5723,7 @@ checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5089,43 +5743,126 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", + "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" diff --git a/Cargo.toml b/Cargo.toml index 6974b8d..29bcff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,48 +5,51 @@ authors = ["filipton "] edition = "2024" [dependencies] -esp-backtrace = { version = "0.19.0", features = [ "panic-handler", "println", "custom-pre-backtrace", "custom-halt", "esp32c3" ] } -esp-hal = { version = "1.1.1", features = [ "esp32c3" ] } -esp-println = { version = "0.17.0", features = ["log-04", "esp32c3"] } -log = { version = "0.4.30" } -esp-radio = { version = "1.0.0-beta.0", features = ["coex", "esp32c3"] } -esp-rtos = { version = "0.3.0", features = ["esp32c3", "embassy", "esp-radio"] } +esp-backtrace = { version = "0.20.0", features = [ "panic-handler", "println", "custom-pre-backtrace", "custom-halt", "esp32c3" ] } +esp-hal = { version = "1.2.2", features = [ "esp32c3" ] } +esp-println = { version = "0.18.0", features = ["log-04", "esp32c3"] } +log = { version = "0.4.34" } +esp-radio = { version = "1.0.0-beta.1", features = ["coex", "esp32c3"] } +esp-rtos = { version = "0.4.0", features = ["esp32c3", "embassy", "esp-radio"] } embassy-executor = { version = "0.10.0", features = ["nightly"] } embassy-net = { version = "0.9.1", features = ["tcp", "udp", "multicast", "dhcpv4", "medium-ethernet", "proto-ipv4", "dns"] } embassy-time = { version = "0.5.1" } -esp-storage = { version = "0.9.0", features = ["esp32c3"] } -embedded-storage = "0.3.1" +esp-storage = { version = "0.10.0", features = ["esp32c3", "embedded-storage"] } +embedded-storage = "0.3.2" static_cell = { version = "2.1.1", features = ["nightly"] } heapless = { version = "0.9.3", default-features = false } -esp-alloc = "0.10.0" -#esp-hal-wifimanager = { version = "0.2.10", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } -esp-hal-wifimanager = { git = "https://github.com/filipton/esp-hal-wifimanager.git", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } -serde = { version = "1.0.228", features = ["alloc", "derive"], default-features = false } -serde_json = { version = "1.0.150", default-features = false, features = ["alloc"] } +esp-alloc = "0.11.0" + +esp-hal-wifimanager = { version = "0.4.0", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } +serde = { version = "1.0.229", features = ["alloc", "derive"], default-features = false } +serde_json = { version = "1.0.154", default-features = false, features = ["alloc"] } adv-shift-registers = "0.2.5" embedded-hal = "1.0.0" -esp-hal-mdns = "0.1.4" +esp-hal-mdns = "0.1.5" embedded-io-async = "0.7.0" macros = { path = "./macros" } nb = "1.1.0" embassy-futures = "0.1.2" -embassy-sync = "0.7.2" +embassy-sync = "0.8.0" ws-framer = { version = "0.3.2", default-features = false, features = ["alloc", "http", "getrandom02"] } embedded-hal-async = "1.0.0" -portable-atomic = { version = "1.13.1", default-features = false } +portable-atomic = { version = "1.15.0", default-features = false } critical-section = "1.2.0" getrandom = { version = "=0.2.15", features = ["custom"] } uuid = { version = "=1.12.1", default-features = false, features = ["v4"] } -esp-hal-ota = { version = "0.5.7", features = ["esp32c3"] } -esp32c3 = { version = "0.32.2" } -anyhow = { version = "1.0.103", default-features = false } +esp-hal-ota = { version = "0.6.1", features = ["esp32c3"] } +esp32c3 = { version = "0.33.0" } +anyhow = { version = "1.0.104", default-features = false } embedded-hal-bus = { version = "0.3.0", features = ["async"] } -#embedded-tls = { git = "https://github.com/drogue-iot/embedded-tls.git", default-features = false, features = ["alloc", "embedded-io-adapters", "log"] } -embedded-tls = { git = "https://github.com/filipton/embedded-tls", default-features = false, features = ["alloc", "embedded-io-adapters", "log"] } -rand_core = { version = "0.6.4", features = ["getrandom"] } -esp-hal-mfrc522 = { version = "0.3.3", features = ["embassy-time"] } -esp-bootloader-esp-idf = { version = "0.5.0", features = ["log-04", "esp32c3"] } -trouble-host = { version = "0.6.0", features = ["scan", "security"] } +embedded-tls = { git = "https://github.com/embassy-rs/embedded-tls", rev = "58126ee5c453147a5e00684075f001d7ef962ca3", default-features = false, features = ["log", "p256", "rsa"] } +# same embassy rev as embedded-tls uses +# riscv32imc has no CAS: let spin (via rsa -> lazy_static) use portable-atomic +spin = { version = "0.9", default-features = false, features = ["portable_atomic"] } +embassy-crypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1" } +embassy-crypto-rustcrypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1", features = ["embassy-crypto-sha256", "embassy-crypto-hmac-sha256", "embassy-crypto-aes128-gcm", "embassy-crypto-p256-arith", "embassy-crypto-p256-ecdh", "embassy-crypto-p256-ecdsa"] } +esp-hal-mfrc522 = { version = "0.3.4", features = ["embassy-time"] } +esp-bootloader-esp-idf = { version = "0.6.0", features = ["log-04", "esp32c3"] } +trouble-host = { version = "0.7.0", features = ["scan", "security"] } # v4 only display-interface = { version = "0.5.0", optional = true } @@ -54,7 +57,7 @@ display-interface-i2c = { version = "0.5.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } embedded-graphics-framebuf = { version = "0.5.0", optional = true } oled_async = { version = "0.2.1", features = ["i2c"], optional = true } -bq27441 = { version = "0.1.0", features = ["async", "embassy"], optional = true } +bq27441 = { version = "0.3.0", features = ["async", "embassy"], optional = true } profont = { version = "0.7.0", optional = true } embedded-layout = { version = "0.4.2", optional = true } embedded-text = { version = "0.7.3", optional = true } diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 97b8c25..fc276a5 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -7,11 +7,11 @@ edition = "2021" proc-macro = true [dependencies] -anyhow = "1.0.102" -convert_case = "0.11.0" +anyhow = "1.0.104" +convert_case = "0.12.0" image = "0.25.10" -proc-macro2 = "1.0.106" -quote = "1.0.45" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -syn = { version = "2.0.117", features = ["extra-traits", "full"] } +proc-macro2 = "1.0.107" +quote = "1.0.47" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +syn = { version = "3.0.9", features = ["extra-traits", "full"] } diff --git a/src/bluetooth.rs b/src/bluetooth.rs index 06860d9..c33c3c3 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -13,7 +13,6 @@ use embassy_sync::{ }; use embassy_time::{Duration, Timer, with_timeout}; use esp_radio::ble::controller::BleConnector; -use rand_core::OsRng; use trouble_host::prelude::*; use core::sync::atomic::{AtomicBool, Ordering}; @@ -112,15 +111,12 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt let address: Address = Address::random(mac_addr); log::info!("[ble] address = {address:x?}"); - let mut resources: HostResources = HostResources::new(); + let mut resources: HostResources<_, DefaultPacketPool, 1, 3> = HostResources::new(); let stack = trouble_host::new(controller, &mut resources) .set_random_address(address) - .set_random_generator_seed(&mut OsRng); - let Host { - central, - mut runner, - .. - } = stack.build(); + .build(); + let central = stack.central(); + let mut runner = stack.runner(); let mut central = match bond_info { Some(ref bond) => { @@ -212,7 +208,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt }; let display_addr = match bond_info { - Some(ref bond_info) => bond_info.identity.bd_addr.into_inner(), + Some(ref bond_info) => bond_info.identity.addr.addr.into_inner(), None => loop { let sig = state.ble_sig.wait().await; if let BleAction::Connect(d) = sig { @@ -225,7 +221,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt let config = ConnectConfig { connect_params: Default::default(), scan_config: ScanConfig { - filter_accept_list: &[(target.kind, &target.addr)], + filter_accept_list: &[target], ..Default::default() }, }; @@ -460,7 +456,7 @@ async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformati let mut buf = [0; 32]; _ = nvs.delete(NVS_BONDING_KEY).await; - buf[..6].copy_from_slice(info.identity.bd_addr.raw()); + buf[..6].copy_from_slice(info.identity.addr.addr.raw()); buf[6..22].copy_from_slice(info.ltk.to_le_bytes().as_slice()); buf[22] = match info.security_level { SecurityLevel::NoEncryption => 0, @@ -487,7 +483,8 @@ async fn load_bonding_info(nvs: &esp_hal_wifimanager::Nvs) -> Option SecurityLevel::NoEncryption, 1 => SecurityLevel::Encrypted, @@ -497,7 +494,7 @@ async fn load_bonding_info(nvs: &esp_hal_wifimanager::Nvs) -> Option, pub aes: AES<'static>, pub flash: FLASH<'static>, - pub sw_interrupt: SW_INTERRUPT<'static>, + pub sw_interrupt: FROM_CPU_INTR0<'static>, // spi #[cfg(feature = "v3")] @@ -80,7 +80,7 @@ impl Board { let bt = peripherals.BT; let aes = peripherals.AES; let flash = peripherals.FLASH; - let sw_interrupt = peripherals.SW_INTERRUPT; + let sw_interrupt = peripherals.FROM_CPU_INTR0; let stackmat_rx = peripherals.GPIO20.degrade(); let usb_dp = peripherals.GPIO19.degrade(); @@ -215,7 +215,7 @@ impl Board { let bt = peripherals.BT; let aes = peripherals.AES; let flash = peripherals.FLASH; - let sw_interrupt = peripherals.SW_INTERRUPT; + let sw_interrupt = peripherals.FROM_CPU_INTR0; let sck = peripherals.GPIO4.degrade(); let miso = peripherals.GPIO5.degrade(); diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index c7ef82b..8077aa8 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -520,7 +520,8 @@ pub async fn lcd_task( && !deeper_sleep_state() && (Instant::now() - last_update).as_millis() > DEEPER_SLEEP_AFTER_MS { - use esp_hal::rtc_cntl::{Rtc, sleep::RtcioWakeupSource}; + use esp_hal::gpio::{Event, Input, InputConfig, Pull, WakeupConfig}; + use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; oled.fbuf.clear(BinaryColor::Off); let text = @@ -530,19 +531,21 @@ pub async fn lcd_task( _ = oled.flush().await; unsafe { - use esp_hal::rtc_cntl::sleep::WakeupLevel; - - let wakeup_pins: &mut [( - &mut dyn esp_hal::gpio::RtcPinWithResistors, - esp_hal::rtc_cntl::sleep::WakeupLevel, - )] = &mut [ - (&mut esp_hal::peripherals::GPIO0::steal(), WakeupLevel::High), - (&mut esp_hal::peripherals::GPIO1::steal(), WakeupLevel::High), - (&mut esp_hal::peripherals::GPIO2::steal(), WakeupLevel::High), - (&mut esp_hal::peripherals::GPIO3::steal(), WakeupLevel::High), + // pull-down like the old RtcioWakeupSource did for a high-level wake + let input_config = InputConfig::default().with_pull(Pull::Down); + let wakeup_config = WakeupConfig::default().with_low_power_path(true); + let mut wakeup_pins = [ + Input::new(esp_hal::peripherals::GPIO0::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO1::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO2::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO3::steal(), input_config), ]; - let rtcio = RtcioWakeupSource::new(wakeup_pins); - Rtc::new(esp_hal::peripherals::LPWR::steal()).sleep_deep(&[&rtcio]); + for pin in &mut wakeup_pins { + pin.apply_wakeup_config(&wakeup_config).unwrap(); + pin.listen(Event::HighLevel); + } + LowPower::new(esp_hal::peripherals::LPWR::steal()) + .sleep_deep(RtcSleepConfig::deep()); } } } diff --git a/src/main.rs b/src/main.rs index 2bb36dc..f665eaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,6 @@ use embassy_executor::Spawner; use embassy_sync::signal::Signal; use embassy_time::{Instant, Timer}; use esp_backtrace as _; -use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal_wifimanager::{Nvs, WIFI_NVS_KEY}; use esp_storage::FlashStorage; use state::{GlobalState, GlobalStateInner, SavedGlobalState, Scene, ota_state, sleep_state}; @@ -95,8 +94,7 @@ async fn main(spawner: Spawner) { set_brownout_detection(false); let board = Board::init(peripherals); - let software_interrupt = SoftwareInterruptControl::new(board.sw_interrupt); - esp_rtos::start(board.timg1.timer0, software_interrupt.software_interrupt0); + esp_rtos::start(board.timg1.timer0, board.sw_interrupt); FkmLogger::set_logger(); log::info!("Version: {}", version::VERSION); diff --git a/src/rfid.rs b/src/rfid.rs index a9c5eda..0cf2366 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -11,8 +11,7 @@ use esp_hal_mfrc522::consts::UidSize; use esp_hal::time::Rate; #[cfg(feature = "v3")] use esp_hal::{ - dma::{DmaRxBuf, DmaTxBuf}, - dma_buffers, + dma_rx_buffer, dma_tx_buffer, gpio::AnyPin, spi::{Mode, master::Spi}, }; @@ -38,18 +37,15 @@ pub async fn rfid_task( esp_hal_mfrc522::MFRC522::new(driver) }; - #[allow(clippy::manual_div_ceil)] #[cfg(feature = "v3")] - let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = dma_buffers!(512); - #[cfg(feature = "v3")] - let Ok(dma_tx_buf) = DmaTxBuf::new(tx_descriptors, tx_buffer) else { + let Ok(dma_tx_buf) = dma_tx_buffer!(512) else { log::error!("Dma tx buf failed"); crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_TX_INIT_FAILED) .await; return; }; #[cfg(feature = "v3")] - let Ok(dma_rx_buf) = DmaRxBuf::new(rx_descriptors, rx_buffer) else { + let Ok(dma_rx_buf) = dma_rx_buffer!(512) else { log::error!("Dma rx buf failed"); crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_RX_INIT_FAILED) .await; diff --git a/src/ws.rs b/src/ws.rs index 8f3c629..3d96f27 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -11,10 +11,10 @@ use embassy_sync::{ signal::Signal, }; use embassy_time::{Duration, Instant, Timer, WithTimeout}; -use embedded_tls::{Aes128GcmSha256, NoVerify, TlsConfig, TlsConnection, TlsContext}; +use embassy_crypto_rustcrypto as _; +use embedded_tls::{Aes128GcmSha256, InsecureNoVerify, TlsConfig, TlsConnection, TlsContext}; use esp_hal_ota::Ota; use esp_storage::FlashStorage; -use rand_core::OsRng; use ws_framer::{WsFrame, WsFrameOwned, WsRxFramer, WsTxFramer, WsUrl, WsUrlOwned}; static FRAME_CHANNEL: Channel = Channel::new(); @@ -188,14 +188,9 @@ async fn ws_loop( let mut socket = if ws_url.secure { let mut tls = TlsConnection::new(socket, ssl_rx_buf, ssl_tx_buf); - let config = TlsConfig::new().enable_rsa_signatures(); - tls.open(TlsContext::new( - &config, - Provider { - rng: OsRng, - verifier: NoVerify {}, - }, - )) + // the "rsa" feature makes TlsConfig offer RSA signatures by default + let config = TlsConfig::new(); + tls.open(TlsContext::new(&config, InsecureNoVerify)) .await .map_err(|e| { log::error!("tls open error: {e:?}"); @@ -821,24 +816,13 @@ impl WsSocket<'_, '_> { } } -struct Provider { - rng: OsRng, - verifier: NoVerify, -} - -impl embedded_tls::CryptoProvider for Provider { - type CipherSuite = Aes128GcmSha256; - - type Signature = &'static [u8]; +/// RNG for embedded-tls (embassy-crypto) +struct EspRng; - fn rng(&mut self) -> impl embedded_tls::CryptoRngCore { - &mut self.rng - } - - fn verifier( - &mut self, - ) -> Result<&mut impl embedded_tls::TlsVerifier, embedded_tls::TlsError> - { - Ok(&mut self.verifier) +impl embassy_crypto::driver::Rng for EspRng { + fn fill_bytes(buf: &mut [u8]) { + _ = crate::custom_rng(buf); } } + +embassy_crypto::rng_impl!(EspRng); From ce7b0bebc740cfee307b6c9c1431833c4a17a7e1 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 17:57:36 +0200 Subject: [PATCH 003/132] chore: bump esp-hal-mdns and esp-hal-ota --- Cargo.lock | 18 ++++-------------- Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d6e165d..a43b64e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,15 +1171,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "dns-protocol" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e95919581371ae96cbf05b0aeec65cdc20bd3a2dca282ab6af808fbddaddb4" -dependencies = [ - "memchr", -] - [[package]] name = "docsplay" version = "0.1.3" @@ -1944,11 +1935,10 @@ dependencies = [ [[package]] name = "esp-hal-mdns" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1050471a978efb28602d6f3272398083195c2fc0a4105a8c9d50a53ad256a92d" +checksum = "026c82351da6f49d57e8ad9513e8509e5be2e5e3b9aa2683577f29e237d60865" dependencies = [ - "dns-protocol", "heapless 0.9.3", "log", ] @@ -1967,9 +1957,9 @@ dependencies = [ [[package]] name = "esp-hal-ota" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fddbb7a4963e2151368db46b3e736a6e6ae692a1dad9de1663adba0e6c78219e" +checksum = "dd36839a6fc2b86cc6152363c989ae624ba830cc726102547e7d4b4f3d4ad9ca" dependencies = [ "embedded-storage", "esp-rom-sys", diff --git a/Cargo.toml b/Cargo.toml index 29bcff0..1b93b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ serde = { version = "1.0.229", features = ["alloc", "derive"], default-features serde_json = { version = "1.0.154", default-features = false, features = ["alloc"] } adv-shift-registers = "0.2.5" embedded-hal = "1.0.0" -esp-hal-mdns = "0.1.5" +esp-hal-mdns = "0.1.6" embedded-io-async = "0.7.0" macros = { path = "./macros" } nb = "1.1.0" @@ -37,7 +37,7 @@ portable-atomic = { version = "1.15.0", default-features = false } critical-section = "1.2.0" getrandom = { version = "=0.2.15", features = ["custom"] } uuid = { version = "=1.12.1", default-features = false, features = ["v4"] } -esp-hal-ota = { version = "0.6.1", features = ["esp32c3"] } +esp-hal-ota = { version = "0.6.2", features = ["esp32c3"] } esp32c3 = { version = "0.33.0" } anyhow = { version = "1.0.104", default-features = false } embedded-hal-bus = { version = "0.3.0", features = ["async"] } From bc2f02b5b966c9724e4f6588c91a819c2ef1cf5c Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:01:37 +0200 Subject: [PATCH 004/132] refactor: atomics instead of static mut state --- src/buttons.rs | 33 ++++----- src/lcd_v3.rs | 14 ++-- src/lcd_v4.rs | 14 ++-- src/main.rs | 23 ++++--- src/rfid.rs | 27 +++----- src/stackmat.rs | 36 +++------- src/state.rs | 162 ++++++++++++++++++++++++++++++-------------- src/utils/logger.rs | 6 +- src/utils/mod.rs | 2 +- src/ws.rs | 38 +++++------ 10 files changed, 187 insertions(+), 168 deletions(-) diff --git a/src/buttons.rs b/src/buttons.rs index 5beac7c..b8a8692 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -401,7 +401,7 @@ async fn submit_up( state.ble_sig.signal(BleAction::StartScan); } 2 => { - if unsafe { !crate::state::AUTO_SETUP } { + if !crate::state::auto_setup() { state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); state.state.signal(); return Ok(true); @@ -410,7 +410,7 @@ async fn submit_up( state_val.menu_scene = Some(MenuScene::Signing); } 3 => { - if unsafe { !crate::state::AUTO_SETUP } { + if !crate::state::auto_setup() { state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); state.state.signal(); return Ok(true); @@ -467,7 +467,7 @@ async fn submit_up( state.ble_sig.signal(BleAction::StartScan); } 2 => { - if unsafe { !crate::state::AUTO_SETUP } { + if !crate::state::auto_setup() { state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); state.state.signal(); return Ok(true); @@ -476,7 +476,7 @@ async fn submit_up( state_val.menu_scene = Some(MenuScene::Signing); } 3 => { - if unsafe { !crate::state::AUTO_SETUP } { + if !crate::state::auto_setup() { state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); state.state.signal(); return Ok(true); @@ -531,14 +531,14 @@ async fn submit_up( _ = state.nvs.delete(NVS_SIGN_KEY).await; _ = state.nvs.set(NVS_SIGN_KEY, sign_key).await; - unsafe { crate::state::SIGN_KEY = sign_key }; - unsafe { crate::state::TRUST_SERVER = true }; + crate::state::set_sign_key(sign_key); + crate::state::set_trust_server(true); crate::ws::send_packet(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Add { firmware: alloc::string::ToString::to_string(crate::version::FIRMWARE), - sign_key: unsafe { crate::state::SIGN_KEY }, + sign_key: crate::state::sign_key(), }, }) .await; @@ -559,16 +559,9 @@ async fn submit_up( state_val.scene = crate::state::Scene::CompetitorInfo; } - unsafe { - crate::state::GROUP_LIMIT = - state_val.possible_groups[state_val.group_selected_idx].limit; - - crate::state::CURRENT_GROUP_ID = Some( - state_val.possible_groups[state_val.group_selected_idx] - .group_id - .clone(), - ); - } + crate::state::set_group_limit( + state_val.possible_groups[state_val.group_selected_idx].limit, + ); state.state.signal(); @@ -599,7 +592,7 @@ async fn inspection_start( return Ok(false); } - if unsafe { crate::state::CURRENT_TIME } != Some(0) { + if crate::state::current_time() != Some(0) { log::warn!("Skipping inspection start because current timer time is not 0"); return Ok(false); } @@ -777,7 +770,7 @@ async fn delegate_hold( }; #[cfg(not(feature = "e2e"))] - if unsafe { !crate::state::TRUST_SERVER } { + if !crate::state::trust_server() { log::error!("Skipping delegate hold. Server not trusted!"); return Ok(false); } @@ -810,7 +803,7 @@ async fn delegate_hold( delegate: true, inspection_time, group_id: solve_group, - sign_key: unsafe { crate::state::SIGN_KEY }, + sign_key: crate::state::sign_key(), }; state_val.delegate_hold = Some(3); diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index a0492ae..f8c1797 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -116,9 +116,7 @@ pub async fn lcd_task( if sleep_state() { lcd.backlight_on(); - unsafe { - crate::state::SLEEP_STATE = false; - } + crate::state::set_sleep_state(false); } let current_scene = current_state.scene.clone(); @@ -156,10 +154,8 @@ pub async fn lcd_task( global_state.state.lock().await.server_connected = Some(false); } - unsafe { - crate::state::SLEEP_STATE = true; - crate::state::TRUST_SERVER = false; - } + crate::state::set_sleep_state(true); + crate::state::set_trust_server(false); global_state.state.signal_reset(); } @@ -624,9 +620,7 @@ async fn process_lcd( loop { let elapsed = (Instant::now() - inspection_start).as_millis(); - unsafe { - crate::state::INSPECTION_TIME = Some(elapsed); - } + crate::state::set_inspection_time(Some(elapsed)); let time_str = ms_to_time_str(elapsed); lcd_driver diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index 8077aa8..96f88f8 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -426,9 +426,7 @@ pub async fn lcd_task( last_update = Instant::now(); if sleep_state() { - unsafe { - crate::state::SLEEP_STATE = false; - } + crate::state::set_sleep_state(false); log::warn!("Sleep wakeup!"); } @@ -507,10 +505,8 @@ pub async fn lcd_task( state.wifi_connected = Some(false); } - unsafe { - crate::state::SLEEP_STATE = true; - crate::state::TRUST_SERVER = false; - } + crate::state::set_sleep_state(true); + crate::state::set_trust_server(false); global_state.state.signal_reset(); } @@ -952,9 +948,7 @@ async fn process_main( let text_rect = Rectangle::new(Point::new(0, 28), Size::new(128, 17)); loop { let elapsed = (Instant::now() - inspection_start).as_millis(); - unsafe { - crate::state::INSPECTION_TIME = Some(elapsed); - } + crate::state::set_inspection_time(Some(elapsed)); let time_str = ms_to_time_str(elapsed); _ = oled.disp.fill_solid(&text_rect, BinaryColor::Off); diff --git a/src/main.rs b/src/main.rs index f665eaa..973d779 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,7 +141,7 @@ async fn main(spawner: Spawner) { let wifi_conn_sig = Rc::new(Signal::new()); if let Ok(sign_key) = nvs.get::(NVS_SIGN_KEY).await { - unsafe { crate::state::SIGN_KEY = sign_key }; + crate::state::set_sign_key(sign_key); } #[cfg(feature = "v4")] if let Ok(saved_volume) = nvs.get::(crate::consts::NVS_BUZZER_VOLUME).await { @@ -432,15 +432,18 @@ async fn logger_task(global_state: GlobalState) { continue; } - #[allow(static_mut_refs)] - let logs_vec = unsafe { - crate::utils::logger::LOGS_WRITER.get_vec( - crate::state::CURRENT_TIME, - crate::state::INSPECTION_TIME, - crate::state::CURRENT_COMPETITOR, - &crate::state::CURRENT_GROUP_ID, - &crate::state::CURRENT_SESSION_ID, - ) + let logs_vec = { + let state = global_state.state.value().await; + #[allow(static_mut_refs)] + unsafe { + crate::utils::logger::LOGS_WRITER.get_vec( + crate::state::current_time(), + crate::state::inspection_time(), + state.current_competitor, + state.solve_group.as_ref().map(|g| g.group_id.as_str()), + state.session_id.as_deref(), + ) + } }; if !logs_vec.is_empty() { diff --git a/src/rfid.rs b/src/rfid.rs index 0cf2366..eb2fea1 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -209,7 +209,7 @@ pub async fn rfid_task( } #[cfg(not(feature = "e2e"))] - if unsafe { !crate::state::TRUST_SERVER } { + if !crate::state::trust_server() { log::error!("Skipping card scan. Server not trusted!"); global_state.state.lock().await.error_text = Some("Server NOT Trusted!".to_string()); _ = mfrc522.picc_halta().await; @@ -221,7 +221,7 @@ pub async fn rfid_task( #[cfg(not(feature = "e2e"))] match menu_scene { Some(MenuScene::Signing) => { - let fkm_token = unsafe { crate::state::FKM_TOKEN }; + let fkm_token = crate::state::fkm_token(); let status = mfrc522 .pcd_authenticate( @@ -298,7 +298,7 @@ pub async fn rfid_task( continue; } Some(MenuScene::Unsigning) => { - let fkm_token = unsafe { crate::state::FKM_TOKEN }; + let fkm_token = crate::state::fkm_token(); let mut key = [0; 6]; key[..4].copy_from_slice(&fkm_token.to_be_bytes()); @@ -350,8 +350,8 @@ pub async fn rfid_task( } #[cfg(not(feature = "e2e"))] - if unsafe { crate::state::SECURE_RFID } { - let fkm_token = unsafe { crate::state::FKM_TOKEN }; + if crate::state::secure_rfid() { + let fkm_token = crate::state::fkm_token(); let mut key = [0; 6]; key[..4].copy_from_slice(&fkm_token.to_be_bytes()); @@ -402,7 +402,7 @@ pub async fn rfid_task( card_id: card_uid as u64, is_competitor, attendance_device: None, - sign_key: unsafe { crate::state::SIGN_KEY }, + sign_key: crate::state::sign_key(), }, ) .await; @@ -435,7 +435,7 @@ pub async fn rfid_task( #[cfg(not(feature = "e2e"))] { _ = mfrc522.picc_halta().await; - if unsafe { crate::state::SECURE_RFID } { + if crate::state::secure_rfid() { _ = mfrc522.pcd_stop_crypto1().await; } } @@ -457,9 +457,6 @@ async fn process_card_info_response( { state.competitor_display = Some(resp.display); state.current_competitor = Some(resp.card_id); - unsafe { - crate::state::CURRENT_COMPETITOR = Some(resp.card_id); - } let competitor_locale = crate::translations::get_locale_index(&resp.country_iso2.to_lowercase()); @@ -470,11 +467,7 @@ async fn process_card_info_response( match resp.possible_groups.len() { 1 => { state.solve_group = Some(resp.possible_groups[0].clone()); - unsafe { - crate::state::GROUP_LIMIT = resp.possible_groups[0].limit; - crate::state::CURRENT_GROUP_ID = - Some(resp.possible_groups[0].group_id.clone()); - } + crate::state::set_group_limit(resp.possible_groups[0].limit); if state.solve_time.is_some() { state.scene = crate::state::Scene::Finished; @@ -532,7 +525,7 @@ async fn process_card_info_response( }; #[cfg(not(feature = "e2e"))] - if unsafe { !crate::state::TRUST_SERVER } { + if !crate::state::trust_server() { log::error!("Skipping solve send. Server not trusted!"); return Ok(()); } @@ -548,7 +541,7 @@ async fn process_card_info_response( delegate: false, inspection_time, group_id: solve_group.group_id.clone(), - sign_key: unsafe { crate::state::SIGN_KEY }, + sign_key: crate::state::sign_key(), }, ) .await; diff --git a/src/stackmat.rs b/src/stackmat.rs index 3db0462..7af71c6 100644 --- a/src/stackmat.rs +++ b/src/stackmat.rs @@ -42,23 +42,19 @@ pub async fn stackmat_task( break; } - let time_limit = unsafe { crate::state::GROUP_LIMIT }; + let time_limit = crate::state::group_limit(); if let Some(limit) = time_limit && time > limit { global_state.timer_signal.signal(limit); global_state.bt_display_signal.signal(limit); - unsafe { - crate::state::CURRENT_TIME = Some(limit); - } + crate::state::set_current_time(Some(limit)); time_end(limit, true, &mut None, &global_state).await; break; } else { global_state.timer_signal.signal(time); global_state.bt_display_signal.signal(time); - unsafe { - crate::state::CURRENT_TIME = Some(time); - } + crate::state::set_current_time(Some(time)); if pads.0.is_high() && pads.1.is_high() { time_end(time, false, &mut None, &global_state).await; @@ -151,22 +147,18 @@ pub async fn stackmat_task( && let Some((last_at, last_ms)) = last_time { let time_interpolated = last_ms + last_at.elapsed().as_millis(); - let time_limit = unsafe { crate::state::GROUP_LIMIT }; + let time_limit = crate::state::group_limit(); if let Some(limit) = time_limit && time_interpolated > limit { global_state.timer_signal.signal(limit); global_state.bt_display_signal.signal(limit); - unsafe { - crate::state::CURRENT_TIME = Some(limit); - } + crate::state::set_current_time(Some(limit)); time_end(limit, true, &mut last_time, &global_state).await; } else { global_state.timer_signal.signal(time_interpolated); global_state.bt_display_signal.signal(time_interpolated); - unsafe { - crate::state::CURRENT_TIME = Some(time_interpolated); - } + crate::state::set_current_time(Some(time_interpolated)); if global_state.timer_stop_signal.signaled() { time_end(time_interpolated, false, &mut last_time, &global_state).await; @@ -253,18 +245,14 @@ pub async fn stackmat_task( crate::qa::send_qa_resp(crate::qa::QaSignal::StackmatConnected); } - unsafe { - crate::state::CURRENT_TIME = Some(parsed.1); - } + crate::state::set_current_time(Some(parsed.1)); - let time_limit = unsafe { crate::state::GROUP_LIMIT }; + let time_limit = crate::state::group_limit(); if let Some(limit) = time_limit && parsed.1 > limit { time_end(limit, true, &mut last_time, &global_state).await; - unsafe { - crate::state::GROUP_LIMIT = None; - } + crate::state::set_group_limit(None); continue; } last_time = Some((Instant::now(), parsed.1)); @@ -303,9 +291,6 @@ pub async fn stackmat_task( if state.session_id.is_none() { state.session_id = Some(uuid::Uuid::new_v4().to_string()); - unsafe { - crate::state::CURRENT_SESSION_ID = state.session_id.clone(); - } } state.time_confirmed = true; @@ -360,9 +345,6 @@ async fn time_end( if state.session_id.is_none() { state.session_id = Some(uuid::Uuid::new_v4().to_string()); - unsafe { - crate::state::CURRENT_SESSION_ID = state.session_id.clone(); - } } if state.current_competitor.is_some() { diff --git a/src/state.rs b/src/state.rs index eaec332..8ec76ee 100644 --- a/src/state.rs +++ b/src/state.rs @@ -13,63 +13,135 @@ use embassy_sync::{ use embassy_time::{Duration, Instant, Timer}; use esp_hal::aes::Aes; use esp_hal_wifimanager::Nvs; +#[cfg(feature = "v4")] +use portable_atomic::AtomicU8; +use portable_atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; -pub static mut SIGN_KEY: u32 = 0; -pub static mut TRUST_SERVER: bool = false; -pub static mut FKM_TOKEN: i32 = 0; -pub static mut SECURE_RFID: bool = false; -pub static mut AUTO_SETUP: bool = false; +// Lock-free flags shared between tasks. Loads/stores are plain instructions on the C3; +// portable-atomic covers the 64-bit ones (single core, critical section fallback). +static SIGN_KEY: AtomicU32 = AtomicU32::new(0); +static TRUST_SERVER: AtomicBool = AtomicBool::new(false); +static FKM_TOKEN: AtomicI32 = AtomicI32::new(0); +static SECURE_RFID: AtomicBool = AtomicBool::new(false); +static AUTO_SETUP: AtomicBool = AtomicBool::new(false); -pub static mut GROUP_LIMIT: Option = None; +static GROUP_LIMIT: AtomicOptU64 = AtomicOptU64::none(); -pub static mut EPOCH_BASE: u64 = 0; -pub static mut SLEEP_STATE: bool = false; -pub static mut DEEPER_SLEEP: bool = false; -pub static mut OTA_STATE: bool = false; +/// 0 = unknown (server didn't send EpochTime yet) +static EPOCH_BASE: AtomicU64 = AtomicU64::new(0); +static SLEEP_STATE: AtomicBool = AtomicBool::new(false); +static DEEPER_SLEEP: AtomicBool = AtomicBool::new(false); +static OTA_STATE: AtomicBool = AtomicBool::new(false); -// Live vars -pub static mut CURRENT_TIME: Option = None; -pub static mut INSPECTION_TIME: Option = None; -pub static mut CURRENT_COMPETITOR: Option = None; -pub static mut CURRENT_GROUP_ID: Option = None; -pub static mut CURRENT_SESSION_ID: Option = None; +// Live vars (logger header) +static CURRENT_TIME: AtomicOptU64 = AtomicOptU64::none(); +static INSPECTION_TIME: AtomicOptU64 = AtomicOptU64::none(); #[cfg(feature = "v4")] -pub static mut BUZZER_VOLUME: u8 = crate::consts::BUZZER_VOLUME_DEFAULT; +static BUZZER_VOLUME: AtomicU8 = AtomicU8::new(crate::consts::BUZZER_VOLUME_DEFAULT); + +/// `Option` in one atomic; `u64::MAX` is `None` (never a valid time/limit). +pub struct AtomicOptU64(AtomicU64); + +impl AtomicOptU64 { + pub const fn none() -> Self { + Self(AtomicU64::new(u64::MAX)) + } + + pub fn get(&self) -> Option { + let v = self.0.load(Ordering::Relaxed); + (v != u64::MAX).then_some(v) + } + + pub fn set(&self, value: Option) { + self.0.store(value.unwrap_or(u64::MAX), Ordering::Relaxed); + } +} + +macro_rules! atomic_accessors { + ($($static:ident: $ty:ty => $get:ident, $set:ident;)*) => {$( + // e2e/qa builds compile out some readers + #[inline(always)] + #[allow(dead_code)] + pub fn $get() -> $ty { + $static.load(Ordering::Relaxed) + } + + #[inline(always)] + #[allow(dead_code)] + pub fn $set(value: $ty) { + $static.store(value, Ordering::Relaxed) + } + )*}; +} + +atomic_accessors! { + SIGN_KEY: u32 => sign_key, set_sign_key; + TRUST_SERVER: bool => trust_server, set_trust_server; + FKM_TOKEN: i32 => fkm_token, set_fkm_token; + SECURE_RFID: bool => secure_rfid, set_secure_rfid; + AUTO_SETUP: bool => auto_setup, set_auto_setup; + SLEEP_STATE: bool => sleep_state, set_sleep_state; + DEEPER_SLEEP: bool => deeper_sleep_state, set_deeper_sleep_state; + OTA_STATE: bool => ota_state, set_ota_state; +} #[cfg(feature = "v4")] +atomic_accessors! { + BUZZER_VOLUME: u8 => buzzer_volume, set_buzzer_volume; +} + #[inline(always)] -pub fn buzzer_volume() -> u8 { - unsafe { BUZZER_VOLUME } +pub fn group_limit() -> Option { + GROUP_LIMIT.get() } -#[cfg(feature = "v4")] #[inline(always)] -pub fn set_buzzer_volume(volume: u8) { - unsafe { - BUZZER_VOLUME = volume; - } +pub fn set_group_limit(limit: Option) { + GROUP_LIMIT.set(limit) } #[inline(always)] -pub fn current_epoch() -> u64 { - unsafe { EPOCH_BASE + Instant::now().as_secs() } +pub fn current_time() -> Option { + CURRENT_TIME.get() +} + +#[inline(always)] +pub fn set_current_time(time: Option) { + CURRENT_TIME.set(time) +} + +#[inline(always)] +pub fn inspection_time() -> Option { + INSPECTION_TIME.get() } #[inline(always)] -pub fn sleep_state() -> bool { - unsafe { SLEEP_STATE } +pub fn set_inspection_time(time: Option) { + INSPECTION_TIME.set(time) +} + +/// Sets the epoch from the server's `current_epoch` (seconds). +pub fn set_epoch(current_epoch: u64) { + EPOCH_BASE.store( + current_epoch.saturating_sub(Instant::now().as_secs()), + Ordering::Relaxed, + ); } +/// True after the server sent its time. #[inline(always)] -pub fn deeper_sleep_state() -> bool { - unsafe { DEEPER_SLEEP } +#[allow(dead_code)] +pub fn epoch_known() -> bool { + EPOCH_BASE.load(Ordering::Relaxed) != 0 } #[inline(always)] -pub fn ota_state() -> bool { - unsafe { OTA_STATE } +pub fn current_epoch() -> u64 { + EPOCH_BASE + .load(Ordering::Relaxed) + .saturating_add(Instant::now().as_secs()) } #[derive(Debug, PartialEq, Clone)] @@ -363,14 +435,9 @@ impl SignaledGlobalStateInner { } pub async fn reset_solve_state(&mut self, save_nvs: Option<&Nvs>) { - unsafe { - GROUP_LIMIT = None; - CURRENT_TIME = None; - INSPECTION_TIME = None; - CURRENT_GROUP_ID = None; - CURRENT_COMPETITOR = None; - CURRENT_SESSION_ID = None; - } + set_group_limit(None); + set_current_time(None); + set_inspection_time(None); self.solve_time = None; self.penalty = None; self.inspection_start = None; @@ -397,14 +464,9 @@ impl SignaledGlobalStateInner { #[allow(dead_code)] pub async fn hard_state_reset(&mut self) { - unsafe { - GROUP_LIMIT = None; - CURRENT_TIME = None; - INSPECTION_TIME = None; - CURRENT_GROUP_ID = None; - CURRENT_COMPETITOR = None; - CURRENT_SESSION_ID = None; - } + set_group_limit(None); + set_current_time(None); + set_inspection_time(None); self.scene = Scene::WaitingForCompetitor; self.inspection_start = None; self.inspection_end = None; @@ -492,7 +554,7 @@ impl SignaledGlobalStateInner { #[cfg(not(feature = "e2e"))] impl SavedGlobalState { pub async fn from_nvs(nvs: &Nvs) -> Option { - while unsafe { EPOCH_BASE == 0 } { + while !epoch_known() { Timer::after_millis(5).await; } @@ -500,7 +562,7 @@ impl SavedGlobalState { let res: SavedGlobalState = serde_json::from_slice(&res).ok()?; // 6hours - if current_epoch() - res.solve_epoch > 6 * 60 * 60 { + if current_epoch().saturating_sub(res.solve_epoch) > 6 * 60 * 60 { log::error!("REMOVE SOLVE: {:?} {:?}", current_epoch(), res.solve_epoch); return None; } diff --git a/src/utils/logger.rs b/src/utils/logger.rs index 54f1ff2..a1223c8 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -1,5 +1,5 @@ use crate::state::{ota_state, sleep_state}; -use alloc::{string::String, vec::Vec}; +use alloc::vec::Vec; #[unsafe(link_section = ".dram2_uninit")] static mut LOGS_BUF: core::mem::MaybeUninit<[u8; 8 * 1024]> = core::mem::MaybeUninit::uninit(); @@ -21,8 +21,8 @@ impl LogsBufWriter { current_time: Option, inspection_time: Option, competitor: Option, - group_id: &Option, - session_id: &Option, + group_id: Option<&str>, + session_id: Option<&str>, ) -> Vec { self.buf[2..10].copy_from_slice(¤t_time.unwrap_or(u64::MAX).to_be_bytes()); self.buf[10..18].copy_from_slice(&inspection_time.unwrap_or(u64::MAX).to_be_bytes()); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 50607af..c44d645 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -69,7 +69,7 @@ pub fn get_efuse_u32() -> u32 { pub fn deeper_sleep() { esp32c3_set_cpu_freq_10mhz(); - unsafe { crate::state::DEEPER_SLEEP = true }; + crate::state::set_deeper_sleep_state(true); } #[cfg(feature = "v3")] diff --git a/src/ws.rs b/src/ws.rs index 3d96f27..6d64019 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -10,7 +10,7 @@ use embassy_sync::{ blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel, pubsub::PubSubChannel, signal::Signal, }; -use embassy_time::{Duration, Instant, Timer, WithTimeout}; +use embassy_time::{Duration, Timer, WithTimeout}; use embassy_crypto_rustcrypto as _; use embedded_tls::{Aes128GcmSha256, InsecureNoVerify, TlsConfig, TlsConnection, TlsContext}; use esp_hal_ota::Ota; @@ -75,10 +75,10 @@ pub async fn ws_task( }; loop { - unsafe { crate::state::TRUST_SERVER = false }; - unsafe { crate::state::SECURE_RFID = false }; - unsafe { crate::state::AUTO_SETUP = false }; - unsafe { crate::state::FKM_TOKEN = 0 }; + crate::state::set_trust_server(false); + crate::state::set_secure_rfid(false); + crate::state::set_auto_setup(false); + crate::state::set_fkm_token(0); let ws_fut = ws_loop( &global_state, @@ -255,7 +255,7 @@ async fn ws_loop( .map(|h| h.value.parse::()) { let mut key = [0; 16]; - key[..4].copy_from_slice(&unsafe { crate::state::SIGN_KEY.to_be_bytes() }); + key[..4].copy_from_slice(&crate::state::sign_key().to_be_bytes()); let mut block = [0; 16]; block.copy_from_slice(&random_signed.to_be_bytes()); @@ -273,8 +273,8 @@ async fn ws_loop( "[trust] random: {random}, recv_random: {recv_random} | fkm_token: {fkm_token}" ); if random == recv_random { - unsafe { crate::state::TRUST_SERVER = true }; - unsafe { crate::state::FKM_TOKEN = fkm_token }; + crate::state::set_trust_server(true); + crate::state::set_fkm_token(fkm_token); } else { #[cfg(not(feature = "e2e"))] { @@ -299,7 +299,7 @@ async fn ws_loop( tag: None, data: crate::structs::TimerPacketInner::Add { firmware: alloc::string::ToString::to_string(crate::version::FIRMWARE), - sign_key: unsafe { crate::state::SIGN_KEY }, + sign_key: crate::state::sign_key(), }, }) .await; @@ -465,9 +465,9 @@ async fn ws_rw( crate::translations::select_locale(&default_locale, &global_state); crate::translations::set_default_locale(); - unsafe { crate::state::FKM_TOKEN = fkm_token }; - unsafe { crate::state::SECURE_RFID = secure_rfid }; - unsafe { crate::state::AUTO_SETUP = auto_setup }; + crate::state::set_fkm_token(fkm_token); + crate::state::set_secure_rfid(secure_rfid); + crate::state::set_auto_setup(auto_setup); } TimerPacketInner::ApiError(e) => { // if should_reset_time reset time @@ -478,9 +478,9 @@ async fn ws_rw( let mut state = global_state.state.lock().await; state.custom_message = Some((line1, line2)); } - TimerPacketInner::EpochTime { current_epoch } => unsafe { - crate::state::EPOCH_BASE = current_epoch - Instant::now().as_secs(); - }, + TimerPacketInner::EpochTime { current_epoch } => { + crate::state::set_epoch(current_epoch); + } TimerPacketInner::DelegateResponse(_) => { tagged_publisher.publish((69420, timer_packet)).await; } @@ -496,16 +496,14 @@ async fn ws_rw( } #[cfg(not(feature = "e2e"))] - if unsafe { !crate::state::TRUST_SERVER } { + if !crate::state::trust_server() { continue; } log::info!("Start update: {firmware}/{version}"); log::info!("Begin update size: {size} crc: {crc}"); ota.ota_begin(size, crc).map_err(WsRwError::OtaError)?; - unsafe { - crate::state::OTA_STATE = true; - } + crate::state::set_ota_state(true); let mut state = global_state.state.lock().await; state.scene = Scene::Update; @@ -585,7 +583,7 @@ async fn ws_rw( } #[cfg(not(feature = "e2e"))] - if unsafe { !crate::state::TRUST_SERVER } { + if !crate::state::trust_server() { continue; } From 252986dd9443e4118ed50897c856ee495e6d7a7d Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:06:50 +0200 Subject: [PATCH 005/132] fix: bounded error log ring buffer with rate limit --- src/battery_v4.rs | 12 +- src/bluetooth.rs | 33 ++-- src/buttons.rs | 54 +----- src/lcd_v3.rs | 11 +- src/lcd_v4.rs | 25 +-- src/main.rs | 9 +- src/rfid.rs | 21 +-- src/stackmat.rs | 3 +- src/state.rs | 10 +- src/utils/backtrace_store.rs | 16 +- src/utils/error_log.rs | 353 +++++++++++++++++------------------ src/utils/mod.rs | 5 +- src/utils/ring_log.rs | 103 ++++++++++ src/utils/shared_i2c.rs | 3 +- src/ws.rs | 26 +-- 15 files changed, 341 insertions(+), 343 deletions(-) create mode 100644 src/utils/ring_log.rs diff --git a/src/battery_v4.rs b/src/battery_v4.rs index bd136f9..aefe815 100644 --- a/src/battery_v4.rs +++ b/src/battery_v4.rs @@ -21,8 +21,7 @@ const BAT_MAX: f64 = BATTERY_CURVE[BATTERY_CURVE.len() - 1].0; pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) { let Ok(mut gauge) = bq27441::Bq27441Async::new(i2c).await else { log::error!("BQ27441 init failed!"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::BATTERY_INIT_FAILED) - .await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::BATTERY_INIT_FAILED); return; }; @@ -54,8 +53,7 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) _ => { crate::utils::error_log::add_error( crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, - ) - .await; + ); } }; let mut mv = last_mv; @@ -66,8 +64,7 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) _ => { crate::utils::error_log::add_error( crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, - ) - .await; + ); } }; if soc == 0 { @@ -84,8 +81,7 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) _ => { crate::utils::error_log::add_error( crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, - ) - .await; + ); } }; let charging = ma >= 0; diff --git a/src/bluetooth.rs b/src/bluetooth.rs index c33c3c3..ebf3909 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -84,8 +84,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt ) else { log::error!("Cannot init ble connector"); if !BLE_INIT_FAILED_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error(crate::utils::error_log::codes::BLE_INIT_FAILED) - .await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::BLE_INIT_FAILED); BLE_INIT_FAILED_LOGGED.store(true, Ordering::Relaxed); } @@ -101,8 +100,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_MAC_READ_FAILED_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_MAC_READ_FAILED, - ) - .await; + ); BLE_MAC_READ_FAILED_LOGGED.store(true, Ordering::Relaxed); } @@ -125,8 +123,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_BOND_ADD_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_BOND_ADD_FAILED, - ) - .await; + ); BLE_BOND_ADD_LOGGED.store(true, Ordering::Relaxed); } @@ -163,8 +160,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_SCAN_START_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_SCAN_START_FAILED, - ) - .await; + ); BLE_SCAN_START_LOGGED.store(true, Ordering::Relaxed); } @@ -262,8 +258,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_BONDABLE_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_BONDABLE_FAILED, - ) - .await; + ); BLE_BONDABLE_LOGGED.store(true, Ordering::Relaxed); } @@ -275,8 +270,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_REQUEST_SECURITY_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_REQUEST_SECURITY_FAILED, - ) - .await; + ); BLE_REQUEST_SECURITY_LOGGED.store(true, Ordering::Relaxed); } @@ -308,8 +302,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_PAIRING_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_PAIRING_FAILED, - ) - .await; + ); BLE_PAIRING_LOGGED.store(true, Ordering::Relaxed); } @@ -345,8 +338,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_GATT_CLIENT_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_GATT_CLIENT_FAILED, - ) - .await; + ); BLE_GATT_CLIENT_LOGGED.store(true, Ordering::Relaxed); } @@ -389,8 +381,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_SERVICE_NOT_FOUND_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_SERVICE_NOT_FOUND, - ) - .await; + ); BLE_SERVICE_NOT_FOUND_LOGGED.store(true, Ordering::Relaxed); } @@ -408,8 +399,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt if !BLE_CHARACTERISTIC_NOT_FOUND_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::BLE_CHARACTERISTIC_NOT_FOUND, - ) - .await; + ); BLE_CHARACTERISTIC_NOT_FOUND_LOGGED.store(true, Ordering::Relaxed); } @@ -469,8 +459,7 @@ async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformati log::error!("NVS Bonding key store failed! ({e:?})"); crate::utils::error_log::add_error( crate::utils::error_log::codes::NVS_BONDING_KEY_WRITE_FAILED, - ) - .await; + ); } } diff --git a/src/buttons.rs b/src/buttons.rs index b8a8692..fbb8ce9 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -13,8 +13,6 @@ use esp_hal::gpio::Input; macros::generate_button_handler_enum!(triggered: &ButtonTrigger, hold_time: u64, state: &GlobalState); -static ERROR_LOG_PARSE_FAILED_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); #[cfg(feature = "v3")] const CONFIG_MENU_ERROR_LOG_IDX: usize = 4; @@ -140,7 +138,7 @@ async fn sel_left( return Ok(true); } - let total_items = state_val.error_log_entries.len() + 1; // + Exit + let total_items = crate::utils::error_log::entry_count() + 1; // + Exit state_val.selected_error_log_item = state_val .selected_error_log_item .wrapping_sub(1) @@ -200,9 +198,9 @@ async fn sel_right( #[cfg(feature = "v4")] if state_val.error_log_entry_stage == Some(ErrorLogEntryStage::Details) { if let Some(entry_idx) = state_val.selected_error_log_entry - && let Some(entry) = state_val.error_log_entries.get(entry_idx) + && let Some(entry) = crate::utils::error_log::entry(entry_idx) { - let text = crate::lcd_v4::error_log_entry_details_text(entry); + let text = crate::lcd_v4::error_log_entry_details_text(&entry); let max_scroll = text .split('\n') .count() @@ -219,7 +217,7 @@ async fn sel_right( return Ok(true); } - let total_items = state_val.error_log_entries.len() + 1; // + Exit + let total_items = crate::utils::error_log::entry_count() + 1; // + Exit state_val.selected_error_log_item = (state_val.selected_error_log_item + 1) % total_items; return Ok(true); @@ -327,7 +325,7 @@ async fn submit_up( return Ok(true); } - let exit_idx = state_val.error_log_entries.len(); + let exit_idx = crate::utils::error_log::entry_count(); if state_val.selected_error_log_item == exit_idx { state_val.menu_scene = None; state_val.selected_error_log_item = 0; @@ -335,7 +333,6 @@ async fn submit_up( state_val.error_log_entry_stage = None; state_val.error_log_details_scroll = 0; state_val.selected_config_menu = Some(CONFIG_MENU_ERROR_LOG_IDX); - state_val.error_log_entries.clear(); } else { state_val.selected_error_log_entry = Some(state_val.selected_error_log_item); state_val.error_log_details_scroll = 0; @@ -366,8 +363,7 @@ async fn submit_up( if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED, - ) - .await; + ); LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } @@ -419,25 +415,6 @@ async fn submit_up( state_val.menu_scene = Some(MenuScene::Unsigning); } CONFIG_MENU_ERROR_LOG_IDX => { - state_val.error_log_entries = - match crate::utils::error_log::parse_error_log_entries() { - Ok(entries) => entries, - Err(e) => { - log::error!("Parse error log failed: {e:?}"); - if !ERROR_LOG_PARSE_FAILED_LOGGED - .load(core::sync::atomic::Ordering::Relaxed) - { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::ERROR_LOG_PARSE_FAILED, - ) - .await; - - ERROR_LOG_PARSE_FAILED_LOGGED - .store(true, core::sync::atomic::Ordering::Relaxed); - } - alloc::vec![] - } - }; state_val.selected_error_log_item = 0; state_val.selected_error_log_entry = None; state_val.error_log_entry_stage = None; @@ -488,25 +465,6 @@ async fn submit_up( state_val.menu_scene = Some(MenuScene::BuzzerVolume); } CONFIG_MENU_ERROR_LOG_IDX => { - state_val.error_log_entries = - match crate::utils::error_log::parse_error_log_entries() { - Ok(entries) => entries, - Err(e) => { - log::error!("Parse error log failed: {e:?}"); - if !ERROR_LOG_PARSE_FAILED_LOGGED - .load(core::sync::atomic::Ordering::Relaxed) - { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::ERROR_LOG_PARSE_FAILED, - ) - .await; - - ERROR_LOG_PARSE_FAILED_LOGGED - .store(true, core::sync::atomic::Ordering::Relaxed); - } - alloc::vec![] - } - }; state_val.selected_error_log_item = 0; state_val.selected_error_log_entry = None; state_val.error_log_entry_stage = None; diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index f8c1797..8be4613 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -346,7 +346,7 @@ async fn process_lcd( lcd_driver.clear_all().ok()?; if let Some(entry_idx) = current_state.selected_error_log_entry { - if let Some(entry) = current_state.error_log_entries.get(entry_idx) { + if let Some(entry) = crate::utils::error_log::entry(entry_idx).as_ref() { match entry { crate::utils::error_log::ErrorLogEntry::Code { timestamp, code } => { lcd_driver @@ -387,18 +387,17 @@ async fn process_lcd( .ok()?; } } else { - let exit_idx = current_state.error_log_entries.len(); + let exit_idx = crate::utils::error_log::entry_count(); if current_state.selected_error_log_item == exit_idx { lcd_driver.print(0, "Exit", PrintAlign::Center, true).ok()?; lcd_driver .print(1, "Submit to return", PrintAlign::Center, true) .ok()?; - } else if let Some(entry) = current_state - .error_log_entries - .get(current_state.selected_error_log_item) + } else if let Some(entry) = + crate::utils::error_log::entry(current_state.selected_error_log_item) { lcd_driver - .print(0, &entry.list_label_v3(), PrintAlign::Center, true) + .print(0, &entry.list_label(), PrintAlign::Center, true) .ok()?; lcd_driver .print(1, "Submit for details", PrintAlign::Center, true) diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index 96f88f8..f3dffa0 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -346,7 +346,7 @@ pub async fn lcd_show_critical_code(i2c: SharedI2C, mut display_rst: Output<'sta if let Err(e) = disp_init { log::error!("Disp init error: {e:?} (but continuing i guess)"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED).await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED); } let text = format!("CRITICAL ERROR\nCODE: {code}",); @@ -383,7 +383,7 @@ pub async fn lcd_task( if let Err(e) = disp_init { log::error!("Disp init error: {e:?} (but continuing i guess)"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED).await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED); } let mut data = alloc::vec![embedded_graphics::pixelcolor::BinaryColor::Off; FBUF_SIZE]; @@ -391,8 +391,7 @@ pub async fn lcd_task( log::error!("Disp framebuffer data alloc failed!"); crate::utils::error_log::add_error( crate::utils::error_log::codes::LCD_FRAMEBUFFER_ALLOC_FAILED, - ) - .await; + ); return; }; let fbuf = embedded_graphics_framebuf::FrameBuf::new(data, FBUF_WIDTH, FBUF_HEIGHT); @@ -442,8 +441,7 @@ pub async fn lcd_task( log::error!("OLED FLUSH TIMEOUT"); crate::utils::error_log::add_error( crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ) - .await; + ); } loop { @@ -468,8 +466,7 @@ pub async fn lcd_task( log::error!("OLED FLUSH TIMEOUT"); crate::utils::error_log::add_error( crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ) - .await; + ); } global_state.show_battery.reset(); @@ -495,8 +492,7 @@ pub async fn lcd_task( log::error!("OLED FLUSH TIMEOUT"); crate::utils::error_log::add_error( crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ) - .await; + ); } { @@ -692,7 +688,7 @@ async fn process_main( } Some(MenuScene::ErrorLog) => { if let Some(entry_idx) = current_state.selected_error_log_entry { - if let Some(entry) = current_state.error_log_entries.get(entry_idx) { + if let Some(entry) = crate::utils::error_log::entry(entry_idx).as_ref() { if current_state.error_log_entry_stage == Some(ErrorLogEntryStage::Qr) { match entry { crate::utils::error_log::ErrorLogEntry::Code { timestamp: _, code } => { @@ -730,10 +726,9 @@ async fn process_main( return Ok(()); } - let mut items = current_state - .error_log_entries - .iter() - .map(|entry| entry.list_label_v4()) + let mut items = (0..crate::utils::error_log::entry_count()) + .filter_map(crate::utils::error_log::entry) + .map(|entry| alloc::string::String::from(entry.list_label().as_str())) .collect::>(); items.push("Exit".into()); diff --git a/src/main.rs b/src/main.rs index 973d779..9140507 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,7 +111,7 @@ async fn main(spawner: Spawner) { crate::utils::backtrace_store::verify_panic_flag(); let Ok(nvs) = Nvs::new_from_part_table(unsafe { board.flash.clone_unchecked() }) else { - utils::error_log::add_error(utils::error_log::codes::WRONG_PARTITION_TABLE).await; + utils::error_log::add_error(utils::error_log::codes::WRONG_PARTITION_TABLE); #[cfg(feature = "v3")] lcd_v3::lcd_show_critical_code(board.lcd, utils::error_log::codes::WRONG_PARTITION_TABLE) .await; @@ -267,7 +267,7 @@ async fn main(spawner: Spawner) { let res = ota.ota_mark_app_valid(); if let Err(e) = res { log::error!("Ota mark app valid failed: {e:?}"); - utils::error_log::add_error(utils::error_log::codes::OTA_MARK_VALID_FAILED).await; + utils::error_log::add_error(utils::error_log::codes::OTA_MARK_VALID_FAILED); } } } @@ -288,7 +288,7 @@ async fn main(spawner: Spawner) { let Ok(mut wifi_res) = wifi_res else { log::error!("WifiManager failed!!! Restarting in 1s!"); - utils::error_log::add_error(utils::error_log::codes::WIFI_MANAGER_FAILED).await; + utils::error_log::add_error(utils::error_log::codes::WIFI_MANAGER_FAILED); utils::error_log::save_error_log(&nvs).await; Timer::after_millis(1000).await; esp_hal::system::software_reset(); @@ -329,8 +329,7 @@ async fn main(spawner: Spawner) { Timer::after_millis(1000).await; if parse_retry_count > 3 { log::error!("Cannot parse wsurl! Reseting wifi configuration!"); - utils::error_log::add_error(utils::error_log::codes::MDNS_WS_URL_PARSE_FAILED) - .await; + utils::error_log::add_error(utils::error_log::codes::MDNS_WS_URL_PARSE_FAILED); utils::error_log::save_error_log(&nvs).await; _ = nvs.delete(WIFI_NVS_KEY).await; Timer::after_millis(1000).await; diff --git a/src/rfid.rs b/src/rfid.rs index eb2fea1..23f8936 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -40,15 +40,13 @@ pub async fn rfid_task( #[cfg(feature = "v3")] let Ok(dma_tx_buf) = dma_tx_buffer!(512) else { log::error!("Dma tx buf failed"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_TX_INIT_FAILED) - .await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_TX_INIT_FAILED); return; }; #[cfg(feature = "v3")] let Ok(dma_rx_buf) = dma_rx_buffer!(512) else { log::error!("Dma rx buf failed"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_RX_INIT_FAILED) - .await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_DMA_RX_INIT_FAILED); return; }; @@ -68,8 +66,7 @@ pub async fn rfid_task( .into_async() }) else { log::error!("Rfid task error while creating Spi instance!"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_SPI_CREATE_FAILED) - .await; + crate::utils::error_log::add_error(crate::utils::error_log::codes::RFID_SPI_CREATE_FAILED); return; }; @@ -80,8 +77,7 @@ pub async fn rfid_task( log::error!("Spi bus init failed (cs set high failed)"); crate::utils::error_log::add_error( crate::utils::error_log::codes::RFID_SPI_BUS_INIT_FAILED, - ) - .await; + ); return; }; @@ -101,8 +97,7 @@ pub async fn rfid_task( if !error_logged { crate::utils::error_log::add_error( crate::utils::error_log::codes::RFID_INIT_FAILED, - ) - .await; + ); error_logged = true; } Timer::after(Duration::from_millis(RFID_RETRY_INIT_MS)).await; @@ -137,8 +132,7 @@ pub async fn rfid_task( if !error_logged { crate::utils::error_log::add_error( crate::utils::error_log::codes::RFID_INIT_FAILED, - ) - .await; + ); error_logged = true; } Timer::after(Duration::from_millis(RFID_RETRY_INIT_MS)).await; @@ -516,8 +510,7 @@ async fn process_card_info_response( if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::RFID_SOLVE_GROUP_MISSING, - ) - .await; + ); LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } diff --git a/src/stackmat.rs b/src/stackmat.rs index 7af71c6..02b2f05 100644 --- a/src/stackmat.rs +++ b/src/stackmat.rs @@ -97,8 +97,7 @@ pub async fn stackmat_task( log::error!("Stackmat task error while creating UartRx instance!"); crate::utils::error_log::add_error( crate::utils::error_log::codes::STACKMAT_UART_INIT_FAILED, - ) - .await; + ); return; }; diff --git a/src/state.rs b/src/state.rs index 8ec76ee..770bcf9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,7 +1,6 @@ use crate::consts::NVS_SAVED_STATE; use crate::{ structs::{BleDisplayDevice, PossibleGroup}, - utils::error_log::ErrorLogEntry, utils::signaled_mutex::SignaledMutex, }; use alloc::{rc::Rc, string::String, vec::Vec}; @@ -316,7 +315,6 @@ pub struct SignaledGlobalStateInner { pub group_selected_idx: usize, pub selected_config_menu: Option, - pub error_log_entries: Vec, pub selected_error_log_item: usize, pub selected_error_log_entry: Option, pub error_log_entry_stage: Option, @@ -379,7 +377,6 @@ impl SignaledGlobalStateInner { possible_groups: Vec::new(), group_selected_idx: 0, selected_config_menu: None, - error_log_entries: Vec::new(), selected_error_log_item: 0, selected_error_log_entry: None, error_log_entry_stage: None, @@ -583,8 +580,7 @@ impl SavedGlobalState { if !SAVED_STATE_WRITE_ERR_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::NVS_SAVED_STATE_WRITE_FAILED, - ) - .await; + ); SAVED_STATE_WRITE_ERR_LOGGED.store(true, Ordering::Relaxed); } @@ -602,8 +598,7 @@ impl SavedGlobalState { if !SAVED_STATE_DELETE_ERR_LOGGED.load(Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::NVS_SAVED_STATE_DELETE_FAILED, - ) - .await; + ); SAVED_STATE_DELETE_ERR_LOGGED.store(true, Ordering::Relaxed); } @@ -637,7 +632,6 @@ impl PartialEq for SignaledGlobalStateInner { && self.possible_groups == other.possible_groups && self.group_selected_idx == other.group_selected_idx && self.selected_config_menu == other.selected_config_menu - && self.error_log_entries == other.error_log_entries && self.selected_error_log_item == other.selected_error_log_item && self.selected_error_log_entry == other.selected_error_log_entry && self.error_log_entry_stage == other.error_log_entry_stage diff --git a/src/utils/backtrace_store.rs b/src/utils/backtrace_store.rs index 3e46645..829f125 100644 --- a/src/utils/backtrace_store.rs +++ b/src/utils/backtrace_store.rs @@ -21,9 +21,7 @@ pub fn verify_panic_flag() { if unsafe { NVS_PANIC_FLAG == NVS_PANIC_MAGIC_2 } { log::error!("Two panics in a row! Self recovery..."); - embassy_futures::block_on(crate::utils::error_log::add_error( - crate::utils::error_log::codes::DOUBLE_PANIC_RECOVERY, - )); + crate::utils::error_log::add_error(crate::utils::error_log::codes::DOUBLE_PANIC_RECOVERY); let mut flash = FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() }); let mut buf = [0; 1024]; @@ -35,9 +33,7 @@ pub fn verify_panic_flag() { if let Err(e) = res { log::error!("read_len_err: {e:?}"); - embassy_futures::block_on(crate::utils::error_log::add_error( - crate::utils::error_log::codes::BACKTRACE_READ_FAILED, - )); + crate::utils::error_log::add_error(crate::utils::error_log::codes::BACKTRACE_READ_FAILED); } let mut len = u16::from_be_bytes([buf[0], buf[1]]); @@ -72,8 +68,7 @@ pub async fn read_saved_backtrace() { log::error!("read_len_err: {e:?}"); crate::utils::error_log::add_error( crate::utils::error_log::codes::BACKTRACE_READ_FAILED, - ) - .await; + ); return; } @@ -92,8 +87,7 @@ pub async fn read_saved_backtrace() { log::error!("read_msg_err: {e:?}"); crate::utils::error_log::add_error( crate::utils::error_log::codes::BACKTRACE_READ_FAILED, - ) - .await; + ); return; } @@ -130,7 +124,7 @@ pub async fn read_saved_backtrace() { &[0x00, 0x00], ); - crate::utils::error_log::add_stacktrace(&addrs, saved_version, saved_timestamp).await; + crate::utils::error_log::add_stacktrace(&addrs, saved_version, saved_timestamp); } } diff --git a/src/utils/error_log.rs b/src/utils/error_log.rs index b142738..0f6e300 100644 --- a/src/utils/error_log.rs +++ b/src/utils/error_log.rs @@ -1,25 +1,70 @@ +use super::ring_log::{self, CODE_LEN, STACKTRACE_HEADER_LEN, record_len}; use crate::{consts::NVS_ERROR_LOG, state::current_epoch}; -use alloc::{ - format, - string::{String, ToString}, - vec::Vec, -}; -use anyhow::Result; +use core::{cell::RefCell, fmt::Write}; +use embassy_sync::blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}; +use embassy_time::Instant; use esp_hal_wifimanager::Nvs; +use portable_atomic::{AtomicBool, Ordering}; +const CAPACITY: usize = 2 * 1024; +/// Same code is recorded at most once per this many seconds (a failing peripheral +/// reports in a tight loop). +const RATE_LIMIT_SECS: u64 = 60; +const RATE_LIMIT_SLOTS: usize = 8; +pub const MAX_STACKTRACE_ADDRS: usize = 16; + + +/// Records, oldest first. `b'N'` + epoch u64 BE + code, or `b'S'` + epoch u64 BE + +/// version [u8; 16] + n + n * u32 BE (same format as stored in NVS). #[unsafe(link_section = ".dram2_uninit")] -static mut ERROR_LOG_BUF: core::mem::MaybeUninit<[u8; 2 * 1024]> = core::mem::MaybeUninit::uninit(); -static mut OFFSET: usize = 0; -static mut SAVE_READY: bool = false; +static BUF: UninitBuf = UninitBuf(core::cell::UnsafeCell::new(core::mem::MaybeUninit::uninit())); + +struct UninitBuf(core::cell::UnsafeCell>); +// SAFETY: only accessed inside `LOG.lock` (critical section), see `with_log`. +unsafe impl Sync for UninitBuf {} + +struct ErrorLog { + initialized: bool, + len: usize, + recent: [(u8, u64); RATE_LIMIT_SLOTS], + recent_next: usize, +} + +static LOG: Mutex> = + Mutex::new(RefCell::new(ErrorLog { + initialized: false, + len: 0, + recent: [(0, 0); RATE_LIMIT_SLOTS], + recent_next: 0, + })); +static DIRTY: AtomicBool = AtomicBool::new(false); + +/// Runs `f` with exclusive access to the log state and the (initialized) buffer. +fn with_log(f: impl FnOnce(&mut ErrorLog, &mut [u8; CAPACITY]) -> R) -> R { + LOG.lock(|log| { + let mut log = log.borrow_mut(); + // SAFETY: the critical section serializes all access; the section is not zeroed + // at boot, so zero it once before the first reference is formed. + let buf = unsafe { + let ptr = BUF.0.get(); + if !log.initialized { + (*ptr).write([0; CAPACITY]); + log.initialized = true; + } + (*ptr).assume_init_mut() + }; + f(&mut log, buf) + }) +} #[inline(always)] pub fn is_save_ready() -> bool { - unsafe { SAVE_READY } + DIRTY.load(Ordering::Relaxed) } #[inline(always)] pub fn clear_save_ready() { - unsafe { SAVE_READY = false }; + DIRTY.store(false, Ordering::Relaxed); } #[allow(dead_code)] @@ -98,181 +143,127 @@ pub mod codes { pub const BACKTRACE_READ_FAILED: u8 = 91; } -pub async fn add_error(code: u8) { - unsafe { - #[allow(static_mut_refs)] - let error_log_buf = &mut (*ERROR_LOG_BUF.as_mut_ptr()); - - error_log_buf[OFFSET] = b'N'; - error_log_buf[OFFSET + 1..OFFSET + 1 + 8].copy_from_slice(¤t_epoch().to_be_bytes()); - error_log_buf[OFFSET + 1 + 8] = code; - - OFFSET += 1 + 8 + 1; - SAVE_READY = true; +fn push_record(log: &mut ErrorLog, buf: &mut [u8; CAPACITY], record: &[u8]) { + if ring_log::push(buf, &mut log.len, record) { + DIRTY.store(true, Ordering::Relaxed); } } -pub async fn add_stacktrace(addrs: &[u32], version: &str, timestamp: u64) { - unsafe { - #[allow(static_mut_refs)] - let error_log_buf = &mut (*ERROR_LOG_BUF.as_mut_ptr()); - - let mut version_buf = [0; 16]; - let version_bytes = version.as_bytes(); - let version_len = core::cmp::min(version_bytes.len(), version_buf.len()); - version_buf[..version_len].copy_from_slice(&version_bytes[..version_len]); - - error_log_buf[OFFSET] = b'S'; - error_log_buf[OFFSET + 1..OFFSET + 1 + 8].copy_from_slice(×tamp.to_be_bytes()); - error_log_buf[OFFSET + 1 + 8..OFFSET + 1 + 8 + 16].copy_from_slice(&version_buf); - error_log_buf[OFFSET + 1 + 8 + 16] = addrs.len() as u8; - - for (i, &addr) in addrs.iter().enumerate() { - let start = OFFSET + 1 + 8 + 16 + 1 + i * 4; - let end = start + 4; - error_log_buf[start..end].copy_from_slice(&addr.to_be_bytes()); +/// Records error `code` (rate limited per code). +pub fn add_error(code: u8) { + let now = Instant::now().as_secs(); + let mut record = [0; CODE_LEN]; + record[0] = b'N'; + record[1..9].copy_from_slice(¤t_epoch().to_be_bytes()); + record[9] = code; + + with_log(|log, buf| { + let recently = log + .recent + .iter() + .any(|&(c, at)| c == code && at != 0 && now.saturating_sub(at) < RATE_LIMIT_SECS); + if recently { + return; } - OFFSET += 1 + 8 + 16 + 1 + addrs.len() * 4; - SAVE_READY = true; - } + let slot = log.recent_next; + log.recent[slot] = (code, now.max(1)); + log.recent_next = (slot + 1) % RATE_LIMIT_SLOTS; + push_record(log, buf, &record); + }); } -pub fn dump_error_log() -> &'static [u8] { - #[allow(static_mut_refs)] - unsafe { - &(&*ERROR_LOG_BUF.as_ptr())[..OFFSET] +/// Records a panic backtrace (at most [`MAX_STACKTRACE_ADDRS`] addresses). +pub fn add_stacktrace(addrs: &[u32], version: &str, timestamp: u64) { + let addrs = &addrs[..addrs.len().min(MAX_STACKTRACE_ADDRS)]; + let mut record = [0; STACKTRACE_HEADER_LEN + MAX_STACKTRACE_ADDRS * 4]; + record[0] = b'S'; + record[1..9].copy_from_slice(×tamp.to_be_bytes()); + let version_len = version.len().min(16); + record[9..9 + version_len].copy_from_slice(&version.as_bytes()[..version_len]); + record[STACKTRACE_HEADER_LEN - 1] = addrs.len() as u8; + for (chunk, addr) in record[STACKTRACE_HEADER_LEN..].chunks_exact_mut(4).zip(addrs) { + chunk.copy_from_slice(&addr.to_be_bytes()); } + + let len = STACKTRACE_HEADER_LEN + addrs.len() * 4; + with_log(|log, buf| push_record(log, buf, &record[..len])); } +/// Calls `f` with the raw log (the NVS / crash log dump format). +pub fn with_error_log(f: impl FnOnce(&[u8]) -> R) -> R { + with_log(|log, buf| f(&buf[..log.len])) +} + +/// Restores the log saved in NVS, keeping only the valid prefix. pub async fn load_error_log(nvs: &Nvs) { - let Ok(buf) = nvs.get::>(NVS_ERROR_LOG).await else { + let Ok(saved) = nvs.get::>(NVS_ERROR_LOG).await else { return; }; - let loaded_len = buf.len(); - - #[allow(static_mut_refs)] - let error_log_buf = unsafe { &mut (*ERROR_LOG_BUF.as_mut_ptr()) }; - error_log_buf[..buf.len()].copy_from_slice(&buf); - drop(buf); + let saved = &saved[..saved.len().min(CAPACITY)]; + let valid = ring_log::valid_prefix(saved); + with_log(|log, buf| { + buf[..valid].copy_from_slice(&saved[..valid]); + log.len = valid; + }); +} - let mut offset = 0; - while offset < loaded_len { - let log_type = error_log_buf[offset]; - match log_type { - b'N' => { - // u64 + u8 - offset += 1 + 8 + 1; - } - b'S' => { - // u64 + 16 * u8 + u8 (size) + size * u32 - let size = error_log_buf[offset + 1 + 8 + 16]; - offset += 1 + 8 + 16 + 1 + size as usize * 4; - } - _ => { - break; - } - } +pub async fn save_error_log(nvs: &Nvs) { + // shortcut: copies the log to the heap for the NVS write (rare, throttled by the caller) + let bytes = with_error_log(|b| b.to_vec()); + if let Err(e) = nvs.set(NVS_ERROR_LOG, bytes.as_slice()).await { + log::error!("errorlog save error: {e:?}"); } +} - unsafe { - OFFSET = offset; - } +/// Number of entries in the log. +pub fn entry_count() -> usize { + with_error_log(|buf| Entries { buf }.count()) } -pub async fn save_error_log(nvs: &Nvs) { - #[allow(static_mut_refs)] - let error_log_buf = unsafe { &mut (*ERROR_LOG_BUF.as_mut_ptr()) }; - - unsafe { - _ = nvs.delete(NVS_ERROR_LOG).await; - let res = nvs.set(NVS_ERROR_LOG, &error_log_buf[..OFFSET]).await; - if let Err(e) = res { - log::error!("errorlog save error: {e:?}"); - } - } +/// Entry `idx` (oldest first). +pub fn entry(idx: usize) -> Option { + with_error_log(|buf| Entries { buf }.nth(idx)) } -pub fn parse_error_log_entries() -> Result> { - let mut tmp = Vec::new(); - - #[allow(static_mut_refs)] - let error_log_buf = unsafe { &mut (*ERROR_LOG_BUF.as_mut_ptr()) }; - let max_offset = unsafe { OFFSET }; - - log::warn!("ERROR LOG:"); - let mut offset = 0; - while offset < max_offset { - let log_type = error_log_buf[offset]; - match log_type { - b'N' => { - // u64 + u8 - let entry = ErrorLogEntry::Code { - timestamp: u64::from_be_bytes( - error_log_buf[offset + 1..offset + 1 + 8].try_into()?, - ), - code: error_log_buf[offset + 1 + 8], - }; - - if let ErrorLogEntry::Code { timestamp, code } = entry { - log::warn!( - "Error {} at {}", - code, - crate::utils::error_log::format_timestamp_full(timestamp) - ); - } +struct Entries<'a> { + buf: &'a [u8], +} - tmp.push(entry); - offset += 1 + 8 + 1; - } - b'S' => { - // u64 + 16 * u8 + u8 (size) + size * u32 - - let version_str = - core::str::from_utf8(&error_log_buf[offset + 1 + 8..offset + 1 + 8 + 16])?; - let version_str = version_str.trim_end_matches('\0'); - - let size = error_log_buf[offset + 1 + 8 + 16]; - let mut tmp_addrs = Vec::new(); - for addr in (error_log_buf - [offset + 1 + 8 + 16 + 1..offset + 1 + 8 + 16 + 1 + size as usize * 4]) - .chunks(4) - { - tmp_addrs.push(u32::from_be_bytes(addr.try_into()?)); - } - let entry = ErrorLogEntry::Stacktrace { - timestamp: u64::from_be_bytes( - error_log_buf[offset + 1..offset + 1 + 8].try_into()?, - ), - version: version_str.to_string(), - addrs: tmp_addrs, - }; - if let ErrorLogEntry::Stacktrace { - timestamp, - ref version, - ref addrs, - } = entry - { - log::warn!( - "Panic of ver {version} at {}. Addrs: {addrs:X?}", - crate::utils::error_log::format_timestamp_full(timestamp) - ); - } +impl Iterator for Entries<'_> { + type Item = ErrorLogEntry; - tmp.push(entry); - offset += 1 + 8 + 16 + 1 + size as usize * 4; - } + fn next(&mut self) -> Option { + let len = record_len(self.buf)?; + let (record, rest) = self.buf.split_at(len); + self.buf = rest; + + let timestamp = u64::from_be_bytes(record[1..9].try_into().ok()?); + Some(match record[0] { + b'N' => ErrorLogEntry::Code { + timestamp, + code: record[9], + }, _ => { - break; + let version = core::str::from_utf8(&record[9..25]) + .unwrap_or("") + .trim_end_matches('\0'); + ErrorLogEntry::Stacktrace { + timestamp, + version: heapless::String::try_from(version).unwrap_or_default(), + addrs: record[STACKTRACE_HEADER_LEN..] + .chunks_exact(4) + .take(MAX_STACKTRACE_ADDRS) + .filter_map(|c| c.try_into().ok().map(u32::from_be_bytes)) + .collect(), + } } - } + }) } - - Ok(tmp) } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Hash)] pub enum ErrorLogEntry { Code { timestamp: u64, @@ -280,45 +271,43 @@ pub enum ErrorLogEntry { }, Stacktrace { timestamp: u64, - version: String, - addrs: Vec, + version: heapless::String<16>, + addrs: heapless::Vec, }, } -impl ErrorLogEntry { - #[cfg(feature = "v3")] - pub fn list_label_v3(&self) -> String { - match self { - ErrorLogEntry::Code { timestamp, code } => { - format!("E{code} {}", format_timestamp_compact(*timestamp)) - } - ErrorLogEntry::Stacktrace { timestamp, .. } => { - format!("Panic {}", format_timestamp_compact(*timestamp)) - } - } - } +pub type Label = heapless::String<24>; - #[cfg(feature = "v4")] - pub fn list_label_v4(&self) -> String { - match self { +impl ErrorLogEntry { + pub fn list_label(&self) -> Label { + let mut label = Label::new(); + _ = match self { ErrorLogEntry::Code { timestamp, code } => { - format!("E{code} {}", format_timestamp_compact(*timestamp)) + write!(label, "E{code} {}", format_timestamp_compact(*timestamp)) } ErrorLogEntry::Stacktrace { timestamp, .. } => { - format!("Panic {}", format_timestamp_compact(*timestamp)) + write!(label, "Panic {}", format_timestamp_compact(*timestamp)) } - } + }; + label } } -pub fn format_timestamp_compact(timestamp: u64) -> String { +pub fn format_timestamp_compact(timestamp: u64) -> Label { let (_year, month, day, hour, minute, _second) = epoch_to_ymdhms(timestamp); - format!("{day:02}/{month:02} {hour:02}:{minute:02}") + let mut s = Label::new(); + _ = write!(s, "{day:02}/{month:02} {hour:02}:{minute:02}"); + s } -pub fn format_timestamp_full(timestamp: u64) -> String { +pub fn format_timestamp_full(timestamp: u64) -> Label { let (year, month, day, hour, minute, second) = epoch_to_ymdhms(timestamp); - format!("{day:02}/{month:02}/{year:04} {hour:02}:{minute:02}:{second:02}") + let mut s = Label::new(); + _ = write!( + s, + "{day:02}/{month:02}/{year:04} {hour:02}:{minute:02}:{second:02}" + ); + s } fn epoch_to_ymdhms(timestamp: u64) -> (i32, u32, u32, u32, u32, u32) { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index c44d645..3989151 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -5,6 +5,7 @@ pub mod backtrace_store; pub mod buttons; pub mod error_log; pub mod logger; +pub mod ring_log; pub mod rolling_average; pub mod signaled_mutex; pub mod stackmat; @@ -20,9 +21,7 @@ pub fn spawn_task( } Err(e) => { log::error!("Cannot create task {task_name}: {e:?}"); - embassy_futures::block_on(crate::utils::error_log::add_error( - crate::utils::error_log::codes::TASK_SPAWN_FAILED, - )); + crate::utils::error_log::add_error(crate::utils::error_log::codes::TASK_SPAWN_FAILED); } } } diff --git a/src/utils/ring_log.rs b/src/utils/ring_log.rs new file mode 100644 index 0000000..84198b8 --- /dev/null +++ b/src/utils/ring_log.rs @@ -0,0 +1,103 @@ +//! Variable-length record log in a fixed buffer (no deps, host-testable with +//! `rustc --edition 2024 --test src/utils/ring_log.rs`). + +pub const CODE_LEN: usize = 1 + 8 + 1; +pub const STACKTRACE_HEADER_LEN: usize = 1 + 8 + 16 + 1; + +/// Length of the record starting at `buf[0]`, `None` if it is invalid or truncated. +/// +/// `b'N'` + u64 + code, or `b'S'` + u64 + [u8; 16] + n + n * u32. +pub fn record_len(buf: &[u8]) -> Option { + let len = match *buf.first()? { + b'N' => CODE_LEN, + b'S' => STACKTRACE_HEADER_LEN + *buf.get(STACKTRACE_HEADER_LEN - 1)? as usize * 4, + _ => return None, + }; + (len <= buf.len()).then_some(len) +} + +/// Length of the longest prefix of `buf` made of whole valid records. +pub fn valid_prefix(buf: &[u8]) -> usize { + let mut valid = 0; + while let Some(len) = buf.get(valid..).and_then(record_len) { + valid += len; + } + valid +} + +/// Appends `record` to `buf[..*len]`, dropping the oldest records until it fits. +/// Returns false (log unchanged) if the record can never fit. +pub fn push(buf: &mut [u8], len: &mut usize, record: &[u8]) -> bool { + if record.len() > buf.len() { + return false; + } + + while *len + record.len() > buf.len() { + match record_len(&buf[..*len]) { + Some(oldest) => { + buf.copy_within(oldest..*len, 0); + *len -= oldest; + } + None => *len = 0, + } + } + + buf[*len..*len + record.len()].copy_from_slice(record); + *len += record.len(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn code(c: u8) -> [u8; CODE_LEN] { + let mut r = [0; CODE_LEN]; + r[0] = b'N'; + r[9] = c; + r + } + + #[test] + fn drops_oldest_when_full() { + let mut buf = [0; 35]; + let mut len = 0; + for c in 0..10 { + assert!(push(&mut buf, &mut len, &code(c))); + } + assert_eq!(len, 30); + assert_eq!(buf[9], 7); // oldest kept is code 7 + assert_eq!(buf[29], 9); + assert_eq!(valid_prefix(&buf[..len]), len); + } + + #[test] + fn stacktrace_records_and_oversized() { + let mut buf = [0; 64]; + let mut len = 0; + let mut st = [0; STACKTRACE_HEADER_LEN + 8]; + st[0] = b'S'; + st[STACKTRACE_HEADER_LEN - 1] = 2; + assert!(push(&mut buf, &mut len, &code(1))); + assert!(push(&mut buf, &mut len, &st)); + assert!(push(&mut buf, &mut len, &st)); // evicts code + first stacktrace + assert_eq!(len, st.len()); + assert!(!push(&mut buf, &mut len, &[b'N'; 65])); + } + + #[test] + fn garbage_never_panics() { + assert_eq!(valid_prefix(&[]), 0); + assert_eq!(valid_prefix(&[b'N', 0, 0]), 0); + assert_eq!(valid_prefix(&[b'S'; 30]), 0); + assert_eq!(valid_prefix(&[0xFF; 100]), 0); + let mut ok = code(3).to_vec(); + ok.extend_from_slice(&[b'S', 1]); + assert_eq!(valid_prefix(&ok), CODE_LEN); + + let mut buf = [0xAA; 20]; // corrupt content with len claiming data + let mut len = 20; + assert!(push(&mut buf, &mut len, &code(5))); + assert_eq!(len, CODE_LEN); + } +} diff --git a/src/utils/shared_i2c.rs b/src/utils/shared_i2c.rs index 71ced10..30d6d81 100644 --- a/src/utils/shared_i2c.rs +++ b/src/utils/shared_i2c.rs @@ -29,8 +29,7 @@ impl embedded_hal_async::i2c::I2c for SharedI2C { Err(_) => { crate::utils::error_log::add_error( crate::utils::error_log::codes::SHARED_I2C_TIMEOUT, - ) - .await; + ); Err(esp_hal::i2c::master::Error::Timeout) } } diff --git a/src/ws.rs b/src/ws.rs index 6d64019..acbb690 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -158,8 +158,7 @@ async fn ws_loop( if !DNS_EMPTY_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_DNS_RESOLVE_EMPTY, - ) - .await; + ); DNS_EMPTY_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } @@ -234,8 +233,7 @@ async fn ws_loop( if !HTTP_UPGRADE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_HTTP_UPGRADE_READ_FAILED, - ) - .await; + ); HTTP_UPGRADE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } @@ -321,8 +319,7 @@ async fn ws_loop( log::error!("Connection lost during OTA update: {e:?}"); crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_CONNECTION_LOST_DURING_OTA, - ) - .await; + ); crate::utils::error_log::save_error_log(&global_state.nvs).await; global_state.state.lock().await.custom_message = @@ -536,8 +533,7 @@ async fn ws_rw( if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED, - ) - .await; + ); LOGGED .store(true, core::sync::atomic::Ordering::Relaxed); @@ -553,7 +549,7 @@ async fn ws_rw( TimerPacketInner::DumpCrashLog => { let mut tmp = Vec::new(); tmp.push(b'C'); - tmp.extend_from_slice(crate::utils::error_log::dump_error_log()); + crate::utils::error_log::with_error_log(|log| tmp.extend_from_slice(log)); send_frame(ws_framer::WsFrameOwned::Binary(tmp)).await; } @@ -570,8 +566,7 @@ async fn ws_rw( if !PACKET_PARSE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_PACKET_PARSE_FAILED, - ) - .await; + ); PACKET_PARSE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } @@ -597,8 +592,7 @@ async fn ws_rw( log::error!("OTA flash verify failed!"); crate::utils::error_log::add_error( crate::utils::error_log::codes::OTA_VERIFY_FAILED, - ) - .await; + ); } } @@ -688,8 +682,7 @@ pub async fn send_packet(packet: TimerPacket) { if !PACKET_SERIALIZE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED, - ) - .await; + ); PACKET_SERIALIZE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } @@ -767,8 +760,7 @@ async fn wait_for_tagged_response(tag: u64) -> TimerPacket { if !TAGGED_SUBSCRIBER_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { crate::utils::error_log::add_error( crate::utils::error_log::codes::WS_TAGGED_SUBSCRIBER_FAILED, - ) - .await; + ); TAGGED_SUBSCRIBER_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); } From 75c3c5650758736ac1298165a2ffc663fe8a9e5b Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:09:18 +0200 Subject: [PATCH 006/132] refactor: allocation-free reentrancy-safe logger --- src/main.rs | 23 ++--- src/utils/error_log.rs | 26 ++--- src/utils/logger.rs | 215 ++++++++++++++++++++++++++--------------- src/utils/mod.rs | 33 +++++++ 4 files changed, 190 insertions(+), 107 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9140507..3391965 100644 --- a/src/main.rs +++ b/src/main.rs @@ -431,22 +431,19 @@ async fn logger_task(global_state: GlobalState) { continue; } - let logs_vec = { + let logs = { let state = global_state.state.value().await; - #[allow(static_mut_refs)] - unsafe { - crate::utils::logger::LOGS_WRITER.get_vec( - crate::state::current_time(), - crate::state::inspection_time(), - state.current_competitor, - state.solve_group.as_ref().map(|g| g.group_id.as_str()), - state.session_id.as_deref(), - ) - } + crate::utils::logger::take_ready(&crate::utils::logger::LogsHeader { + current_time: crate::state::current_time(), + inspection_time: crate::state::inspection_time(), + competitor: state.current_competitor, + group_id: state.solve_group.as_ref().map(|g| g.group_id.as_str()), + session_id: state.session_id.as_deref(), + }) }; - if !logs_vec.is_empty() { - ws::send_frame(ws_framer::WsFrameOwned::Binary(logs_vec)).await; + if let Some(logs) = logs { + ws::send_frame(ws_framer::WsFrameOwned::Binary(logs.to_vec())).await; } #[cfg(not(feature = "release_build"))] diff --git a/src/utils/error_log.rs b/src/utils/error_log.rs index 0f6e300..cb9d3a2 100644 --- a/src/utils/error_log.rs +++ b/src/utils/error_log.rs @@ -1,4 +1,7 @@ -use super::ring_log::{self, CODE_LEN, STACKTRACE_HEADER_LEN, record_len}; +use super::{ + UninitBuf, + ring_log::{self, CODE_LEN, STACKTRACE_HEADER_LEN, record_len}, +}; use crate::{consts::NVS_ERROR_LOG, state::current_epoch}; use core::{cell::RefCell, fmt::Write}; use embassy_sync::blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}; @@ -14,14 +17,9 @@ const RATE_LIMIT_SLOTS: usize = 8; pub const MAX_STACKTRACE_ADDRS: usize = 16; -/// Records, oldest first. `b'N'` + epoch u64 BE + code, or `b'S'` + epoch u64 BE + -/// version [u8; 16] + n + n * u32 BE (same format as stored in NVS). +/// Records, oldest first, in the [`ring_log`] format (same as stored in NVS). #[unsafe(link_section = ".dram2_uninit")] -static BUF: UninitBuf = UninitBuf(core::cell::UnsafeCell::new(core::mem::MaybeUninit::uninit())); - -struct UninitBuf(core::cell::UnsafeCell>); -// SAFETY: only accessed inside `LOG.lock` (critical section), see `with_log`. -unsafe impl Sync for UninitBuf {} +static BUF: UninitBuf = UninitBuf::new(); struct ErrorLog { initialized: bool, @@ -43,16 +41,8 @@ static DIRTY: AtomicBool = AtomicBool::new(false); fn with_log(f: impl FnOnce(&mut ErrorLog, &mut [u8; CAPACITY]) -> R) -> R { LOG.lock(|log| { let mut log = log.borrow_mut(); - // SAFETY: the critical section serializes all access; the section is not zeroed - // at boot, so zero it once before the first reference is formed. - let buf = unsafe { - let ptr = BUF.0.get(); - if !log.initialized { - (*ptr).write([0; CAPACITY]); - log.initialized = true; - } - (*ptr).assume_init_mut() - }; + // SAFETY: the critical section serializes all access to BUF. + let buf = unsafe { BUF.get(&mut log.initialized) }; f(&mut log, buf) }) } diff --git a/src/utils/logger.rs b/src/utils/logger.rs index a1223c8..8cc66b0 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -1,71 +1,162 @@ -use crate::state::{ota_state, sleep_state}; -use alloc::vec::Vec; +use super::UninitBuf; +use core::cell::RefCell; +use embassy_sync::blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}; +use portable_atomic::{AtomicBool, Ordering}; +const HALF: usize = 4 * 1024; +/// 'L' + truncated flag + u64 time + u64 inspection + u64 competitor + 20 B group id +/// + 36 B session id + padding +const HEADER_LEN: usize = 100; + +/// Two halves: the logger appends to the active one, the consumer sends the other. #[unsafe(link_section = ".dram2_uninit")] -static mut LOGS_BUF: core::mem::MaybeUninit<[u8; 8 * 1024]> = core::mem::MaybeUninit::uninit(); +static LOGS_BUF: UninitBuf<{ 2 * HALF }> = UninitBuf::new(); -pub struct LogsBufWriter { - buf: &'static mut [u8], +struct LogsState { + initialized: bool, + active: usize, pos: usize, } -#[allow(static_mut_refs)] -pub static mut LOGS_WRITER: LogsBufWriter = LogsBufWriter { - buf: unsafe { &mut *LOGS_BUF.as_mut_ptr() }, - pos: 0, -}; - -impl LogsBufWriter { - pub fn get_vec( - &mut self, - current_time: Option, - inspection_time: Option, - competitor: Option, - group_id: Option<&str>, - session_id: Option<&str>, - ) -> Vec { - self.buf[2..10].copy_from_slice(¤t_time.unwrap_or(u64::MAX).to_be_bytes()); - self.buf[10..18].copy_from_slice(&inspection_time.unwrap_or(u64::MAX).to_be_bytes()); - self.buf[18..26].copy_from_slice(&competitor.unwrap_or(u64::MAX).to_be_bytes()); - self.buf[26..46].fill(0x00); - self.buf[46..82].fill(0x00); - - if let Some(group_id) = group_id - && group_id.len() <= 20 - { - let bytes = &group_id.as_bytes(); - self.buf[26..26 + bytes.len()].copy_from_slice(bytes); +static LOGS: Mutex> = + Mutex::new(RefCell::new(LogsState { + initialized: false, + active: 0, + pos: 0, + })); +static READY_TAKEN: AtomicBool = AtomicBool::new(false); + +/// Live values written into the header of a log batch. +pub struct LogsHeader<'a> { + pub current_time: Option, + pub inspection_time: Option, + pub competitor: Option, + pub group_id: Option<&'a str>, + pub session_id: Option<&'a str>, +} + +/// A finished log batch; the half stays reserved until this is dropped. +pub struct ReadyLogs(&'static [u8]); + +impl core::ops::Deref for ReadyLogs { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0 + } +} + +impl Drop for ReadyLogs { + fn drop(&mut self) { + READY_TAKEN.store(false, Ordering::Release); + } +} + +/// Swaps the halves and returns the finished batch (with `header` filled in), or +/// `None` if nothing was logged or the previous batch is still held. +pub fn take_ready(header: &LogsHeader) -> Option { + if READY_TAKEN.swap(true, Ordering::Acquire) { + return None; + } + + let ready = LOGS.lock(|state| { + let mut state = state.try_borrow_mut().ok()?; + if state.pos == 0 { + return None; } - if let Some(session_id) = session_id - && session_id.len() <= 36 - { - let bytes = &session_id.as_bytes(); - self.buf[46..46 + bytes.len()].copy_from_slice(bytes); + let (half, len) = (state.active, state.pos); + state.active ^= 1; + state.pos = 0; + + // SAFETY: inside the critical section; the returned half is only written again + // after the next swap, which needs READY_TAKEN (held by the ReadyLogs). + let buf = unsafe { LOGS_BUF.get(&mut state.initialized) }; + let batch = &mut buf[half * HALF..half * HALF + len]; + write_header(batch, header); + + let ptr = batch.as_ptr(); + // SAFETY: points into a 'static buffer; exclusively ours until ReadyLogs drops. + Some(unsafe { core::slice::from_raw_parts(ptr, len) }) + }); + + match ready { + Some(bytes) => Some(ReadyLogs(bytes)), + None => { + READY_TAKEN.store(false, Ordering::Release); + None } + } +} - let tmp = self.buf[0..self.pos].to_vec(); - self.pos = 0; +fn write_header(batch: &mut [u8], header: &LogsHeader) { + batch[2..10].copy_from_slice(&header.current_time.unwrap_or(u64::MAX).to_be_bytes()); + batch[10..18].copy_from_slice(&header.inspection_time.unwrap_or(u64::MAX).to_be_bytes()); + batch[18..26].copy_from_slice(&header.competitor.unwrap_or(u64::MAX).to_be_bytes()); + batch[26..82].fill(0x00); - tmp + if let Some(group_id) = header.group_id.filter(|g| g.len() <= 20) { + batch[26..26 + group_id.len()].copy_from_slice(group_id.as_bytes()); } - fn mark_truncated(&mut self) { - self.buf[1] = 0x01; + if let Some(session_id) = header.session_id.filter(|s| s.len() <= 36) { + batch[46..46 + session_id.len()].copy_from_slice(session_id.as_bytes()); } +} - fn write_raw(&mut self, bytes: &[u8]) { - if self.pos >= self.buf.len() { +/// Appends one line record (u16 len + level + text) to the active half. +#[cfg_attr(any(feature = "bat_dev_lcd", feature = "qa"), allow(dead_code))] +fn append_line(level: &str, args: &core::fmt::Arguments) { + LOGS.lock(|state| { + // a log from inside the formatting below (or an ISR) is dropped instead of tearing + let Ok(mut state) = state.try_borrow_mut() else { return; + }; + let state = &mut *state; + // SAFETY: inside the critical section, only the active half is written. + let buf = unsafe { LOGS_BUF.get(&mut state.initialized) }; + let half = &mut buf[state.active * HALF..(state.active + 1) * HALF]; + + if state.pos == 0 { + half[0] = b'L'; + half[1] = 0x00; + state.pos = HEADER_LEN; } + if state.pos + 3 > HALF { + half[1] = 0x01; // truncated + return; + } + + let mut w = HalfWriter { + buf: half, + pos: state.pos + 2, + }; + w.write_raw(level.as_bytes()); + _ = core::fmt::write(&mut w, *args); + let end = w.pos; + + let line_len = (end - state.pos - 2) as u16; + half[state.pos..state.pos + 2].copy_from_slice(&line_len.to_be_bytes()); + state.pos = end; + }); +} + +#[cfg_attr(any(feature = "bat_dev_lcd", feature = "qa"), allow(dead_code))] +struct HalfWriter<'a> { + buf: &'a mut [u8], + pos: usize, +} + +impl HalfWriter<'_> { + #[cfg_attr(any(feature = "bat_dev_lcd", feature = "qa"), allow(dead_code))] + fn write_raw(&mut self, bytes: &[u8]) { let len = bytes.len().min(self.buf.len() - self.pos); self.buf[self.pos..self.pos + len].copy_from_slice(&bytes[..len]); self.pos += len; } } -impl core::fmt::Write for LogsBufWriter { +impl core::fmt::Write for HalfWriter<'_> { fn write_str(&mut self, s: &str) -> core::fmt::Result { self.write_raw(s.as_bytes()); Ok(()) @@ -91,13 +182,7 @@ impl FkmLogger { impl log::Log for FkmLogger { fn enabled(&self, metadata: &log::Metadata) -> bool { - let level = metadata.level(); - //let target = metadata.target(); - - if level <= FILTER_MAX { - return true; - } - false + metadata.level() <= FILTER_MAX } fn log(&self, record: &log::Record) { @@ -119,12 +204,11 @@ impl log::Log for FkmLogger { log::Level::Debug => BLUE, log::Level::Trace => CYAN, }; - let reset = RESET; - esp_println::println!("{}{} - {}{}", color, record.level(), record.args(), reset); + esp_println::println!("{}{} - {}{}", color, record.level(), record.args(), RESET); #[cfg(not(any(feature = "bat_dev_lcd", feature = "qa")))] - if !ota_state() && !sleep_state() { + if !crate::state::ota_state() && !crate::state::sleep_state() { let level: &str = match record.level() { log::Level::Error => "E ", log::Level::Warn => "W ", @@ -133,28 +217,7 @@ impl log::Log for FkmLogger { log::Level::Trace => "T ", }; - unsafe { - #[allow(clippy::deref_addrof)] - let w = &mut *(&raw mut LOGS_WRITER); - if w.pos == 0 { - w.buf[0] = b'L'; - w.buf[1] = 0x00; - w.pos = 100; // 2 + u64 + u64 + u64 + max20char + 36char uuid + padding - } - - if w.pos + 3 > w.buf.len() { - w.mark_truncated(); - return; - } - - let line_start = w.pos; - w.pos += 2; - w.write_raw(level.as_bytes()); - - _ = core::fmt::write(w, *record.args()); - let line_len = w.pos - line_start - 2; - w.buf[line_start..line_start + 2].copy_from_slice(&(line_len as u16).to_be_bytes()); - } + append_line(level, record.args()); } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3989151..625d3b4 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -10,6 +10,39 @@ pub mod rolling_average; pub mod signaled_mutex; pub mod stackmat; +/// A buffer for a `#[link_section = ".dram2_uninit"]` static (that region is not +/// zeroed at boot, so `StaticCell`/`Mutex` state can't live there). Callers must +/// serialize access themselves (a critical section) and call [`UninitBuf::get`] +/// only from inside it. +pub struct UninitBuf(core::cell::UnsafeCell>); + +// SAFETY: every access goes through `get`, whose contract requires exclusive access. +unsafe impl Sync for UninitBuf {} + +impl UninitBuf { + pub const fn new() -> Self { + Self(core::cell::UnsafeCell::new(core::mem::MaybeUninit::uninit())) + } + + /// Zeroes the buffer on first use (`initialized` is the caller's flag). + /// + /// # Safety + /// The caller must hold exclusive access (e.g. a critical section) for the whole + /// lifetime of the returned reference, and use one `initialized` flag per buffer. + #[allow(clippy::mut_from_ref)] + pub unsafe fn get(&self, initialized: &mut bool) -> &mut [u8; N] { + let ptr = self.0.get(); + // SAFETY: exclusive access is guaranteed by the caller. + unsafe { + if !*initialized { + (*ptr).write([0; N]); + *initialized = true; + } + (*ptr).assume_init_mut() + } + } +} + pub fn spawn_task( spawner: &Spawner, task_name: &str, From 868df4186741a5c1e8631586daf92aaabea4fb97 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:11:29 +0200 Subject: [PATCH 007/132] refactor: static global state instead of Rc --- src/main.rs | 25 +++++++++++++------------ src/state.rs | 4 ++-- src/ws.rs | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3391965..0ee9c57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,7 +136,8 @@ async fn main(spawner: Spawner) { utils::error_log::load_error_log(&nvs).await; - let global_state = Rc::new(GlobalStateInner::new(&nvs, board.aes)); + static GLOBAL_STATE: static_cell::StaticCell = static_cell::StaticCell::new(); + let global_state: GlobalState = GLOBAL_STATE.init(GlobalStateInner::new(&nvs, board.aes)); let wifi_setup_sig = Rc::new(Signal::new()); let wifi_conn_sig = Rc::new(Signal::new()); @@ -163,7 +164,7 @@ async fn main(spawner: Spawner) { spawn_task( &spawner, "lcd_v3::lcd_task", - lcd_v3::lcd_task(board.lcd, global_state.clone(), wifi_setup_sig.clone()), + lcd_v3::lcd_task(board.lcd, global_state, wifi_setup_sig.clone()), ); #[cfg(feature = "v4")] spawn_task( @@ -172,7 +173,7 @@ async fn main(spawner: Spawner) { lcd_v4::lcd_task( board.i2c.clone(), board.display_rst, - global_state.clone(), + global_state, wifi_setup_sig.clone(), ), ); @@ -181,20 +182,20 @@ async fn main(spawner: Spawner) { spawn_task( &spawner, "battery_v3::battery_read_task", - battery_v3::battery_read_task(board.battery, board.adc1, global_state.clone()), + battery_v3::battery_read_task(board.battery, board.adc1, global_state), ); #[cfg(feature = "v4")] spawn_task( &spawner, "battery_v4::battery_read_task", - battery_v4::battery_read_task(board.i2c.clone(), global_state.clone()), + battery_v4::battery_read_task(board.i2c.clone(), global_state), ); spawn_task( &spawner, "buttons::buttons_task", buttons::buttons_task( - global_state.clone(), + global_state, #[cfg(feature = "v4")] board.buttons, #[cfg(feature = "v3")] @@ -211,7 +212,7 @@ async fn main(spawner: Spawner) { board.uart1, #[cfg(not(feature = "timer-func"))] board.stackmat_rx, - global_state.clone(), + global_state, #[cfg(feature = "timer-func")] board.pads, ), @@ -236,7 +237,7 @@ async fn main(spawner: Spawner) { board.spi2, #[cfg(feature = "v3")] board.spi_dma, - global_state.clone(), + global_state, ), ); @@ -244,7 +245,7 @@ async fn main(spawner: Spawner) { spawn_task( &spawner, "qa::qa_processor", - qa::qa_processor(global_state.clone()), + qa::qa_processor(global_state), ); let mut wm_settings = esp_hal_wifimanager::WmSettings { @@ -351,18 +352,18 @@ async fn main(spawner: Spawner) { ws::ws_task( wifi_res.sta_stack, ws_url, - global_state.clone(), + global_state, ws_sleep_sig.clone(), wifi_conn_sig, ), ); - spawn_task(&spawner, "logger_task", logger_task(global_state.clone())); + spawn_task(&spawner, "logger_task", logger_task(global_state)); let ble_sleep_sig = Rc::new(Signal::new()); spawn_task( &spawner, "bluetooth::bluetooth_timer_task", - bluetooth::bluetooth_timer_task(board.bt, global_state.clone(), ble_sleep_sig.clone()), + bluetooth::bluetooth_timer_task(board.bt, global_state, ble_sleep_sig.clone()), ); set_brownout_detection(true); diff --git a/src/state.rs b/src/state.rs index 770bcf9..c4ee84c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,7 @@ use crate::{ structs::{BleDisplayDevice, PossibleGroup}, utils::signaled_mutex::SignaledMutex, }; -use alloc::{rc::Rc, string::String, vec::Vec}; +use alloc::{string::String, vec::Vec}; use embassy_sync::{ blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, mutex::Mutex, @@ -253,7 +253,7 @@ pub enum BleAction { Unpair, } -pub type GlobalState = Rc; +pub type GlobalState = &'static GlobalStateInner; pub struct GlobalStateInner { pub state: SignaledMutex, pub timer_signal: Signal, diff --git a/src/ws.rs b/src/ws.rs index acbb690..7dfe403 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -308,7 +308,7 @@ async fn ws_loop( let res = ws_rw( &mut rx_framer, &mut tx_framer, - global_state.clone(), + global_state, &mut socket, wifi_conn_sig, ) From 79d753a16fa6ca2f4a1cb3347edd3a360cbedd7f Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:13:04 +0200 Subject: [PATCH 008/132] refactor: signaled mutex change detection without state clone --- src/state.rs | 79 ++++++++++++++++++------------------- src/structs.rs | 4 +- src/utils/signaled_mutex.rs | 62 ++++++++++++++++++++--------- 3 files changed, 85 insertions(+), 60 deletions(-) diff --git a/src/state.rs b/src/state.rs index c4ee84c..0ffdc1e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -143,7 +143,7 @@ pub fn current_epoch() -> u64 { .saturating_add(Instant::now().as_secs()) } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Hash)] #[allow(dead_code)] pub enum Scene { Update, @@ -165,7 +165,7 @@ pub enum Scene { Finished, } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Hash)] pub enum MenuScene { Signing, Unsigning, @@ -175,7 +175,7 @@ pub enum MenuScene { BuzzerVolume, } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Hash)] pub enum ErrorLogEntryStage { #[cfg(feature = "v4")] Qr, @@ -617,44 +617,43 @@ impl SavedGlobalState { pub async fn clear_saved_global_state(_nvs: &Nvs) {} } -impl PartialEq for SignaledGlobalStateInner { - fn eq(&self, other: &Self) -> bool { - let result = self.scene == other.scene - && self.menu_scene == other.menu_scene - && self.inspection_start == other.inspection_start - && self.inspection_end == other.inspection_end - && self.solve_time == other.solve_time - && self.penalty == other.penalty - && self.session_id == other.session_id - && self.time_confirmed == other.time_confirmed - && self.solve_group == other.solve_group - && self.error_text == other.error_text - && self.possible_groups == other.possible_groups - && self.group_selected_idx == other.group_selected_idx - && self.selected_config_menu == other.selected_config_menu - && self.selected_error_log_item == other.selected_error_log_item - && self.selected_error_log_entry == other.selected_error_log_entry - && self.error_log_entry_stage == other.error_log_entry_stage - && self.error_log_details_scroll == other.error_log_details_scroll - && self.discovered_bluetooth_devices == other.discovered_bluetooth_devices - && self.selected_bluetooth_item == other.selected_bluetooth_item - && self.device_added == other.device_added - && self.server_connected == other.server_connected - && self.wifi_connected == other.wifi_connected - && self.stackmat_connected == other.stackmat_connected - && self.current_competitor == other.current_competitor - && self.current_judge == other.current_judge - && self.competitor_display == other.competitor_display - && self.delegate_used == other.delegate_used - && self.delegate_hold == other.delegate_hold - // battery_status intentionally excluded (v4 hw) - && self.custom_message == other.custom_message; +impl core::hash::Hash for SignaledGlobalStateInner { + /// Fields that trigger a redraw when changed (battery_status intentionally excluded). + fn hash(&self, h: &mut H) { + self.scene.hash(h); + self.menu_scene.hash(h); + self.inspection_start.map(|i| i.as_ticks()).hash(h); + self.inspection_end.map(|i| i.as_ticks()).hash(h); + self.solve_time.hash(h); + self.penalty.hash(h); + self.session_id.hash(h); + self.time_confirmed.hash(h); + self.solve_group.hash(h); + self.error_text.hash(h); + self.possible_groups.hash(h); + self.group_selected_idx.hash(h); + self.selected_config_menu.hash(h); + self.selected_error_log_item.hash(h); + self.selected_error_log_entry.hash(h); + self.error_log_entry_stage.hash(h); + self.error_log_details_scroll.hash(h); + self.discovered_bluetooth_devices.hash(h); + self.selected_bluetooth_item.hash(h); + self.device_added.hash(h); + self.server_connected.hash(h); + self.wifi_connected.hash(h); + self.stackmat_connected.hash(h); + self.current_competitor.hash(h); + self.current_judge.hash(h); + self.competitor_display.hash(h); + self.delegate_used.hash(h); + self.delegate_hold.hash(h); + self.custom_message.hash(h); #[cfg(feature = "bat_dev_lcd")] - let result = result - && self.current_bat_read == other.current_bat_read - && self.avg_bat_read == other.avg_bat_read; - - result + { + self.current_bat_read.map(f32::to_bits).hash(h); + self.avg_bat_read.map(f32::to_bits).hash(h); + } } } diff --git a/src/structs.rs b/src/structs.rs index 037565b..3956033 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -24,7 +24,7 @@ pub const CONFIG_MENU_ITEMS: [&str; 7] = [ "Exit", ]; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Hash)] pub struct BleDisplayDevice { pub name: String, pub addr: [u8; 6], @@ -173,7 +173,7 @@ pub struct CardInfoResponsePacket { pub possible_groups: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)] #[serde(rename_all = "camelCase")] pub struct PossibleGroup { pub group_id: String, diff --git a/src/utils/signaled_mutex.rs b/src/utils/signaled_mutex.rs index 8487169..a6fe305 100644 --- a/src/utils/signaled_mutex.rs +++ b/src/utils/signaled_mutex.rs @@ -1,23 +1,25 @@ +use core::hash::{Hash, Hasher}; use embassy_sync::{ blocking_mutex::raw::RawMutex, mutex::{Mutex, MutexGuard}, signal::Signal, }; -pub struct SignaledMutex { +/// Async mutex that signals waiters when a `lock()` guard actually changed the value. +/// +/// Change detection hashes the value on the first mutable access and again on drop, +/// so read-only locks cost nothing and no copy of the value is kept. +pub struct SignaledMutex { inner: Mutex, update_sig: Signal, } #[allow(dead_code)] -impl SignaledMutex { +impl SignaledMutex { pub fn new(initial: T) -> Self { - let sig = Signal::new(); - //sig.signal(()); - Self { inner: Mutex::new(initial), - update_sig: sig, + update_sig: Signal::new(), } } @@ -38,13 +40,10 @@ impl SignaledMutex { } pub async fn lock(&self) -> SignaledMutexGuard<'_, M, T> { - let inner_guard = self.inner.lock().await; - let old_value = (*inner_guard).clone(); - SignaledMutexGuard { update_sig: &self.update_sig, - inner_guard, - old_value, + inner_guard: self.inner.lock().await, + hash_before: None, } } @@ -53,35 +52,62 @@ impl SignaledMutex { self.inner.lock().await } + /// Locks without change detection (callers signal manually if needed). pub async fn value(&self) -> MutexGuard<'_, M, T> { self.inner.lock().await } } -pub struct SignaledMutexGuard<'a, M: RawMutex, T: Clone + PartialEq> { +fn hash_of(value: &T) -> u64 { + let mut hasher = Fnv1a(0xcbf2_9ce4_8422_2325); + value.hash(&mut hasher); + hasher.finish() +} + +/// FNV-1a: tiny, no_std, good enough to detect a change (not for adversarial input). +struct Fnv1a(u64); + +impl Hasher for Fnv1a { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 = (self.0 ^ b as u64).wrapping_mul(0x0100_0000_01b3); + } + } +} + +pub struct SignaledMutexGuard<'a, M: RawMutex, T: Hash> { update_sig: &'a Signal, inner_guard: MutexGuard<'a, M, T>, - - old_value: T, + /// Hash before the first mutable access (`None` = not mutated). + hash_before: Option, } -impl Drop for SignaledMutexGuard<'_, M, T> { +impl Drop for SignaledMutexGuard<'_, M, T> { fn drop(&mut self) { - if *self.inner_guard != self.old_value { + if let Some(before) = self.hash_before + && hash_of(&*self.inner_guard) != before + { self.update_sig.signal(()); // signal value change (if actually changed) } } } -impl core::ops::Deref for SignaledMutexGuard<'_, M, T> { +impl core::ops::Deref for SignaledMutexGuard<'_, M, T> { type Target = T; fn deref(&self) -> &Self::Target { self.inner_guard.deref() } } -impl core::ops::DerefMut for SignaledMutexGuard<'_, M, T> { +impl core::ops::DerefMut for SignaledMutexGuard<'_, M, T> { fn deref_mut(&mut self) -> &mut Self::Target { + if self.hash_before.is_none() { + self.hash_before = Some(hash_of(&*self.inner_guard)); + } self.inner_guard.deref_mut() } } From d67e7b962092a909cd760221be5760ac02519b7e Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:23:12 +0200 Subject: [PATCH 009/132] refactor: bounded state and packet strings --- host-tests/Cargo.lock | 139 ++++++++++++++++++ host-tests/Cargo.toml | 15 ++ host-tests/src/lib.rs | 77 ++++++++++ host-tests/src/old_structs.rs | 255 ++++++++++++++++++++++++++++++++++ src/bluetooth.rs | 4 +- src/buttons.rs | 13 +- src/lcd_v3.rs | 7 +- src/lcd_v4.rs | 11 +- src/qa.rs | 22 +-- src/rfid.rs | 15 +- src/stackmat.rs | 5 +- src/state.rs | 37 +++-- src/structs.rs | 62 +++++---- src/utils/bounded.rs | 236 +++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + src/ws.rs | 12 +- 16 files changed, 828 insertions(+), 83 deletions(-) create mode 100644 host-tests/Cargo.lock create mode 100644 host-tests/Cargo.toml create mode 100644 host-tests/src/lib.rs create mode 100644 host-tests/src/old_structs.rs create mode 100644 src/utils/bounded.rs diff --git a/host-tests/Cargo.lock b/host-tests/Cargo.lock new file mode 100644 index 0000000..4279b51 --- /dev/null +++ b/host-tests/Cargo.lock @@ -0,0 +1,139 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "fkm-host-tests" +version = "0.0.0" +dependencies = [ + "heapless", + "serde", + "serde_json", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/host-tests/Cargo.toml b/host-tests/Cargo.toml new file mode 100644 index 0000000..618360f --- /dev/null +++ b/host-tests/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fkm-host-tests" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive", "alloc"] } +serde_json = "1" +heapless = "0.9.3" + +[features] +e2e = [] +v3 = [] +v4 = [] diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs new file mode 100644 index 0000000..caa332c --- /dev/null +++ b/host-tests/src/lib.rs @@ -0,0 +1,77 @@ +//! Host tests for firmware modules that don't depend on esp-hal. +//! Run: `cargo +stable test --target $(rustc +stable -vV | sed -n 's/host: //p')` +//! (the firmware's .cargo/config.toml would otherwise select the riscv target). +//! `old_structs.rs` is the pre-bounded packet definition, kept to check wire compatibility. +extern crate alloc; + +#[path = "../../src/utils/bounded.rs"] +pub mod bounded; +#[path = "../../src/utils/ring_log.rs"] +pub mod ring_log; +pub mod utils { + pub use crate::bounded; +} +#[path = "../../src/structs.rs"] +pub mod structs; +#[path = "old_structs.rs"] +pub mod old_structs; + +#[cfg(test)] +mod tests { + use crate::{old_structs as old, structs as new}; + use serde_json::{from_str, to_string, Value}; + + // every variant the firmware sends or receives, with realistic values + const PACKETS: &[&str] = &[ + r#"{"tag":12,"data":{"solve":{"solve_time":12345,"penalty":-1,"competitor_id":7,"judge_id":9,"timestamp":1700000000,"session_id":"0b6a7d2e-1f4c-4f7e-9b1a-2c3d4e5f6a7b","delegate":false,"inspection_time":8000,"group_id":"333-r1-g2","sign_key":42}}}"#, + r#"{"data":{"card_info_request":{"card_id":123,"is_competitor":true,"sign_key":5}}}"#, + r#"{"data":{"card_info_request":{"card_id":123,"is_competitor":false,"attendance_device":true,"sign_key":5}}}"#, + r#"{"data":{"battery":{"level":87.5,"voltage":3.91}}}"#, + r#"{"data":{"battery":{"level":null,"voltage":null}}}"#, + r#"{"data":{"add":{"firmware":"STATION","sign_key":77}}}"#, + r#"{"tag":3,"data":{"card_info_response":{"card_id":1,"display":"Jan Kowalski","country_iso2":"PL","can_compete":true,"possible_groups":[{"groupId":"333-r1","name":"3x3 R1","secondaryText":null,"useInspection":true,"limit":600000},{"groupId":"222","name":"2x2","secondaryText":"Group B","useInspection":false,"limit":null}]}}}"#, + r#"{"tag":4,"data":{"solve_confirm":{"competitor_id":1,"session_id":"0b6a7d2e-1f4c-4f7e-9b1a-2c3d4e5f6a7b","message":"Solve saved: 12.34"}}}"#, + r#"{"tag":5,"data":{"api_error":{"error":"Competitor not found","should_reset_time":true}}}"#, + r#"{"data":{"delegate_response":{"should_scan_cards":true,"solve_time":1000,"penalty":2}}}"#, + r#"{"data":{"custom_message":{"line1":"Hello","line2":"World"}}}"#, + r#"{"data":{"start_update":{"version":"D1700000000","build_time":0,"size":1234567,"crc":99,"firmware":"STATION"}}}"#, + r#"{"data":{"device_settings":{"added":true,"locales":[{"locale":"pl","translations":[{"key":"A","translation":"Zażółć \"gęślą\""}]}],"default_locale":"en","fkm_token":1,"secure_rfid":false,"auto_setup":true,"sound_enabled":true}}}"#, + r#"{"data":{"epoch_time":{"current_epoch":1700000000}}}"#, + r#"{"data":{"set_device_settings":{"volume":5}}}"#, + r#"{"data":"attendance_marked"}"#, + r#"{"data":"dump_crash_log"}"#, + ]; + + #[test] + fn same_json_as_before() { + for json in PACKETS { + let o: old::TimerPacket = from_str(json).unwrap_or_else(|e| panic!("old {json}: {e}")); + let n: new::TimerPacket = from_str(json).unwrap_or_else(|e| panic!("new {json}: {e}")); + let (os, ns) = (to_string(&o).unwrap(), to_string(&n).unwrap()); + assert_eq!(os, ns, "serialization differs for {json}"); + assert_eq!(from_str::(&ns).unwrap(), from_str::(json).unwrap()); + } + } + + #[test] + fn oversized_input_is_truncated_not_rejected() { + let long = "x".repeat(500); + let json = format!( + r#"{{"data":{{"api_error":{{"error":"{long}","should_reset_time":false}}}}}}"# + ); + let n: new::TimerPacket = from_str(&json).unwrap(); + let new::TimerPacketInner::ApiError(e) = n.data else { panic!() }; + assert_eq!(e.error.len(), 128); + + let groups = (0..20) + .map(|i| format!(r#"{{"groupId":"g{i}","name":"n","secondaryText":null,"useInspection":true,"limit":null}}"#)) + .collect::>() + .join(","); + let json = format!( + r#"{{"data":{{"card_info_response":{{"card_id":1,"display":"d","country_iso2":"pl","can_compete":true,"possible_groups":[{groups}]}}}}}}"# + ); + let n: new::TimerPacket = from_str(&json).unwrap(); + let new::TimerPacketInner::CardInfoResponse(r) = n.data else { panic!() }; + assert_eq!(r.possible_groups.len(), new::MAX_POSSIBLE_GROUPS); + } +} diff --git a/host-tests/src/old_structs.rs b/host-tests/src/old_structs.rs new file mode 100644 index 0000000..3956033 --- /dev/null +++ b/host-tests/src/old_structs.rs @@ -0,0 +1,255 @@ +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "v3")] +pub const CONFIG_MENU_ITEMS: [&str; 6] = [ + "Reset Settings", + "BT Display", + "Sign Cards", + "Un-Sign Cards", + "Error Log", + "Exit", +]; +#[cfg(feature = "v4")] +pub const CONFIG_MENU_ITEMS: [&str; 7] = [ + "Reset Settings", + "BT Display", + "Sign Cards", + "Un-Sign Cards", + "Buzzer Volume", + "Error Log", + "Exit", +]; + +#[derive(Clone, Debug, PartialEq, Hash)] +pub struct BleDisplayDevice { + pub name: String, + pub addr: [u8; 6], +} + +#[derive(Deserialize, Debug)] +pub struct ConnSettings { + pub mdns: bool, + pub ws_url: Option, +} + +impl Default for ConnSettings { + fn default() -> Self { + Self { + mdns: true, + ws_url: None, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TimerPacket { + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + pub data: TimerPacketInner, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TimerPacketInner { + StartUpdate { + version: String, + build_time: u64, // NOT USED + size: u32, + crc: u32, + firmware: String, + }, + Solve { + solve_time: u64, + penalty: i64, + competitor_id: u64, + judge_id: u64, + timestamp: u64, + session_id: String, // UUID + delegate: bool, + inspection_time: i64, + group_id: String, + sign_key: u32, + }, + SolveConfirm(SolveConfirmPacket), + DelegateResponse(DelegateResponsePacket), + ApiError(ApiError), + CustomMessage { + line1: String, + line2: String, + }, + + CardInfoRequest { + card_id: u64, + is_competitor: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + attendance_device: Option, + + sign_key: u32, + }, + CardInfoResponse(CardInfoResponsePacket), + AttendanceMarked, + DeviceSettings { + added: bool, + locales: Vec, + default_locale: String, + fkm_token: i32, + secure_rfid: bool, + auto_setup: bool, + sound_enabled: bool, + }, + Battery { + level: Option, + voltage: Option, + }, + Add { + firmware: String, + sign_key: u32, + }, + EpochTime { + current_epoch: u64, + }, + SetDeviceSettings { + volume: Option, + }, + DumpCrashLog, + + // packet for end to end testing + #[cfg(feature = "e2e")] + TestPacket(TestPacketData), + + #[cfg(feature = "e2e")] + TestAck(SnapshotData), +} + +#[cfg(feature = "e2e")] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum TestPacketData { + HardStateReset, + ResetState, + ScanCard(u64), + ButtonPress { pin: u8, press_time: u64 }, + StackmatTime(u64), + StackmatReset, +} + +#[cfg(feature = "e2e")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SnapshotData { + pub scene: usize, + pub inspection_time: Option, + pub solve_time: Option, + pub penalty: Option, + pub time_confirmed: bool, + pub possible_groups: usize, + pub group_selected_idx: usize, + pub current_competitor: Option, + pub current_judge: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TranslationLocale { + pub locale: String, + pub translations: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TranslationRecord { + pub key: String, + pub translation: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CardInfoResponsePacket { + pub card_id: u64, + pub display: String, + pub country_iso2: String, + pub can_compete: bool, + pub possible_groups: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct PossibleGroup { + pub group_id: String, + pub name: String, + pub secondary_text: Option, + pub use_inspection: bool, + pub limit: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SolveConfirmPacket { + pub competitor_id: u64, + pub session_id: String, + pub message: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DelegateResponsePacket { + pub should_scan_cards: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + pub solve_time: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub penalty: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ApiError { + pub error: String, + pub should_reset_time: bool, +} + +pub trait FromPacket: Sized { + fn from_packet(packet: TimerPacket) -> Result; +} + +impl FromPacket for CardInfoResponsePacket { + fn from_packet(packet: TimerPacket) -> Result { + match packet.data { + TimerPacketInner::CardInfoResponse(card_info_response_packet) => { + Ok(card_info_response_packet) + } + TimerPacketInner::ApiError(api_error) => Err(api_error), + _ => Err(ApiError { + error: "Wrong response type!".to_string(), + should_reset_time: false, + }), + } + } +} + +impl FromPacket for SolveConfirmPacket { + fn from_packet(packet: TimerPacket) -> Result { + match packet.data { + TimerPacketInner::SolveConfirm(solve_confirm_packet) => Ok(solve_confirm_packet), + TimerPacketInner::ApiError(api_error) => Err(api_error), + _ => Err(ApiError { + error: "Wrong response type!".to_string(), + should_reset_time: false, + }), + } + } +} + +impl FromPacket for DelegateResponsePacket { + fn from_packet(packet: TimerPacket) -> Result { + match packet.data { + TimerPacketInner::DelegateResponse(delegate_response_packet) => { + Ok(delegate_response_packet) + } + TimerPacketInner::ApiError(api_error) => Err(api_error), + _ => Err(ApiError { + error: "Wrong response type!".to_string(), + should_reset_time: false, + }), + } + } +} diff --git a/src/bluetooth.rs b/src/bluetooth.rs index ebf3909..410102c 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -3,7 +3,7 @@ use crate::{ state::{BleAction, GlobalState, MenuScene}, structs::BleDisplayDevice, }; -use alloc::{rc::Rc, string::ToString, vec::Vec}; +use alloc::{rc::Rc, vec::Vec}; use core::cell::RefCell; use embassy_futures::select::{Either, select, select3, select4}; use embassy_sync::{ @@ -507,7 +507,7 @@ impl EventHandler for BleDiscovery<'_> { log::info!("Disovered FKM Display! [{:?}] ({name})", report.addr); _ = self.sender.try_send(BleDisplayDevice { - name: name.to_string(), + name: name.into(), addr: report.addr.into_inner(), }); } diff --git a/src/buttons.rs b/src/buttons.rs index fbb8ce9..e5e7093 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -7,7 +7,6 @@ use crate::{ structs::DelegateResponsePacket, utils::buttons::{Button, ButtonTrigger, ButtonsHandler}, }; -use alloc::string::ToString; use embassy_time::{Instant, Timer}; use esp_hal::gpio::Input; @@ -398,7 +397,7 @@ async fn submit_up( } 2 => { if !crate::state::auto_setup() { - state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); + state_val.error_text = Some("AutoSetup Mode Disabled".into()); state.state.signal(); return Ok(true); } @@ -407,7 +406,7 @@ async fn submit_up( } 3 => { if !crate::state::auto_setup() { - state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); + state_val.error_text = Some("AutoSetup Mode Disabled".into()); state.state.signal(); return Ok(true); } @@ -445,7 +444,7 @@ async fn submit_up( } 2 => { if !crate::state::auto_setup() { - state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); + state_val.error_text = Some("AutoSetup Mode Disabled".into()); state.state.signal(); return Ok(true); } @@ -454,7 +453,7 @@ async fn submit_up( } 3 => { if !crate::state::auto_setup() { - state_val.error_text = Some("AutoSetup Mode Disabled".to_string()); + state_val.error_text = Some("AutoSetup Mode Disabled".into()); state.state.signal(); return Ok(true); } @@ -495,7 +494,7 @@ async fn submit_up( crate::ws::send_packet(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Add { - firmware: alloc::string::ToString::to_string(crate::version::FIRMWARE), + firmware: crate::version::FIRMWARE.into(), sign_key: crate::state::sign_key(), }, }) @@ -745,7 +744,7 @@ async fn delegate_hold( let session_id = match &state_val.session_id { Some(sess_id) => sess_id.clone(), None => { - let sess_id = uuid::Uuid::new_v4().to_string(); + let sess_id = crate::state::new_session_id(); state_val.session_id = Some(sess_id.clone()); sess_id } diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 8be4613..500ef2c 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -1,5 +1,5 @@ use ag_lcd_async::LcdDisplay; -use alloc::{rc::Rc, string::ToString}; +use alloc::rc::Rc; use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; use embassy_time::{Delay, Duration, Instant, Timer}; use embedded_hal::digital::OutputPin; @@ -591,9 +591,10 @@ async fn process_lcd( lcd_driver .print( 0, - ¤t_state + current_state .competitor_display - .unwrap_or("??????".to_string()), + .as_deref() + .unwrap_or("??????"), PrintAlign::Center, true, ) diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index f3dffa0..6340ed3 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -908,11 +908,12 @@ async fn process_main( } } Scene::CompetitorInfo => { - let mut text = current_state - .competitor_display - .clone() - .unwrap_or("------".to_string()) - .to_string(); + let mut text = alloc::string::String::from( + current_state + .competitor_display + .as_deref() + .unwrap_or("------"), + ); if let Some(ref group) = current_state.solve_group && let Some(ref secondary_text) = group.secondary_text diff --git a/src/qa.rs b/src/qa.rs index 1333e66..f76219a 100644 --- a/src/qa.rs +++ b/src/qa.rs @@ -20,17 +20,17 @@ pub async fn qa_processor(global_state: GlobalState) { if res { global_state.state.lock().await.custom_message = - Some((format!("PERIPHERALS QA"), format!("PASSED"))); + Some((format!("PERIPHERALS QA").into(), format!("PASSED").into())); } else { global_state.state.lock().await.custom_message = - Some((format!("PERIPHERALS QA"), format!("NOT GOOD"))); + Some((format!("PERIPHERALS QA").into(), format!("NOT GOOD").into())); } } async fn qa_inner(global_state: &GlobalState) -> bool { for i in 1..5 { global_state.state.lock().await.custom_message = - Some((format!("Button {i}"), format!("Down"))); + Some((format!("Button {i}").into(), format!("Down").into())); loop { let val = BACK_SIGNAL.wait().await; @@ -44,7 +44,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } global_state.state.lock().await.custom_message = - Some((format!("Button {i}"), format!("Up"))); + Some((format!("Button {i}").into(), format!("Up").into())); loop { let val = BACK_SIGNAL.wait().await; @@ -59,7 +59,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } let first_card_scan; - global_state.state.lock().await.custom_message = Some((format!("Scan card"), format!("1"))); + global_state.state.lock().await.custom_message = Some((format!("Scan card").into(), format!("1").into())); loop { let val = BACK_SIGNAL.wait().await; if let QaSignal::Rfid(card) = val { @@ -71,7 +71,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } global_state.state.lock().await.custom_message = - Some((format!("Scan card"), format!("1 again"))); + Some((format!("Scan card").into(), format!("1 again").into())); loop { let val = BACK_SIGNAL.wait().await; if let QaSignal::Rfid(card) = val { @@ -83,7 +83,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { return false; } - global_state.state.lock().await.custom_message = Some((format!("Scan card"), format!("2"))); + global_state.state.lock().await.custom_message = Some((format!("Scan card").into(), format!("2").into())); loop { let val = BACK_SIGNAL.wait().await; if let QaSignal::Rfid(card) = val { @@ -96,7 +96,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } global_state.state.lock().await.custom_message = - Some((format!("Connect"), format!("Stackmat"))); + Some((format!("Connect").into(), format!("Stackmat").into())); loop { let val = BACK_SIGNAL.wait().await; if let QaSignal::StackmatConnected = val { @@ -107,7 +107,7 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } global_state.state.lock().await.custom_message = - Some((format!("Stackmat solve"), format!("> 1s"))); + Some((format!("Stackmat solve").into(), format!("> 1s").into())); loop { let val = BACK_SIGNAL.wait().await; if let QaSignal::Stackmat(time) = val { @@ -120,8 +120,8 @@ async fn qa_inner(global_state: &GlobalState) -> bool { } global_state.state.lock().await.custom_message = Some(( - format!("Setup WIFI"), - format!("FKM-{:X}", crate::utils::get_efuse_u32()), + format!("Setup WIFI").into(), + format!("FKM-{:X}", crate::utils::get_efuse_u32()).into(), )); loop { let val = BACK_SIGNAL.wait().await; diff --git a/src/rfid.rs b/src/rfid.rs index 23f8936..9ce70b3 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -2,7 +2,6 @@ use crate::consts::RFID_RETRY_INIT_MS; use crate::state::{GlobalState, MenuScene, current_epoch, sleep_state}; use crate::structs::{CardInfoResponsePacket, SolveConfirmPacket}; use crate::translations::{TranslationKey, get_translation}; -use alloc::string::ToString; use anyhow::{Result, anyhow}; use embassy_time::{Duration, Instant, Timer}; use esp_hal_mfrc522::consts::UidSize; @@ -205,7 +204,7 @@ pub async fn rfid_task( #[cfg(not(feature = "e2e"))] if !crate::state::trust_server() { log::error!("Skipping card scan. Server not trusted!"); - global_state.state.lock().await.error_text = Some("Server NOT Trusted!".to_string()); + global_state.state.lock().await.error_text = Some("Server NOT Trusted!".into()); _ = mfrc522.picc_halta().await; continue; } @@ -475,7 +474,7 @@ async fn process_card_info_response( } _ => { state.reset_solve_state(None).await; - state.error_text = Some(get_translation(TranslationKey::EMPTY_GROUPS_ERROR)); + state.error_text = Some(get_translation(TranslationKey::EMPTY_GROUPS_ERROR).into()); } } } @@ -497,7 +496,7 @@ async fn process_card_info_response( let session_id = match &state.session_id { Some(sess_id) => sess_id.clone(), None => { - let sess_id = uuid::Uuid::new_v4().to_string(); + let sess_id = crate::state::new_session_id(); state.session_id = Some(sess_id.clone()); sess_id } @@ -548,9 +547,9 @@ async fn process_card_info_response( let first_line = words[..2].join(" "); let second_line = words[2..].join(" "); - state.custom_message = Some((first_line, second_line)); + state.custom_message = Some((first_line.into(), second_line.into())); } else { - state.custom_message = Some(("Solve".to_string(), "sent".to_string())); + state.custom_message = Some(("Solve".into(), "sent".into())); } drop(state); @@ -565,8 +564,8 @@ async fn process_card_info_response( && state.time_confirmed { state.custom_message = Some(( - get_translation(TranslationKey::CARDS_CANNOT_BE_THE_SAME_HEADER), - get_translation(TranslationKey::CARDS_CANNOT_BE_THE_SAME_FOOTER), + get_translation(TranslationKey::CARDS_CANNOT_BE_THE_SAME_HEADER).into(), + get_translation(TranslationKey::CARDS_CANNOT_BE_THE_SAME_FOOTER).into(), )); drop(state); Timer::after_millis(8000).await; diff --git a/src/stackmat.rs b/src/stackmat.rs index 02b2f05..bd05a0c 100644 --- a/src/stackmat.rs +++ b/src/stackmat.rs @@ -5,7 +5,6 @@ use crate::{ consts::{INSPECTION_TIME_DNF, INSPECTION_TIME_PLUS2}, state::{GlobalState, Scene}, }; -use alloc::string::ToString; use embassy_time::{Instant, Timer}; #[cfg(feature = "timer-func")] @@ -289,7 +288,7 @@ pub async fn stackmat_task( state.penalty = Some(-1); if state.session_id.is_none() { - state.session_id = Some(uuid::Uuid::new_v4().to_string()); + state.session_id = Some(crate::state::new_session_id()); } state.time_confirmed = true; @@ -343,7 +342,7 @@ async fn time_end( }; if state.session_id.is_none() { - state.session_id = Some(uuid::Uuid::new_v4().to_string()); + state.session_id = Some(crate::state::new_session_id()); } if state.current_competitor.is_some() { diff --git a/src/state.rs b/src/state.rs index 0ffdc1e..85d29e3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,14 @@ use crate::consts::NVS_SAVED_STATE; use crate::{ - structs::{BleDisplayDevice, PossibleGroup}, - utils::signaled_mutex::SignaledMutex, + structs::{ + BleDisplayDevice, DisplayText, MAX_BLE_DEVICES, MAX_POSSIBLE_GROUPS, MessageText, + PossibleGroup, SessionId, + }, + utils::{ + bounded::{BoundedVec, CappedVec}, + signaled_mutex::SignaledMutex, + }, }; -use alloc::{string::String, vec::Vec}; use embassy_sync::{ blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, mutex::Mutex, @@ -121,6 +126,12 @@ pub fn set_inspection_time(time: Option) { INSPECTION_TIME.set(time) } +/// A fresh random (v4) uuid, formatted without allocating. +pub fn new_session_id() -> SessionId { + let mut buf = [0; uuid::fmt::Hyphenated::LENGTH]; + SessionId::truncated(uuid::Uuid::new_v4().hyphenated().encode_lower(&mut buf)) +} + /// Sets the epoch from the server's `current_epoch` (seconds). pub fn set_epoch(current_epoch: u64) { EPOCH_BASE.store( @@ -305,13 +316,13 @@ pub struct SignaledGlobalStateInner { pub inspection_end: Option, pub solve_time: Option, pub penalty: Option, - pub session_id: Option, + pub session_id: Option, pub time_confirmed: bool, pub solve_group: Option, - pub error_text: Option, + pub error_text: Option, - pub possible_groups: Vec, + pub possible_groups: CappedVec, pub group_selected_idx: usize, pub selected_config_menu: Option, @@ -320,7 +331,7 @@ pub struct SignaledGlobalStateInner { pub error_log_entry_stage: Option, pub error_log_details_scroll: usize, - pub discovered_bluetooth_devices: Vec, + pub discovered_bluetooth_devices: BoundedVec, pub selected_bluetooth_item: usize, pub sound_enabled: bool, @@ -331,7 +342,7 @@ pub struct SignaledGlobalStateInner { pub current_competitor: Option, pub current_judge: Option, - pub competitor_display: Option, + pub competitor_display: Option, pub delegate_used: bool, pub delegate_hold: Option, @@ -345,7 +356,7 @@ pub struct SignaledGlobalStateInner { #[cfg(feature = "bat_dev_lcd")] pub avg_bat_read: Option, - pub custom_message: Option<(String, String)>, + pub custom_message: Option<(DisplayText, DisplayText)>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -353,7 +364,7 @@ pub struct SavedGlobalState { pub inspection_time: Option, pub solve_time: u64, pub penalty: i8, - pub session_id: String, + pub session_id: SessionId, pub current_competitor: u64, pub solve_epoch: u64, pub solve_group: PossibleGroup, @@ -374,7 +385,7 @@ impl SignaledGlobalStateInner { solve_group: None, error_text: None, - possible_groups: Vec::new(), + possible_groups: CappedVec::default(), group_selected_idx: 0, selected_config_menu: None, selected_error_log_item: 0, @@ -382,7 +393,7 @@ impl SignaledGlobalStateInner { error_log_entry_stage: None, error_log_details_scroll: 0, selected_bluetooth_item: 0, - discovered_bluetooth_devices: Vec::new(), + discovered_bluetooth_devices: BoundedVec::new(), sound_enabled: true, device_added: None, @@ -555,7 +566,7 @@ impl SavedGlobalState { Timer::after_millis(5).await; } - let res = nvs.get::>(NVS_SAVED_STATE).await.ok()?; + let res = nvs.get::>(NVS_SAVED_STATE).await.ok()?; let res: SavedGlobalState = serde_json::from_slice(&res).ok()?; // 6hours diff --git a/src/structs.rs b/src/structs.rs index 3956033..41c67d9 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,9 +1,19 @@ -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; +use crate::utils::bounded::{BoundedString, CappedVec}; +use alloc::{string::String, vec::Vec}; use serde::{Deserialize, Serialize}; +// Caps for server-provided text; longer input is truncated, never grows the heap. +pub type SessionId = BoundedString<36>; // uuid +pub type GroupId = BoundedString<48>; +pub type ShortText = BoundedString<32>; +pub type DisplayText = BoundedString<64>; +pub type MessageText = BoundedString<128>; +pub type VersionText = BoundedString<32>; +pub type FirmwareText = BoundedString<16>; +pub type LocaleCode = BoundedString<8>; +pub const MAX_POSSIBLE_GROUPS: usize = 8; +pub const MAX_BLE_DEVICES: usize = 8; + #[cfg(feature = "v3")] pub const CONFIG_MENU_ITEMS: [&str; 6] = [ "Reset Settings", @@ -26,7 +36,7 @@ pub const CONFIG_MENU_ITEMS: [&str; 7] = [ #[derive(Clone, Debug, PartialEq, Hash)] pub struct BleDisplayDevice { - pub name: String, + pub name: ShortText, pub addr: [u8; 6], } @@ -56,11 +66,11 @@ pub struct TimerPacket { #[serde(rename_all = "snake_case")] pub enum TimerPacketInner { StartUpdate { - version: String, + version: VersionText, build_time: u64, // NOT USED size: u32, crc: u32, - firmware: String, + firmware: FirmwareText, }, Solve { solve_time: u64, @@ -68,18 +78,18 @@ pub enum TimerPacketInner { competitor_id: u64, judge_id: u64, timestamp: u64, - session_id: String, // UUID + session_id: SessionId, delegate: bool, inspection_time: i64, - group_id: String, + group_id: GroupId, sign_key: u32, }, SolveConfirm(SolveConfirmPacket), DelegateResponse(DelegateResponsePacket), ApiError(ApiError), CustomMessage { - line1: String, - line2: String, + line1: DisplayText, + line2: DisplayText, }, CardInfoRequest { @@ -95,8 +105,10 @@ pub enum TimerPacketInner { AttendanceMarked, DeviceSettings { added: bool, + // shortcut: translations are parsed into heap Vecs, then copied into the fixed + // table (rare packet); a streaming DeserializeSeed would avoid the transient alloc locales: Vec, - default_locale: String, + default_locale: LocaleCode, fkm_token: i32, secure_rfid: bool, auto_setup: bool, @@ -107,7 +119,7 @@ pub enum TimerPacketInner { voltage: Option, }, Add { - firmware: String, + firmware: FirmwareText, sign_key: u32, }, EpochTime { @@ -167,18 +179,18 @@ pub struct TranslationRecord { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct CardInfoResponsePacket { pub card_id: u64, - pub display: String, - pub country_iso2: String, + pub display: DisplayText, + pub country_iso2: LocaleCode, pub can_compete: bool, - pub possible_groups: Vec, + pub possible_groups: CappedVec, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)] #[serde(rename_all = "camelCase")] pub struct PossibleGroup { - pub group_id: String, - pub name: String, - pub secondary_text: Option, + pub group_id: GroupId, + pub name: ShortText, + pub secondary_text: Option, pub use_inspection: bool, pub limit: Option, } @@ -186,8 +198,8 @@ pub struct PossibleGroup { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct SolveConfirmPacket { pub competitor_id: u64, - pub session_id: String, - pub message: String, + pub session_id: SessionId, + pub message: MessageText, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -203,7 +215,7 @@ pub struct DelegateResponsePacket { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ApiError { - pub error: String, + pub error: MessageText, pub should_reset_time: bool, } @@ -219,7 +231,7 @@ impl FromPacket for CardInfoResponsePacket { } TimerPacketInner::ApiError(api_error) => Err(api_error), _ => Err(ApiError { - error: "Wrong response type!".to_string(), + error: "Wrong response type!".into(), should_reset_time: false, }), } @@ -232,7 +244,7 @@ impl FromPacket for SolveConfirmPacket { TimerPacketInner::SolveConfirm(solve_confirm_packet) => Ok(solve_confirm_packet), TimerPacketInner::ApiError(api_error) => Err(api_error), _ => Err(ApiError { - error: "Wrong response type!".to_string(), + error: "Wrong response type!".into(), should_reset_time: false, }), } @@ -247,7 +259,7 @@ impl FromPacket for DelegateResponsePacket { } TimerPacketInner::ApiError(api_error) => Err(api_error), _ => Err(ApiError { - error: "Wrong response type!".to_string(), + error: "Wrong response type!".into(), should_reset_time: false, }), } diff --git a/src/utils/bounded.rs b/src/utils/bounded.rs new file mode 100644 index 0000000..dd0ea00 --- /dev/null +++ b/src/utils/bounded.rs @@ -0,0 +1,236 @@ +//! Fixed-capacity string/vec that serialize like `String`/`Vec` but never grow the +//! heap: over-long input is truncated (string at a char boundary, extra list items +//! skipped) instead of failing the whole packet. + +use core::{fmt, hash::Hash, marker::PhantomData}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{IgnoredAny, SeqAccess, Visitor}, +}; + +#[derive(Clone, Default, PartialEq, Eq, Hash)] +pub struct BoundedString(heapless::String); + +impl BoundedString { + /// `s` truncated to at most `N` bytes (at a char boundary). + pub fn truncated(s: &str) -> Self { + let mut end = s.len().min(N); + while !s.is_char_boundary(end) { + end -= 1; + } + let mut out = heapless::String::new(); + // cannot fail: end <= N + _ = out.push_str(&s[..end]); + Self(out) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl core::ops::Deref for BoundedString { + type Target = str; + fn deref(&self) -> &str { + self.0.as_str() + } +} + +impl From<&str> for BoundedString { + fn from(s: &str) -> Self { + Self::truncated(s) + } +} + +impl From for BoundedString { + fn from(s: alloc::string::String) -> Self { + Self::truncated(&s) + } +} + +impl PartialEq for BoundedString { + fn eq(&self, other: &str) -> bool { + self.as_str() == other + } +} + +impl PartialEq<&str> for BoundedString { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +impl fmt::Display for BoundedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Debug for BoundedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self.as_str(), f) + } +} + +impl fmt::Write for BoundedString { + /// Appends what fits (truncating) and never fails. + fn write_str(&mut self, s: &str) -> fmt::Result { + let room = N - self.0.len(); + let mut end = s.len().min(room); + while !s.is_char_boundary(end) { + end -= 1; + } + _ = self.0.push_str(&s[..end]); + Ok(()) + } +} + +impl Serialize for BoundedString { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, const N: usize> Deserialize<'de> for BoundedString { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl Visitor<'_> for V { + type Value = BoundedString; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a string") + } + fn visit_str(self, v: &str) -> Result { + Ok(BoundedString::truncated(v)) + } + } + deserializer.deserialize_str(V::) + } +} + +#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)] +pub struct BoundedVec(heapless::Vec); + +impl BoundedVec { + pub const fn new() -> Self { + Self(heapless::Vec::new()) + } + + /// Pushes `item`, dropping it if full (returns whether it was stored). + pub fn push(&mut self, item: T) -> bool { + self.0.push(item).is_ok() + } +} + +impl core::ops::Deref for BoundedVec { + type Target = heapless::Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl core::ops::DerefMut for BoundedVec { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Serialize for BoundedVec { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.0.iter()) + } +} + +impl<'de, T: Deserialize<'de>, const N: usize> Deserialize<'de> for BoundedVec { + fn deserialize>(deserializer: D) -> Result { + struct V(PhantomData); + impl<'de, T: Deserialize<'de>, const N: usize> Visitor<'de> for V { + type Value = BoundedVec; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list") + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = BoundedVec::new(); + while out.len() < N { + match seq.next_element()? { + Some(item) => _ = out.push(item), + None => return Ok(out), + } + } + while seq.next_element::()?.is_some() {} + Ok(out) + } + } + deserializer.deserialize_seq(V::(PhantomData)) + } +} + +/// Heap-backed list with the same truncating `Deserialize` as [`BoundedVec`]: for lists +/// inside packets, so a packet stays small wherever it is queued but can't grow past `N`. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct CappedVec(alloc::vec::Vec); + +impl Default for CappedVec { + fn default() -> Self { + Self(alloc::vec::Vec::new()) + } +} + +impl core::ops::Deref for CappedVec { + type Target = [T]; + fn deref(&self) -> &[T] { + &self.0 + } +} + +impl CappedVec { + pub fn clear(&mut self) { + self.0.clear(); + } +} + +impl IntoIterator for CappedVec { + type Item = T; + type IntoIter = alloc::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Serialize for CappedVec { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.0.iter()) + } +} + +impl<'de, T: Deserialize<'de>, const N: usize> Deserialize<'de> for CappedVec { + fn deserialize>(deserializer: D) -> Result { + let items = BoundedVec::::deserialize(deserializer)?; + Ok(Self(items.0.into_iter().collect())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncates_at_char_boundary() { + assert_eq!(BoundedString::<4>::truncated("abcdef").as_str(), "abcd"); + assert_eq!(BoundedString::<4>::truncated("abcó").as_str(), "abc"); + assert_eq!(BoundedString::<4>::truncated("").as_str(), ""); + let s: BoundedString<3> = serde_json::from_str("\"a\\\"bcd\"").unwrap(); + assert_eq!(s.as_str(), "a\"b"); + assert_eq!(serde_json::to_string(&s).unwrap(), "\"a\\\"b\""); + } + + #[test] + fn vec_skips_extra_items() { + let v: BoundedVec, 2> = + serde_json::from_str(r#"["aaa", "b", "c", {"x": [1]}]"#).unwrap(); + assert_eq!(v.len(), 2); + assert_eq!(v[0].as_str(), "aa"); + let v: BoundedVec = serde_json::from_str("[1, 2, 3, 4]").unwrap(); + assert_eq!(v.as_slice(), &[1, 2]); + assert_eq!(serde_json::to_string(&v).unwrap(), "[1,2]"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 625d3b4..e4149f2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ use embassy_executor::{SpawnError, SpawnToken, Spawner}; pub mod arc; pub mod backtrace_store; +pub mod bounded; pub mod buttons; pub mod error_log; pub mod logger; diff --git a/src/ws.rs b/src/ws.rs index 7dfe403..22ccfc9 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -3,7 +3,7 @@ use crate::{ state::{GlobalState, Scene, ota_state}, structs::{ApiError, FromPacket, TimerPacket, TimerPacketInner}, }; -use alloc::{boxed::Box, rc::Rc, string::ToString, vec::Vec}; +use alloc::{boxed::Box, rc::Rc, vec::Vec}; use core::str::FromStr; use embassy_net::{IpAddress, Stack, tcp::TcpSocket}; use embassy_sync::{ @@ -277,7 +277,7 @@ async fn ws_loop( #[cfg(not(feature = "e2e"))] { global_state.state.lock().await.error_text = - Some("Server Not Trusted!".to_string()); + Some("Server Not Trusted!".into()); } } } @@ -296,7 +296,7 @@ async fn ws_loop( crate::ws::send_packet(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Add { - firmware: alloc::string::ToString::to_string(crate::version::FIRMWARE), + firmware: crate::version::FIRMWARE.into(), sign_key: crate::state::sign_key(), }, }) @@ -323,7 +323,7 @@ async fn ws_loop( crate::utils::error_log::save_error_log(&global_state.nvs).await; global_state.state.lock().await.custom_message = - Some(("Connection lost".to_string(), "during update".to_string())); + Some(("Connection lost".into(), "during update".into())); Timer::after_millis(5000).await; esp_hal::system::software_reset(); @@ -728,7 +728,7 @@ where .await .map_err(|_| ApiError { should_reset_time: false, - error: "Channel full".to_string(), + error: "Channel full".into(), })?; let packet = if timeout { @@ -737,7 +737,7 @@ where .await .map_err(|_| ApiError { should_reset_time: false, - error: "Communication timeout!".to_string(), + error: "Communication timeout!".into(), })? } else { wait_for_tagged_response(tag).await From df2d6649f34c8dd0adf6f5c21fc2e5dc5ff37e41 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:25:42 +0200 Subject: [PATCH 010/132] refactor: alloc-free translation lookup without static mut --- host-tests/src/lib.rs | 2 + src/rfid.rs | 2 +- src/translations.rs | 197 ++++++++++++++++++++++-------------------- src/utils/bounded.rs | 6 ++ src/utils/mod.rs | 1 + src/utils/text.rs | 49 +++++++++++ src/ws.rs | 4 +- 7 files changed, 163 insertions(+), 98 deletions(-) create mode 100644 src/utils/text.rs diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index caa332c..eeb7042 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -8,6 +8,8 @@ extern crate alloc; pub mod bounded; #[path = "../../src/utils/ring_log.rs"] pub mod ring_log; +#[path = "../../src/utils/text.rs"] +pub mod text; pub mod utils { pub use crate::bounded; } diff --git a/src/rfid.rs b/src/rfid.rs index 9ce70b3..d0b3776 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -474,7 +474,7 @@ async fn process_card_info_response( } _ => { state.reset_solve_state(None).await; - state.error_text = Some(get_translation(TranslationKey::EMPTY_GROUPS_ERROR).into()); + state.error_text = Some(get_translation(TranslationKey::EMPTY_GROUPS_ERROR).as_str().into()); } } } diff --git a/src/translations.rs b/src/translations.rs index 81a0dc3..6a671cf 100644 --- a/src/translations.rs +++ b/src/translations.rs @@ -1,137 +1,144 @@ -use crate::{state::GlobalState, structs::TranslationRecord}; -use alloc::{ - string::{String, ToString}, - vec::Vec, +use crate::{ + state::GlobalState, + structs::{DisplayText, LocaleCode, TranslationRecord}, + utils::text::fill_placeholders, }; -use core::fmt::Display; -use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct LocalLocale { - pub locale: String, - pub translations: Vec>, -} +use alloc::vec::Vec; +use core::{cell::RefCell, fmt::Display}; +use embassy_sync::blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}; + +pub type TranslationText = DisplayText; + +/// Locales sent by the server are kept (heap, ~2 KB each) up to this many. +const MAX_LOCALES: usize = 8; +const MISSING: &str = "#####"; -static mut SELECTED_LOCALE: usize = usize::MAX; -static mut DEFAULT_LOCALE: usize = usize::MAX; -pub static LOCALES: Mutex> = Mutex::new(Vec::new()); macros::load_default_translations!("src/default_translation.json"); -#[allow(dead_code)] +struct Locale { + code: LocaleCode, + translations: [Option; TRANSLATIONS_COUNT], +} + +struct Locales { + list: Vec, + /// `usize::MAX` = none (fallback translations) + selected: usize, + default: usize, +} + +static LOCALES: Mutex> = + Mutex::new(RefCell::new(Locales { + list: Vec::new(), + selected: usize::MAX, + default: usize::MAX, + })); + +fn with_locales(f: impl FnOnce(&mut Locales) -> R) -> R { + LOCALES.lock(|locales| f(&mut locales.borrow_mut())) +} + pub fn clear_locales() { - if let Ok(mut t) = LOCALES.try_lock() { - t.clear(); - } + with_locales(|l| l.list.clear()); } pub fn select_locale(locale: &str, global_state: &GlobalState) { - let locale = locale.to_lowercase(); - let locale_idx = get_locale_index(&locale); - select_locale_idx(locale_idx, global_state); - + select_locale_idx(get_locale_index(locale), global_state); log::info!("Selected locale: {locale}"); } -pub fn select_locale_idx(mut locale_idx: usize, global_state: &GlobalState) { - unsafe { - if locale_idx == SELECTED_LOCALE { - return; - } - - if locale_idx == usize::MAX { - locale_idx = DEFAULT_LOCALE; +pub fn select_locale_idx(locale_idx: usize, global_state: &GlobalState) { + let changed = with_locales(|l| { + if locale_idx == l.selected { + return false; } + l.selected = match locale_idx { + usize::MAX => l.default, + idx => idx, + }; + true + }); - SELECTED_LOCALE = locale_idx; + if changed { global_state.state.signal(); // reload locale } } pub fn set_default_locale() { - unsafe { - DEFAULT_LOCALE = SELECTED_LOCALE; - } + with_locales(|l| l.default = l.selected); } pub fn restore_default_locale() { - unsafe { - SELECTED_LOCALE = DEFAULT_LOCALE; - } + with_locales(|l| l.selected = l.default); } +/// Index of `locale` (case-insensitive), `usize::MAX` if unknown. pub fn get_locale_index(locale: &str) -> usize { - if let Ok(t) = LOCALES.try_lock() { - t.iter() - .enumerate() - .find(|(_, l)| l.locale == locale) - .map(|(i, _)| i) + with_locales(|l| { + l.list + .iter() + .position(|loc| loc.code.eq_ignore_ascii_case(locale)) .unwrap_or(usize::MAX) - } else { - usize::MAX - } + }) } pub fn current_locale_index() -> usize { - unsafe { SELECTED_LOCALE } + with_locales(|l| l.selected) } -pub fn process_locale(locale: String, records: Vec) { - let locale = locale.to_lowercase(); - if let Ok(mut t) = LOCALES.try_lock() { - let tmp_locale = match t.iter_mut().find(|l| l.locale == locale) { - Some(tmp_locale) => tmp_locale, - None => { - let idx = t.len(); - t.push(LocalLocale { - locale, - translations: alloc::vec![None; TRANSLATIONS_COUNT], +/// Stores the server's translations for `locale` (unknown keys are ignored). +pub fn process_locale(locale: &str, records: &[TranslationRecord]) { + with_locales(|l| { + let idx = match l + .list + .iter() + .position(|loc| loc.code.eq_ignore_ascii_case(locale)) + { + Some(idx) => idx, + None if l.list.len() < MAX_LOCALES => { + let mut code = LocaleCode::truncated(locale); + code.make_ascii_lowercase(); + l.list.push(Locale { + code, + translations: core::array::from_fn(|_| None), }); - - let locale = t.get_mut(idx); - match locale { - Some(locale) => locale, - None => { - log::error!("Process locale failed!"); - return; - } - } + l.list.len() - 1 + } + None => { + log::error!("Too many locales, ignoring {locale}"); + return; } }; + let translations = &mut l.list[idx].translations; for record in records { - if let Some(key) = TranslationKey::from_key_str(&record.key) { - tmp_locale.translations[key] = Some(record.translation); + if let Some(slot) = TranslationKey::from_key_str(&record.key) + .and_then(|key| translations.get_mut(key)) + { + *slot = Some(TranslationText::truncated(&record.translation)); } } - } + }); } -pub fn get_translation(key: usize) -> String { - if let Ok(t) = LOCALES.try_lock() { - if let Some(locale) = t.get(unsafe { SELECTED_LOCALE }) { - return locale - .translations - .get(key) - .map(|t| t.as_ref().unwrap_or(&"#####".to_string()).to_string()) - .unwrap_or("#####".to_string()); - } else { - return FALLBACK_TRANSLATIONS - .get(key) - .map(|t| t.to_string()) - .unwrap_or("#####".to_string()); - } - } - - "#####".to_string() +pub fn get_translation(key: usize) -> TranslationText { + with_locales(|l| match l.list.get(l.selected) { + Some(locale) => locale + .translations + .get(key) + .and_then(Clone::clone) + .unwrap_or_else(|| MISSING.into()), + None => FALLBACK_TRANSLATIONS + .get(key) + .copied() + .unwrap_or(MISSING) + .into(), + }) } -pub fn get_translation_params(key: usize, params: &[T]) -> String { - let mut translation = get_translation(key); - for (i, arg) in params.iter().enumerate() { - let placeholder = alloc::format!("{{{i}}}"); - translation = translation.replace(&placeholder, &arg.to_string()); - } - - translation +/// [`get_translation`] with `{i}` replaced by `params[i]`. +pub fn get_translation_params(key: usize, params: &[T]) -> TranslationText { + let mut out = TranslationText::default(); + fill_placeholders(&get_translation(key), params, &mut out); + out } diff --git a/src/utils/bounded.rs b/src/utils/bounded.rs index dd0ea00..d43ee35 100644 --- a/src/utils/bounded.rs +++ b/src/utils/bounded.rs @@ -36,6 +36,12 @@ impl core::ops::Deref for BoundedString { } } +impl core::ops::DerefMut for BoundedString { + fn deref_mut(&mut self) -> &mut str { + self.0.as_mut_str() + } +} + impl From<&str> for BoundedString { fn from(s: &str) -> Self { Self::truncated(s) diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e4149f2..21da9b3 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -10,6 +10,7 @@ pub mod ring_log; pub mod rolling_average; pub mod signaled_mutex; pub mod stackmat; +pub mod text; /// A buffer for a `#[link_section = ".dram2_uninit"]` static (that region is not /// zeroed at boot, so `StaticCell`/`Mutex` state can't live there). Callers must diff --git a/src/utils/text.rs b/src/utils/text.rs new file mode 100644 index 0000000..ea8d6b6 --- /dev/null +++ b/src/utils/text.rs @@ -0,0 +1,49 @@ +//! Small text helpers (no deps, host-tested in host-tests/). + +use core::fmt::{Display, Write}; + +/// Writes `template` to `out`, replacing `{i}` with `params[i]` (unknown or out-of-range +/// placeholders are kept as-is). Never allocates; `out` decides what to do when full. +pub fn fill_placeholders(template: &str, params: &[T], out: &mut impl Write) { + let mut rest = template; + while let Some(open) = rest.find('{') { + _ = out.write_str(&rest[..open]); + let after = &rest[open + 1..]; + let param = after.find('}').and_then(|close| { + let idx: usize = after[..close].parse().ok()?; + Some((params.get(idx)?, close)) + }); + + match param { + Some((param, close)) => { + _ = write!(out, "{param}"); + rest = &after[close + 1..]; + } + None => { + _ = out.write_char('{'); + rest = after; + } + } + } + _ = out.write_str(rest); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill(t: &str, p: &[&str]) -> String { + let mut s = String::new(); + fill_placeholders(t, p, &mut s); + s + } + + #[test] + fn placeholders() { + assert_eq!(fill("of the competitor ({0})", &["12.34"]), "of the competitor (12.34)"); + assert_eq!(fill("{1} {0} {0}", &["a", "b"]), "b a a"); + assert_eq!(fill("{2} {x} { {", &["a"]), "{2} {x} { {"); + assert_eq!(fill("", &["a"]), ""); + assert_eq!(fill("ó{0}ł", &["ż"]), "óżł"); + } +} diff --git a/src/ws.rs b/src/ws.rs index 22ccfc9..9619065 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -454,8 +454,8 @@ async fn ws_rw( for locale in locales { crate::translations::process_locale( - locale.locale, - locale.translations, + &locale.locale, + &locale.translations, ); } From d710218851a6b8d9b8e304aa0775735d1afcc0a5 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:27:47 +0200 Subject: [PATCH 011/132] refactor: log_once helper and fatal task spawn failure --- src/bluetooth.rs | 91 ++++++------------------------------------------ src/buttons.rs | 10 +----- src/rfid.rs | 10 +----- src/state.rs | 20 ++--------- src/utils/mod.rs | 15 +++++++- src/ws.rs | 61 ++++---------------------------- 6 files changed, 35 insertions(+), 172 deletions(-) diff --git a/src/bluetooth.rs b/src/bluetooth.rs index 410102c..a857b86 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -15,18 +15,7 @@ use embassy_time::{Duration, Timer, with_timeout}; use esp_radio::ble::controller::BleConnector; use trouble_host::prelude::*; -use core::sync::atomic::{AtomicBool, Ordering}; - -static BLE_INIT_FAILED_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_MAC_READ_FAILED_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_BOND_ADD_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_SCAN_START_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_BONDABLE_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_REQUEST_SECURITY_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_PAIRING_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_GATT_CLIENT_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_SERVICE_NOT_FOUND_LOGGED: AtomicBool = AtomicBool::new(false); -static BLE_CHARACTERISTIC_NOT_FOUND_LOGGED: AtomicBool = AtomicBool::new(false); + #[embassy_executor::task] pub async fn bluetooth_timer_task( @@ -83,11 +72,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt esp_radio::ble::Config::default(), ) else { log::error!("Cannot init ble connector"); - if !BLE_INIT_FAILED_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error(crate::utils::error_log::codes::BLE_INIT_FAILED); - - BLE_INIT_FAILED_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_INIT_FAILED); return; }; @@ -97,13 +82,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt esp_hal::efuse::base_mac_address().as_bytes().try_into() else { log::error!("Cannot read bluetooth mac addr"); - if !BLE_MAC_READ_FAILED_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_MAC_READ_FAILED, - ); - - BLE_MAC_READ_FAILED_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_MAC_READ_FAILED); continue; }; let address: Address = Address::random(mac_addr); @@ -120,13 +99,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt Some(ref bond) => { if let Err(e) = stack.add_bond_information(bond.clone()) { log::error!("Add bond information failed! ({e:?})"); - if !BLE_BOND_ADD_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_BOND_ADD_FAILED, - ); - - BLE_BOND_ADD_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_BOND_ADD_FAILED); break; } central @@ -157,13 +130,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt Ok(s) => s, Err(e) => { log::error!("Cannot start ble scan! ({e:?})"); - if !BLE_SCAN_START_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_SCAN_START_FAILED, - ); - - BLE_SCAN_START_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_SCAN_START_FAILED); return; } }; @@ -255,25 +222,13 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt // Allow bonding if a bond isn't already stored if let Err(e) = conn.set_bondable(bond_info.is_none()) { log::error!("Set bondable failed! ({e:?})"); - if !BLE_BONDABLE_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_BONDABLE_FAILED, - ); - - BLE_BONDABLE_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_BONDABLE_FAILED); continue; } { if let Err(e) = conn.request_security() { log::error!("Request security failed ({e:?})"); - if !BLE_REQUEST_SECURITY_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_REQUEST_SECURITY_FAILED, - ); - - BLE_REQUEST_SECURITY_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_REQUEST_SECURITY_FAILED); continue; } @@ -299,13 +254,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt } ConnectionEvent::PairingFailed(err) => { log::error!("Pairing failed: {:?}", err); - if !BLE_PAIRING_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_PAIRING_FAILED, - ); - - BLE_PAIRING_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_PAIRING_FAILED); break; } ConnectionEvent::Disconnected { reason } => { @@ -335,13 +284,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt GattClient::<_, DefaultPacketPool, 10>::new(&stack, &conn).await else { log::error!("Failed to create Gatt client!"); - if !BLE_GATT_CLIENT_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_GATT_CLIENT_FAILED, - ); - - BLE_GATT_CLIENT_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_GATT_CLIENT_FAILED); continue; }; @@ -378,13 +321,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt let Some(service) = services.first().cloned() else { log::error!("Cannot find ble service!"); - if !BLE_SERVICE_NOT_FOUND_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_SERVICE_NOT_FOUND, - ); - - BLE_SERVICE_NOT_FOUND_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_SERVICE_NOT_FOUND); return; }; @@ -396,13 +333,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt .await else { log::error!("Cannot find ble characteristic!"); - if !BLE_CHARACTERISTIC_NOT_FOUND_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BLE_CHARACTERISTIC_NOT_FOUND, - ); - - BLE_CHARACTERISTIC_NOT_FOUND_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::BLE_CHARACTERISTIC_NOT_FOUND); return; }; diff --git a/src/buttons.rs b/src/buttons.rs index e5e7093..96ccdc8 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -357,15 +357,7 @@ async fn submit_up( .await { log::error!("Cannot save buzzer volume to NVS: {e:?}"); - static LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); - if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED, - ); - - LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED); } state_val.menu_scene = None; diff --git a/src/rfid.rs b/src/rfid.rs index d0b3776..24be003 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -504,15 +504,7 @@ async fn process_card_info_response( let Some(ref solve_group) = state.solve_group else { log::error!("Solve group is none! (How would that happen?)"); - static LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); - if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::RFID_SOLVE_GROUP_MISSING, - ); - - LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::RFID_SOLVE_GROUP_MISSING); return Ok(()); }; diff --git a/src/state.rs b/src/state.rs index 85d29e3..2d065f6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -579,8 +579,6 @@ impl SavedGlobalState { } pub async fn to_nvs(&self, nvs: &Nvs) { - use core::sync::atomic::{AtomicBool, Ordering}; - static SAVED_STATE_WRITE_ERR_LOGGED: AtomicBool = AtomicBool::new(false); let res = serde_json::to_vec(&self); if let Ok(vec) = res { @@ -588,31 +586,17 @@ impl SavedGlobalState { let res = nvs.set(NVS_SAVED_STATE, vec.as_slice()).await; if let Err(e) = res { log::error!("{e:?} Faile to write to nvs! (SAVED_STATE {})", vec.len()); - if !SAVED_STATE_WRITE_ERR_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::NVS_SAVED_STATE_WRITE_FAILED, - ); - - SAVED_STATE_WRITE_ERR_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::NVS_SAVED_STATE_WRITE_FAILED); } } } pub async fn clear_saved_global_state(nvs: &Nvs) { - use core::sync::atomic::{AtomicBool, Ordering}; - static SAVED_STATE_DELETE_ERR_LOGGED: AtomicBool = AtomicBool::new(false); let res = nvs.delete(NVS_SAVED_STATE).await; if let Err(e) = res { log::error!("{e:?} Faile to delete nvs key! (SAVED_STATE)",); - if !SAVED_STATE_DELETE_ERR_LOGGED.load(Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::NVS_SAVED_STATE_DELETE_FAILED, - ); - - SAVED_STATE_DELETE_ERR_LOGGED.store(true, Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::NVS_SAVED_STATE_DELETE_FAILED); } } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 21da9b3..b24a0bb 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -45,6 +45,18 @@ impl UninitBuf { } } +/// Records error `code` only the first time this call site runs (per boot). +#[macro_export] +macro_rules! log_once { + ($code:expr) => {{ + static LOGGED: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false); + if !LOGGED.swap(true, portable_atomic::Ordering::Relaxed) { + $crate::utils::error_log::add_error($code); + } + }}; +} + +/// Spawns a task; the firmware can't work without any of its tasks, so failure resets. pub fn spawn_task( spawner: &Spawner, task_name: &str, @@ -55,8 +67,9 @@ pub fn spawn_task( spawner.spawn(token); } Err(e) => { - log::error!("Cannot create task {task_name}: {e:?}"); + log::error!("Cannot create task {task_name}: {e:?}, resetting"); crate::utils::error_log::add_error(crate::utils::error_log::codes::TASK_SPAWN_FAILED); + esp_hal::system::software_reset(); } } } diff --git a/src/ws.rs b/src/ws.rs index 9619065..07671d0 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -21,16 +21,6 @@ static FRAME_CHANNEL: Channel = Chann static TAGGED_RETURN: PubSubChannel = PubSubChannel::new(); -static DNS_EMPTY_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); -static HTTP_UPGRADE_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); -static PACKET_PARSE_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); -static PACKET_SERIALIZE_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); -static TAGGED_SUBSCRIBER_LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); const WS_BUF_SIZE: usize = 8192; const TLS_BUF_SIZE: usize = 16640; @@ -155,13 +145,7 @@ async fn ws_loop( let Some(IpAddress::Ipv4(addr)) = res.first() else { log::error!("[WS]Dns resolver empty vec"); - if !DNS_EMPTY_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_DNS_RESOLVE_EMPTY, - ); - - DNS_EMPTY_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::WS_DNS_RESOLVE_EMPTY); Timer::after_millis(1000).await; continue; }; @@ -230,13 +214,7 @@ async fn ws_loop( let n = socket.read(rx_framer.mut_buf()).await.map_err(|_| ())?; if n == 0 { log::error!("error while reading http response"); - if !HTTP_UPGRADE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_HTTP_UPGRADE_READ_FAILED, - ); - - HTTP_UPGRADE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::WS_HTTP_UPGRADE_READ_FAILED); return Err(()); } @@ -528,16 +506,7 @@ async fn ws_rw( .await { log::error!("Cannot save buzzer volume to NVS: {e:?}"); - static LOGGED: core::sync::atomic::AtomicBool = - core::sync::atomic::AtomicBool::new(false); - if !LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED, - ); - - LOGGED - .store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED); } } @@ -563,13 +532,7 @@ async fn ws_rw( } Err(e) => { log::error!("timer_packet_fail: {e:?}\nTried to parse:\n{text}\n\n"); - if !PACKET_PARSE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_PACKET_PARSE_FAILED, - ); - - PACKET_PARSE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::WS_PACKET_PARSE_FAILED); } }, WsFrame::Binary(data) => { @@ -679,13 +642,7 @@ pub async fn send_packet(packet: TimerPacket) { } Err(e) => { log::error!("send_packet json to_string failed: {e:?}"); - if !PACKET_SERIALIZE_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED, - ); - - PACKET_SERIALIZE_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED); } } } @@ -757,13 +714,7 @@ async fn wait_for_tagged_response(tag: u64) -> TimerPacket { }, Err(_) => { log::error!("failed to get TAGGED_RETURN subscriber! Retry!"); - if !TAGGED_SUBSCRIBER_LOGGED.load(core::sync::atomic::Ordering::Relaxed) { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_TAGGED_SUBSCRIBER_FAILED, - ); - - TAGGED_SUBSCRIBER_LOGGED.store(true, core::sync::atomic::Ordering::Relaxed); - } + crate::log_once!(crate::utils::error_log::codes::WS_TAGGED_SUBSCRIBER_FAILED); Timer::after_millis(500).await; } } From cc5af4c9860c5fc13a4a0e7ea509880eb2599cc1 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:30:44 +0200 Subject: [PATCH 012/132] refactor: ws send/request api with mandatory timeouts --- src/battery_v3.rs | 5 ++-- src/battery_v4.rs | 5 ++-- src/buttons.rs | 15 ++++++---- src/consts.rs | 5 ++++ src/main.rs | 2 +- src/rfid.rs | 6 ++-- src/ws.rs | 71 +++++++++++++++++++++++++++++++++++++++-------- 7 files changed, 83 insertions(+), 26 deletions(-) diff --git a/src/battery_v3.rs b/src/battery_v3.rs index 93523c5..6b61b9d 100644 --- a/src/battery_v3.rs +++ b/src/battery_v3.rs @@ -79,14 +79,13 @@ pub async fn battery_read_task( let bat_percentage = bat_percentage(bat_calc_mv); if state.state.lock().await.server_connected == Some(true) { - crate::ws::send_packet(crate::structs::TimerPacket { + _ = crate::ws::send(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Battery { level: Some(bat_percentage as f64), voltage: Some(bat_calc_mv / 1000.0), }, - }) - .await; + }); } log::info!("calc({read}): {bat_calc_mv}mV {bat_percentage}%"); diff --git a/src/battery_v4.rs b/src/battery_v4.rs index aefe815..2e3f552 100644 --- a/src/battery_v4.rs +++ b/src/battery_v4.rs @@ -99,14 +99,13 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) if last_sent.elapsed().as_millis() >= BATTERY_SEND_INTERVAL_MS { if state.state.lock().await.server_connected == Some(true) { - crate::ws::send_packet(crate::structs::TimerPacket { + _ = crate::ws::send(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Battery { level: Some(soc as f64), voltage: Some(mv), }, - }) - .await; + }); } log::info!("Battery {mv}mv {soc}% (avg current: {ma}mA)"); diff --git a/src/buttons.rs b/src/buttons.rs index 96ccdc8..90c8fb7 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -4,7 +4,6 @@ use crate::{ BleAction, ErrorLogEntryStage, GlobalState, MenuScene, Scene, current_epoch, deeper_sleep_state, sleep_state, }, - structs::DelegateResponsePacket, utils::buttons::{Button, ButtonTrigger, ButtonsHandler}, }; use embassy_time::{Instant, Timer}; @@ -483,14 +482,17 @@ async fn submit_up( crate::state::set_sign_key(sign_key); crate::state::set_trust_server(true); - crate::ws::send_packet(crate::structs::TimerPacket { + if crate::ws::send(crate::structs::TimerPacket { tag: None, data: crate::structs::TimerPacketInner::Add { firmware: crate::version::FIRMWARE.into(), sign_key: crate::state::sign_key(), }, }) - .await; + .is_err() + { + log::error!("Add packet dropped: ws queue full"); + } return Ok(true); } @@ -759,8 +761,11 @@ async fn delegate_hold( drop(state_val); let resp = - crate::ws::send_tagged_request::(69420, packet, false) - .await; + crate::ws::delegate_request( + packet, + embassy_time::Duration::from_millis(crate::consts::DELEGATE_TIMEOUT_MS), + ) + .await; log::info!("Delegate resp: {resp:?}"); if let Ok(resp) = resp { diff --git a/src/consts.rs b/src/consts.rs index ac0ad4d..f72e70d 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -20,6 +20,11 @@ pub const LCD_INSPECTION_FRAME_TIME: u64 = 1000 / 30; pub const RFID_RETRY_INIT_MS: u64 = 1500; pub const WS_RETRY_MS: u64 = 1000; +/// Total time for a request (send + tagged response); was 5 s send + 5 s response. +pub const WS_REQUEST_TIMEOUT_MS: u64 = 10_000; +/// The delegate answers on the web panel, so wait long (the buttons task is blocked). +#[cfg_attr(feature = "qa", allow(dead_code))] +pub const DELEGATE_TIMEOUT_MS: u64 = 5 * 60_000; /// v3 buttons are shift-register scanned with no hardware debounce / strong /// pull-down filtering; require this long of a stable sample before edges fire. diff --git a/src/main.rs b/src/main.rs index 0ee9c57..1735eed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -444,7 +444,7 @@ async fn logger_task(global_state: GlobalState) { }; if let Some(logs) = logs { - ws::send_frame(ws_framer::WsFrameOwned::Binary(logs.to_vec())).await; + _ = ws::send_raw_binary(&logs); } #[cfg(not(feature = "release_build"))] diff --git a/src/rfid.rs b/src/rfid.rs index 24be003..e504d5e 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -390,13 +390,14 @@ pub async fn rfid_task( || state.current_competitor.unwrap_or_default() == card_uid as u64 }; - let resp = crate::ws::send_request::( + let resp = crate::ws::request::( crate::structs::TimerPacketInner::CardInfoRequest { card_id: card_uid as u64, is_competitor, attendance_device: None, sign_key: crate::state::sign_key(), }, + embassy_time::Duration::from_millis(crate::consts::WS_REQUEST_TIMEOUT_MS), ) .await; @@ -514,7 +515,7 @@ async fn process_card_info_response( return Ok(()); } - let resp = crate::ws::send_request::( + let resp = crate::ws::request::( crate::structs::TimerPacketInner::Solve { solve_time: state.solve_time.ok_or(anyhow!("Solve time is None"))?, penalty: state.penalty.unwrap_or(0) as i64, @@ -527,6 +528,7 @@ async fn process_card_info_response( group_id: solve_group.group_id.clone(), sign_key: crate::state::sign_key(), }, + embassy_time::Duration::from_millis(crate::consts::WS_REQUEST_TIMEOUT_MS), ) .await; diff --git a/src/ws.rs b/src/ws.rs index 07671d0..d5fede3 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -1,7 +1,7 @@ use crate::{ consts::WS_RETRY_MS, state::{GlobalState, Scene, ota_state}, - structs::{ApiError, FromPacket, TimerPacket, TimerPacketInner}, + structs::{ApiError, DelegateResponsePacket, FromPacket, TimerPacket, TimerPacketInner}, }; use alloc::{boxed::Box, rc::Rc, vec::Vec}; use core::str::FromStr; @@ -457,7 +457,7 @@ async fn ws_rw( crate::state::set_epoch(current_epoch); } TimerPacketInner::DelegateResponse(_) => { - tagged_publisher.publish((69420, timer_packet)).await; + tagged_publisher.publish((DELEGATE_TAG, timer_packet)).await; } TimerPacketInner::StartUpdate { version, @@ -635,7 +635,7 @@ pub async fn send_test_ack(global_state: &GlobalState) { .await; } -pub async fn send_packet(packet: TimerPacket) { +async fn send_packet(packet: TimerPacket) { match serde_json::to_string(&packet) { Ok(string) => { FRAME_CHANNEL.send(WsFrameOwned::Text(string)).await; @@ -656,18 +656,65 @@ pub fn clear_frame_channel() { FRAME_CHANNEL.clear(); } -pub async fn send_request(packet: TimerPacketInner) -> Result -where - T: FromPacket, -{ - let mut tag_bytes = [0; 8]; - _ = getrandom::getrandom(&mut tag_bytes); - let tag = u64::from_be_bytes(tag_bytes); +/// Tag under which untagged `DelegateResponse` pushes are delivered. +const DELEGATE_TAG: u64 = 69420; - send_tagged_request(tag, packet, true).await +/// The outgoing queue is full; nothing was sent. +#[derive(Debug)] +pub struct WsBusy; + +/// Queues `packet` without waiting. +pub fn send(packet: TimerPacket) -> Result<(), WsBusy> { + let text = serde_json::to_string(&packet).map_err(|e| { + log::error!("send json to_string failed: {e:?}"); + crate::log_once!(crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED); + WsBusy + })?; + FRAME_CHANNEL + .try_send(WsFrameOwned::Text(text)) + .map_err(|_| WsBusy) +} + +/// Queues a binary frame without waiting. +pub fn send_raw_binary(bytes: &[u8]) -> Result<(), WsBusy> { + FRAME_CHANNEL + .try_send(WsFrameOwned::Binary(bytes.to_vec())) + .map_err(|_| WsBusy) +} + +/// Sends `packet` and waits (at most `timeout` in total) for the tagged response. +pub async fn request( + packet: TimerPacketInner, + timeout: Duration, +) -> Result { + let tag = crate::utils::get_random_u64(); + send_tagged_request(tag, packet, false) + .with_timeout(timeout) + .await + .unwrap_or_else(|_| Err(timeout_error())) +} + +#[cfg_attr(feature = "qa", allow(dead_code))] +/// Sends a delegate `Solve` and waits (at most `timeout`) for the server's +/// `DelegateResponse` (an untagged push answered by a person, hence the long timeout). +pub async fn delegate_request( + packet: TimerPacketInner, + timeout: Duration, +) -> Result { + send_tagged_request(DELEGATE_TAG, packet, false) + .with_timeout(timeout) + .await + .unwrap_or_else(|_| Err(timeout_error())) +} + +fn timeout_error() -> ApiError { + ApiError { + should_reset_time: false, + error: "Communication timeout!".into(), + } } -pub async fn send_tagged_request( +async fn send_tagged_request( tag: u64, packet: TimerPacketInner, timeout: bool, From fa3117e75a86e3cdec43262cd5405db90d8e75b0 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:31:45 +0200 Subject: [PATCH 013/132] fix: atomic saved-state write, drop unused arc --- src/state.rs | 4 +-- src/utils/arc.rs | 70 ------------------------------------------------ src/utils/mod.rs | 1 - 3 files changed, 2 insertions(+), 73 deletions(-) delete mode 100644 src/utils/arc.rs diff --git a/src/state.rs b/src/state.rs index 2d065f6..06d71b6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -543,7 +543,7 @@ impl SignaledGlobalStateInner { let inspection_time = self .inspection_end .zip(self.inspection_start) - .map(|(end, start)| (end - start).as_millis()); + .map(|(end, start)| end.saturating_duration_since(start).as_millis()); crate::structs::SnapshotData { scene: self.scene.to_index(), @@ -582,7 +582,7 @@ impl SavedGlobalState { let res = serde_json::to_vec(&self); if let Ok(vec) = res { - _ = nvs.delete(NVS_SAVED_STATE).await; + // set replaces the old value atomically (deleting first could lose it on power loss) let res = nvs.set(NVS_SAVED_STATE, vec.as_slice()).await; if let Err(e) = res { log::error!("{e:?} Faile to write to nvs! (SAVED_STATE {})", vec.len()); diff --git a/src/utils/arc.rs b/src/utils/arc.rs deleted file mode 100644 index 5681140..0000000 --- a/src/utils/arc.rs +++ /dev/null @@ -1,70 +0,0 @@ -use alloc::boxed::Box; -use core::{ops::Deref, ptr::NonNull}; -use portable_atomic::AtomicUsize; - -pub struct Arc { - ptr: NonNull>, -} - -pub struct ArcInner { - count: AtomicUsize, - data: T, -} - -unsafe impl Send for Arc {} -unsafe impl Sync for Arc {} - -#[allow(dead_code)] -impl Arc { - pub fn new(data: T) -> Self { - let ptr = Box::new(ArcInner { - count: AtomicUsize::new(1), - data, - }); - - Arc { - ptr: NonNull::new(Box::into_raw(ptr)).unwrap_or(NonNull::dangling()), - } - } - - fn inner(&self) -> &ArcInner { - unsafe { self.ptr.as_ref() } - } -} - -impl Deref for Arc { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.inner().data - } -} - -impl Clone for Arc { - fn clone(&self) -> Self { - critical_section::with(|_cs| { - self.inner() - .count - .add(1, core::sync::atomic::Ordering::Relaxed); - }); - - Arc { ptr: self.ptr } - } -} - -impl Drop for Arc { - fn drop(&mut self) { - critical_section::with(|_cs| { - let old = self - .inner() - .count - .fetch_sub(1, core::sync::atomic::Ordering::Relaxed); - - if old == 1 { - unsafe { - drop(Box::from_raw(self.ptr.as_ptr())); - } - } - }); - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b24a0bb..a1be299 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,5 @@ use embassy_executor::{SpawnError, SpawnToken, Spawner}; -pub mod arc; pub mod backtrace_store; pub mod bounded; pub mod buttons; From be27c5dd5af21e5ce6239110a766385aa784a5d7 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:45:50 +0200 Subject: [PATCH 014/132] chore: esp-hal-mdns 0.2 --- Cargo.lock | 8 +++---- Cargo.toml | 2 +- src/mdns.rs | 67 ++++++++++++++++------------------------------------- 3 files changed, 25 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a43b64e..983a768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1935,12 +1935,12 @@ dependencies = [ [[package]] name = "esp-hal-mdns" -version = "0.1.6" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "026c82351da6f49d57e8ad9513e8509e5be2e5e3b9aa2683577f29e237d60865" +checksum = "7f1c7448022f3f7cb5dbc53505303656b6e716d5206f540c4943d73798c5a6a7" dependencies = [ - "heapless 0.9.3", - "log", + "embassy-net", + "embassy-time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1b93b36..7cbb0d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ serde = { version = "1.0.229", features = ["alloc", "derive"], default-features serde_json = { version = "1.0.154", default-features = false, features = ["alloc"] } adv-shift-registers = "0.2.5" embedded-hal = "1.0.0" -esp-hal-mdns = "0.1.6" +esp-hal-mdns = { version = "0.2.0", features = ["embassy"] } embedded-io-async = "0.7.0" macros = { path = "./macros" } nb = "1.1.0" diff --git a/src/mdns.rs b/src/mdns.rs index 88c28da..e692192 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -1,59 +1,32 @@ use crate::consts::MDNS_RESEND_INTERVAL; -use embassy_net::{ - IpAddress, IpEndpoint, Stack, - udp::{PacketMetadata, UdpSocket}, -}; +use embassy_net::Stack; use embassy_time::{Duration, Timer}; -use esp_hal_mdns::MdnsQuery; const FKMTIME_MDNS: &str = "_fkmtime._tcp.local."; +/// Resolves the `ws` TXT value of the fkmtime service, retrying until found. pub async fn mdns_query(stack: Stack<'static>) -> heapless::String<255> { - let mut rx_buffer = [0; 1024]; - let mut tx_buffer = [0; 1024]; - let mut rx_meta = [PacketMetadata::EMPTY; 16]; - let mut tx_meta = [PacketMetadata::EMPTY; 16]; - let mut sock = UdpSocket::new( - stack, - &mut rx_meta, - &mut rx_buffer, - &mut tx_meta, - &mut tx_buffer, - ); - - let ip_addr = IpAddress::v4(224, 0, 0, 251); - let ip_endpoint = IpEndpoint::new(ip_addr, 5353); - _ = sock.bind(5353); - _ = stack.join_multicast_group(ip_addr); - - let mut mdns = MdnsQuery::new(FKMTIME_MDNS, MDNS_RESEND_INTERVAL, || { - esp_hal::time::Instant::now() - .duration_since_epoch() - .as_millis() - }); - let mut data_buf = [0; 1024]; - - let tmp; + let mut buf = [0; 1024]; loop { - if let Some(data) = mdns.should_send_mdns_packet() { - _ = sock.send_to(data, ip_endpoint).await; - } - - if sock.may_recv() { - let res = sock.recv_from(&mut data_buf).await; - if let Ok((n, _endpoint)) = res { - let resp = mdns.parse_mdns_query(&data_buf[..n], Some("ws")); - - if let Some(value) = resp.2 { - tmp = value; - break; + match esp_hal_mdns::resolve( + stack, + FKMTIME_MDNS, + &mut buf, + Duration::from_millis(MDNS_RESEND_INTERVAL), + Duration::from_secs(30), + ) + .await + { + Ok(info) => { + if let Some(ws) = info.txt.get("ws").and_then(|w| w.try_into().ok()) { + return ws; } } + Err(e) => { + log::warn!("mdns: {e:?}"); + // a bind error returns immediately; don't spin + Timer::after(Duration::from_secs(1)).await; + } } - - Timer::after(Duration::from_millis(50)).await; } - _ = stack.leave_multicast_group(ip_addr); - - tmp } From cd0e056b4089aecb9b12579abd52d5c74dc474c5 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:46:57 +0200 Subject: [PATCH 015/132] chore: esp-hal-ota 0.7 --- Cargo.lock | 15 +++++++++++++-- Cargo.toml | 2 +- src/main.rs | 2 +- src/ws.rs | 10 +++++----- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 983a768..c6659c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1967,6 +1967,17 @@ dependencies = [ "log", ] +[[package]] +name = "esp-hal-ota" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169b6da547c8dc76277919a420e8e969ffd9db9a704cfeaad086ccfaf879a6ea" +dependencies = [ + "embedded-storage", + "esp-rom-sys", + "esp32c3 0.33.0", +] + [[package]] name = "esp-hal-procmacros" version = "0.23.0" @@ -1996,7 +2007,7 @@ dependencies = [ "embedded-storage", "esp-hal", "esp-hal-dhcp-server", - "esp-hal-ota", + "esp-hal-ota 0.6.2", "esp-nvs", "esp-radio", "esp-rtos", @@ -2400,7 +2411,7 @@ dependencies = [ "esp-hal", "esp-hal-mdns", "esp-hal-mfrc522", - "esp-hal-ota", + "esp-hal-ota 0.7.1", "esp-hal-wifimanager", "esp-println", "esp-radio", diff --git a/Cargo.toml b/Cargo.toml index 7cbb0d7..3075807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ portable-atomic = { version = "1.15.0", default-features = false } critical-section = "1.2.0" getrandom = { version = "=0.2.15", features = ["custom"] } uuid = { version = "=1.12.1", default-features = false, features = ["v4"] } -esp-hal-ota = { version = "0.6.2", features = ["esp32c3"] } +esp-hal-ota = { version = "0.7.1", features = ["esp32c3"] } esp32c3 = { version = "0.33.0" } anyhow = { version = "1.0.104", default-features = false } embedded-hal-bus = { version = "0.3.0", features = ["async"] } diff --git a/src/main.rs b/src/main.rs index 1735eed..15e00dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,7 +265,7 @@ async fn main(spawner: Spawner) { if let Ok(mut ota) = esp_hal_ota::Ota::new(FlashStorage::new(unsafe { board.flash.clone_unchecked() })) { - let res = ota.ota_mark_app_valid(); + let res = ota.mark_app_valid(); if let Err(e) = res { log::error!("Ota mark app valid failed: {e:?}"); utils::error_log::add_error(utils::error_log::codes::OTA_MARK_VALID_FAILED); diff --git a/src/ws.rs b/src/ws.rs index d5fede3..b45bfc9 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -318,7 +318,7 @@ async fn ws_loop( #[derive(Debug)] #[allow(dead_code)] enum WsRwError { - OtaError(esp_hal_ota::OtaError), + OtaError(esp_hal_ota::Error), TaggedPublisherError, SocketWriteError, SocketReadError, @@ -477,7 +477,7 @@ async fn ws_rw( log::info!("Start update: {firmware}/{version}"); log::info!("Begin update size: {size} crc: {crc}"); - ota.ota_begin(size, crc).map_err(WsRwError::OtaError)?; + ota.begin(size, crc).map_err(WsRwError::OtaError)?; crate::state::set_ota_state(true); let mut state = global_state.state.lock().await; @@ -545,10 +545,10 @@ async fn ws_rw( continue; } - let res = ota.ota_write_chunk(data); + let res = ota.write(data); if res == Ok(true) { log::info!("OTA complete! Veryfying.."); - if ota.ota_flush(true, true).is_ok() { + if ota.finish(true).is_ok() { log::info!("OTA restart!"); esp_hal::system::software_reset(); } else { @@ -559,7 +559,7 @@ async fn ws_rw( } } - let progress = (ota.get_ota_progress() * 100.0) as u8; + let progress = ota.progress().map_or(0, |p| p.percent()); global_state.update_progress.signal(progress); FRAME_CHANNEL From 37f734741289973917e97dd867079b21a0d7848a Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:48:40 +0200 Subject: [PATCH 016/132] chore: esp-hal-mfrc522 0.4 --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/rfid.rs | 125 +++++++++++++++++++++------------------------------- 3 files changed, 54 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6659c7..92544ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,9 +1945,9 @@ dependencies = [ [[package]] name = "esp-hal-mfrc522" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab66d38140daa0dd2ff6b9768929679b7d355bc33cc847e6a650157ed9e49a1e" +checksum = "9af6699634394c1dd59e1dff02669ded3274242475410265aca569267db90316" dependencies = [ "embassy-time", "embedded-hal-async", diff --git a/Cargo.toml b/Cargo.toml index 3075807..f746d99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ embedded-tls = { git = "https://github.com/embassy-rs/embedded-tls", rev = "5812 spin = { version = "0.9", default-features = false, features = ["portable_atomic"] } embassy-crypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1" } embassy-crypto-rustcrypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1", features = ["embassy-crypto-sha256", "embassy-crypto-hmac-sha256", "embassy-crypto-aes128-gcm", "embassy-crypto-p256-arith", "embassy-crypto-p256-ecdh", "embassy-crypto-p256-ecdsa"] } -esp-hal-mfrc522 = { version = "0.3.4", features = ["embassy-time"] } +esp-hal-mfrc522 = { version = "0.4.0", features = ["embassy-time"] } esp-bootloader-esp-idf = { version = "0.6.0", features = ["log-04", "esp32c3"] } trouble-host = { version = "0.7.0", features = ["scan", "security"] } diff --git a/src/rfid.rs b/src/rfid.rs index e504d5e..0cecc68 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -4,7 +4,7 @@ use crate::structs::{CardInfoResponsePacket, SolveConfirmPacket}; use crate::translations::{TranslationKey, get_translation}; use anyhow::{Result, anyhow}; use embassy_time::{Duration, Instant, Timer}; -use esp_hal_mfrc522::consts::UidSize; +use esp_hal_mfrc522::{Key, KeyType}; #[cfg(feature = "v3")] use esp_hal::time::Rate; @@ -32,8 +32,8 @@ pub async fn rfid_task( ) { #[cfg(feature = "v4")] let mut mfrc522 = { - let driver = esp_hal_mfrc522::drivers::I2CDriver::new(i2c, 0x28); - esp_hal_mfrc522::MFRC522::new(driver) + let driver = esp_hal_mfrc522::I2cInterface::new(i2c, 0x28); + esp_hal_mfrc522::Mfrc522::new(driver) }; #[cfg(feature = "v3")] @@ -80,15 +80,15 @@ pub async fn rfid_task( return; }; - esp_hal_mfrc522::MFRC522::new(esp_hal_mfrc522::drivers::SpiDriver::new(spi)) + esp_hal_mfrc522::Mfrc522::new(esp_hal_mfrc522::SpiInterface::new(spi)) }; #[cfg(not(feature = "e2e"))] { let mut error_logged = false; loop { - _ = mfrc522.pcd_init().await; - if mfrc522.pcd_is_init().await { + _ = mfrc522.init().await; + if matches!(mfrc522.is_initialized().await, Ok(true)) { break; } @@ -104,7 +104,7 @@ pub async fn rfid_task( } #[cfg(not(feature = "e2e"))] - log::debug!("PCD ver: {:?}", mfrc522.pcd_get_version().await); + log::debug!("PCD ver: {:?}", mfrc522.version().await); let mut rfid_sleep = false; let mut last_card = (0, Instant::now()); @@ -114,14 +114,14 @@ pub async fn rfid_task( rfid_sleep = sleep_state(); match rfid_sleep { - true => _ = mfrc522.pcd_soft_power_down().await, + true => _ = mfrc522.power_down().await, false => { - _ = mfrc522.pcd_soft_power_up().await; + _ = mfrc522.power_up().await; Timer::after_millis(100).await; let mut error_logged = false; loop { - _ = mfrc522.pcd_init().await; - if mfrc522.pcd_is_init().await { + _ = mfrc522.init().await; + if matches!(mfrc522.is_initialized().await, Ok(true)) { break; } @@ -163,16 +163,16 @@ pub async fn rfid_task( log::debug!("[E2E] Card scan: {card_uid}"); #[cfg(not(feature = "e2e"))] - if mfrc522.picc_is_new_card_present().await.is_err() { + if !matches!(mfrc522.is_new_card_present().await, Ok(true)) { continue; } #[cfg(not(feature = "e2e"))] - let Ok(card) = mfrc522.get_card(UidSize::Four).await else { + let Ok(card) = mfrc522.select().await else { continue; }; #[cfg(not(feature = "e2e"))] - let card_uid = card.get_number(); + let card_uid = card.to_u128(); log::info!("Card UID: {card_uid}"); let last_scan_time = (Instant::now().saturating_duration_since(last_card.1)).as_millis(); @@ -197,7 +197,7 @@ pub async fn rfid_task( { crate::qa::send_qa_resp(crate::qa::QaSignal::Rfid(card_uid)); - _ = mfrc522.picc_halta().await; + _ = mfrc522.halt().await; continue; } @@ -205,7 +205,7 @@ pub async fn rfid_task( if !crate::state::trust_server() { log::error!("Skipping card scan. Server not trusted!"); global_state.state.lock().await.error_text = Some("Server NOT Trusted!".into()); - _ = mfrc522.picc_halta().await; + _ = mfrc522.halt().await; continue; } @@ -217,12 +217,7 @@ pub async fn rfid_task( let fkm_token = crate::state::fkm_token(); let status = mfrc522 - .pcd_authenticate( - esp_hal_mfrc522::consts::PICCCommand::PICC_CMD_MF_AUTH_KEY_A, - 63, - &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], - &card, - ) + .authenticate(KeyType::A, 63, &Key::DEFAULT, &card) .await; let mut key = [0; 6]; @@ -230,7 +225,7 @@ pub async fn rfid_task( log::debug!("signing auth status: {status:?}"); if status.is_ok() { - let mut buff = [0; 18]; + let mut buff = [0u8; 16]; buff[..6].copy_from_slice(&key); buff[6] = 0xFF; @@ -239,43 +234,38 @@ pub async fn rfid_task( buff[9] = 0x69; buff[10..16].copy_from_slice(&key); - let res = mfrc522.mifare_write(63, &buff, 16).await; + let res = mfrc522.write_block(63, &buff).await; if let Err(e) = res { log::error!("write res: {e:?}"); global_state.sign_unsign_progress.signal(false); global_state.state.signal(); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } - //_ = mfrc522.pcd_stop_crypto1().await; + //_ = mfrc522.stop_crypto1().await; let status = mfrc522 - .pcd_authenticate( - esp_hal_mfrc522::consts::PICCCommand::PICC_CMD_MF_AUTH_KEY_A, - 63, - &key, - &card, - ) + .authenticate(KeyType::A, 63, &Key(key), &card) .await; if status.is_err() { log::error!("Cannot auth card!"); global_state.sign_unsign_progress.signal(false); global_state.state.signal(); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } let mut buff = [0; 16]; buff[..16].clone_from_slice(&card_uid.to_be_bytes()); - let res = mfrc522.mifare_write(62, &buff, 16).await; + let res = mfrc522.write_block(62, &buff).await; if res.is_err() { log::error!("Cannot write secured rfid info!"); global_state.sign_unsign_progress.signal(false); global_state.state.signal(); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } @@ -286,8 +276,8 @@ pub async fn rfid_task( global_state.state.signal(); } - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } Some(MenuScene::Unsigning) => { @@ -296,20 +286,15 @@ pub async fn rfid_task( key[..4].copy_from_slice(&fkm_token.to_be_bytes()); let status = mfrc522 - .pcd_authenticate( - esp_hal_mfrc522::consts::PICCCommand::PICC_CMD_MF_AUTH_KEY_A, - 63, - &key, - &card, - ) + .authenticate(KeyType::A, 63, &Key(key), &card) .await; log::debug!("unsigning auth status: {status:?}"); if status.is_ok() { let buff = [0; 16]; - _ = mfrc522.mifare_write(62, &buff, 16).await; + _ = mfrc522.write_block(62, &buff).await; - let mut buff = [0; 18]; + let mut buff = [0u8; 16]; buff[..6].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); buff[6] = 0xFF; buff[7] = 0x07; @@ -317,13 +302,13 @@ pub async fn rfid_task( buff[9] = 0x69; buff[10..16].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); - let res = mfrc522.mifare_write(63, &buff, 16).await; + let res = mfrc522.write_block(63, &buff).await; if let Err(e) = res { log::error!("cannot unsecure card error: {e:?}"); global_state.sign_unsign_progress.signal(false); global_state.state.signal(); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } @@ -335,8 +320,8 @@ pub async fn rfid_task( global_state.state.signal(); } - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } _ => {} @@ -349,37 +334,29 @@ pub async fn rfid_task( key[..4].copy_from_slice(&fkm_token.to_be_bytes()); let status = mfrc522 - .pcd_authenticate( - esp_hal_mfrc522::consts::PICCCommand::PICC_CMD_MF_AUTH_KEY_A, - 63, - &key, - &card, - ) + .authenticate(KeyType::A, 63, &Key(key), &card) .await; if status.is_err() { log::error!("Cannot auth card!"); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } - let mut buff = [0; 18]; - let mut byte_count = 18; - let res = mfrc522.mifare_read(62, &mut buff, &mut byte_count).await; - if res.is_err() { + let Ok(buff) = mfrc522.read_block(62).await else { log::error!("Cannot read secured rfid info!"); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; - } + }; - let secured_uid = u128::from_be_bytes(buff[..16].try_into().unwrap_or_default()); + let secured_uid = u128::from_be_bytes(buff); if secured_uid != card_uid { log::error!("Card is not secure!"); - log::debug!("read: {res:?}, data: {buff:#?}"); - _ = mfrc522.picc_halta().await; - _ = mfrc522.pcd_stop_crypto1().await; + log::debug!("read data: {buff:#?}"); + _ = mfrc522.halt().await; + _ = mfrc522.stop_crypto1().await; continue; } } @@ -428,9 +405,9 @@ pub async fn rfid_task( #[cfg(not(feature = "e2e"))] { - _ = mfrc522.picc_halta().await; + _ = mfrc522.halt().await; if crate::state::secure_rfid() { - _ = mfrc522.pcd_stop_crypto1().await; + _ = mfrc522.stop_crypto1().await; } } } From 8369e2cbd969f3861a584e40ff3fbec12be59f3a Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:50:18 +0200 Subject: [PATCH 017/132] chore: drop rsa from embedded-tls --- Cargo.lock | 178 ++++++++++------------------------------------------- Cargo.toml | 4 +- src/ws.rs | 1 - 3 files changed, 35 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92544ab..31e323c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,12 +303,6 @@ dependencies = [ "vsimd", ] -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit_field" version = "0.10.3" @@ -1126,7 +1120,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1243,7 +1236,7 @@ dependencies = [ "digest 0.11.3", "elliptic-curve 0.14.1", "rfc6979", - "signature 3.0.0", + "signature", "zeroize", ] @@ -1253,12 +1246,37 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7a6f957c944e22c2c1904618863702e8af302953454ac738e6df0c7038c4d88" dependencies = [ + "edge-nal", "edge-raw", + "embassy-futures", + "embassy-time", "heapless 0.9.3", "num_enum", "rand_core 0.10.1", ] +[[package]] +name = "edge-nal" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590182a48668fa4988cd97b9b500bcc9a213e9b122b4e0008e934b3916c64031" +dependencies = [ + "embassy-time", + "embedded-io-async 0.7.0", +] + +[[package]] +name = "edge-nal-embassy" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ad23a63d41bdda528a3b725ae9119ce58abeea265b41fc7f8b254c8d07b2a2" +dependencies = [ + "edge-nal", + "embassy-futures", + "embassy-net", + "embedded-io-async 0.7.0", +] + [[package]] name = "edge-raw" version = "0.8.0" @@ -1333,7 +1351,7 @@ dependencies = [ "embassy-crypto", "hmac 0.13.0", "p256 0.14.0", - "sha2 0.11.1", + "sha2", ] [[package]] @@ -1728,7 +1746,6 @@ dependencies = [ "heapless 0.9.3", "log", "portable-atomic", - "rsa", ] [[package]] @@ -1843,7 +1860,7 @@ dependencies = [ "esp-storage", "jiff", "log", - "sha2 0.11.1", + "sha2", "strum", ] @@ -1918,21 +1935,6 @@ dependencies = [ "xtensa-lx-rt", ] -[[package]] -name = "esp-hal-dhcp-server" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cced67bda05f7109af8dba0d83c2625734a60c86319740b8bebdf83b1e2d20f1" -dependencies = [ - "edge-dhcp", - "embassy-futures", - "embassy-net", - "embassy-sync 0.8.0", - "embassy-time", - "heapless 0.9.3", - "log", -] - [[package]] name = "esp-hal-mdns" version = "0.2.0" @@ -1994,10 +1996,13 @@ dependencies = [ [[package]] name = "esp-hal-wifimanager" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a700c9cfd05e173ef91f3c5855323cfa71533d0ee88e44820cff8498278f6dc6" +checksum = "7d1ff0383337e95853dfc91d261af8a380731c614e0ff3b50ddec5dad8469b5e" dependencies = [ + "edge-dhcp", + "edge-nal", + "edge-nal-embassy", "embassy-executor", "embassy-futures", "embassy-net", @@ -2006,7 +2011,6 @@ dependencies = [ "embedded-io-async 0.7.0", "embedded-storage", "esp-hal", - "esp-hal-dhcp-server", "esp-hal-ota 0.6.2", "esp-nvs", "esp-radio", @@ -2429,7 +2433,6 @@ dependencies = [ "qrcodegen-no-heap", "serde", "serde_json", - "spin", "static_cell", "trouble-host", "uuid", @@ -3104,9 +3107,6 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" -dependencies = [ - "spin", -] [[package]] name = "lebe" @@ -3428,22 +3428,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.8", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -3474,16 +3458,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -3502,7 +3476,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -3930,7 +3903,7 @@ dependencies = [ "elliptic-curve 0.14.1", "primefield", "primeorder 0.14.0", - "sha2 0.11.1", + "sha2", ] [[package]] @@ -4087,27 +4060,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der 0.7.10", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki", -] - [[package]] name = "png" version = "0.18.1" @@ -4399,7 +4351,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -4690,27 +4641,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "sha2 0.10.9", - "signature 2.2.0", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -4862,17 +4792,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.1" @@ -4899,16 +4818,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "3.0.0" @@ -5006,25 +4915,6 @@ dependencies = [ "somni-parser", ] -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index f746d99..a925ce7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,10 +41,8 @@ esp-hal-ota = { version = "0.7.1", features = ["esp32c3"] } esp32c3 = { version = "0.33.0" } anyhow = { version = "1.0.104", default-features = false } embedded-hal-bus = { version = "0.3.0", features = ["async"] } -embedded-tls = { git = "https://github.com/embassy-rs/embedded-tls", rev = "58126ee5c453147a5e00684075f001d7ef962ca3", default-features = false, features = ["log", "p256", "rsa"] } +embedded-tls = { git = "https://github.com/embassy-rs/embedded-tls", rev = "58126ee5c453147a5e00684075f001d7ef962ca3", default-features = false, features = ["log", "p256"] } # same embassy rev as embedded-tls uses -# riscv32imc has no CAS: let spin (via rsa -> lazy_static) use portable-atomic -spin = { version = "0.9", default-features = false, features = ["portable_atomic"] } embassy-crypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1" } embassy-crypto-rustcrypto = { git = "https://github.com/embassy-rs/embassy", rev = "822a76ea91f2c0885c6eb0c65fe91181e31133e1", features = ["embassy-crypto-sha256", "embassy-crypto-hmac-sha256", "embassy-crypto-aes128-gcm", "embassy-crypto-p256-arith", "embassy-crypto-p256-ecdh", "embassy-crypto-p256-ecdsa"] } esp-hal-mfrc522 = { version = "0.4.0", features = ["embassy-time"] } diff --git a/src/ws.rs b/src/ws.rs index b45bfc9..f7593f8 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -171,7 +171,6 @@ async fn ws_loop( let mut socket = if ws_url.secure { let mut tls = TlsConnection::new(socket, ssl_rx_buf, ssl_tx_buf); - // the "rsa" feature makes TlsConfig offer RSA signatures by default let config = TlsConfig::new(); tls.open(TlsContext::new(&config, InsecureNoVerify)) .await From 2617180ec2ebce85eaf586441c05d72f1d37d602 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:51:34 +0200 Subject: [PATCH 018/132] chore: patch esp-nvs with slim fork --- Cargo.lock | 3 +-- Cargo.toml | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31e323c..b430876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2035,8 +2035,7 @@ checksum = "911aadae41ec99104efa865bd0b6e486b4b735999f9b571963748a46d16495ab" [[package]] name = "esp-nvs" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe94ff32323389591a43f90589698586d7375322d8d4e3ba3bf9efc54b3f6b76" +source = "git+https://github.com/filipton/esp-nvs.git?rev=9fbd51dc8766507e25003f9b79689832a2b8a798#9fbd51dc8766507e25003f9b79689832a2b8a798" dependencies = [ "embedded-storage", "strum", diff --git a/Cargo.toml b/Cargo.toml index a925ce7..16fc3ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,3 +104,7 @@ opt-level = 3 [build-dependencies] dotenvy = "0.15.7" + +# slimmer esp-nvs (sorted vec map instead of btreemap, same on-flash format) +[patch.crates-io] +esp-nvs = { git = "https://github.com/filipton/esp-nvs.git", rev = "9fbd51dc8766507e25003f9b79689832a2b8a798" } From e18a765fb7d081a8617c743cb69f32bc65b70df9 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 18:56:02 +0200 Subject: [PATCH 019/132] chore: esp-hal-wifimanager 0.6 --- Cargo.lock | 1289 +--------------------------------------- Cargo.toml | 2 +- src/bluetooth.rs | 9 +- src/buttons.rs | 25 +- src/consts.rs | 10 +- src/lcd_v3.rs | 7 +- src/lcd_v4.rs | 7 +- src/main.rs | 52 +- src/state.rs | 12 +- src/utils/error_log.rs | 4 +- src/ws.rs | 10 +- 11 files changed, 85 insertions(+), 1342 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b430876..f0bdba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,17 +73,6 @@ dependencies = [ "embedded-hal-async", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.15", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -91,7 +80,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -124,12 +112,6 @@ dependencies = [ "equator", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "allocator-api2" version = "0.3.1" @@ -284,25 +266,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" -[[package]] -name = "base64-simd" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" -dependencies = [ - "simd-abstraction", -] - -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref 0.5.2", - "vsimd", -] - [[package]] name = "bit_field" version = "0.10.3" @@ -452,31 +415,6 @@ name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -dependencies = [ - "allocator-api2 0.2.21", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] [[package]] name = "bytemuck" @@ -496,21 +434,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" version = "1.8.0" @@ -567,35 +490,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror", -] - [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "compact_str" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "const-default" version = "1.0.0" @@ -614,12 +514,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "const-str" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" - [[package]] name = "convert_case" version = "0.6.0" @@ -663,12 +557,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" -[[package]] -name = "cow-utils" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" - [[package]] name = "cpubits" version = "0.1.1" @@ -786,38 +674,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "cssparser" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser-color" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa233e1dcd9c13a5d3e3a8a2c0f5a727bac380398345dbcb31db4597edc86b" -dependencies = [ - "cssparser", -] - -[[package]] -name = "cssparser-macros" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" -dependencies = [ - "quote", - "syn 3.0.9", -] - [[package]] name = "ctr" version = "0.10.1" @@ -940,34 +796,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "data-url" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" -dependencies = [ - "matches", -] - [[package]] name = "dbl" version = "0.3.2" @@ -1153,17 +981,6 @@ dependencies = [ "embedded-hal-async", ] -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "docsplay" version = "0.1.3" @@ -1199,27 +1016,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dragonbox_ecma" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66" - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - [[package]] name = "dyn-smooth" version = "0.2.0" @@ -1629,12 +1425,6 @@ dependencies = [ "embedded-hal-async", ] -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - [[package]] name = "embedded-io" version = "0.6.1" @@ -1809,19 +1599,13 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" -[[package]] -name = "escape-simd" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120339709b24832c07464b0838828361b735d793c409b8ca3147db0dae601542" - [[package]] name = "esp-alloc" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f73e8d355019c740f6a5a78429633040617d28a378c271464caadc52c78216e3" dependencies = [ - "allocator-api2 0.3.1", + "allocator-api2", "document-features", "enumset", "esp-config", @@ -1957,18 +1741,6 @@ dependencies = [ "log", ] -[[package]] -name = "esp-hal-ota" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd36839a6fc2b86cc6152363c989ae624ba830cc726102547e7d4b4f3d4ad9ca" -dependencies = [ - "embedded-storage", - "esp-rom-sys", - "esp32c3 0.33.0", - "log", -] - [[package]] name = "esp-hal-ota" version = "0.7.1" @@ -1978,6 +1750,7 @@ dependencies = [ "embedded-storage", "esp-rom-sys", "esp32c3 0.33.0", + "log", ] [[package]] @@ -1996,9 +1769,9 @@ dependencies = [ [[package]] name = "esp-hal-wifimanager" -version = "0.4.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1ff0383337e95853dfc91d261af8a380731c614e0ff3b50ddec5dad8469b5e" +checksum = "826c2deeb776539ee9a4d19b333ade11c7d4e8cc57eea72ea5f2af6c6667dba6" dependencies = [ "edge-dhcp", "edge-nal", @@ -2011,7 +1784,7 @@ dependencies = [ "embedded-io-async 0.7.0", "embedded-storage", "esp-hal", - "esp-hal-ota 0.6.2", + "esp-hal-ota", "esp-nvs", "esp-radio", "esp-rtos", @@ -2077,7 +1850,7 @@ version = "1.0.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50247f4e91000fc87661e77254126c74acc4535f822ee247f7778807202dc938" dependencies = [ - "allocator-api2 0.3.1", + "allocator-api2", "bt-hci", "bt-hci-transport", "docsplay", @@ -2147,7 +1920,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f8e99f512a839c78a77c2e55e1f0d208851cfa48935847d5e0b5e8b2c5a6289" dependencies = [ - "allocator-api2 0.3.1", + "allocator-api2", "defmt 1.2.0", "document-features", "embassy-executor", @@ -2331,12 +2104,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "fax" version = "0.2.7" @@ -2414,7 +2181,7 @@ dependencies = [ "esp-hal", "esp-hal-mdns", "esp-hal-mfrc522", - "esp-hal-ota 0.7.1", + "esp-hal-ota", "esp-hal-wifimanager", "esp-println", "esp-radio", @@ -2682,31 +2449,13 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] - [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.12", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2 0.2.21", + "ahash", ] [[package]] @@ -2741,6 +2490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" dependencies = [ "hash32", + "serde_core", "stable_deref_trait", ] @@ -2803,93 +2553,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_segmenter" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" -dependencies = [ - "icu_collections", - "icu_locale_fallback", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "smallvec", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" - [[package]] name = "ident_case" version = "1.0.1" @@ -2938,14 +2601,13 @@ checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "include-minifier" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4aab0efa4c730ef528fc8249cd190c69f193c64128fb20b204e0855fec19dc1" +checksum = "7c4fc83dc9160a76b1f077c52748712293fd1cc9b34cada3dcfeee26788bc0f9" dependencies = [ - "minify-html", + "flate2", "proc-macro2", "quote", - "serde_json", "syn 2.0.119", ] @@ -2957,8 +2619,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -3012,15 +2672,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3092,15 +2743,6 @@ dependencies = [ "libc", ] -[[package]] -name = "json-escape-simd" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6520172708e1b08b95f31bbf76cec064db4203a615cf2fb4e1128d6dd142b033" -dependencies = [ - "escape-simd", -] - [[package]] name = "lazy_static" version = "1.5.1" @@ -3136,60 +2778,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "lightningcss" -version = "1.0.0-alpha.72" +name = "linked_list_allocator" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d31b760f96e8fdfe1d0c295e4bf76c503d6f15d2d470d53bd8cd1f7aa8c7d934" -dependencies = [ - "ahash 0.8.12", - "bitflags 2.13.2", - "const-str", - "cssparser", - "cssparser-color", - "dashmap", - "data-encoding", - "getrandom 0.3.4", - "indexmap", - "itertools 0.10.5", - "lazy_static", - "lightningcss-derive", - "parcel_selectors", - "parcel_sourcemap", - "pastey", - "pathdiff", - "rayon", - "serde", - "serde-content", - "smallvec", -] +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" [[package]] -name = "lightningcss-derive" -version = "1.0.0-alpha.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "linked_list_allocator" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "litrs" -version = "1.0.1" +name = "litrs" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" @@ -3259,12 +2855,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3287,40 +2877,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" -[[package]] -name = "minify-html" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644c1511457e5f70dcbc03c932d4bd26351eceb8657b119c919dde2e65b6d120" -dependencies = [ - "ahash 0.8.12", - "aho-corasick", - "lightningcss", - "memchr", - "minify-html-common", - "once_cell", - "oxc_allocator", - "oxc_codegen", - "oxc_minifier", - "oxc_parser", - "oxc_span", -] - -[[package]] -name = "minify-html-common" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c3d71ae301c4e36949937ed9d121e1bf6847f2dc69ec20929b379d14659aac" -dependencies = [ - "ahash 0.8.12", - "aho-corasick", - "itertools 0.14.0", - "memchr", - "once_cell", - "serde", - "serde_json", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3396,12 +2952,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nonmax" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" - [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -3522,366 +3072,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "outref" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "owo-colors" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" - -[[package]] -name = "oxc-browserslist" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb7a1163a5501f935f8722d839b576491b749c695e7a066aa0b8df988b806df" -dependencies = [ - "flate2", - "postcard", - "rustc-hash", - "serde", - "serde_json", - "thiserror", -] - -[[package]] -name = "oxc-miette" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4356a61f2ed4c9b3610245215fbf48970eb277126919f87db9d0efa93a74245c" -dependencies = [ - "cfg-if", - "owo-colors", - "oxc-miette-derive", - "textwrap", - "thiserror", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "oxc-miette-derive" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "oxc_allocator" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433214c659b860685d987ca25a523a544d35ebf87ee3658a942fd1c664cfa49b" -dependencies = [ - "allocator-api2 0.2.21", - "bumpalo", - "hashbrown 0.16.1", - "oxc_data_structures", - "rustc-hash", -] - -[[package]] -name = "oxc_ast" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e78ea25d23521092edcd509198635dd0bd96df7fb71349bcd8087bb8f6a615d" -dependencies = [ - "bitflags 2.13.2", - "oxc_allocator", - "oxc_ast_macros", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_estree", - "oxc_regular_expression", - "oxc_span", - "oxc_syntax", -] - -[[package]] -name = "oxc_ast_macros" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3f29bf83925451a7ebbd30d3ff449414cc230c9b63aba70487c2ca74ea1e3ed" -dependencies = [ - "phf 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "oxc_ast_visit" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808862f42c9331954cf187261d6a48dbf471ccbe60b6a35f6b1056c11f32e95b" -dependencies = [ - "oxc_allocator", - "oxc_ast", - "oxc_span", - "oxc_syntax", -] - -[[package]] -name = "oxc_codegen" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "480bab938439c921d34750708abf15aacf253ea11e2f348e4b085cbf697f4ea2" -dependencies = [ - "bitflags 2.13.2", - "cow-utils", - "dragonbox_ecma", - "itoa", - "oxc_allocator", - "oxc_ast", - "oxc_data_structures", - "oxc_index", - "oxc_semantic", - "oxc_sourcemap", - "oxc_span", - "oxc_syntax", - "rustc-hash", -] - -[[package]] -name = "oxc_compat" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd06b5cb59ece933be410adc6f811108a10c567fa2d105321f8d0f01cc4ab0c6" -dependencies = [ - "cow-utils", - "oxc-browserslist", - "oxc_syntax", - "rustc-hash", - "serde", -] - -[[package]] -name = "oxc_data_structures" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd090988aa69c1e394f424289abb9bb1281448a072419ca556a07228ad36b854" - -[[package]] -name = "oxc_diagnostics" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b8a09558d38ee7f23faf0249cdb37b5b26f53a7375941f8636c41c661b283ce" -dependencies = [ - "cow-utils", - "oxc-miette", - "percent-encoding", -] - -[[package]] -name = "oxc_ecmascript" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0002ece1cc266aa6654913d39cf552b0cf501afce9d953b87c283dfcd307266" -dependencies = [ - "cow-utils", - "num-bigint", - "num-traits", - "oxc_allocator", - "oxc_ast", - "oxc_span", - "oxc_syntax", -] - -[[package]] -name = "oxc_estree" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af85b24b7e10bf0ccb252f16d38492f51236c1ba146f62013993667cbf7fa649" - -[[package]] -name = "oxc_index" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b" -dependencies = [ - "nonmax", - "serde", -] - -[[package]] -name = "oxc_mangler" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb751e1971f0809547347edfebe3755d5bed354aacf3ca7fe7216b901a11dd1d" -dependencies = [ - "itertools 0.14.0", - "oxc_allocator", - "oxc_ast", - "oxc_data_structures", - "oxc_index", - "oxc_semantic", - "oxc_span", - "oxc_syntax", - "rustc-hash", -] - -[[package]] -name = "oxc_minifier" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e836caa36befa6f9603eebf56b0c08d1e164ae31160c8e99ab3f535203065a4c" -dependencies = [ - "cow-utils", - "oxc_allocator", - "oxc_ast", - "oxc_ast_visit", - "oxc_codegen", - "oxc_compat", - "oxc_data_structures", - "oxc_ecmascript", - "oxc_index", - "oxc_mangler", - "oxc_parser", - "oxc_regular_expression", - "oxc_semantic", - "oxc_span", - "oxc_syntax", - "oxc_traverse", - "rustc-hash", -] - -[[package]] -name = "oxc_parser" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb730ab93ff23bbc471ef7109f847afa709bb284dd52a5d3366283d724858158" -dependencies = [ - "bitflags 2.13.2", - "cow-utils", - "memchr", - "num-bigint", - "num-traits", - "oxc_allocator", - "oxc_ast", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_ecmascript", - "oxc_regular_expression", - "oxc_span", - "oxc_syntax", - "rustc-hash", - "seq-macro", -] - -[[package]] -name = "oxc_regular_expression" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bafc3035e3b0fe555ae56f7bbc108a7ee520b0858250ba16d197e44ca83746" -dependencies = [ - "bitflags 2.13.2", - "oxc_allocator", - "oxc_ast_macros", - "oxc_diagnostics", - "oxc_span", - "phf 0.13.1", - "rustc-hash", - "unicode-id-start", -] - -[[package]] -name = "oxc_semantic" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578e5fa0b975a28e1d1ffa6c81c8df094545fe3c3225956d4e93ffe1ade3dae0" -dependencies = [ - "itertools 0.14.0", - "oxc_allocator", - "oxc_ast", - "oxc_ast_visit", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_ecmascript", - "oxc_index", - "oxc_span", - "oxc_syntax", - "phf 0.13.1", - "rustc-hash", - "self_cell", -] - -[[package]] -name = "oxc_sourcemap" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d378eb8bad20e89d66276aebab51f6a5408571092cac94abdd3eabb773713d6" -dependencies = [ - "base64-simd 0.8.0", - "json-escape-simd", - "rustc-hash", - "serde", - "serde_json", -] - -[[package]] -name = "oxc_span" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71243fe1ece27f71a6be5e2ab11d051792eeb578f3b056d7c9bbe82ec8043bf3" -dependencies = [ - "compact_str", - "oxc-miette", - "oxc_allocator", - "oxc_ast_macros", - "oxc_estree", -] - -[[package]] -name = "oxc_syntax" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c99e555ed497111004a71fb2aa6f8fb90b0d3e2733aceeb035e24701d69634" -dependencies = [ - "bitflags 2.13.2", - "cow-utils", - "dragonbox_ecma", - "nonmax", - "oxc_allocator", - "oxc_ast_macros", - "oxc_data_structures", - "oxc_estree", - "oxc_index", - "oxc_span", - "phf 0.13.1", - "unicode-id-start", -] - -[[package]] -name = "oxc_traverse" -version = "0.95.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d00264248ef474988ea02bfa44ccacd9a1d8716f0efb33a9cd6f992cf07b7b" -dependencies = [ - "itoa", - "oxc_allocator", - "oxc_ast", - "oxc_ast_visit", - "oxc_data_structures", - "oxc_ecmascript", - "oxc_semantic", - "oxc_span", - "oxc_syntax", - "rustc-hash", -] - [[package]] name = "p256" version = "0.13.2" @@ -3905,49 +3095,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "parcel_selectors" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05f71e01edca03d245ab0a9f7ce13a974ceb79baaae8faf2ba0b11de6b90913" -dependencies = [ - "bitflags 2.13.2", - "cssparser", - "log", - "phf 0.11.3", - "phf_codegen", - "precomputed-hash", - "rustc-hash", - "smallvec", -] - -[[package]] -name = "parcel_sourcemap" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" -dependencies = [ - "base64-simd 0.7.0", - "data-url", - "rkyv", - "serde", - "serde_json", - "vlq", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "paste" version = "1.0.15" @@ -3960,99 +3107,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.8", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4119,29 +3179,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4151,12 +3188,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "prettyplease" version = "0.2.37" @@ -4247,26 +3278,6 @@ dependencies = [ "embedded-graphics", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "pulp" version = "0.22.3" @@ -4489,15 +3500,6 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "regex-automata" version = "0.4.18" @@ -4515,15 +3517,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "rfc6979" version = "0.6.0" @@ -4599,35 +3592,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rlsf" version = "0.2.3" @@ -4670,12 +3634,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -4703,18 +3661,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - -[[package]] -name = "seq-macro" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" - [[package]] name = "serde" version = "1.0.229" @@ -4725,15 +3671,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-content" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" -dependencies = [ - "serde", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -4827,15 +3764,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "simd-abstraction" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" -dependencies = [ - "outref 0.1.0", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -4857,24 +3785,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - [[package]] name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" -[[package]] -name = "smawk" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" - [[package]] name = "smoltcp" version = "0.13.1" @@ -4920,12 +3836,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "static_cell" version = "2.1.1" @@ -4968,17 +3878,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -5001,17 +3900,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "tap" version = "1.0.1" @@ -5027,17 +3915,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "textwrap" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" -dependencies = [ - "icu_segmenter", - "smawk", - "unicode-width", -] - [[package]] name = "thiserror" version = "2.0.21" @@ -5081,23 +3958,6 @@ dependencies = [ "zune-jpeg", ] -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - [[package]] name = "toml" version = "0.8.23" @@ -5281,12 +4141,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" -[[package]] -name = "unicode-id-start" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" - [[package]] name = "unicode-ident" version = "1.0.27" @@ -5299,12 +4153,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unitrait" version = "2.0.0" @@ -5339,12 +4187,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "1.12.1" @@ -5383,24 +4225,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vlq" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" - [[package]] name = "void" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5536,12 +4366,6 @@ dependencies = [ "primefield", ] -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - [[package]] name = "ws-framer" version = "0.3.2" @@ -5633,29 +4457,6 @@ dependencies = [ "hashlink", ] -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.63" @@ -5676,66 +4477,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", - "synstructure", -] - [[package]] name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 16fc3ff..f3bf53a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ static_cell = { version = "2.1.1", features = ["nightly"] } heapless = { version = "0.9.3", default-features = false } esp-alloc = "0.11.0" -esp-hal-wifimanager = { version = "0.4.0", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } +esp-hal-wifimanager = { version = "0.6.0", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } serde = { version = "1.0.229", features = ["alloc", "derive"], default-features = false } serde_json = { version = "1.0.154", default-features = false, features = ["alloc"] } adv-shift-registers = "0.2.5" diff --git a/src/bluetooth.rs b/src/bluetooth.rs index a857b86..6c29612 100644 --- a/src/bluetooth.rs +++ b/src/bluetooth.rs @@ -246,7 +246,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt } if !security_level.encrypted() { - _ = state.nvs.delete(NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; break 'outer; } @@ -267,7 +267,7 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt /* || reason.into_inner() == 0x3e */ { // auth failed - _ = state.nvs.delete(NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; if let Some(ref bond_info) = bond_info { _ = stack.remove_bond_information(bond_info.identity); } @@ -375,7 +375,6 @@ async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalSt async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformation) { let mut buf = [0; 32]; - _ = nvs.delete(NVS_BONDING_KEY).await; buf[..6].copy_from_slice(info.identity.addr.addr.raw()); buf[6..22].copy_from_slice(info.ltk.to_le_bytes().as_slice()); @@ -385,7 +384,7 @@ async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformati SecurityLevel::EncryptedAuthenticated => 2, }; - let res = nvs.set(NVS_BONDING_KEY, buf.as_slice()).await; + let res = nvs.set(&NVS_BONDING_KEY, buf.as_slice()).await; if let Err(e) = res { log::error!("NVS Bonding key store failed! ({e:?})"); crate::utils::error_log::add_error( @@ -395,7 +394,7 @@ async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformati } async fn load_bonding_info(nvs: &esp_hal_wifimanager::Nvs) -> Option { - let Ok(buf) = nvs.get::>(NVS_BONDING_KEY).await else { + let Ok(buf) = nvs.get::>(&NVS_BONDING_KEY).await else { return None; }; diff --git a/src/buttons.rs b/src/buttons.rs index 90c8fb7..6bc8fe4 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -280,7 +280,7 @@ async fn submit_up( state_val.discovered_bluetooth_devices[state_val.selected_bluetooth_item] ); - _ = state.nvs.delete(NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; state.ble_sig.signal( BleAction::Connect( state_val.discovered_bluetooth_devices[state_val.selected_bluetooth_item] @@ -292,7 +292,7 @@ async fn submit_up( == state_val.discovered_bluetooth_devices.len() { log::debug!("[BtD] Unpair current device"); - _ = state.nvs.delete(NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; state.ble_sig.signal(BleAction::Unpair); } else if state_val.selected_bluetooth_item == state_val.discovered_bluetooth_devices.len() + 1 @@ -352,7 +352,7 @@ async fn submit_up( let current_volume = crate::state::buzzer_volume(); if let Err(e) = state .nvs - .set(crate::consts::NVS_BUZZER_VOLUME, current_volume) + .set(&crate::consts::NVS_BUZZER_VOLUME, current_volume) .await { log::error!("Cannot save buzzer volume to NVS: {e:?}"); @@ -374,10 +374,10 @@ async fn submit_up( 0 => { // Reset settings - _ = state.nvs.delete(esp_hal_wifimanager::WIFI_NVS_KEY).await; - _ = state.nvs.delete(NVS_SIGN_KEY).await; - _ = state.nvs.delete(NVS_BONDING_KEY).await; - _ = state.nvs.delete(NVS_ERROR_LOG).await; + _ = state.nvs.delete(&esp_hal_wifimanager::WIFI_NVS_KEY).await; + _ = state.nvs.delete(&NVS_SIGN_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_ERROR_LOG).await; Timer::after_millis(250).await; esp_hal::system::software_reset(); @@ -421,10 +421,10 @@ async fn submit_up( 0 => { // Reset settings - _ = state.nvs.delete(esp_hal_wifimanager::WIFI_NVS_KEY).await; - _ = state.nvs.delete(NVS_SIGN_KEY).await; - _ = state.nvs.delete(NVS_BONDING_KEY).await; - _ = state.nvs.delete(NVS_ERROR_LOG).await; + _ = state.nvs.delete(&esp_hal_wifimanager::WIFI_NVS_KEY).await; + _ = state.nvs.delete(&NVS_SIGN_KEY).await; + _ = state.nvs.delete(&NVS_BONDING_KEY).await; + _ = state.nvs.delete(&NVS_ERROR_LOG).await; Timer::after_millis(250).await; esp_hal::system::software_reset(); @@ -477,8 +477,7 @@ async fn submit_up( _ = getrandom::getrandom(&mut sign_key); let sign_key = u32::from_be_bytes(sign_key) >> 1; - _ = state.nvs.delete(NVS_SIGN_KEY).await; - _ = state.nvs.set(NVS_SIGN_KEY, sign_key).await; + _ = state.nvs.set(&NVS_SIGN_KEY, sign_key).await; crate::state::set_sign_key(sign_key); crate::state::set_trust_server(true); diff --git a/src/consts.rs b/src/consts.rs index f72e70d..32357c2 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -37,7 +37,7 @@ pub const INSPECTION_TIME_DNF: u64 = 17000; pub const INSPECTION_TIME_PLUS2: u64 = 15000; #[cfg(feature = "v4")] -pub const NVS_BUZZER_VOLUME: &str = "BUZZER_VOLUME"; +pub const NVS_BUZZER_VOLUME: esp_hal_wifimanager::Key = esp_hal_wifimanager::Key::from_str("BUZZER_VOLUME"); #[cfg(feature = "v4")] pub const BUZZER_VOLUME_MIN: u8 = 0; #[cfg(feature = "v4")] @@ -45,7 +45,7 @@ pub const BUZZER_VOLUME_MAX: u8 = 25; #[cfg(feature = "v4")] pub const BUZZER_VOLUME_DEFAULT: u8 = 5; -pub const NVS_BONDING_KEY: &str = "BONDING_KEY"; -pub const NVS_SIGN_KEY: &str = "SIGN_KEY"; -pub const NVS_SAVED_STATE: &str = "SAVED_STATE"; -pub const NVS_ERROR_LOG: &str = "ERROR_LOG"; +pub const NVS_BONDING_KEY: esp_hal_wifimanager::Key = esp_hal_wifimanager::Key::from_str("BONDING_KEY"); +pub const NVS_SIGN_KEY: esp_hal_wifimanager::Key = esp_hal_wifimanager::Key::from_str("SIGN_KEY"); +pub const NVS_SAVED_STATE: esp_hal_wifimanager::Key = esp_hal_wifimanager::Key::from_str("SAVED_STATE"); +pub const NVS_ERROR_LOG: esp_hal_wifimanager::Key = esp_hal_wifimanager::Key::from_str("ERROR_LOG"); diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 500ef2c..0f4f1dc 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -1,6 +1,5 @@ use ag_lcd_async::LcdDisplay; -use alloc::rc::Rc; -use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; use embassy_time::{Delay, Duration, Instant, Timer}; use embedded_hal::digital::OutputPin; use embedded_hal_async::delay::DelayNs; @@ -58,7 +57,7 @@ pub async fn lcd_show_critical_code( pub async fn lcd_task( lcd_shifter: adv_shift_registers::wrappers::ShifterValue, global_state: GlobalState, - wifi_setup_sig: Rc>, + wifi_setup_sig: &'static Signal, ) { let mut lcd = { let bl_pin = lcd_shifter.get_pin_mut(1, true); @@ -188,7 +187,7 @@ async fn process_lcd( global_state: &GlobalState, lcd_driver: &mut LcdAbstract<80, 16, 2, 3>, lcd: &mut LcdDisplay, - wifi_setup_sig: &Signal, + wifi_setup_sig: &Signal, ) -> Option<()> { #[cfg(feature = "bat_dev_lcd")] { diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index 6340ed3..2e50ca0 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -15,13 +15,12 @@ use crate::{ }; use alloc::{ format, - rc::Rc, string::{String, ToString}, vec::Vec, }; use anyhow::{Result, anyhow}; use display_interface_i2c::I2CInterface; -use embassy_sync::{blocking_mutex::raw::NoopRawMutex, signal::Signal}; +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; use embassy_time::{Delay, Instant, Timer}; use embedded_graphics::{ Drawable, @@ -361,7 +360,7 @@ pub async fn lcd_task( i2c: SharedI2C, mut display_rst: Output<'static>, global_state: GlobalState, - wifi_setup_sig: Rc>, + wifi_setup_sig: &'static Signal, ) { let di = display_interface_i2c::I2CInterface::new(i2c, 0x3C, 0x40); let raw_disp = @@ -646,7 +645,7 @@ async fn process_top_bar( async fn process_main( current_state: &SignaledGlobalStateInner, global_state: &GlobalState, - wifi_setup_sig: &Signal, + wifi_setup_sig: &Signal, oled: &mut OledData<'_>, ) -> Result<()> { if let Some(ref error_text) = current_state.error_text { diff --git a/src/main.rs b/src/main.rs index 15e00dd..2dc76d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,8 @@ async fn main(spawner: Spawner) { config }); - esp_alloc::heap_allocator!(size: 120 * 1024); + // wifimanager 0.5+ keeps its signals/buffers in statics instead of the heap + esp_alloc::heap_allocator!(size: 112 * 1024); /* { const HEAP_SIZE: usize = 8 * 1024; @@ -110,7 +111,7 @@ async fn main(spawner: Spawner) { #[cfg(feature = "release_build")] crate::utils::backtrace_store::verify_panic_flag(); - let Ok(nvs) = Nvs::new_from_part_table(unsafe { board.flash.clone_unchecked() }) else { + let Ok(nvs) = Nvs::new(unsafe { board.flash.clone_unchecked() }) else { utils::error_log::add_error(utils::error_log::codes::WRONG_PARTITION_TABLE); #[cfg(feature = "v3")] lcd_v3::lcd_show_critical_code(board.lcd, utils::error_log::codes::WRONG_PARTITION_TABLE) @@ -134,18 +135,18 @@ async fn main(spawner: Spawner) { let wake_reason = esp_hal::rtc_cntl::wakeup_cause(); log::info!("Wake reason: {:?} {:?}", reason, wake_reason); - utils::error_log::load_error_log(&nvs).await; + utils::error_log::load_error_log(nvs).await; static GLOBAL_STATE: static_cell::StaticCell = static_cell::StaticCell::new(); - let global_state: GlobalState = GLOBAL_STATE.init(GlobalStateInner::new(&nvs, board.aes)); - let wifi_setup_sig = Rc::new(Signal::new()); - let wifi_conn_sig = Rc::new(Signal::new()); + let global_state: GlobalState = GLOBAL_STATE.init(GlobalStateInner::new(nvs, board.aes)); + static WIFI_SETUP_SIG: esp_hal_wifimanager::ProvisioningSignal = Signal::new(); + static WIFI_CONN_SIG: esp_hal_wifimanager::ConnSignal = Signal::new(); - if let Ok(sign_key) = nvs.get::(NVS_SIGN_KEY).await { + if let Ok(sign_key) = nvs.get::(&NVS_SIGN_KEY).await { crate::state::set_sign_key(sign_key); } #[cfg(feature = "v4")] - if let Ok(saved_volume) = nvs.get::(crate::consts::NVS_BUZZER_VOLUME).await { + if let Ok(saved_volume) = nvs.get::(&crate::consts::NVS_BUZZER_VOLUME).await { if (crate::consts::BUZZER_VOLUME_MIN..=crate::consts::BUZZER_VOLUME_MAX) .contains(&saved_volume) { @@ -164,7 +165,7 @@ async fn main(spawner: Spawner) { spawn_task( &spawner, "lcd_v3::lcd_task", - lcd_v3::lcd_task(board.lcd, global_state, wifi_setup_sig.clone()), + lcd_v3::lcd_task(board.lcd, global_state, &WIFI_SETUP_SIG), ); #[cfg(feature = "v4")] spawn_task( @@ -174,7 +175,7 @@ async fn main(spawner: Spawner) { board.i2c.clone(), board.display_rst, global_state, - wifi_setup_sig.clone(), + &WIFI_SETUP_SIG, ), ); @@ -249,8 +250,9 @@ async fn main(spawner: Spawner) { ); let mut wm_settings = esp_hal_wifimanager::WmSettings { - wifi_panel: esp_hal_wifimanager::include_minified!("src/panel.html"), - wifi_conn_signal: Some(wifi_conn_sig.clone()), + wifi_panel: esp_hal_wifimanager::include_panel!("src/panel.html"), + wifi_conn_signal: Some(&WIFI_CONN_SIG), + provisioning_signal: Some(&WIFI_SETUP_SIG), ..Default::default() }; @@ -280,17 +282,16 @@ async fn main(spawner: Spawner) { #[cfg(feature = "qa")] None, #[cfg(not(feature = "qa"))] - Some(&nvs), + Some(nvs), board.wifi, unsafe { board.bt.clone_unchecked() }, - Some(wifi_setup_sig), ) .await; - let Ok(mut wifi_res) = wifi_res else { + let Ok(wifi_res) = wifi_res else { log::error!("WifiManager failed!!! Restarting in 1s!"); utils::error_log::add_error(utils::error_log::codes::WIFI_MANAGER_FAILED); - utils::error_log::save_error_log(&nvs).await; + utils::error_log::save_error_log(nvs).await; Timer::after_millis(1000).await; esp_hal::system::software_reset(); }; @@ -303,9 +304,8 @@ async fn main(spawner: Spawner) { crate::qa::send_qa_resp(crate::qa::QaSignal::WifiSetup); let conn_settings: ConnSettings = wifi_res - .data - .take() - .and_then(|d| serde_json::from_value(d).ok()) + .data() + .and_then(|d| serde_json::from_str(d).ok()) .unwrap_or_default(); let mut parse_retry_count = 0; @@ -313,7 +313,7 @@ async fn main(spawner: Spawner) { let url = if conn_settings.mdns || conn_settings.ws_url.is_none() || parse_retry_count > 0 { log::info!("Starting mdns lookup..."); global_state.state.lock().await.scene = Scene::MdnsWait; - let mdns_res = mdns::mdns_query(wifi_res.sta_stack).await; + let mdns_res = mdns::mdns_query(wifi_res.stack()).await; log::info!("Mdns result: {mdns_res:?}"); mdns_res.to_string() @@ -331,8 +331,8 @@ async fn main(spawner: Spawner) { if parse_retry_count > 3 { log::error!("Cannot parse wsurl! Reseting wifi configuration!"); utils::error_log::add_error(utils::error_log::codes::MDNS_WS_URL_PARSE_FAILED); - utils::error_log::save_error_log(&nvs).await; - _ = nvs.delete(WIFI_NVS_KEY).await; + utils::error_log::save_error_log(nvs).await; + _ = nvs.delete(&WIFI_NVS_KEY).await; Timer::after_millis(1000).await; esp_hal::system::software_reset(); @@ -350,11 +350,11 @@ async fn main(spawner: Spawner) { &spawner, "ws::ws_task", ws::ws_task( - wifi_res.sta_stack, + wifi_res.stack(), ws_url, global_state, ws_sleep_sig.clone(), - wifi_conn_sig, + &WIFI_CONN_SIG, ), ); spawn_task(&spawner, "logger_task", logger_task(global_state)); @@ -368,7 +368,7 @@ async fn main(spawner: Spawner) { set_brownout_detection(true); global_state.state.lock().await.scene = Scene::WaitingForCompetitor; - if let Some(saved_state) = SavedGlobalState::from_nvs(&nvs).await { + if let Some(saved_state) = SavedGlobalState::from_nvs(nvs).await { global_state .state .lock() @@ -380,7 +380,7 @@ async fn main(spawner: Spawner) { 'outer: loop { Timer::after_millis(100).await; if utils::error_log::is_save_ready() { - utils::error_log::save_error_log(&nvs).await; + utils::error_log::save_error_log(nvs).await; utils::error_log::clear_save_ready(); } diff --git a/src/state.rs b/src/state.rs index 06d71b6..9f5a859 100644 --- a/src/state.rs +++ b/src/state.rs @@ -277,7 +277,7 @@ pub struct GlobalStateInner { #[cfg(feature = "v4")] pub buzzer_sound_test: Signal, - pub nvs: Nvs, + pub nvs: &'static Nvs, pub aes: Mutex>, #[cfg(feature = "e2e")] @@ -285,7 +285,7 @@ pub struct GlobalStateInner { } impl GlobalStateInner { - pub fn new(nvs: &Nvs, aes: esp_hal::peripherals::AES<'static>) -> Self { + pub fn new(nvs: &'static Nvs, aes: esp_hal::peripherals::AES<'static>) -> Self { Self { state: SignaledMutex::new(SignaledGlobalStateInner::new()), timer_signal: Signal::new(), @@ -298,7 +298,7 @@ impl GlobalStateInner { #[cfg(feature = "v4")] buzzer_sound_test: Signal::new(), - nvs: nvs.clone(), + nvs, aes: Mutex::new(Aes::new(aes)), #[cfg(feature = "e2e")] @@ -566,7 +566,7 @@ impl SavedGlobalState { Timer::after_millis(5).await; } - let res = nvs.get::>(NVS_SAVED_STATE).await.ok()?; + let res = nvs.get::>(&NVS_SAVED_STATE).await.ok()?; let res: SavedGlobalState = serde_json::from_slice(&res).ok()?; // 6hours @@ -583,7 +583,7 @@ impl SavedGlobalState { let res = serde_json::to_vec(&self); if let Ok(vec) = res { // set replaces the old value atomically (deleting first could lose it on power loss) - let res = nvs.set(NVS_SAVED_STATE, vec.as_slice()).await; + let res = nvs.set(&NVS_SAVED_STATE, vec.as_slice()).await; if let Err(e) = res { log::error!("{e:?} Faile to write to nvs! (SAVED_STATE {})", vec.len()); crate::log_once!(crate::utils::error_log::codes::NVS_SAVED_STATE_WRITE_FAILED); @@ -593,7 +593,7 @@ impl SavedGlobalState { pub async fn clear_saved_global_state(nvs: &Nvs) { - let res = nvs.delete(NVS_SAVED_STATE).await; + let res = nvs.delete(&NVS_SAVED_STATE).await; if let Err(e) = res { log::error!("{e:?} Faile to delete nvs key! (SAVED_STATE)",); crate::log_once!(crate::utils::error_log::codes::NVS_SAVED_STATE_DELETE_FAILED); diff --git a/src/utils/error_log.rs b/src/utils/error_log.rs index cb9d3a2..d9f8e9a 100644 --- a/src/utils/error_log.rs +++ b/src/utils/error_log.rs @@ -187,7 +187,7 @@ pub fn with_error_log(f: impl FnOnce(&[u8]) -> R) -> R { /// Restores the log saved in NVS, keeping only the valid prefix. pub async fn load_error_log(nvs: &Nvs) { - let Ok(saved) = nvs.get::>(NVS_ERROR_LOG).await else { + let Ok(saved) = nvs.get::>(&NVS_ERROR_LOG).await else { return; }; @@ -202,7 +202,7 @@ pub async fn load_error_log(nvs: &Nvs) { pub async fn save_error_log(nvs: &Nvs) { // shortcut: copies the log to the heap for the NVS write (rare, throttled by the caller) let bytes = with_error_log(|b| b.to_vec()); - if let Err(e) = nvs.set(NVS_ERROR_LOG, bytes.as_slice()).await { + if let Err(e) = nvs.set(&NVS_ERROR_LOG, bytes.as_slice()).await { log::error!("errorlog save error: {e:?}"); } } diff --git a/src/ws.rs b/src/ws.rs index f7593f8..c371c6f 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -42,7 +42,7 @@ pub async fn ws_task( ws_url: WsUrlOwned, global_state: GlobalState, ws_sleep_sig: Rc>, - wifi_conn_sig: Rc>, + wifi_conn_sig: &'static Signal, ) { log::debug!("ws_url: {ws_url:?}"); @@ -80,7 +80,7 @@ pub async fn ws_task( &mut *ws_tx_buf, &mut *ssl_rx_buf, &mut *ssl_tx_buf, - &wifi_conn_sig, + wifi_conn_sig, ); let res = embassy_futures::select::select(ws_fut, ws_sleep_sig.wait()).await; @@ -119,7 +119,7 @@ async fn ws_loop( ws_tx_buf: &mut [u8], ssl_rx_buf: &mut [u8], ssl_tx_buf: &mut [u8], - wifi_conn_sig: &Rc>, + wifi_conn_sig: &Signal, ) -> Result<(), ()> { loop { { @@ -330,7 +330,7 @@ async fn ws_rw( framer_tx: &mut WsTxFramer<'_>, global_state: GlobalState, socket: &mut WsSocket<'_, '_>, - wifi_conn_sig: &Rc>, + wifi_conn_sig: &Signal, ) -> Result<(), WsRwError> { let mut ota = Ota::new(FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() @@ -501,7 +501,7 @@ async fn ws_rw( if let Err(e) = global_state .nvs - .set(crate::consts::NVS_BUZZER_VOLUME, volume) + .set(&crate::consts::NVS_BUZZER_VOLUME, volume) .await { log::error!("Cannot save buzzer volume to NVS: {e:?}"); From 52c903caab1f6ba8e8c877ff3a027e58f62065d0 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:09:40 +0200 Subject: [PATCH 020/132] refactor: ble state machine with command queue and timeouts --- host-tests/src/lib.rs | 70 +++++ src/bluetooth.rs | 474 --------------------------------- src/bluetooth/codec.rs | 73 ++++++ src/bluetooth/mod.rs | 577 +++++++++++++++++++++++++++++++++++++++++ src/state.rs | 4 +- 5 files changed, 722 insertions(+), 476 deletions(-) delete mode 100644 src/bluetooth.rs create mode 100644 src/bluetooth/codec.rs create mode 100644 src/bluetooth/mod.rs diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index eeb7042..63dbc0f 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -17,6 +17,76 @@ pub mod utils { pub mod structs; #[path = "old_structs.rs"] pub mod old_structs; +#[path = "../../src/bluetooth/codec.rs"] +pub mod ble_codec; + +#[cfg(test)] +mod ble_tests { + use crate::ble_codec::*; + + #[test] + fn decodes_legacy_bond_as_random_address() { + // pre-version format: [0..6] addr, [6..22] ltk, [22] level, rest zero + let mut buf = [0u8; BOND_LEN]; + buf[..6].copy_from_slice(&[1, 2, 3, 4, 5, 6]); + buf[6..22].copy_from_slice(&[7; 16]); + buf[22] = 1; + let bond = decode_bond(&buf).unwrap(); + assert_eq!(bond.addr, [1, 2, 3, 4, 5, 6]); + assert_eq!(bond.ltk, [7; 16]); + assert_eq!(bond.security_level, 1); + assert!(bond.random_addr); + } + + #[test] + fn bond_round_trip() { + for random_addr in [true, false] { + let bond = BondRecord { + addr: [9, 8, 7, 6, 5, 4], + random_addr, + ltk: [3; 16], + security_level: 2, + }; + assert_eq!(decode_bond(&encode_bond(&bond)), Some(bond)); + } + } + + #[test] + fn rejects_bad_bonds() { + assert_eq!(decode_bond(&[]), None); + assert_eq!(decode_bond(&[0; 31]), None); + let mut buf = [0u8; BOND_LEN]; + buf[22] = 3; // unknown security level + assert_eq!(decode_bond(&buf), None); + buf[22] = 0; + buf[31] = 7; // unknown version + assert_eq!(decode_bond(&buf), None); + } + + #[test] + fn parses_device_names() { + // flags, then complete local name + let adv = [ + 2, 0x01, 0x06, 9, 0x09, b'F', b'K', b'M', b'D', b'-', b'1', b'2', b'3', + ]; + assert_eq!(parse_device_name(&adv), Some("FKMD-123")); + assert!(is_fkm_display("FKMD-123")); + assert!(!is_fkm_display("Phone")); + // shortened name ending exactly at the buffer end + assert_eq!(parse_device_name(&[3, 0x08, b'a', b'b']), Some("ab")); + // no name, zero length, truncated, empty, invalid utf8 + assert_eq!(parse_device_name(&[2, 0x01, 0x06]), None); + assert_eq!(parse_device_name(&[0, 0x09, b'a']), None); + assert_eq!(parse_device_name(&[5, 0x09, b'a']), None); + assert_eq!(parse_device_name(&[]), None); + assert_eq!(parse_device_name(&[2, 0x09, 0xff]), None); + // never panics on arbitrary input + for n in 0..=255u8 { + let junk = [n, n.wrapping_mul(7), 0x09, n, 1, 0x08]; + let _ = parse_device_name(&junk); + } + } +} #[cfg(test)] mod tests { diff --git a/src/bluetooth.rs b/src/bluetooth.rs deleted file mode 100644 index 6c29612..0000000 --- a/src/bluetooth.rs +++ /dev/null @@ -1,474 +0,0 @@ -use crate::{ - consts::NVS_BONDING_KEY, - state::{BleAction, GlobalState, MenuScene}, - structs::BleDisplayDevice, -}; -use alloc::{rc::Rc, vec::Vec}; -use core::cell::RefCell; -use embassy_futures::select::{Either, select, select3, select4}; -use embassy_sync::{ - blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, - channel::{Channel, Sender}, - signal::Signal, -}; -use embassy_time::{Duration, Timer, with_timeout}; -use esp_radio::ble::controller::BleConnector; -use trouble_host::prelude::*; - - - -#[embassy_executor::task] -pub async fn bluetooth_timer_task( - bt: esp_hal::peripherals::BT<'static>, - state: GlobalState, - sleep_sig: Rc>, -) { - loop { - let mut sleep = false; - embassy_futures::select::select(bluetooth_loop(&bt, &state), async { - loop { - if sleep_sig.wait().await { - sleep = true; - break; - } - } - }) - .await; - - Timer::after_millis(1000).await; - if sleep { - loop { - if !sleep_sig.wait().await { - break; - } - } - } - } -} - -async fn bluetooth_loop(bt: &esp_hal::peripherals::BT<'static>, state: &GlobalState) { - loop { - let mut bond_info = if let Some(bond_info) = load_bonding_info(&state.nvs).await { - log::info!("Bond stored."); - Some(bond_info) - } else { - log::info!("No bond stored."); - - let current_menu_scene = state.state.lock().await.menu_scene.clone(); - if current_menu_scene != Some(MenuScene::BtDisplay) { - loop { - let sig = state.ble_sig.wait().await; - if let BleAction::StartScan = sig { - break; - } - } - } - - None - }; - - let Ok(connector) = BleConnector::new( - unsafe { bt.clone_unchecked() }, - esp_radio::ble::Config::default(), - ) else { - log::error!("Cannot init ble connector"); - crate::log_once!(crate::utils::error_log::codes::BLE_INIT_FAILED); - return; - }; - - let controller: ExternalController<_, 20> = ExternalController::new(connector); - - let Ok(mac_addr): Result<[u8; 6], _> = - esp_hal::efuse::base_mac_address().as_bytes().try_into() - else { - log::error!("Cannot read bluetooth mac addr"); - crate::log_once!(crate::utils::error_log::codes::BLE_MAC_READ_FAILED); - continue; - }; - let address: Address = Address::random(mac_addr); - log::info!("[ble] address = {address:x?}"); - - let mut resources: HostResources<_, DefaultPacketPool, 1, 3> = HostResources::new(); - let stack = trouble_host::new(controller, &mut resources) - .set_random_address(address) - .build(); - let central = stack.central(); - let mut runner = stack.runner(); - - let mut central = match bond_info { - Some(ref bond) => { - if let Err(e) = stack.add_bond_information(bond.clone()) { - log::error!("Add bond information failed! ({e:?})"); - crate::log_once!(crate::utils::error_log::codes::BLE_BOND_ADD_FAILED); - break; - } - central - } - None => { - let discovery_channel: Channel = - embassy_sync::channel::Channel::new(); - let printer = BleDiscovery { - seen: RefCell::new(heapless::Deque::new()), - sender: discovery_channel.sender(), - }; - - let mut scanner = Scanner::new(central); - { - state - .state - .lock() - .await - .discovered_bluetooth_devices - .clear(); - } - - let _ = select4( - runner.run_with_handler(&printer), - async { - let config = ScanConfig::default(); - let mut _session = match scanner.scan(&config).await { - Ok(s) => s, - Err(e) => { - log::error!("Cannot start ble scan! ({e:?})"); - crate::log_once!(crate::utils::error_log::codes::BLE_SCAN_START_FAILED); - return; - } - }; - - loop { - Timer::after_millis(10000).await; - } - }, - async { - loop { - let recv = discovery_channel.receive().await; - { - let mut state = state.state.lock().await; - if state.selected_bluetooth_item - >= state.discovered_bluetooth_devices.len() - && state.selected_bluetooth_item > 0 - { - state.selected_bluetooth_item += 1; - } - - state.discovered_bluetooth_devices.push(recv); - } - } - }, - async { - loop { - Timer::after_millis(200).await; - if state.ble_sig.signaled() { - break; - } - } - }, - ) - .await; - - scanner.into_inner() - } - }; - - let display_addr = match bond_info { - Some(ref bond_info) => bond_info.identity.addr.addr.into_inner(), - None => loop { - let sig = state.ble_sig.wait().await; - if let BleAction::Connect(d) = sig { - break d.addr; - } - }, - }; - let target: Address = Address::random(display_addr); - - let config = ConnectConfig { - connect_params: Default::default(), - scan_config: ScanConfig { - filter_accept_list: &[target], - ..Default::default() - }, - }; - - log::info!("Scanning for peripheral..."); - let _ = select3( - runner.run(), - async { - loop { - let sig = state.ble_sig.wait().await; - if let BleAction::Unpair = sig { - break; - } - } - }, - async { - 'outer: loop { - log::info!("Connecting to {:?}", target); - let conn = match with_timeout(Duration::from_secs(5), central.connect(&config)) - .await - { - Ok(Ok(conn)) => conn, - Ok(Err(e)) => { - log::error!("Failed to connect: {:?}", e); - Timer::after(Duration::from_secs(1)).await; - continue; - } - Err(_) => { - log::error!("Timeout connecting"); - Timer::after(Duration::from_secs(1)).await; - continue; - } - }; - - // Allow bonding if a bond isn't already stored - if let Err(e) = conn.set_bondable(bond_info.is_none()) { - log::error!("Set bondable failed! ({e:?})"); - crate::log_once!(crate::utils::error_log::codes::BLE_BONDABLE_FAILED); - continue; - } - { - if let Err(e) = conn.request_security() { - log::error!("Request security failed ({e:?})"); - crate::log_once!(crate::utils::error_log::codes::BLE_REQUEST_SECURITY_FAILED); - continue; - } - - loop { - match conn.next().await { - ConnectionEvent::PairingComplete { - security_level, - bond, - } => { - log::info!("Pairing complete: {:?}", security_level); - - if let Some(bond) = bond { - store_bonding_info(&state.nvs, &bond).await; - bond_info = Some(bond); - } - - if !security_level.encrypted() { - _ = state.nvs.delete(&NVS_BONDING_KEY).await; - break 'outer; - } - - break; - } - ConnectionEvent::PairingFailed(err) => { - log::error!("Pairing failed: {:?}", err); - crate::log_once!(crate::utils::error_log::codes::BLE_PAIRING_FAILED); - break; - } - ConnectionEvent::Disconnected { reason } => { - log::error!( - "Disconnected1: {:?} ({:x})", - reason, - reason.into_inner() - ); - if reason.into_inner() == 0x05 - /* || reason.into_inner() == 0x3e */ - { - // auth failed - _ = state.nvs.delete(&NVS_BONDING_KEY).await; - if let Some(ref bond_info) = bond_info { - _ = stack.remove_bond_information(bond_info.identity); - } - break 'outer; - } - break; - } - _ => {} - } - } - } - - let Ok(client) = - GattClient::<_, DefaultPacketPool, 10>::new(&stack, &conn).await - else { - log::error!("Failed to create Gatt client!"); - crate::log_once!(crate::utils::error_log::codes::BLE_GATT_CLIENT_FAILED); - continue; - }; - - let conn_fut = async { - loop { - if let ConnectionEvent::Disconnected { reason } = conn.next().await { - log::info!("Disconnected2: {:?}", reason); - break; - } - } - }; - - let write_fut = async { - let services = match with_timeout( - Duration::from_secs(5), - client.services_by_uuid(&Uuid::from( - 0xa5bad9f2700a4c3db9e2e58ad262d40eu128, - )), - ) - .await - { - Ok(Ok(conn)) => conn, - Ok(Err(e)) => { - log::error!("Failed to connect: {:?}", e); - Timer::after(Duration::from_secs(1)).await; - return; - } - Err(_) => { - log::error!("Timeout connecting"); - Timer::after(Duration::from_secs(1)).await; - return; - } - }; - - let Some(service) = services.first().cloned() else { - log::error!("Cannot find ble service!"); - crate::log_once!(crate::utils::error_log::codes::BLE_SERVICE_NOT_FOUND); - return; - }; - - let Ok(c) = client - .characteristic_by_uuid::( - &service, - &Uuid::from(0xa5178cade4e045988053a4a78b9281e2u128), - ) - .await - else { - log::error!("Cannot find ble characteristic!"); - crate::log_once!(crate::utils::error_log::codes::BLE_CHARACTERISTIC_NOT_FOUND); - return; - }; - - let mut data = [0; 8]; - loop { - let ms = state.bt_display_signal.wait().await; - data.copy_from_slice(&ms.to_be_bytes()); - - if !conn.is_connected() { - break; - } - - if client - .write_characteristic_without_response(&c, &data[..]) - .await - .is_err() - { - break; - } - } - }; - - let gatt_and_conn_events = select(conn_fut, write_fut); - match select(client.task(), gatt_and_conn_events).await { - Either::Second(Either::First(_)) => { - log::info!("Connection event loop finished (disconnected)"); - } - Either::Second(Either::Second(_)) => { - log::info!("GATT operations finished"); - } - _ => {} - } - } - }, - ) - .await; - } -} - -async fn store_bonding_info(nvs: &esp_hal_wifimanager::Nvs, info: &BondInformation) { - let mut buf = [0; 32]; - - buf[..6].copy_from_slice(info.identity.addr.addr.raw()); - buf[6..22].copy_from_slice(info.ltk.to_le_bytes().as_slice()); - buf[22] = match info.security_level { - SecurityLevel::NoEncryption => 0, - SecurityLevel::Encrypted => 1, - SecurityLevel::EncryptedAuthenticated => 2, - }; - - let res = nvs.set(&NVS_BONDING_KEY, buf.as_slice()).await; - if let Err(e) = res { - log::error!("NVS Bonding key store failed! ({e:?})"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::NVS_BONDING_KEY_WRITE_FAILED, - ); - } -} - -async fn load_bonding_info(nvs: &esp_hal_wifimanager::Nvs) -> Option { - let Ok(buf) = nvs.get::>(&NVS_BONDING_KEY).await else { - return None; - }; - - if buf.len() != 32 { - return None; - } - - // only the 6 address bytes are stored; peers are connected as random addresses - let addr = Address::random(buf[..6].try_into().unwrap_or_default()); - let security_level = match buf[22] { - 0 => SecurityLevel::NoEncryption, - 1 => SecurityLevel::Encrypted, - 2 => SecurityLevel::EncryptedAuthenticated, - _ => return None, - }; - let ltk = LongTermKey::from_le_bytes(buf[6..22].try_into().unwrap_or_default()); - - Some(BondInformation { - identity: Identity { addr, irk: None }, - security_level, - is_bonded: true, - ltk, - }) -} - -#[allow(dead_code)] -struct BleDiscovery<'a> { - seen: RefCell>, - sender: Sender<'a, NoopRawMutex, BleDisplayDevice, 10>, -} - -impl EventHandler for BleDiscovery<'_> { - fn on_adv_reports(&self, mut it: LeAdvReportsIter<'_>) { - let mut seen = self.seen.borrow_mut(); - while let Some(Ok(report)) = it.next() { - if !seen.iter().any(|b| b.raw() == report.addr.raw()) { - if let Some(name) = parse_device_name(report.data) - && name.starts_with("FKMD-") - { - log::info!("Disovered FKM Display! [{:?}] ({name})", report.addr); - - _ = self.sender.try_send(BleDisplayDevice { - name: name.into(), - addr: report.addr.into_inner(), - }); - } - - if seen.is_full() { - seen.pop_front(); - } - _ = seen.push_back(report.addr); - } - } - } -} - -#[allow(dead_code)] -fn parse_device_name(data: &[u8]) -> Option<&str> { - let mut i = 0; - while i < data.len() { - let len = data[i] as usize; - if len == 0 || i + len >= data.len() { - break; - } - - let ad_type = data[i + 1]; - let ad_data = &data[i + 2..i + 1 + len]; - - // 0x09 = Complete Local Name, 0x08 = Shortened Local Name - if ad_type == 0x09 || ad_type == 0x08 { - return core::str::from_utf8(ad_data).ok(); - } - - i += 1 + len; - } - None -} diff --git a/src/bluetooth/codec.rs b/src/bluetooth/codec.rs new file mode 100644 index 0000000..cda880c --- /dev/null +++ b/src/bluetooth/codec.rs @@ -0,0 +1,73 @@ +//! Pure helpers of the BLE display link (no hardware types, host-tested). + +/// Size of the bond record stored in NVS. +pub const BOND_LEN: usize = 32; + +/// Layout: `[0..6]` peer address, `[6..22]` LTK (little endian), `[22]` security level, +/// `[23]` address kind (1 = random), `[31]` record version. +/// Version 0 records (written before the kind byte existed) have zeros in `[23..32]` and are +/// random addresses. +const VERSION: u8 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BondRecord { + pub addr: [u8; 6], + pub random_addr: bool, + pub ltk: [u8; 16], + /// 0 = no encryption, 1 = encrypted, 2 = encrypted + authenticated + pub security_level: u8, +} + +pub fn encode_bond(bond: &BondRecord) -> [u8; BOND_LEN] { + let mut buf = [0; BOND_LEN]; + buf[..6].copy_from_slice(&bond.addr); + buf[6..22].copy_from_slice(&bond.ltk); + buf[22] = bond.security_level; + buf[23] = bond.random_addr as u8; + buf[31] = VERSION; + buf +} + +pub fn decode_bond(buf: &[u8]) -> Option { + let buf: &[u8; BOND_LEN] = buf.try_into().ok()?; + let random_addr = match buf[31] { + 0 => true, + VERSION => buf[23] != 0, + _ => return None, + }; + if buf[22] > 2 { + return None; + } + + let mut addr = [0; 6]; + addr.copy_from_slice(&buf[..6]); + let mut ltk = [0; 16]; + ltk.copy_from_slice(&buf[6..22]); + Some(BondRecord { + addr, + random_addr, + ltk, + security_level: buf[22], + }) +} + +/// Local name (complete or shortened) from BLE advertising data. +pub fn parse_device_name(mut data: &[u8]) -> Option<&str> { + while let [len, rest @ ..] = data { + let len = *len as usize; + if len == 0 || len > rest.len() { + return None; + } + + let (field, next) = rest.split_at(len); + if let [0x08 | 0x09, name @ ..] = field { + return core::str::from_utf8(name).ok(); + } + data = next; + } + None +} + +pub fn is_fkm_display(name: &str) -> bool { + name.starts_with("FKMD-") +} diff --git a/src/bluetooth/mod.rs b/src/bluetooth/mod.rs new file mode 100644 index 0000000..d7f066b --- /dev/null +++ b/src/bluetooth/mod.rs @@ -0,0 +1,577 @@ +//! BLE link to an FKM display: scan → pair/bond → stream the timer value over GATT. +//! +//! One task, phases as plain async fns: `run` (idle until there is a bond or a scan request) +//! → `session` (one radio/host stack) → `scan` (only without a bond) → `connect_loop`. +//! Every await on the peer has a timeout, reconnects back off, and UI commands arrive through +//! a queue so they can't overwrite each other. + +mod codec; + +use crate::{ + consts::NVS_BONDING_KEY, + state::{BleAction, GlobalState, MenuScene}, + structs::{BleDisplayDevice, MAX_BLE_DEVICES}, + utils::error_log::codes, +}; +use alloc::{rc::Rc, vec::Vec}; +use core::cell::{Cell, RefCell}; +use embassy_futures::select::{Either, select, select3}; +use embassy_sync::{ + blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, + channel::Channel, + signal::Signal, +}; +use embassy_time::{Duration, Timer, with_timeout}; +use esp_hal::peripherals::BT; +use esp_radio::ble::controller::BleConnector; +use trouble_host::prelude::*; + +const DISPLAY_SERVICE: u128 = 0xa5bad9f2700a4c3db9e2e58ad262d40e; +const TIME_CHARACTERISTIC: u128 = 0xa5178cade4e045988053a4a78b9281e2; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const PAIRING_TIMEOUT: Duration = Duration::from_secs(10); +const GATT_TIMEOUT: Duration = Duration::from_secs(5); +const MIN_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(8); +/// HCI disconnect reason "authentication failure": the display no longer knows our bond. +const AUTH_FAILURE: u8 = 0x05; + +type Ctrl = ExternalController, 20>; +type Pool = DefaultPacketPool; + +/// UI → BLE task commands. A queue, so StartScan/Connect/Unpair can't overwrite each other. +pub struct BleCommands(Channel); + +impl BleCommands { + pub const fn new() -> Self { + Self(Channel::new()) + } + + /// Queues a command (named like the `Signal` it replaced, so callers stay unchanged). + #[cfg_attr(feature = "qa", allow(dead_code))] // qa builds have no BLE menu + pub fn signal(&self, cmd: BleAction) { + if self.0.try_send(cmd).is_err() { + log::warn!("[ble] command queue full, dropped"); + } + } + + async fn wait_for(&self, mut accept: impl FnMut(&BleAction) -> bool) -> BleAction { + loop { + let cmd = self.0.receive().await; + if accept(&cmd) { + return cmd; + } + } + } +} + +#[embassy_executor::task] +pub async fn bluetooth_timer_task( + bt: BT<'static>, + state: GlobalState, + sleep_sig: Rc>, +) { + loop { + // sleeping drops the whole BLE stack + select(run(&bt, state), wait_sleep(&sleep_sig, true)).await; + // let the controller shut down before it can be created again + Timer::after_millis(1000).await; + wait_sleep(&sleep_sig, false).await; + } +} + +async fn wait_sleep(sig: &Signal, sleep: bool) { + while sig.wait().await != sleep {} +} + +async fn run(bt: &BT<'static>, state: GlobalState) { + let mut backoff = Backoff::new(); + loop { + let bond = load_bond(state).await; + if bond.is_none() { + wait_for_scan_request(state).await; + } + + match session(bt, state, bond).await { + Ok(()) => backoff.reset(), + Err(()) => backoff.wait().await, + } + } +} + +async fn wait_for_scan_request(state: GlobalState) { + // the pairing menu is already open (e.g. after unpairing from it) + if state.state.lock().await.menu_scene == Some(MenuScene::BtDisplay) { + return; + } + state + .ble_sig + .wait_for(|c| matches!(c, BleAction::StartScan)) + .await; +} + +/// One controller + host stack; `Err` means it couldn't start or the host stopped. +async fn session( + bt: &BT<'static>, + state: GlobalState, + bond: Option, +) -> Result<(), ()> { + // SAFETY: only one session (and so one connector) exists at a time; the previous one is + // dropped before `run` creates the next. + let connector = BleConnector::new(unsafe { bt.clone_unchecked() }, Default::default()) + .map_err(|e| { + log::error!("[ble] cannot init connector ({e:?})"); + crate::log_once!(codes::BLE_INIT_FAILED); + })?; + let mac: [u8; 6] = esp_hal::efuse::base_mac_address() + .as_bytes() + .try_into() + .map_err(|_| { + log::error!("[ble] cannot read mac address"); + crate::log_once!(codes::BLE_MAC_READ_FAILED); + })?; + + let mut resources: HostResources = HostResources::new(); + let stack = trouble_host::new(ExternalController::new(connector), &mut resources) + .set_random_address(Address::random(mac)) + .build(); + let mut runner = stack.runner(); + let discovery = Discovery::new(); + + match select( + runner.run_with_handler(&discovery), + link(&stack, state, bond, &discovery), + ) + .await + { + Either::First(res) => { + log::error!("[ble] host stopped ({res:?})"); + Err(()) + } + Either::Second(res) => res, + } +} + +async fn link( + stack: &Stack<'_, Ctrl, Pool>, + state: GlobalState, + bond: Option, + discovery: &Discovery, +) -> Result<(), ()> { + let mut central = stack.central(); + let (target, bond) = match bond { + Some(bond) => { + stack.add_bond_information(bond.clone()).map_err(|e| { + log::error!("[ble] add bond failed ({e:?})"); + crate::log_once!(codes::BLE_BOND_ADD_FAILED); + })?; + (bond.identity.addr, Some(bond)) + } + None => { + let (c, picked) = scan(central, state, discovery).await; + central = c; + match picked? { + Some(device) => (Address::random(device.addr), None), + None => return Ok(()), + } + } + }; + + // re-pairing or unpairing from the menu ends the link (the buttons already dropped the bond) + let ended = state + .ble_sig + .wait_for(|c| matches!(c, BleAction::Unpair | BleAction::StartScan)); + select( + ended, + connect_loop(stack, &mut central, target, bond, state), + ) + .await; + Ok(()) +} + +/// Lists displays in the menu until the user picks one (`Ok(None)` when they unpair instead). +async fn scan<'a>( + central: Central<'a, Ctrl, Pool>, + state: GlobalState, + discovery: &Discovery, +) -> ( + Central<'a, Ctrl, Pool>, + Result, ()>, +) { + { + let mut state = state.state.lock().await; + state.discovered_bluetooth_devices.clear(); + state.selected_bluetooth_item = 0; + } + + let mut scanner = Scanner::new(central); + let picked = match scanner.scan(&ScanConfig::default()).await { + Ok(_session) => { + discovery.start(); + let picked = state + .ble_sig + .wait_for(|c| matches!(c, BleAction::Connect(_) | BleAction::Unpair)); + let picked = match select(picked, list_displays(state, discovery)).await { + Either::First(BleAction::Connect(device)) => Some(device), + _ => None, + }; + discovery.stop(); + Ok(picked) + } + Err(e) => { + log::error!("[ble] cannot start scan ({e:?})"); + crate::log_once!(codes::BLE_SCAN_START_FAILED); + Err(()) + } + }; + (scanner.into_inner(), picked) +} + +async fn list_displays(state: GlobalState, discovery: &Discovery) { + loop { + let device = discovery.found.receive().await; + let mut state = state.state.lock().await; + // keep the cursor on "Unpair"/"Exit" (listed after the devices) when it's there + let cursor_after_list = state.selected_bluetooth_item > 0 + && state.selected_bluetooth_item >= state.discovered_bluetooth_devices.len(); + if state.discovered_bluetooth_devices.push(device) && cursor_after_list { + state.selected_bluetooth_item += 1; + } + } +} + +async fn connect_loop( + stack: &Stack<'_, Ctrl, Pool>, + central: &mut Central<'_, Ctrl, Pool>, + target: Address, + mut bond: Option, + state: GlobalState, +) { + let config = ConnectConfig { + connect_params: Default::default(), + scan_config: ScanConfig { + filter_accept_list: &[target], + ..Default::default() + }, + }; + + let mut backoff = Backoff::new(); + loop { + match connect_once(stack, central, &config, &mut bond, state).await { + Attempt::Retry { worked } => { + if worked { + backoff.reset(); + } + backoff.wait().await; + } + Attempt::Unbond => { + log::warn!("[ble] bond rejected, forgetting it"); + _ = state.nvs.delete(&NVS_BONDING_KEY).await; + return; + } + } + } +} + +enum Attempt { + /// `worked`: the link reached the streaming state before it ended + Retry { worked: bool }, + /// the display refused our keys; it has to be paired again + Unbond, +} + +async fn connect_once( + stack: &Stack<'_, Ctrl, Pool>, + central: &mut Central<'_, Ctrl, Pool>, + config: &ConnectConfig<'_>, + bond: &mut Option, + state: GlobalState, +) -> Attempt { + const FAILED: Attempt = Attempt::Retry { worked: false }; + + let conn = match with_timeout(CONNECT_TIMEOUT, central.connect(config)).await { + Ok(Ok(conn)) => conn, + Ok(Err(e)) => { + log::warn!("[ble] connect failed ({e:?})"); + return FAILED; + } + Err(_) => return FAILED, // display off or out of range + }; + + // only allow bonding when there is no bond yet + if let Err(e) = conn.set_bondable(bond.is_none()) { + log::error!("[ble] set bondable failed ({e:?})"); + crate::log_once!(codes::BLE_BONDABLE_FAILED); + return FAILED; + } + if let Err(e) = conn.request_security() { + log::error!("[ble] request security failed ({e:?})"); + crate::log_once!(codes::BLE_REQUEST_SECURITY_FAILED); + return FAILED; + } + + match with_timeout(PAIRING_TIMEOUT, pairing(&conn)).await { + Ok(Pairing::Done { + encrypted, + new_bond, + }) => { + if let Some(new_bond) = new_bond { + store_bond(state, &new_bond).await; + *bond = Some(new_bond); + } + if !encrypted { + return Attempt::Unbond; + } + } + // the display may still take unencrypted writes, so carry on as before + Ok(Pairing::Failed) => {} + Ok(Pairing::Rejected) => return Attempt::Unbond, + Ok(Pairing::Disconnected) => return FAILED, + Err(_) => { + log::warn!("[ble] pairing timed out"); + return FAILED; + } + } + + Attempt::Retry { + worked: stream(stack, &conn, state).await, + } +} + +enum Pairing { + Done { + encrypted: bool, + new_bond: Option, + }, + Failed, + Rejected, + Disconnected, +} + +async fn pairing(conn: &Connection<'_, Pool>) -> Pairing { + loop { + match conn.next().await { + ConnectionEvent::PairingComplete { + security_level, + bond, + } => { + log::info!("[ble] pairing complete: {security_level:?}"); + return Pairing::Done { + encrypted: security_level.encrypted(), + new_bond: bond, + }; + } + ConnectionEvent::PairingFailed(e) => { + log::error!("[ble] pairing failed ({e:?})"); + crate::log_once!(codes::BLE_PAIRING_FAILED); + return Pairing::Failed; + } + ConnectionEvent::Disconnected { reason } => { + log::warn!("[ble] disconnected while pairing ({reason:?})"); + return match reason.into_inner() { + AUTH_FAILURE => Pairing::Rejected, + _ => Pairing::Disconnected, + }; + } + _ => {} + } + } +} + +/// Streams timer values until the link breaks; returns whether streaming started. +async fn stream( + stack: &Stack<'_, Ctrl, Pool>, + conn: &Connection<'_, Pool>, + state: GlobalState, +) -> bool { + let client = match with_timeout(GATT_TIMEOUT, GattClient::<_, Pool, 10>::new(stack, conn)).await + { + Ok(Ok(client)) => client, + _ => { + log::error!("[ble] gatt client failed"); + crate::log_once!(codes::BLE_GATT_CLIENT_FAILED); + return false; + } + }; + + let streaming = Cell::new(false); + select3( + client.task(), + disconnected(conn), + write_times(&client, state, &streaming), + ) + .await; + streaming.get() +} + +async fn disconnected(conn: &Connection<'_, Pool>) { + loop { + if let ConnectionEvent::Disconnected { reason } = conn.next().await { + log::info!("[ble] disconnected ({reason:?})"); + return; + } + } +} + +async fn write_times( + client: &GattClient<'_, Ctrl, Pool, 10>, + state: GlobalState, + streaming: &Cell, +) { + let service = Uuid::from(DISPLAY_SERVICE); + let Ok(Ok(services)) = with_timeout(GATT_TIMEOUT, client.services_by_uuid(&service)).await + else { + log::warn!("[ble] service discovery failed"); + return; + }; + let Some(service) = services.first() else { + log::error!("[ble] display service not found"); + crate::log_once!(codes::BLE_SERVICE_NOT_FOUND); + return; + }; + + let characteristic = Uuid::from(TIME_CHARACTERISTIC); + let Ok(Ok(characteristic)) = with_timeout( + GATT_TIMEOUT, + client.characteristic_by_uuid::(service, &characteristic), + ) + .await + else { + log::error!("[ble] time characteristic not found"); + crate::log_once!(codes::BLE_CHARACTERISTIC_NOT_FOUND); + return; + }; + + streaming.set(true); + loop { + let time = state.bt_display_signal.wait().await; + if client + .write_characteristic_without_response(&characteristic, &time.to_be_bytes()) + .await + .is_err() + { + return; + } + } +} + +/// Collects FKM displays from advertising reports while `scan` runs. +struct Discovery { + scanning: Cell, + seen: RefCell>, + found: Channel, +} + +impl Discovery { + fn new() -> Self { + Self { + scanning: Cell::new(false), + seen: RefCell::new(heapless::Vec::new()), + found: Channel::new(), + } + } + + fn start(&self) { + if let Ok(mut seen) = self.seen.try_borrow_mut() { + seen.clear(); + } + self.found.clear(); + self.scanning.set(true); + } + + fn stop(&self) { + self.scanning.set(false); + } +} + +impl EventHandler for Discovery { + fn on_adv_reports(&self, mut reports: LeAdvReportsIter<'_>) { + if !self.scanning.get() { + return; + } + let Ok(mut seen) = self.seen.try_borrow_mut() else { + return; + }; + + while let Some(Ok(report)) = reports.next() { + let addr = report.addr.into_inner(); + if seen.contains(&addr) { + continue; + } + let Some(name) = + codec::parse_device_name(report.data).filter(|n| codec::is_fkm_display(n)) + else { + continue; + }; + + if seen.push(addr).is_ok() { + log::info!("[ble] found display {name}"); + _ = self.found.try_send(BleDisplayDevice { + name: name.into(), + addr, + }); + } + } + } +} + +struct Backoff(Duration); + +impl Backoff { + fn new() -> Self { + Self(MIN_BACKOFF) + } + + fn reset(&mut self) { + self.0 = MIN_BACKOFF; + } + + async fn wait(&mut self) { + Timer::after(self.0).await; + self.0 = (self.0 * 2).min(MAX_BACKOFF); + } +} + +async fn store_bond(state: GlobalState, bond: &BondInformation) { + let record = codec::BondRecord { + addr: bond.identity.addr.addr.into_inner(), + random_addr: bond.identity.addr.kind == AddrKind::RANDOM, + ltk: bond.ltk.to_le_bytes(), + security_level: match bond.security_level { + SecurityLevel::NoEncryption => 0, + SecurityLevel::Encrypted => 1, + SecurityLevel::EncryptedAuthenticated => 2, + }, + }; + + let buf = codec::encode_bond(&record); + if let Err(e) = state.nvs.set(&NVS_BONDING_KEY, buf.as_slice()).await { + log::error!("[ble] storing bond failed ({e:?})"); + crate::log_once!(codes::NVS_BONDING_KEY_WRITE_FAILED); + } +} + +async fn load_bond(state: GlobalState) -> Option { + let buf = state.nvs.get::>(&NVS_BONDING_KEY).await.ok()?; + let record = codec::decode_bond(&buf)?; + + let addr = match record.random_addr { + true => Address::random(record.addr), + false => Address { + kind: AddrKind::PUBLIC, + addr: BdAddr::new(record.addr), + }, + }; + let security_level = match record.security_level { + 0 => SecurityLevel::NoEncryption, + 1 => SecurityLevel::Encrypted, + _ => SecurityLevel::EncryptedAuthenticated, + }; + Some(BondInformation { + identity: Identity { addr, irk: None }, + security_level, + is_bonded: true, + ltk: LongTermKey::from_le_bytes(record.ltk), + }) +} diff --git a/src/state.rs b/src/state.rs index 9f5a859..c54e0ce 100644 --- a/src/state.rs +++ b/src/state.rs @@ -272,7 +272,7 @@ pub struct GlobalStateInner { pub bt_display_signal: Signal, pub update_progress: Signal, pub sign_unsign_progress: Signal, - pub ble_sig: Signal, + pub ble_sig: crate::bluetooth::BleCommands, pub show_battery: Signal, #[cfg(feature = "v4")] pub buzzer_sound_test: Signal, @@ -293,7 +293,7 @@ impl GlobalStateInner { bt_display_signal: Signal::new(), update_progress: Signal::new(), sign_unsign_progress: Signal::new(), - ble_sig: Signal::new(), + ble_sig: crate::bluetooth::BleCommands::new(), show_battery: Signal::new(), #[cfg(feature = "v4")] buzzer_sound_test: Signal::new(), From 593c7d82bbacfe20a56ba7ac3fd04ddc3c3c85fb Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:10:06 +0200 Subject: [PATCH 021/132] test: serde-json-core output matches serde_json --- host-tests/Cargo.lock | 17 ++++++++++++ host-tests/Cargo.toml | 1 + host-tests/src/lib.rs | 61 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/host-tests/Cargo.lock b/host-tests/Cargo.lock index 4279b51..e1474ff 100644 --- a/host-tests/Cargo.lock +++ b/host-tests/Cargo.lock @@ -14,6 +14,7 @@ version = "0.0.0" dependencies = [ "heapless", "serde", + "serde-json-core", "serde_json", ] @@ -66,6 +67,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -76,6 +83,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-json-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e6795fbc00a1b26d78c7a0dbda143215d9dd0ee27c8a63ae0e1ccd6b83706" +dependencies = [ + "ryu", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" diff --git a/host-tests/Cargo.toml b/host-tests/Cargo.toml index 618360f..60b05d0 100644 --- a/host-tests/Cargo.toml +++ b/host-tests/Cargo.toml @@ -8,6 +8,7 @@ publish = false serde = { version = "1", features = ["derive", "alloc"] } serde_json = "1" heapless = "0.9.3" +serde-json-core = { version = "0.7.0", default-features = false } [features] e2e = [] diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index eeb7042..df2a574 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -77,3 +77,64 @@ mod tests { assert_eq!(r.possible_groups.len(), new::MAX_POSSIBLE_GROUPS); } } + +/// Outgoing packets are serialized with serde-json-core on the device; it must produce the +/// same bytes serde_json did. +#[cfg(test)] +mod outgoing_json { + use crate::structs::{TimerPacket, TimerPacketInner}; + + fn same(p: &TimerPacket) { + let mut buf = [0u8; 1024]; + let n = serde_json_core::to_slice(p, &mut buf).expect("serde-json-core"); + assert_eq!(core::str::from_utf8(&buf[..n]).unwrap(), serde_json::to_string(p).unwrap()); + } + + #[test] + fn identical_to_serde_json() { + let packets = [ + TimerPacketInner::Solve { + solve_time: 12345, + penalty: -1, + competitor_id: 7, + judge_id: 9, + timestamp: 1_700_000_000, + session_id: "0b6a7d2e-1f4c-4f7e-9b1a-2c3d4e5f6a7b".into(), + delegate: false, + inspection_time: 8000, + group_id: "333-r1-g2 \"q\" \\ ż".into(), + sign_key: u32::MAX, + }, + TimerPacketInner::CardInfoRequest { card_id: u64::MAX, is_competitor: true, attendance_device: None, sign_key: 5 }, + TimerPacketInner::CardInfoRequest { card_id: 1, is_competitor: false, attendance_device: Some(true), sign_key: 5 }, + TimerPacketInner::Battery { level: Some(87.5), voltage: Some(3.91) }, + TimerPacketInner::Battery { level: Some(0.1), voltage: Some(4.123456789) }, + TimerPacketInner::Battery { level: Some(100.0), voltage: Some(1e-7) }, + TimerPacketInner::Battery { level: None, voltage: None }, + TimerPacketInner::Add { firmware: "STATION".into(), sign_key: 77 }, + ]; + for data in packets { + same(&TimerPacket { tag: None, data: data.clone() }); + same(&TimerPacket { tag: Some(u64::MAX), data }); + } + } + + #[cfg(feature = "e2e")] + #[test] + fn test_ack_identical() { + same(&TimerPacket { + tag: None, + data: TimerPacketInner::TestAck(crate::structs::SnapshotData { + scene: 3, + inspection_time: Some(15000), + solve_time: None, + penalty: Some(-1), + time_confirmed: true, + possible_groups: 2, + group_selected_idx: 1, + current_competitor: Some(9), + current_judge: None, + }), + }); + } +} From 46c43bf3869003daf761d86955d9d90d0c1b1dd5 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:10:06 +0200 Subject: [PATCH 022/132] refactor: deadlock-free ws on ws-framer 0.4 --- Cargo.lock | 41 +- Cargo.toml | 3 +- src/main.rs | 24 +- src/ws.rs | 1312 +++++++++++++++++++++++++-------------------------- 4 files changed, 667 insertions(+), 713 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0bdba6..f465116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,7 +923,7 @@ dependencies = [ "bitvec", "convert_case 0.6.0", "dd-manifest-tree", - "itertools 0.14.0", + "itertools", "prettyplease", "proc-macro2", "quote", @@ -2198,6 +2198,7 @@ dependencies = [ "profont", "qrcodegen-no-heap", "serde", + "serde-json-core", "serde_json", "static_cell", "trouble-host", @@ -2672,15 +2673,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -3431,7 +3423,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools 0.14.0", + "itertools", "libc", "libfuzzer-sys", "log", @@ -3671,6 +3663,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-json-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e6795fbc00a1b26d78c7a0dbda143215d9dd0ee27c8a63ae0e1ccd6b83706" +dependencies = [ + "ryu", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -4368,26 +4370,13 @@ dependencies = [ [[package]] name = "ws-framer" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bacd6cff21323641597fe251e294f823e04292202a6e3c9721a1e8b82bea57bf" +checksum = "dff96a9b275d0beb2ccdbca7faec38c4fb84fdab77a931867205338cd2466404" dependencies = [ "getrandom 0.2.15", "httparse", "itoa", - "ws-framer-macros", -] - -[[package]] -name = "ws-framer-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fe343b024b086505b3c647de6eae0c26235934cb9138a180e3ac5f97a6aeda" -dependencies = [ - "itertools 0.13.0", - "proc-macro2", - "quote", - "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f3bf53a..259ea61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,8 @@ macros = { path = "./macros" } nb = "1.1.0" embassy-futures = "0.1.2" embassy-sync = "0.8.0" -ws-framer = { version = "0.3.2", default-features = false, features = ["alloc", "http", "getrandom02"] } +ws-framer = { version = "0.4.0", default-features = false, features = ["http", "getrandom02"] } +serde-json-core = { version = "0.7.0", default-features = false } embedded-hal-async = "1.0.0" portable-atomic = { version = "1.15.0", default-features = false } critical-section = "1.2.0" diff --git a/src/main.rs b/src/main.rs index 2dc76d0..4ec8832 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ extern crate alloc; use crate::consts::NVS_SIGN_KEY; use alloc::rc::Rc; -use alloc::string::ToString; use board::Board; use consts::LOG_SEND_INTERVAL_MS; use embassy_executor::Spawner; @@ -18,7 +17,6 @@ use esp_storage::FlashStorage; use state::{GlobalState, GlobalStateInner, SavedGlobalState, Scene, ota_state, sleep_state}; use structs::ConnSettings; use utils::{logger::FkmLogger, set_brownout_detection, spawn_task}; -use ws_framer::{WsUrl, WsUrlOwned}; mod bluetooth; mod board; @@ -316,14 +314,18 @@ async fn main(spawner: Spawner) { let mdns_res = mdns::mdns_query(wifi_res.stack()).await; log::info!("Mdns result: {mdns_res:?}"); - mdns_res.to_string() + mdns_res } else { - conn_settings.ws_url.clone().unwrap_or_default() + // a url longer than 255 bytes counts as unparsable + conn_settings + .ws_url + .as_deref() + .and_then(|u| heapless::String::try_from(u).ok()) + .unwrap_or_default() }; - let ws_url = WsUrl::from_str(&url); - match ws_url { - Some(ws_url) => break WsUrlOwned::new(&ws_url), + match ws_framer::WsUrl::parse(&url) { + Some(_) => break url, None => { parse_retry_count += 1; log::error!("Mdns parse failed! Retry ({parse_retry_count}).."); @@ -389,11 +391,7 @@ async fn main(spawner: Spawner) { ble_sleep_sig.signal(last_sleep); if last_sleep { log::warn!("Entering sleep mode!"); - ws::send_frame(ws_framer::WsFrameOwned::Close( - 4000, - "Going into sleep mode".to_string(), - )) - .await; + ws::close(4000, "Going into sleep mode"); // only wait that 15s when going into sleep mode (to make sure close is sent and to // debounce sleeps) @@ -444,7 +442,7 @@ async fn logger_task(global_state: GlobalState) { }; if let Some(logs) = logs { - _ = ws::send_raw_binary(&logs); + _ = ws::send_logs(logs); } #[cfg(not(feature = "release_build"))] diff --git a/src/ws.rs b/src/ws.rs index c371c6f..0be7995 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -1,806 +1,772 @@ +//! Websocket connection to the timer server. +//! +//! One task owns the socket. Other tasks queue packets ([`send`], [`request`]) and wake it; +//! the task serializes them straight into the tx buffer. The reader never waits on that queue: +//! replies it owes (pong, OTA ack, crash log) are flags the writer serves first. + use crate::{ consts::WS_RETRY_MS, state::{GlobalState, Scene, ota_state}, structs::{ApiError, DelegateResponsePacket, FromPacket, TimerPacket, TimerPacketInner}, + utils::{UninitBuf, error_log::codes, logger::ReadyLogs}, }; -use alloc::{boxed::Box, rc::Rc, vec::Vec}; -use core::str::FromStr; +use alloc::rc::Rc; +use core::{cell::Cell, fmt::Write as _, str::FromStr}; +use embassy_crypto_rustcrypto as _; +use embassy_futures::select::{Either, Either3, select, select3}; use embassy_net::{IpAddress, Stack, tcp::TcpSocket}; use embassy_sync::{ - blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel, pubsub::PubSubChannel, + blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}, + channel::Channel, signal::Signal, }; use embassy_time::{Duration, Timer, WithTimeout}; -use embassy_crypto_rustcrypto as _; use embedded_tls::{Aes128GcmSha256, InsecureNoVerify, TlsConfig, TlsConnection, TlsContext}; use esp_hal_ota::Ota; use esp_storage::FlashStorage; -use ws_framer::{WsFrame, WsFrameOwned, WsRxFramer, WsTxFramer, WsUrl, WsUrlOwned}; - -static FRAME_CHANNEL: Channel = Channel::new(); -static TAGGED_RETURN: PubSubChannel = - PubSubChannel::new(); - +use portable_atomic::{AtomicBool, AtomicU64, Ordering}; +use ws_framer::{WsFrame, WsRxError, WsRxFramer, WsTxFramer, WsUrl}; const WS_BUF_SIZE: usize = 8192; const TLS_BUF_SIZE: usize = 16640; +const OUT_QUEUE_LEN: usize = 8; +const REQUEST_SLOTS: usize = 4; +/// Close code telling the server the device goes to sleep. +const CLOSE_SLEEP: u16 = 4000; #[unsafe(link_section = ".dram2_uninit")] -static mut WS_RX_BUF: core::mem::MaybeUninit<[u8; WS_BUF_SIZE]> = core::mem::MaybeUninit::uninit(); +static WS_RX_BUF: UninitBuf = UninitBuf::new(); #[unsafe(link_section = ".dram2_uninit")] -static mut WS_TX_BUF: core::mem::MaybeUninit<[u8; WS_BUF_SIZE]> = core::mem::MaybeUninit::uninit(); +static WS_TX_BUF: UninitBuf = UninitBuf::new(); #[unsafe(link_section = ".dram2_uninit")] -static mut TLS_RX_BUF: core::mem::MaybeUninit<[u8; TLS_BUF_SIZE]> = - core::mem::MaybeUninit::uninit(); +static TLS_RX_BUF: UninitBuf = UninitBuf::new(); #[unsafe(link_section = ".dram2_uninit")] -static mut TLS_TX_BUF: core::mem::MaybeUninit<[u8; TLS_BUF_SIZE]> = - core::mem::MaybeUninit::uninit(); - -#[embassy_executor::task] -pub async fn ws_task( - stack: Stack<'static>, - ws_url: WsUrlOwned, - global_state: GlobalState, - ws_sleep_sig: Rc>, - wifi_conn_sig: &'static Signal, -) { - log::debug!("ws_url: {ws_url:?}"); +static TLS_TX_BUF: UninitBuf = UninitBuf::new(); - let mut rx_buf = [0; WS_BUF_SIZE]; - let mut tx_buf = [0; WS_BUF_SIZE]; - - #[allow(static_mut_refs)] - let (ws_rx_buf, ws_tx_buf, ssl_rx_buf, ssl_tx_buf): ( - &mut [u8], - &mut [u8], - &mut [u8], - &mut [u8], - ) = unsafe { - ( - &mut *WS_RX_BUF.as_mut_ptr(), - &mut *WS_TX_BUF.as_mut_ptr(), - &mut *TLS_RX_BUF.as_mut_ptr(), - &mut *TLS_TX_BUF.as_mut_ptr(), - ) - }; +type Cs = CriticalSectionRawMutex; - loop { - crate::state::set_trust_server(false); - crate::state::set_secure_rfid(false); - crate::state::set_auto_setup(false); - crate::state::set_fkm_token(0); +static OUT: Channel = Channel::new(); +/// Wakes the connection task when something was queued for it. +static WAKE: Signal = Signal::new(); +static PENDING_LOGS: Mutex>> = Mutex::new(Cell::new(None)); +static PENDING_CLOSE: Mutex>> = Mutex::new(Cell::new(None)); - let ws_fut = ws_loop( - &global_state, - ws_url.as_ref(), - stack, - &mut rx_buf, - &mut tx_buf, - &mut *ws_rx_buf, - &mut *ws_tx_buf, - &mut *ssl_rx_buf, - &mut *ssl_tx_buf, - wifi_conn_sig, - ); - - let res = embassy_futures::select::select(ws_fut, ws_sleep_sig.wait()).await; - - match res { - embassy_futures::select::Either::First(res) => { - if let Err(e) = res { - log::error!("Ws_loop errored! {e:?}"); - } - } - embassy_futures::select::Either::Second(sleep) => { - if sleep { - loop { - let sleep = ws_sleep_sig.wait().await; - if !sleep { - break; - } - } - } - } - } +/// A request waiting for the response with its tag (tag 0 = free slot). +struct Slot { + tag: AtomicU64, + response: Signal, +} - Timer::after_millis(500).await; +static SLOTS: [Slot; REQUEST_SLOTS] = [const { + Slot { + tag: AtomicU64::new(0), + response: Signal::new(), } +}; REQUEST_SLOTS]; + +/// The untagged `DelegateResponse` push, delivered while a delegate request waits for it. +static DELEGATE_RESPONSE: Signal = Signal::new(); +static DELEGATE_WAITING: AtomicBool = AtomicBool::new(false); + +/// The outgoing queue is full; nothing was sent. +#[derive(Debug)] +pub struct WsBusy; + +/// Queues `packet` without waiting. +pub fn send(packet: TimerPacket) -> Result<(), WsBusy> { + OUT.try_send(packet).map_err(|_| WsBusy)?; + WAKE.signal(()); + Ok(()) } -// TODO: maybe make less args? -#[allow(clippy::too_many_arguments)] -async fn ws_loop( - global_state: &GlobalState, - ws_url: WsUrl<'_>, - stack: Stack<'static>, - rx_buf: &mut [u8], - tx_buf: &mut [u8], - ws_rx_buf: &mut [u8], - ws_tx_buf: &mut [u8], - ssl_rx_buf: &mut [u8], - ssl_tx_buf: &mut [u8], - wifi_conn_sig: &Signal, -) -> Result<(), ()> { - loop { - { - global_state.state.lock().await.server_connected = Some(false); - } +/// Hands a finished log batch to the connection (sent as one binary message). +pub fn send_logs(logs: ReadyLogs) -> Result<(), WsBusy> { + let busy = PENDING_LOGS.lock(|l| { + let busy = l.take().is_some(); + l.set(Some(logs)); + busy + }); + WAKE.signal(()); + if busy { Err(WsBusy) } else { Ok(()) } +} - let ip = if let Ok(addr) = embassy_net::Ipv4Address::from_str(ws_url.ip) { - addr - } else { - let dns_resolver = embassy_net::dns::DnsSocket::new(stack); - let res = dns_resolver - .query(ws_url.ip, embassy_net::dns::DnsQueryType::A) - .await; +/// Sends a Close frame (code [`CLOSE_SLEEP`] also pauses the connection for 30 s). +pub fn close(code: u16, reason: &'static str) { + PENDING_CLOSE.lock(|c| c.set(Some((code, reason)))); + WAKE.signal(()); +} - let res = match res { - Ok(res) => res, - Err(e) => { - log::error!("[WS]Dns resolver error: {e:?}"); - Timer::after_millis(1000).await; - continue; - } - }; +#[cfg(feature = "e2e")] +pub async fn send_test_ack(global_state: &GlobalState) { + let data = TimerPacketInner::TestAck(global_state.state.value().await.snapshot_data()); + _ = send(TimerPacket { tag: None, data }); +} - let Some(IpAddress::Ipv4(addr)) = res.first() else { - log::error!("[WS]Dns resolver empty vec"); - crate::log_once!(crate::utils::error_log::codes::WS_DNS_RESOLVE_EMPTY); - Timer::after_millis(1000).await; - continue; - }; - *addr - }; - - let mut socket = TcpSocket::new(stack, rx_buf, tx_buf); - socket.set_timeout(Some(embassy_time::Duration::from_secs(15))); - - let remote_endpoint = (ip, ws_url.port); - let r = socket.connect(remote_endpoint).await; - if let Err(e) = r { - // but if wifi conneceted signal was sent remove wifi connection lost msg - if wifi_conn_sig.signaled() && wifi_conn_sig.wait().await { - global_state.state.lock().await.wifi_connected = Some(true); - } +/// Frees its request slot when dropped (also when the waiting future is cancelled). +struct SlotGuard(&'static Slot); - log::error!("connect error: {e:?}"); - Timer::after_millis(WS_RETRY_MS).await; - continue; - } +impl Drop for SlotGuard { + fn drop(&mut self) { + self.0.tag.store(0, Ordering::Release); + } +} - let mut socket = if ws_url.secure { - let mut tls = TlsConnection::new(socket, ssl_rx_buf, ssl_tx_buf); +fn claim_slot(tag: u64) -> Result { + let slot = SLOTS + .iter() + .find(|s| { + s.tag + .compare_exchange(0, tag, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + }) + .ok_or_else(|| api_error("Too many requests"))?; + slot.response.reset(); + Ok(SlotGuard(slot)) +} - let config = TlsConfig::new(); - tls.open(TlsContext::new(&config, InsecureNoVerify)) - .await - .map_err(|e| { - log::error!("tls open error: {e:?}"); - })?; +/// Sends a request and queues it under a fresh tag; the caller waits on the slot. +fn send_request(data: TimerPacketInner) -> Result { + let tag = crate::utils::get_random_u64().max(1); + let slot = claim_slot(tag)?; + send(TimerPacket { + tag: Some(tag), + data, + }) + .map_err(|_| api_error("Channel full"))?; + Ok(slot) +} - WsSocket::Tls(Box::new(tls)) - } else { - WsSocket::Raw(socket) - }; +/// Sends `packet` and waits (at most `timeout` in total) for the tagged response. +pub async fn request( + packet: TimerPacketInner, + timeout: Duration, +) -> Result { + let slot = send_request(packet)?; + let response = slot + .0 + .response + .wait() + .with_timeout(timeout) + .await + .map_err(|_| timeout_error())?; + T::from_packet(response) +} - { - let mut state = global_state.state.lock().await; - state.server_connected = Some(true); - state.wifi_connected = Some(true); +#[cfg_attr(feature = "qa", allow(dead_code))] +/// Sends a delegate `Solve` and waits (at most `timeout`) for the server's untagged +/// `DelegateResponse` push (answered by a person, hence the long timeout). A tagged reply +/// (an `ApiError`) ends the wait early. +pub async fn delegate_request( + packet: TimerPacketInner, + timeout: Duration, +) -> Result { + struct Waiting; + impl Drop for Waiting { + fn drop(&mut self) { + DELEGATE_WAITING.store(false, Ordering::Release); } + } - log::info!("connected!"); - let mut tx_framer = WsTxFramer::new(true, ws_tx_buf); - let mut rx_framer = WsRxFramer::new(ws_rx_buf); - - let random = crate::utils::get_random_u64(); - let path = alloc::format!( - "{}?id={}&ver={}&hw={}&firmware={}&random={}", - ws_url.path, - crate::utils::get_efuse_u32(), - crate::version::VERSION, - crate::version::HW_VER, - crate::version::FIRMWARE, - random - ); - - socket - .write_all(tx_framer.generate_http_upgrade(ws_url.host, &path, None)) - .await - .map_err(|_| ())?; + DELEGATE_RESPONSE.reset(); + DELEGATE_WAITING.store(true, Ordering::Release); + let _waiting = Waiting; + let slot = send_request(packet)?; - let headers = loop { - let n = socket.read(rx_framer.mut_buf()).await.map_err(|_| ())?; - if n == 0 { - log::error!("error while reading http response"); - crate::log_once!(crate::utils::error_log::codes::WS_HTTP_UPGRADE_READ_FAILED); - return Err(()); - } + let response = select(slot.0.response.wait(), DELEGATE_RESPONSE.wait()) + .with_timeout(timeout) + .await + .map_err(|_| timeout_error())?; + let (Either::First(p) | Either::Second(p)) = response; + DelegateResponsePacket::from_packet(p) +} - let res = rx_framer.process_http_response(n); - if let Some(resp) = res { - log::info!("http_resp_code: {}", resp.status_code); - break resp.headers; - } - }; +fn api_error(error: &str) -> ApiError { + ApiError { + should_reset_time: false, + error: error.into(), + } +} - if let Some(Ok(random_signed)) = headers - .iter() - .find(|h| h.name.to_lowercase() == "randomsigned") - .map(|h| h.value.parse::()) - { - let mut key = [0; 16]; - key[..4].copy_from_slice(&crate::state::sign_key().to_be_bytes()); - - let mut block = [0; 16]; - block.copy_from_slice(&random_signed.to_be_bytes()); - - global_state - .aes - .lock() - .await - .decrypt(&mut block, esp_hal::aes::Key::Key128(key)); +fn timeout_error() -> ApiError { + api_error("Communication timeout!") +} - let recv_random = u64::from_be_bytes(block[..8].try_into().unwrap_or_default()); - let fkm_token = i32::from_be_bytes(block[8..12].try_into().unwrap_or_default()); +#[embassy_executor::task] +pub async fn ws_task( + stack: Stack<'static>, + ws_url: heapless::String<255>, + global_state: GlobalState, + ws_sleep_sig: Rc>, + wifi_conn_sig: &'static Signal, +) { + let Some(ws_url) = WsUrl::parse(&ws_url) else { + log::error!("Invalid ws url: {ws_url}"); + return; + }; + log::debug!("ws_url: {ws_url:?}"); - log::debug!( - "[trust] random: {random}, recv_random: {recv_random} | fkm_token: {fkm_token}" - ); - if random == recv_random { - crate::state::set_trust_server(true); - crate::state::set_fkm_token(fkm_token); - } else { - #[cfg(not(feature = "e2e"))] - { - global_state.state.lock().await.error_text = - Some("Server Not Trusted!".into()); - } - } + let mut tcp_rx = [0; WS_BUF_SIZE]; + let mut tcp_tx = [0; WS_BUF_SIZE]; + let (mut init_a, mut init_b, mut init_c, mut init_d) = (false, false, false, false); + // SAFETY: `ws_task` is spawned once and these are the only references to the buffers. + let mut bufs = unsafe { + Buffers { + ws_rx: WS_RX_BUF.get(&mut init_a), + ws_tx: WS_TX_BUF.get(&mut init_b), + tls_rx: TLS_RX_BUF.get(&mut init_c), + tls_tx: TLS_TX_BUF.get(&mut init_d), + tcp_rx: &mut tcp_rx, + tcp_tx: &mut tcp_tx, } + }; - _ = FRAME_CHANNEL.try_send(WsFrameOwned::Ping(alloc::vec::Vec::new())); - - #[cfg(feature = "auto_add")] - { - if !global_state - .state - .lock() - .await - .device_added - .unwrap_or(false) - { - crate::ws::send_packet(crate::structs::TimerPacket { - tag: None, - data: crate::structs::TimerPacketInner::Add { - firmware: crate::version::FIRMWARE.into(), - sign_key: crate::state::sign_key(), - }, - }) - .await; - } - } + loop { + crate::state::set_trust_server(false); + crate::state::set_secure_rfid(false); + crate::state::set_auto_setup(false); + crate::state::set_fkm_token(0); - loop { - let res = ws_rw( - &mut rx_framer, - &mut tx_framer, - global_state, - &mut socket, - wifi_conn_sig, - ) - .await; - - if let Err(e) = res { + let conn = run(global_state, ws_url, stack, &mut bufs, wifi_conn_sig); + match select(conn, ws_sleep_sig.wait()).await { + Either::First(Err(e)) => { if ota_state() { log::error!("Connection lost during OTA update: {e:?}"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::WS_CONNECTION_LOST_DURING_OTA, - ); - crate::utils::error_log::save_error_log(&global_state.nvs).await; - + crate::utils::error_log::add_error(codes::WS_CONNECTION_LOST_DURING_OTA); + crate::utils::error_log::save_error_log(global_state.nvs).await; global_state.state.lock().await.custom_message = Some(("Connection lost".into(), "during update".into())); Timer::after_millis(5000).await; esp_hal::system::software_reset(); } - - log::error!("ws_rw_error: {e:?}"); - Timer::after_millis(WS_RETRY_MS).await; - break; + log::error!("ws error: {e:?}"); } + Either::Second(true) => while ws_sleep_sig.wait().await {}, + Either::Second(false) => {} } + + global_state.state.lock().await.server_connected = Some(false); + Timer::after_millis(WS_RETRY_MS).await; } } +struct Buffers<'a> { + ws_rx: &'a mut [u8], + ws_tx: &'a mut [u8], + tls_rx: &'a mut [u8], + tls_tx: &'a mut [u8], + tcp_rx: &'a mut [u8], + tcp_tx: &'a mut [u8], +} + #[derive(Debug)] -#[allow(dead_code)] -enum WsRwError { - OtaError(esp_hal_ota::Error), - TaggedPublisherError, - SocketWriteError, - SocketReadError, +#[allow(dead_code)] // fields are only read through Debug (logs) +enum WsError { + Dns, + Connect, + Tls, + Socket, + /// Handshake rejected (status code; 101 with a wrong `Sec-WebSocket-Accept` too) + Upgrade(u16), + Closed, WifiDisconnected, - Other, + Ota(esp_hal_ota::Error), } -async fn ws_rw( - framer_rx: &mut WsRxFramer<'_>, - framer_tx: &mut WsTxFramer<'_>, +/// One connection: connect, handshake, then serve until it fails. +async fn run( global_state: GlobalState, - socket: &mut WsSocket<'_, '_>, - wifi_conn_sig: &Signal, -) -> Result<(), WsRwError> { + url: WsUrl<'_>, + stack: Stack<'static>, + bufs: &mut Buffers<'_>, + wifi_conn_sig: &Signal, +) -> Result { + global_state.state.lock().await.server_connected = Some(false); + + let ip = resolve(stack, url.hostname).await?; + let mut tcp = TcpSocket::new(stack, &mut *bufs.tcp_rx, &mut *bufs.tcp_tx); + tcp.set_timeout(Some(Duration::from_secs(15))); + if let Err(e) = tcp.connect((ip, url.port)).await { + // a reconnected wifi clears the "wifi lost" message even if the server is down + if wifi_conn_sig.signaled() && wifi_conn_sig.wait().await { + global_state.state.lock().await.wifi_connected = Some(true); + } + log::error!("connect error: {e:?}"); + return Err(WsError::Connect); + } + + let mut socket = if url.secure { + let mut tls = TlsConnection::new(tcp, &mut *bufs.tls_rx, &mut *bufs.tls_tx); + tls.open(TlsContext::new(&TlsConfig::new(), InsecureNoVerify)) + .await + .map_err(|e| { + log::error!("tls open error: {e:?}"); + WsError::Tls + })?; + Socket::Tls(tls) + } else { + Socket::Raw(tcp) + }; + + { + let mut state = global_state.state.lock().await; + state.server_connected = Some(true); + state.wifi_connected = Some(true); + } + log::info!("connected!"); + + let mut tx = WsTxFramer::new(true, &mut *bufs.ws_tx); + let mut rx = WsRxFramer::new(&mut *bufs.ws_rx); + handshake(global_state, url, &mut socket, &mut tx, &mut rx).await?; + + #[cfg(feature = "auto_add")] + if !global_state.state.lock().await.device_added.unwrap_or(false) { + _ = send(TimerPacket { + tag: None, + data: TimerPacketInner::Add { + firmware: crate::version::FIRMWARE.into(), + sign_key: crate::state::sign_key(), + }, + }); + } + let mut ota = Ota::new(FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() })) - .map_err(WsRwError::OtaError)?; - let tagged_publisher = TAGGED_RETURN - .publisher() - .map_err(|_| WsRwError::TaggedPublisherError)?; - let recv = FRAME_CHANNEL.receiver(); + .map_err(WsError::Ota)?; + let mut owed = Owed { + ping: true, + ..Owed::default() + }; loop { - let read_fut = socket.read(framer_rx.mut_buf()); - let write_fut = recv.receive(); - - let res = match embassy_futures::select::select3(read_fut, write_fut, wifi_conn_sig.wait()) - .await - { - embassy_futures::select::Either3::First(read_res) => { - read_res.map_err(|_| WsRwError::SocketReadError) - } - embassy_futures::select::Either3::Second(write_frame) => { - let mut offset = 0; - let frame_ref = write_frame.into_ref(); - - loop { - let (data, splitted) = framer_tx.partial_frame(&frame_ref, &mut offset); - socket - .write_all(data) - .await - .map_err(|_| WsRwError::SocketWriteError)?; - - // if frame not splitted, break from loop - if !splitted { - if let WsFrameOwned::Close(code, _) = write_frame - && code == 4000 - { - // going into sleep mode, so sleep for 30s (in 15s from that frame - // radio should disable and this task will be stopped - - Timer::after_millis(30000).await; - } - - break; - } - - log::warn!("Frame splitted!"); - } - - continue; - } - embassy_futures::select::Either3::Third(state) => { - if state { - // wifi connected signal - continue; - } + flush(&mut socket, &mut tx, &mut owed).await?; + match select3(socket.read(rx.read_buf()), WAKE.wait(), wifi_conn_sig.wait()).await { + Either3::First(Ok(0) | Err(_)) => return Err(WsError::Socket), + Either3::First(Ok(n)) => rx.advance(n), + Either3::Second(()) | Either3::Third(true) => continue, + Either3::Third(false) => { global_state.state.lock().await.wifi_connected = Some(false); - log::error!("Wifi disconnected, ws_rw stop."); - return Err(WsRwError::WifiDisconnected); + return Err(WsError::WifiDisconnected); } - }; - - let n = match res { - Ok(n) => n, - Err(e) => return Err(e), - }; - if n == 0 { - log::warn!("read_n: 0"); - return Err(WsRwError::Other); } - framer_rx.revolve_write_offset(n); - while let Some(frame) = framer_rx.process_data() { - //log::warn!("recv_frame: opcode:{}", frame.opcode()); - + while let Some(frame) = rx.next_frame() { match frame { - WsFrame::Text(text) => match serde_json::from_str::(text) { - Ok(timer_packet) => { - //log::info!("Timer packet recv: {timer_packet:?}"); - if let Some(tag) = timer_packet.tag { - tagged_publisher.publish((tag, timer_packet.clone())).await; - } - - match timer_packet.data { - TimerPacketInner::DeviceSettings { - added, - locales, - default_locale, - fkm_token, - secure_rfid, - auto_setup, - sound_enabled, - } => { - let mut state = global_state.state.lock().await; - state.device_added = Some(added); - state.sound_enabled = sound_enabled; - crate::translations::clear_locales(); - - for locale in locales { - crate::translations::process_locale( - &locale.locale, - &locale.translations, - ); - } - - crate::translations::select_locale(&default_locale, &global_state); - crate::translations::set_default_locale(); - - crate::state::set_fkm_token(fkm_token); - crate::state::set_secure_rfid(secure_rfid); - crate::state::set_auto_setup(auto_setup); - } - TimerPacketInner::ApiError(e) => { - // if should_reset_time reset time - let mut state = global_state.state.lock().await; - state.error_text = Some(e.error); - } - TimerPacketInner::CustomMessage { line1, line2 } => { - let mut state = global_state.state.lock().await; - state.custom_message = Some((line1, line2)); - } - TimerPacketInner::EpochTime { current_epoch } => { - crate::state::set_epoch(current_epoch); - } - TimerPacketInner::DelegateResponse(_) => { - tagged_publisher.publish((DELEGATE_TAG, timer_packet)).await; - } - TimerPacketInner::StartUpdate { - version, - build_time: _, - size, - crc, - firmware, - } => { - if firmware != crate::version::FIRMWARE { - continue; - } - - #[cfg(not(feature = "e2e"))] - if !crate::state::trust_server() { - continue; - } - - log::info!("Start update: {firmware}/{version}"); - log::info!("Begin update size: {size} crc: {crc}"); - ota.begin(size, crc).map_err(WsRwError::OtaError)?; - crate::state::set_ota_state(true); - - let mut state = global_state.state.lock().await; - state.scene = Scene::Update; - drop(state); - - clear_frame_channel(); - FRAME_CHANNEL - .send(WsFrameOwned::Binary(alloc::vec::Vec::new())) - .await; - } - - #[allow(clippy::collapsible_match)] - TimerPacketInner::SetDeviceSettings { volume } => { - #[cfg(feature = "v4")] - if let Some(volume) = volume { - let volume = volume.clamp( - crate::consts::BUZZER_VOLUME_MIN, - crate::consts::BUZZER_VOLUME_MAX, - ); - crate::state::set_buzzer_volume(volume); - - if let Err(e) = global_state - .nvs - .set(&crate::consts::NVS_BUZZER_VOLUME, volume) - .await - { - log::error!("Cannot save buzzer volume to NVS: {e:?}"); - crate::log_once!(crate::utils::error_log::codes::NVS_BUZZER_VOLUME_WRITE_FAILED); - } - } - - #[cfg(feature = "v3")] - { - _ = volume; - } - } - TimerPacketInner::DumpCrashLog => { - let mut tmp = Vec::new(); - tmp.push(b'C'); - crate::utils::error_log::with_error_log(|log| tmp.extend_from_slice(log)); - - send_frame(ws_framer::WsFrameOwned::Binary(tmp)).await; - } - - #[cfg(feature = "e2e")] - TimerPacketInner::TestPacket(test_packet) => { - parse_test_packet(test_packet, &global_state).await; - } - _ => {} - } - } + Ok(WsFrame::Text(text)) => match serde_json::from_str::(text) { + Ok(packet) => handle_packet(global_state, packet, &mut ota, &mut owed).await?, Err(e) => { - log::error!("timer_packet_fail: {e:?}\nTried to parse:\n{text}\n\n"); - crate::log_once!(crate::utils::error_log::codes::WS_PACKET_PARSE_FAILED); + log::error!("timer_packet_fail: {e:?}\nTried to parse:\n{text}"); + crate::log_once!(codes::WS_PACKET_PARSE_FAILED); } }, - WsFrame::Binary(data) => { - if !crate::state::ota_state() { - continue; - } - - #[cfg(not(feature = "e2e"))] - if !crate::state::trust_server() { - continue; - } - - let res = ota.write(data); - if res == Ok(true) { - log::info!("OTA complete! Veryfying.."); - if ota.finish(true).is_ok() { - log::info!("OTA restart!"); - esp_hal::system::software_reset(); - } else { - log::error!("OTA flash verify failed!"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::OTA_VERIFY_FAILED, - ); - } - } - - let progress = ota.progress().map_or(0, |p| p.percent()); - global_state.update_progress.signal(progress); - - FRAME_CHANNEL - .send(WsFrameOwned::Binary(alloc::vec::Vec::new())) - .await; + Ok(WsFrame::Binary(data)) => handle_ota_chunk(global_state, data, &mut ota, &mut owed)?, + Ok(WsFrame::Ping(_)) => owed.pong = true, + Ok(WsFrame::Close(code, reason)) => { + log::warn!("server closed the connection: {code} {reason}"); + return Err(WsError::Closed); } - WsFrame::Ping(_) => { - _ = FRAME_CHANNEL.try_send(WsFrameOwned::Pong(alloc::vec::Vec::new())); + Ok(WsFrame::Pong(_)) => {} + Err(e) => { + // the framer dropped that frame; the connection goes on + log::error!("ws frame error: {e:?}"); + if matches!(e, WsRxError::Oversized { .. }) { + crate::log_once!(codes::WS_PACKET_PARSE_FAILED); + } } - _ => {} } } } } -#[cfg(feature = "e2e")] -async fn parse_test_packet( - test_packet: crate::structs::TestPacketData, - global_state: &GlobalState, -) { - log::warn!("TEST PACKET: {test_packet:?}"); - - match test_packet { - crate::structs::TestPacketData::ResetState => { - global_state - .state - .lock() - .await - .reset_solve_state(None) - .await; - - global_state - .e2e - .stackmat_sig - .signal((crate::utils::stackmat::StackmatTimerState::Reset, 0)); +async fn resolve(stack: Stack<'static>, hostname: &str) -> Result { + if let Ok(addr) = embassy_net::Ipv4Address::from_str(hostname) { + return Ok(addr); + } - send_test_ack(&global_state).await; + let res = embassy_net::dns::DnsSocket::new(stack) + .query(hostname, embassy_net::dns::DnsQueryType::A) + .await + .map_err(|e| { + log::error!("[WS]Dns resolver error: {e:?}"); + WsError::Dns + })?; + match res.first() { + Some(IpAddress::Ipv4(addr)) => Ok(*addr), + _ => { + log::error!("[WS]Dns resolver empty vec"); + crate::log_once!(codes::WS_DNS_RESOLVE_EMPTY); + Err(WsError::Dns) } - crate::structs::TestPacketData::HardStateReset => { - global_state - .e2e - .stackmat_sig - .signal((crate::utils::stackmat::StackmatTimerState::Reset, 0)); + } +} - global_state.state.lock().await.hard_state_reset().await; +/// HTTP upgrade (101 + `Sec-WebSocket-Accept`) and the `randomsigned` trust check. +async fn handshake( + global_state: GlobalState, + url: WsUrl<'_>, + socket: &mut Socket<'_, '_>, + tx: &mut WsTxFramer<'_>, + rx: &mut WsRxFramer<'_>, +) -> Result<(), WsError> { + let random = crate::utils::get_random_u64(); + let mut path = heapless::String::<512>::new(); + write!( + path, + "{}?id={}&ver={}&hw={}&firmware={}&random={}", + url.path, + crate::utils::get_efuse_u32(), + crate::version::VERSION, + crate::version::HW_VER, + crate::version::FIRMWARE, + random + ) + .map_err(|_| WsError::Upgrade(0))?; + + let request = tx + .http_upgrade_request(url.host, &path, &[]) + .ok_or(WsError::Upgrade(0))?; + socket.write_all(request).await?; + let accept = tx.accept_key(); + + let random_signed = loop { + let n = socket.read(rx.read_buf()).await.map_err(|_| WsError::Socket)?; + if n == 0 { + log::error!("error while reading http response"); + crate::log_once!(codes::WS_HTTP_UPGRADE_READ_FAILED); + return Err(WsError::Socket); } - crate::structs::TestPacketData::ScanCard(uid) => { - global_state.e2e.card_scan_sig.signal(uid as u128) + rx.advance(n); + + match rx.http_response() { + None => continue, + Some(Err(e)) => { + log::error!("bad http response: {e:?}"); + crate::log_once!(codes::WS_HTTP_UPGRADE_READ_FAILED); + return Err(WsError::Upgrade(0)); + } + Some(Ok(resp)) => { + log::info!("http_resp_code: {}", resp.status_code); + if !resp.is_websocket_accepted(&accept) { + return Err(WsError::Upgrade(resp.status_code)); + } + break resp + .header("randomsigned") + .and_then(|v| core::str::from_utf8(v).ok()?.parse::().ok()); + } } - crate::structs::TestPacketData::ButtonPress { pin, press_time } => global_state - .e2e - .buttons_sig - .signal((pin as usize, press_time)), - crate::structs::TestPacketData::StackmatTime(ms) => global_state - .e2e - .stackmat_sig - .signal((crate::utils::stackmat::StackmatTimerState::Running, ms)), - crate::structs::TestPacketData::StackmatReset => { - global_state - .e2e - .stackmat_sig - .signal((crate::utils::stackmat::StackmatTimerState::Reset, 0)); + }; + + if let Some(random_signed) = random_signed { + let mut key = [0; 16]; + key[..4].copy_from_slice(&crate::state::sign_key().to_be_bytes()); + let mut block = random_signed.to_be_bytes(); + global_state + .aes + .lock() + .await + .decrypt(&mut block, esp_hal::aes::Key::Key128(key)); + + let (recv_random, token) = block.split_at(8); + let recv_random = u64::from_be_bytes(recv_random.try_into().unwrap_or_default()); + let fkm_token = i32::from_be_bytes(token[..4].try_into().unwrap_or_default()); + log::debug!("[trust] random: {random}, recv_random: {recv_random} | fkm_token: {fkm_token}"); + + if random == recv_random { + crate::state::set_trust_server(true); + crate::state::set_fkm_token(fkm_token); + } else { + #[cfg(not(feature = "e2e"))] + { + global_state.state.lock().await.error_text = Some("Server Not Trusted!".into()); + } } } + + Ok(()) } -#[cfg(feature = "e2e")] -pub async fn send_test_ack(global_state: &GlobalState) { - send_packet(TimerPacket { - tag: None, - data: TimerPacketInner::TestAck(global_state.state.value().await.snapshot_data()), - }) - .await; +/// Replies the reader owes the server; the writer sends them before queued packets. +#[derive(Default)] +struct Owed { + ping: bool, + pong: bool, + ota_ack: bool, + crash_log: bool, } -async fn send_packet(packet: TimerPacket) { - match serde_json::to_string(&packet) { - Ok(string) => { - FRAME_CHANNEL.send(WsFrameOwned::Text(string)).await; +/// Writes everything owed or queued. +async fn flush( + socket: &mut Socket<'_, '_>, + tx: &mut WsTxFramer<'_>, + owed: &mut Owed, +) -> Result<(), WsError> { + if core::mem::take(&mut owed.ping) { + write_frame(socket, tx.ping(&[])).await?; + } + if core::mem::take(&mut owed.pong) { + write_frame(socket, tx.pong(&[])).await?; + } + if core::mem::take(&mut owed.ota_ack) { + write_frame(socket, tx.binary(&[])).await?; + } + if core::mem::take(&mut owed.crash_log) { + // shortcut: the log lock can't be held across the write, so the dump (≤ 2 KB, only on + // the server's debug command) is copied to the heap; a framer API to re-borrow the + // built frame would avoid it + let dump = crate::utils::error_log::with_error_log(|log| { + let mut dump = alloc::vec::Vec::with_capacity(log.len() + 1); + dump.push(b'C'); + dump.extend_from_slice(log); + dump + }); + write_chunked(socket, tx, &dump).await?; + } + + while let Ok(packet) = OUT.try_receive() { + match tx.text_with(|buf| serde_json_core::to_slice(&packet, buf).ok()) { + Some(frame) => socket.write_all(frame).await?, + None => { + log::error!("packet doesn't fit the tx buffer"); + crate::log_once!(codes::WS_PACKET_SERIALIZE_FAILED); + } } - Err(e) => { - log::error!("send_packet json to_string failed: {e:?}"); - crate::log_once!(crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED); + } + + if let Some(logs) = PENDING_LOGS.lock(Cell::take) { + write_chunked(socket, tx, &logs).await?; + } + + if let Some((code, reason)) = PENDING_CLOSE.lock(Cell::take) { + write_frame(socket, tx.close(code, reason)).await?; + if code == CLOSE_SLEEP { + // the radio is switched off within 15 s; don't reconnect before that + Timer::after_millis(30_000).await; } } -} -pub async fn send_frame(frame: WsFrameOwned) { - FRAME_CHANNEL.send(frame).await; + Ok(()) } -#[allow(dead_code)] -pub fn clear_frame_channel() { - FRAME_CHANNEL.clear(); +/// Binary message, split into frames as large as the tx buffer allows. +async fn write_chunked( + socket: &mut Socket<'_, '_>, + tx: &mut WsTxFramer<'_>, + data: &[u8], +) -> Result<(), WsError> { + let frame = WsFrame::Binary(data); + let mut offset = 0; + while let Some((part, more)) = tx.frame_chunk(&frame, &mut offset) { + socket.write_all(part).await?; + if !more { + break; + } + } + Ok(()) } -/// Tag under which untagged `DelegateResponse` pushes are delivered. -const DELEGATE_TAG: u64 = 69420; - -/// The outgoing queue is full; nothing was sent. -#[derive(Debug)] -pub struct WsBusy; - -/// Queues `packet` without waiting. -pub fn send(packet: TimerPacket) -> Result<(), WsBusy> { - let text = serde_json::to_string(&packet).map_err(|e| { - log::error!("send json to_string failed: {e:?}"); - crate::log_once!(crate::utils::error_log::codes::WS_PACKET_SERIALIZE_FAILED); - WsBusy - })?; - FRAME_CHANNEL - .try_send(WsFrameOwned::Text(text)) - .map_err(|_| WsBusy) +/// Writes a control frame built by the framer (empty ones always fit). +async fn write_frame(socket: &mut Socket<'_, '_>, frame: Option<&[u8]>) -> Result<(), WsError> { + match frame { + Some(frame) => socket.write_all(frame).await, + None => Ok(()), + } } -/// Queues a binary frame without waiting. -pub fn send_raw_binary(bytes: &[u8]) -> Result<(), WsBusy> { - FRAME_CHANNEL - .try_send(WsFrameOwned::Binary(bytes.to_vec())) - .map_err(|_| WsBusy) -} +/// Side effects of a received packet; tagged ones also go to the waiting request. +async fn handle_packet( + global_state: GlobalState, + packet: TimerPacket, + ota: &mut Ota>, + owed: &mut Owed, +) -> Result<(), WsError> { + match &packet.data { + TimerPacketInner::DeviceSettings { + added, + locales, + default_locale, + fkm_token, + secure_rfid, + auto_setup, + sound_enabled, + } => { + let mut state = global_state.state.lock().await; + state.device_added = Some(*added); + state.sound_enabled = *sound_enabled; + crate::translations::clear_locales(); + for locale in locales { + crate::translations::process_locale(&locale.locale, &locale.translations); + } + crate::translations::select_locale(default_locale, &global_state); + crate::translations::set_default_locale(); -/// Sends `packet` and waits (at most `timeout` in total) for the tagged response. -pub async fn request( - packet: TimerPacketInner, - timeout: Duration, -) -> Result { - let tag = crate::utils::get_random_u64(); - send_tagged_request(tag, packet, false) - .with_timeout(timeout) - .await - .unwrap_or_else(|_| Err(timeout_error())) -} + crate::state::set_fkm_token(*fkm_token); + crate::state::set_secure_rfid(*secure_rfid); + crate::state::set_auto_setup(*auto_setup); + } + TimerPacketInner::ApiError(e) => { + global_state.state.lock().await.error_text = Some(e.error.clone()); + } + TimerPacketInner::CustomMessage { line1, line2 } => { + global_state.state.lock().await.custom_message = Some((line1.clone(), line2.clone())); + } + TimerPacketInner::EpochTime { current_epoch } => crate::state::set_epoch(*current_epoch), + TimerPacketInner::DelegateResponse(_) if packet.tag.is_none() => { + if DELEGATE_WAITING.load(Ordering::Acquire) { + DELEGATE_RESPONSE.signal(packet); + } + return Ok(()); + } + TimerPacketInner::StartUpdate { + version, + size, + crc, + firmware, + .. + } => { + if firmware.as_str() != crate::version::FIRMWARE { + return Ok(()); + } + #[cfg(not(feature = "e2e"))] + if !crate::state::trust_server() { + return Ok(()); + } -#[cfg_attr(feature = "qa", allow(dead_code))] -/// Sends a delegate `Solve` and waits (at most `timeout`) for the server's -/// `DelegateResponse` (an untagged push answered by a person, hence the long timeout). -pub async fn delegate_request( - packet: TimerPacketInner, - timeout: Duration, -) -> Result { - send_tagged_request(DELEGATE_TAG, packet, false) - .with_timeout(timeout) - .await - .unwrap_or_else(|_| Err(timeout_error())) -} + log::info!("Start update: {firmware}/{version} size: {size} crc: {crc}"); + ota.begin(*size, *crc).map_err(WsError::Ota)?; + crate::state::set_ota_state(true); + global_state.state.lock().await.scene = Scene::Update; -fn timeout_error() -> ApiError { - ApiError { - should_reset_time: false, - error: "Communication timeout!".into(), + OUT.clear(); + owed.ota_ack = true; + } + #[cfg(feature = "v4")] + TimerPacketInner::SetDeviceSettings { + volume: Some(volume), + } => { + let volume = (*volume).clamp( + crate::consts::BUZZER_VOLUME_MIN, + crate::consts::BUZZER_VOLUME_MAX, + ); + crate::state::set_buzzer_volume(volume); + if let Err(e) = global_state + .nvs + .set(&crate::consts::NVS_BUZZER_VOLUME, volume) + .await + { + log::error!("Cannot save buzzer volume to NVS: {e:?}"); + crate::log_once!(codes::NVS_BUZZER_VOLUME_WRITE_FAILED); + } + } + TimerPacketInner::DumpCrashLog => owed.crash_log = true, + #[cfg(feature = "e2e")] + TimerPacketInner::TestPacket(test_packet) => { + parse_test_packet(test_packet.clone(), &global_state).await; + } + _ => {} } -} -async fn send_tagged_request( - tag: u64, - packet: TimerPacketInner, - timeout: bool, -) -> Result -where - T: FromPacket, -{ - let packet = TimerPacket { - tag: Some(tag), - data: packet, - }; + if let Some(tag) = packet.tag + && let Some(slot) = SLOTS + .iter() + .find(|s| s.tag.load(Ordering::Acquire) == tag) + { + slot.response.signal(packet); + } + Ok(()) +} - send_packet(packet) - .with_timeout(Duration::from_millis(5000)) - .await - .map_err(|_| ApiError { - should_reset_time: false, - error: "Channel full".into(), - })?; +/// A binary frame is the next OTA chunk (acked so the server sends the next one). +fn handle_ota_chunk( + global_state: GlobalState, + data: &[u8], + ota: &mut Ota>, + owed: &mut Owed, +) -> Result<(), WsError> { + if !ota_state() { + return Ok(()); + } + #[cfg(not(feature = "e2e"))] + if !crate::state::trust_server() { + return Ok(()); + } - let packet = if timeout { - wait_for_tagged_response(tag) - .with_timeout(Duration::from_millis(5000)) - .await - .map_err(|_| ApiError { - should_reset_time: false, - error: "Communication timeout!".into(), - })? - } else { - wait_for_tagged_response(tag).await - }; + if ota.write(data).map_err(WsError::Ota)? { + log::info!("OTA complete! Verifying.."); + if let Err(e) = ota.finish(true) { + log::error!("OTA finish failed: {e:?}"); + crate::utils::error_log::add_error(codes::OTA_VERIFY_FAILED); + return Err(WsError::Ota(e)); + } + log::info!("OTA restart!"); + esp_hal::system::software_reset(); + } - FromPacket::from_packet(packet) + global_state + .update_progress + .signal(ota.progress().map_or(0, |p| p.percent())); + owed.ota_ack = true; + Ok(()) } -async fn wait_for_tagged_response(tag: u64) -> TimerPacket { - loop { - match TAGGED_RETURN.subscriber() { - Ok(mut subscriber) => loop { - let (packet_tag, packet) = subscriber.next_message_pure().await; - if packet_tag == tag { - return packet; - } - }, - Err(_) => { - log::error!("failed to get TAGGED_RETURN subscriber! Retry!"); - crate::log_once!(crate::utils::error_log::codes::WS_TAGGED_SUBSCRIBER_FAILED); - Timer::after_millis(500).await; - } +#[cfg(feature = "e2e")] +async fn parse_test_packet(test_packet: crate::structs::TestPacketData, global_state: &GlobalState) { + use crate::structs::TestPacketData; + use crate::utils::stackmat::StackmatTimerState; + + log::warn!("TEST PACKET: {test_packet:?}"); + let e2e = &global_state.e2e; + match test_packet { + TestPacketData::ResetState => { + global_state.state.lock().await.reset_solve_state(None).await; + e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)); + send_test_ack(global_state).await; } + TestPacketData::HardStateReset => { + e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)); + global_state.state.lock().await.hard_state_reset().await; + } + TestPacketData::ScanCard(uid) => e2e.card_scan_sig.signal(uid as u128), + TestPacketData::ButtonPress { pin, press_time } => { + e2e.buttons_sig.signal((pin as usize, press_time)) + } + TestPacketData::StackmatTime(ms) => { + e2e.stackmat_sig.signal((StackmatTimerState::Running, ms)) + } + TestPacketData::StackmatReset => e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)), } } -enum WsSocket<'a, 'b> { - Tls(Box, Aes128GcmSha256>>), +enum Socket<'a, 'b> { + Tls(TlsConnection<'b, TcpSocket<'a>, Aes128GcmSha256>), Raw(TcpSocket<'a>), } -impl WsSocket<'_, '_> { - pub async fn read(&mut self, buf: &mut [u8]) -> Result { +impl Socket<'_, '_> { + async fn read(&mut self, buf: &mut [u8]) -> Result { match self { - WsSocket::Tls(tls_connection) => tls_connection.read(buf).await.map_err(|_| ()), - WsSocket::Raw(tcp_socket) => tcp_socket.read(buf).await.map_err(|_| ()), + Socket::Tls(tls) => tls.read(buf).await.map_err(|_| ()), + Socket::Raw(tcp) => tcp.read(buf).await.map_err(|_| ()), } } - pub async fn write_all(&mut self, buf: &[u8]) -> Result<(), ()> { - let mut written = 0; - while written < buf.len() { - written += self.write(&buf[written..]).await?; + async fn write_all(&mut self, mut buf: &[u8]) -> Result<(), WsError> { + while !buf.is_empty() { + let n = match self { + Socket::Tls(tls) => tls.write(buf).await.map_err(|_| WsError::Socket)?, + Socket::Raw(tcp) => tcp.write(buf).await.map_err(|_| WsError::Socket)?, + }; + if n == 0 { + return Err(WsError::Socket); + } + buf = &buf[n..]; + } + if let Socket::Tls(tls) = self { + tls.flush().await.map_err(|_| WsError::Socket)?; } - Ok(()) } - - pub async fn write(&mut self, buf: &[u8]) -> Result { - let n = match self { - WsSocket::Tls(tls_connection) => { - let n = tls_connection.write(buf).await.map_err(|_| ())?; - tls_connection.flush().await.map_err(|_| ())?; - n - } - WsSocket::Raw(tcp_socket) => tcp_socket.write(buf).await.map_err(|_| ())?, - }; - - Ok(n) - } } /// RNG for embedded-tls (embassy-crypto) From 663107762e68f5832a48a7fee840d0290c19eb69 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:07:42 +0200 Subject: [PATCH 023/132] feat: rtc crash record and task watchdog --- src/main.rs | 22 ++- src/utils/backtrace_store.rs | 281 +++++++++++++++++------------------ src/utils/error_log.rs | 6 +- src/watchdog.rs | 84 +++++++++++ 4 files changed, 239 insertions(+), 154 deletions(-) create mode 100644 src/watchdog.rs diff --git a/src/main.rs b/src/main.rs index 4ec8832..113e4f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod structs; mod translations; mod utils; mod version; +mod watchdog; mod ws; #[cfg(feature = "v3")] @@ -61,6 +62,9 @@ pub fn custom_rng(buf: &mut [u8]) -> Result<(), getrandom::Error> { getrandom::register_custom_getrandom!(custom_rng); esp_bootloader_esp_idf::esp_app_desc!(); +/// Uptime after which a crash no longer counts as part of a crash loop. +const HEALTHY_AFTER_SECS: u64 = 60; + #[esp_rtos::main] async fn main(spawner: Spawner) { let peripherals = esp_hal::init({ @@ -95,6 +99,11 @@ async fn main(spawner: Spawner) { let board = Board::init(peripherals); esp_rtos::start(board.timg1.timer0, board.sw_interrupt); FkmLogger::set_logger(); + spawn_task( + &spawner, + "watchdog::watchdog_task", + watchdog::watchdog_task(board.timg0.wdt), + ); log::info!("Version: {}", version::VERSION); log::info!("Hardware Rev: {}", version::HW_VER); @@ -106,9 +115,6 @@ async fn main(spawner: Spawner) { #[cfg(feature = "qa")] log::info!("This firmware is in QA mode!"); - #[cfg(feature = "release_build")] - crate::utils::backtrace_store::verify_panic_flag(); - let Ok(nvs) = Nvs::new(unsafe { board.flash.clone_unchecked() }) else { utils::error_log::add_error(utils::error_log::codes::WRONG_PARTITION_TABLE); #[cfg(feature = "v3")] @@ -134,6 +140,7 @@ async fn main(spawner: Spawner) { log::info!("Wake reason: {:?} {:?}", reason, wake_reason); utils::error_log::load_error_log(nvs).await; + utils::backtrace_store::report_saved_crash(); static GLOBAL_STATE: static_cell::StaticCell = static_cell::StaticCell::new(); let global_state: GlobalState = GLOBAL_STATE.init(GlobalStateInner::new(nvs, board.aes)); @@ -345,8 +352,6 @@ async fn main(spawner: Spawner) { } }; - utils::backtrace_store::read_saved_backtrace().await; - let ws_sleep_sig = Rc::new(Signal::new()); spawn_task( &spawner, @@ -378,9 +383,16 @@ async fn main(spawner: Spawner) { .parse_saved_state(saved_state); } + let boot = Instant::now(); + let mut healthy = false; let mut last_sleep = false; 'outer: loop { Timer::after_millis(100).await; + watchdog::heartbeat(watchdog::TaskId::Main); + if !healthy && boot.elapsed().as_secs() >= HEALTHY_AFTER_SECS { + utils::backtrace_store::mark_healthy(); + healthy = true; + } if utils::error_log::is_save_ready() { utils::error_log::save_error_log(nvs).await; utils::error_log::clear_save_ready(); diff --git a/src/utils/backtrace_store.rs b/src/utils/backtrace_store.rs index 829f125..1b3e6b2 100644 --- a/src/utils/backtrace_store.rs +++ b/src/utils/backtrace_store.rs @@ -1,130 +1,149 @@ -use alloc::vec::Vec; -use esp_storage::FlashStorage; +//! Crash record kept in RTC fast RAM (survives software and watchdog resets, not power loss). +//! +//! The panic handler only does plain atomic stores here: no locks, no heap, no flash. +//! The record is turned into an error log entry on the next boot. -#[esp_hal::ram(unstable(rtc_fast, persistent))] -static mut NVS_PANIC_FLAG: u32 = 0; - -const NVS_PANIC_MAGIC_1: u32 = 0x1A1A_1A1A; -const NVS_PANIC_MAGIC_2: u32 = 0xDEAD_BEEF; +use portable_atomic::{AtomicU32, Ordering}; const MAX_BACKTRACE_ADDRESSES: usize = 10; const RA_OFFSET: usize = 4; -const SAVED_VERSION_LEN: usize = 16; -const SAVED_TIMESTAMP_LEN: usize = 8; - -#[cfg(feature = "release_build")] -pub fn verify_panic_flag() { - use embedded_storage::nor_flash::NorFlash; - - const NVS_OFFSET: u32 = 0x9000; - const NVS_SIZE: u32 = 0x4000; - - if unsafe { NVS_PANIC_FLAG == NVS_PANIC_MAGIC_2 } { - log::error!("Two panics in a row! Self recovery..."); - crate::utils::error_log::add_error(crate::utils::error_log::codes::DOUBLE_PANIC_RECOVERY); - - let mut flash = FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() }); - let mut buf = [0; 1024]; - let res = embedded_storage::ReadStorage::read( - &mut flash, - (NVS_OFFSET + NVS_SIZE - 2) as u32, - &mut buf[..2], - ); - - if let Err(e) = res { - log::error!("read_len_err: {e:?}"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::BACKTRACE_READ_FAILED); - } +const VERSION_WORDS: usize = 4; + +const MAGIC: u32 = 0xF4C0_A5E1; +const KIND_NONE: u32 = 0; +const KIND_PANIC: u32 = 1; +const KIND_WATCHDOG: u32 = 2; + +// word layout of `CRASH` +const W_MAGIC: usize = 0; +const W_KIND: usize = 1; +const W_CONSECUTIVE: usize = 2; +const W_TASK: usize = 3; +const W_EPOCH: usize = 4; // 2 words +const W_VERSION: usize = 6; // VERSION_WORDS words +const W_ADDR_COUNT: usize = W_VERSION + VERSION_WORDS; +const W_ADDRS: usize = W_ADDR_COUNT + 1; +const W_CHECKSUM: usize = W_ADDRS + MAX_BACKTRACE_ADDRESSES; +const WORDS: usize = W_CHECKSUM + 1; + +#[allow(clippy::declare_interior_mutable_const)] +const ZERO: AtomicU32 = AtomicU32::new(0); + +// Any bit pattern is valid (power-on garbage fails the magic/checksum check). +#[esp_hal::ram(unstable(rtc_fast, persistent))] +static CRASH: [AtomicU32; WORDS] = [ZERO; WORDS]; - let mut len = u16::from_be_bytes([buf[0], buf[1]]); - if len > 1024 || len == 0xff { - len = 0; - } +fn get(w: usize) -> u32 { + CRASH[w].load(Ordering::Relaxed) +} - if len == 0 { - _ = flash.erase(NVS_OFFSET, NVS_OFFSET + NVS_SIZE); - } else { - _ = flash.erase(NVS_OFFSET, NVS_OFFSET + NVS_SIZE - 2 - len as u32); - } +fn set(w: usize, v: u32) { + CRASH[w].store(v, Ordering::Relaxed); +} - unsafe { NVS_PANIC_FLAG = 0 }; - } +fn checksum() -> u32 { + (W_KIND..W_CHECKSUM).fold(MAGIC, |acc, w| acc.rotate_left(5) ^ get(w)) } -pub async fn read_saved_backtrace() { - if let Some(nvs_part) = esp_hal_wifimanager::Nvs::read_nvs_partition_offset(unsafe { - esp_hal::peripherals::FLASH::steal() - }) { - let mut flash = FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() }); - - let mut buf = [0; 1024]; - let res = embedded_storage::ReadStorage::read( - &mut flash, - (nvs_part.0 + nvs_part.1 - 2) as u32, - &mut buf[..2], - ); - - if let Err(e) = res { - log::error!("read_len_err: {e:?}"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BACKTRACE_READ_FAILED, - ); - return; - } +fn is_valid() -> bool { + get(W_MAGIC) == MAGIC && get(W_CHECKSUM) == checksum() +} - let len = u16::from_be_bytes([buf[0], buf[1]]); - if len == 0 || len > 1024 || len == 0xff { - return; - } +fn seal() { + set(W_MAGIC, MAGIC); + set(W_CHECKSUM, checksum()); +} - let res = embedded_storage::ReadStorage::read( - &mut flash, - (nvs_part.0 + nvs_part.1 - 2 - len as usize) as u32, - &mut buf[..len as usize], - ); - - if let Err(e) = res { - log::error!("read_msg_err: {e:?}"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BACKTRACE_READ_FAILED, - ); - return; - } +/// Starts a new record (consecutive crash count carries over a valid previous one). +fn record(kind: u32, task: u32, addrs: &[usize]) { + let consecutive = if is_valid() { get(W_CONSECUTIVE) } else { 0 }; + set(W_KIND, kind); + set(W_CONSECUTIVE, consecutive.saturating_add(1)); + set(W_TASK, task); + + let epoch = crate::state::current_epoch(); + set(W_EPOCH, epoch as u32); + set(W_EPOCH + 1, (epoch >> 32) as u32); + + let mut version = [0u8; VERSION_WORDS * 4]; + let bytes = crate::version::VERSION.as_bytes(); + let len = bytes.len().min(version.len()); + version[..len].copy_from_slice(&bytes[..len]); + for (i, chunk) in version.chunks_exact(4).enumerate() { + set(W_VERSION + i, u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } - const HEADER_LEN: usize = SAVED_VERSION_LEN + SAVED_TIMESTAMP_LEN; - let len = len as usize; - if len < HEADER_LEN || !(len - HEADER_LEN).is_multiple_of(4) { - return; - } + let count = addrs.len().min(MAX_BACKTRACE_ADDRESSES); + set(W_ADDR_COUNT, count as u32); + for (i, addr) in addrs.iter().take(count).enumerate() { + set(W_ADDRS + i, addr.saturating_sub(RA_OFFSET) as u32); + } + + seal(); +} + +/// Called by the task watchdog right before it lets the hardware watchdog reset the chip. +pub fn record_watchdog(task: u8) { + record(KIND_WATCHDOG, task as u32, &[]); +} + +/// Moves the crash record (if any) into the error log. Call after `load_error_log`. +/// +/// Returns how many crashes happened in a row before this boot. +pub fn report_saved_crash() -> u32 { + if !is_valid() { + set(W_KIND, KIND_NONE); + set(W_CONSECUTIVE, 0); + seal(); + return 0; + } - let version_raw = &buf[..SAVED_VERSION_LEN]; - let saved_version = core::str::from_utf8(version_raw) - .unwrap_or(crate::version::VERSION) - .trim_end_matches('\0'); - let saved_timestamp = u64::from_be_bytes( - buf[SAVED_VERSION_LEN..HEADER_LEN] - .try_into() - .unwrap_or([0; 8]), - ); - let addr_data = &buf[HEADER_LEN..len]; - - log::error!("Last crash info:"); - let mut addrs = Vec::new(); - for addr in addr_data.chunks(4) { - if let Ok(addr) = addr.try_into() { - let addr: u32 = u32::from_be_bytes(addr); - addrs.push(addr); - log::error!("0x{:X}", addr); + let consecutive = get(W_CONSECUTIVE); + match get(W_KIND) { + KIND_PANIC => { + let mut version = [0u8; VERSION_WORDS * 4]; + for (i, chunk) in version.chunks_exact_mut(4).enumerate() { + chunk.copy_from_slice(&get(W_VERSION + i).to_le_bytes()); } + let version = core::str::from_utf8(&version) + .unwrap_or(crate::version::VERSION) + .trim_end_matches('\0'); + let epoch = (get(W_EPOCH) as u64) | ((get(W_EPOCH + 1) as u64) << 32); + + let mut addrs = [0u32; MAX_BACKTRACE_ADDRESSES]; + let count = (get(W_ADDR_COUNT) as usize).min(MAX_BACKTRACE_ADDRESSES); + for (i, addr) in addrs.iter_mut().take(count).enumerate() { + *addr = get(W_ADDRS + i); + } + + log::error!("Last crash info:"); + for addr in &addrs[..count] { + log::error!("0x{addr:X}"); + } + crate::utils::error_log::add_stacktrace(&addrs[..count], version, epoch); + } + KIND_WATCHDOG => { + log::error!("Last reset: task watchdog (task {})", get(W_TASK)); + crate::utils::error_log::add_error(crate::utils::error_log::codes::WATCHDOG_RESET); } + _ => {} + } - _ = embedded_storage::Storage::write( - &mut flash, - (nvs_part.0 + nvs_part.1 - 2) as u32, - &[0x00, 0x00], - ); + if consecutive >= 2 { + log::error!("{consecutive} crashes in a row!"); + crate::utils::error_log::add_error(crate::utils::error_log::codes::REPEATED_CRASHES); + } - crate::utils::error_log::add_stacktrace(&addrs, saved_version, saved_timestamp); + set(W_KIND, KIND_NONE); + seal(); + consecutive +} + +/// Called once the firmware has run fine for a while: the next crash starts a new count. +pub fn mark_healthy() { + if is_valid() && get(W_CONSECUTIVE) != 0 { + set(W_CONSECUTIVE, 0); + seal(); } } @@ -139,7 +158,7 @@ pub fn backtrace() -> [Option; MAX_BACKTRACE_ADDRESSES] { } pub fn backtrace_internal(fp: u32, suppress: i32) -> [Option; MAX_BACKTRACE_ADDRESSES] { - let mut result = [None; 10]; + let mut result = [None; MAX_BACKTRACE_ADDRESSES]; let mut index = 0; let mut fp = fp; @@ -195,44 +214,14 @@ fn is_valid_ram_address(address: u32) -> bool { #[unsafe(no_mangle)] pub extern "Rust" fn custom_pre_backtrace() { let backtrace = backtrace(); - unsafe { - if NVS_PANIC_FLAG != NVS_PANIC_MAGIC_1 && NVS_PANIC_FLAG != NVS_PANIC_MAGIC_2 { - NVS_PANIC_FLAG = NVS_PANIC_MAGIC_1; - } else if NVS_PANIC_FLAG == NVS_PANIC_MAGIC_1 { - NVS_PANIC_FLAG = NVS_PANIC_MAGIC_2; - } - } - - let mut tmp = Vec::new(); - let mut version = [0; SAVED_VERSION_LEN]; - let version_bytes = crate::version::VERSION.as_bytes(); - let version_len = core::cmp::min(version_bytes.len(), SAVED_VERSION_LEN); - version[..version_len].copy_from_slice(&version_bytes[..version_len]); - tmp.extend_from_slice(&version); - tmp.extend_from_slice(&crate::state::current_epoch().to_be_bytes()); + let mut addrs = [0usize; MAX_BACKTRACE_ADDRESSES]; + let mut count = 0; for addr in backtrace.into_iter().flatten() { - tmp.extend_from_slice(&((addr - RA_OFFSET) as u32).to_be_bytes()); - } - - if let Some(nvs_part) = esp_hal_wifimanager::Nvs::read_nvs_partition_offset(unsafe { - esp_hal::peripherals::FLASH::steal() - }) { - let mut flash = FlashStorage::new(unsafe { esp_hal::peripherals::FLASH::steal() }); - _ = embedded_storage::Storage::write( - &mut flash, - (nvs_part.0 + nvs_part.1 - 2) as u32, - &(tmp.len() as u16).to_be_bytes(), - ); - - _ = embedded_storage::Storage::write( - &mut flash, - (nvs_part.0 + nvs_part.1 - 2 - tmp.len()) as u32, - &tmp, - ); + addrs[count] = addr; + count += 1; } - let delay = esp_hal::delay::Delay::new(); - delay.delay_millis(100); + record(KIND_PANIC, 0, &addrs[..count]); } #[unsafe(no_mangle)] diff --git a/src/utils/error_log.rs b/src/utils/error_log.rs index d9f8e9a..78eb74d 100644 --- a/src/utils/error_log.rs +++ b/src/utils/error_log.rs @@ -128,9 +128,9 @@ pub mod codes { pub const SHARED_I2C_TIMEOUT: u8 = 81; // Crash recovery (90-99) - #[cfg(feature = "release_build")] - pub const DOUBLE_PANIC_RECOVERY: u8 = 90; - pub const BACKTRACE_READ_FAILED: u8 = 91; + /// Several crashes / watchdog resets in a row (was "double panic recovery"). + pub const REPEATED_CRASHES: u8 = 90; + pub const WATCHDOG_RESET: u8 = 92; } fn push_record(log: &mut ErrorLog, buf: &mut [u8; CAPACITY], record: &[u8]) { diff --git a/src/watchdog.rs b/src/watchdog.rs new file mode 100644 index 0000000..1ee8c42 --- /dev/null +++ b/src/watchdog.rs @@ -0,0 +1,84 @@ +//! Task watchdog: the TIMG0 hardware watchdog is fed only while every armed task is alive. +//! +//! A task is armed by its first [`heartbeat`] and must then beat at least every +//! [`TASK_TIMEOUT_SECS`]. A stuck executor stops the feeding task itself, so the hardware +//! watchdog resets the chip after [`HW_TIMEOUT_SECS`]. + +use embassy_time::{Instant, Timer}; +use esp_hal::{ + peripherals::TIMG0, + timer::timg::{MwdtStage, Wdt}, +}; +use portable_atomic::{AtomicU32, Ordering}; + +#[derive(Clone, Copy, Debug)] +pub enum TaskId { + Main, + Ws, + Lcd, + Buttons, + Stackmat, +} + +const TASKS: [TaskId; 5] = [ + TaskId::Main, + TaskId::Ws, + TaskId::Lcd, + TaskId::Buttons, + TaskId::Stackmat, +]; +const TASK_TIMEOUT_SECS: u32 = 60; +const HW_TIMEOUT_SECS: u64 = 10; + +#[allow(clippy::declare_interior_mutable_const)] +const NOT_ARMED: AtomicU32 = AtomicU32::new(0); +/// Uptime in seconds + 1 of each task's last heartbeat (0 = not armed). +static LAST_BEAT: [AtomicU32; TASKS.len()] = [NOT_ARMED; TASKS.len()]; + +fn now() -> u32 { + (Instant::now().as_secs() as u32).saturating_add(1) +} + +/// Marks `task` alive (and arms it on the first call). +pub fn heartbeat(task: TaskId) { + LAST_BEAT[task as usize].store(now(), Ordering::Relaxed); +} + +/// The first armed task that missed its deadline. +fn stale_task() -> Option { + let now = now(); + // tasks are allowed to idle while sleeping; they re-arm on their next heartbeat + let sleeping = crate::state::sleep_state(); + + TASKS.into_iter().find(|&task| { + let beat = &LAST_BEAT[task as usize]; + if sleeping && !matches!(task, TaskId::Main) { + beat.store(0, Ordering::Relaxed); + return false; + } + + let last = beat.load(Ordering::Relaxed); + last != 0 && now.saturating_sub(last) > TASK_TIMEOUT_SECS + }) +} + +#[embassy_executor::task] +pub async fn watchdog_task(mut wdt: Wdt>) { + wdt.set_timeout( + MwdtStage::Stage0, + esp_hal::time::Duration::from_secs(HW_TIMEOUT_SECS), + ); + wdt.enable(); + + loop { + Timer::after_secs(1).await; + match stale_task() { + None => wdt.feed(), + Some(task) => { + log::error!("Task {task:?} stopped responding! Resetting"); + crate::utils::backtrace_store::record_watchdog(task as u8); + esp_hal::system::software_reset(); + } + } + } +} From cc524820f58395c90ca26d3f077ca04efa641757 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:10:02 +0200 Subject: [PATCH 024/132] feat: power supervisor, throttled error log save, heap stats --- Cargo.toml | 2 +- src/main.rs | 86 ++++++++++++++++------------------- src/power.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 48 deletions(-) create mode 100644 src/power.rs diff --git a/Cargo.toml b/Cargo.toml index 259ea61..290d620 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ esp-storage = { version = "0.10.0", features = ["esp32c3", "embedded-storage"] } embedded-storage = "0.3.2" static_cell = { version = "2.1.1", features = ["nightly"] } heapless = { version = "0.9.3", default-features = false } -esp-alloc = "0.11.0" +esp-alloc = { version = "0.11.0", features = ["internal-heap-stats"] } esp-hal-wifimanager = { version = "0.6.0", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } serde = { version = "1.0.229", features = ["alloc", "derive"], default-features = false } diff --git a/src/main.rs b/src/main.rs index 113e4f8..87f376a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod board; mod buttons; mod consts; mod mdns; +mod power; mod rfid; mod stackmat; mod state; @@ -64,6 +65,8 @@ esp_bootloader_esp_idf::esp_app_desc!(); /// Uptime after which a crash no longer counts as part of a crash loop. const HEALTHY_AFTER_SECS: u64 = 60; +/// Error log flash writes are throttled (repeating errors must not wear the flash). +const ERROR_LOG_SAVE_INTERVAL: embassy_time::Duration = embassy_time::Duration::from_secs(60); #[esp_rtos::main] async fn main(spawner: Spawner) { @@ -352,7 +355,7 @@ async fn main(spawner: Spawner) { } }; - let ws_sleep_sig = Rc::new(Signal::new()); + let ws_sleep_sig: Rc = Rc::new(Signal::new()); spawn_task( &spawner, "ws::ws_task", @@ -366,7 +369,7 @@ async fn main(spawner: Spawner) { ); spawn_task(&spawner, "logger_task", logger_task(global_state)); - let ble_sleep_sig = Rc::new(Signal::new()); + let ble_sleep_sig: Rc = Rc::new(Signal::new()); spawn_task( &spawner, "bluetooth::bluetooth_timer_task", @@ -375,64 +378,51 @@ async fn main(spawner: Spawner) { set_brownout_detection(true); global_state.state.lock().await.scene = Scene::WaitingForCompetitor; - if let Some(saved_state) = SavedGlobalState::from_nvs(nvs).await { - global_state - .state - .lock() - .await - .parse_saved_state(saved_state); - } + spawn_task( + &spawner, + "restore_saved_state_task", + restore_saved_state_task(global_state, nvs), + ); + let mut power = power::Supervisor::new(&wifi_res, ws_sleep_sig, ble_sleep_sig); let boot = Instant::now(); let mut healthy = false; - let mut last_sleep = false; - 'outer: loop { + let mut last_error_log_save: Option = None; + loop { Timer::after_millis(100).await; watchdog::heartbeat(watchdog::TaskId::Main); if !healthy && boot.elapsed().as_secs() >= HEALTHY_AFTER_SECS { utils::backtrace_store::mark_healthy(); healthy = true; } - if utils::error_log::is_save_ready() { - utils::error_log::save_error_log(nvs).await; + + if utils::error_log::is_save_ready() + && last_error_log_save.is_none_or(|t| t.elapsed() >= ERROR_LOG_SAVE_INTERVAL) + { + // cleared first so errors added while saving trigger the next save utils::error_log::clear_save_ready(); + utils::error_log::save_error_log(nvs).await; + last_error_log_save = Some(Instant::now()); } - if sleep_state() != last_sleep { - last_sleep = sleep_state(); - ble_sleep_sig.signal(last_sleep); - if last_sleep { - log::warn!("Entering sleep mode!"); - ws::close(4000, "Going into sleep mode"); - - // only wait that 15s when going into sleep mode (to make sure close is sent and to - // debounce sleeps) - let start = Instant::now(); - while start.elapsed().as_millis() < 15000 { - if sleep_state() != last_sleep { - // send sleep to ws to make sure clean state of - // ws is reached in next outer loop iteration - ws_sleep_sig.signal(true); - - continue 'outer; - } - - Timer::after_millis(100).await; - } - } + power.tick().await; + } +} - ws_sleep_sig.signal(last_sleep); - match last_sleep { - true => wifi_res.stop_radio(), - false => wifi_res.restart_radio(), - } +/// Restores an unfinished solve; waits for the server time, so it can't run in the main loop. +#[embassy_executor::task] +async fn restore_saved_state_task(global_state: GlobalState, nvs: &'static Nvs) { + if let Some(saved_state) = SavedGlobalState::from_nvs(nvs).await { + let mut state = global_state.state.lock().await; + // a new solve may have started while waiting for the server time + if state.scene == Scene::WaitingForCompetitor { + state.parse_saved_state(saved_state); } } } #[embassy_executor::task] async fn logger_task(global_state: GlobalState) { - #[cfg(not(feature = "release_build"))] let mut heap_start = Instant::now(); loop { @@ -457,12 +447,14 @@ async fn logger_task(global_state: GlobalState) { _ = ws::send_logs(logs); } - #[cfg(not(feature = "release_build"))] - if (Instant::now() - heap_start).as_millis() >= consts::PRINT_HEAP_INTERVAL_MS { - if global_state.state.lock().await.server_connected == Some(true) { - log::info!("{}", esp_alloc::HEAP.stats()); - } - + if heap_start.elapsed().as_millis() >= consts::PRINT_HEAP_INTERVAL_MS { + let stats = esp_alloc::HEAP.stats(); + log::info!( + "Heap: used {} max {} of {} bytes", + stats.current_usage, + stats.max_usage, + stats.size + ); heap_start = Instant::now(); } } diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..276ef75 --- /dev/null +++ b/src/power.rs @@ -0,0 +1,124 @@ +//! Sleep state machine: Awake → Sleeping (radio off) → DeepSleep (chip off, button wakes it). +//! +//! Display/input code only calls [`request_sleep`] / [`wake`] / [`enter_deep_sleep`]; +//! [`Supervisor::tick`] (main loop) does the radio, websocket and BLE side of each transition. + +use alloc::rc::Rc; +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; +use embassy_time::{Duration, Instant}; +use esp_hal_wifimanager::WifiManager; + +pub type SleepSignal = Signal; + +/// Time between the websocket close frame and the radio going off; also debounces quick wakes. +const SLEEP_SETTLE: Duration = Duration::from_secs(15); + +pub fn is_sleeping() -> bool { + crate::state::sleep_state() +} + +#[allow(dead_code)] // called by the display code once its sleep entry moves here +pub fn request_sleep() { + crate::state::set_sleep_state(true); +} + +#[allow(dead_code)] // called by the display/buttons code once its wake path moves here +pub fn wake() { + crate::state::set_sleep_state(false); +} + +/// Powers the chip down until one of the four buttons is pressed (wake = reset). +#[cfg(feature = "v4")] +#[allow(dead_code)] // called by the display code once its deep sleep entry moves here +pub fn enter_deep_sleep() -> ! { + use esp_hal::gpio::{Event, Input, InputConfig, Pin, Pull, WakeupConfig}; + use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; + + // SAFETY: called right before deep sleep, after which nothing else uses these pins. + let (pins, lpwr) = unsafe { + ( + [ + esp_hal::peripherals::GPIO0::steal().degrade(), + esp_hal::peripherals::GPIO1::steal().degrade(), + esp_hal::peripherals::GPIO2::steal().degrade(), + esp_hal::peripherals::GPIO3::steal().degrade(), + ], + esp_hal::peripherals::LPWR::steal(), + ) + }; + + // pull-down like the old RtcioWakeupSource did for a high-level wake + let input_config = InputConfig::default().with_pull(Pull::Down); + let wakeup_config = WakeupConfig::default().with_low_power_path(true); + let mut wakeup_pins = pins.map(|pin| Input::new(pin, input_config)); + for pin in &mut wakeup_pins { + if let Err(e) = pin.apply_wakeup_config(&wakeup_config) { + log::error!("Wakeup pin config failed: {e:?}"); + } + pin.listen(Event::HighLevel); + } + + LowPower::new(lpwr).sleep_deep(RtcSleepConfig::deep()) +} + +/// v3 has no wakeable deep sleep: the cpu clock drops to 10 MHz until the next reset. +#[cfg(feature = "v3")] +#[allow(dead_code)] // called by the display code once its deeper sleep entry moves here +pub fn enter_deep_sleep() { + crate::utils::deeper_sleep(); +} + +/// Applies sleep transitions to the radio, websocket and BLE tasks. +pub struct Supervisor<'a> { + wifi: &'a WifiManager, + ws_sleep: Rc, + ble_sleep: Rc, + sleeping: bool, + /// Set while waiting [`SLEEP_SETTLE`] before turning the radio off. + settling_since: Option, +} + +impl<'a> Supervisor<'a> { + pub fn new(wifi: &'a WifiManager, ws_sleep: Rc, ble_sleep: Rc) -> Self { + Self { + wifi, + ws_sleep, + ble_sleep, + sleeping: false, + settling_since: None, + } + } + + /// Call periodically (never blocks for long). + pub async fn tick(&mut self) { + let sleeping = is_sleeping(); + + if let Some(since) = self.settling_since { + if !sleeping { + // woken while settling: reset the ws state, the next tick restarts everything + self.settling_since = None; + self.ws_sleep.signal(true); + } else if since.elapsed() >= SLEEP_SETTLE { + self.settling_since = None; + self.ws_sleep.signal(true); + self.wifi.stop_radio(); + } + return; + } + + if sleeping == self.sleeping { + return; + } + + self.sleeping = sleeping; + self.ble_sleep.signal(sleeping); + if sleeping { + log::warn!("Entering sleep mode!"); + crate::ws::close(4000, "Going into sleep mode"); + self.settling_since = Some(Instant::now()); + } else { + self.ws_sleep.signal(false); + self.wifi.restart_radio(); + } + } +} From b2f5b2dce5c4b95cc5c569942a023112811248b1 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:13:42 +0200 Subject: [PATCH 025/132] fix: battery gauge outage backoff --- src/battery_v4.rs | 74 +++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/src/battery_v4.rs b/src/battery_v4.rs index 2e3f552..4b0a7b9 100644 --- a/src/battery_v4.rs +++ b/src/battery_v4.rs @@ -14,6 +14,8 @@ const BATTERY_CURVE: [(f64, u8); 11] = [ (4000.0, 88), (4100.0, 100), ]; +const READ_INTERVAL_MS: u64 = 100; +const MAX_RETRY_MS: u64 = 30_000; const BAT_MIN: f64 = BATTERY_CURVE[0].0; const BAT_MAX: f64 = BATTERY_CURVE[BATTERY_CURVE.len() - 1].0; @@ -33,56 +35,41 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) let mut last_soc = 0; let mut last_charging = true; - let mut last_mv = 0.0; - let mut last_ma = 0; let mut last_sent = Instant::now(); + let mut outage = false; + let mut retry_ms = READ_INTERVAL_MS; loop { if sleep_state() { Timer::after_millis(500).await; continue; } - let mut soc = last_soc; - match embassy_time::with_timeout( - embassy_time::Duration::from_millis(1500), - gauge.state_of_charge(), - ) - .await - { - Ok(Ok(val)) => soc = val as u8, - _ => { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, - ); - } - }; - let mut mv = last_mv; - match embassy_time::with_timeout(embassy_time::Duration::from_millis(1500), gauge.voltage()) - .await - { - Ok(Ok(val)) => mv = val as f64, - _ => { + let soc = read(gauge.state_of_charge()).await; + let mv = read(gauge.voltage()).await; + let ma = read(gauge.average_current()).await; + let (Some(soc), Some(mv), Some(ma)) = (soc, mv, ma) else { + // a missing/broken gauge must not flood the error log or the shared i2c bus + if !outage { + outage = true; + log::warn!("Battery gauge not responding"); crate::utils::error_log::add_error( crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, ); } + retry_ms = (retry_ms * 2).min(MAX_RETRY_MS); + Timer::after_millis(retry_ms).await; + continue; }; - if soc == 0 { - soc = bat_percentage(calculate(mv)); + if outage { + outage = false; + log::info!("Battery gauge responding again"); } - let mut ma = last_ma; - match embassy_time::with_timeout( - embassy_time::Duration::from_millis(1500), - gauge.average_current(), - ) - .await - { - Ok(Ok(val)) => ma = val, - _ => { - crate::utils::error_log::add_error( - crate::utils::error_log::codes::BATTERY_I2C_TIMEOUT, - ); - } + retry_ms = READ_INTERVAL_MS; + + let mv = mv as f64; + let soc = match soc as u8 { + 0 => bat_percentage(calculate(mv)), + soc => soc, }; let charging = ma >= 0; @@ -112,13 +99,18 @@ pub async fn battery_read_task(i2c: SharedI2C, state: crate::state::GlobalState) last_sent = Instant::now(); } - last_mv = mv; - last_ma = ma; - - Timer::after_millis(100).await; + Timer::after_millis(READ_INTERVAL_MS).await; } } +/// One gauge read; `None` on error or timeout. +async fn read(fut: impl core::future::Future>) -> Option { + embassy_time::with_timeout(embassy_time::Duration::from_millis(1500), fut) + .await + .ok()? + .ok() +} + fn interpolate(v1: f64, p1: u8, v2: f64, p2: u8, voltage: f64) -> u8 { let percentage = p1 as f64 + (voltage - v1) * (p2 as f64 - p1 as f64) / (v2 - v1); percentage as u8 From 2bf488d3d36cb1f6b30812025550e1de3bf4b659 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:13:42 +0200 Subject: [PATCH 026/132] refactor: static board resources, drop unused fields --- src/board.rs | 70 +++++++++++++++++------------------------ src/main.rs | 6 ++-- src/utils/shared_i2c.rs | 17 +++++----- 3 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/board.rs b/src/board.rs index 2de9ac5..91c7844 100644 --- a/src/board.rs +++ b/src/board.rs @@ -1,33 +1,32 @@ +#[cfg(any(feature = "v3", not(feature = "timer-func")))] +use esp_hal::gpio::{AnyPin, Pin}; use esp_hal::{ - gpio::{AnyPin, Input, InputConfig, Output, Pin, Pull}, - peripherals::{ - ADC1, AES, BT, FLASH, FROM_CPU_INTR0, Peripherals, SPI2, TIMG0, TIMG1, UART1, WIFI, - }, - rng::Rng, + gpio::{Input, InputConfig, Output, Pull}, + peripherals::{AES, BT, FLASH, FROM_CPU_INTR0, Peripherals, TIMG0, TIMG1, WIFI}, timer::timg::TimerGroup, }; #[cfg(feature = "v4")] use crate::utils::shared_i2c::SharedI2C; -#[allow(dead_code)] pub struct Board { // peripherals pub timg0: TimerGroup<'static, TIMG0<'static>>, pub timg1: TimerGroup<'static, TIMG1<'static>>, - pub rng: Rng, - pub uart1: UART1<'static>, - pub spi2: SPI2<'static>, - pub adc1: ADC1<'static>, + #[cfg(not(feature = "timer-func"))] + pub uart1: esp_hal::peripherals::UART1<'static>, pub wifi: WIFI<'static>, pub bt: BT<'static>, - pub spi_dma: esp_hal::peripherals::DMA_CH0<'static>, pub aes: AES<'static>, pub flash: FLASH<'static>, pub sw_interrupt: FROM_CPU_INTR0<'static>, // spi #[cfg(feature = "v3")] + pub spi2: esp_hal::peripherals::SPI2<'static>, + #[cfg(feature = "v3")] + pub spi_dma: esp_hal::peripherals::DMA_CH0<'static>, + #[cfg(feature = "v3")] pub miso: AnyPin<'static>, #[cfg(feature = "v3")] pub mosi: AnyPin<'static>, @@ -43,6 +42,7 @@ pub struct Board { #[cfg(feature = "v4")] pub buzzer: esp_hal::ledc::channel::Channel<'static, esp_hal::ledc::LowSpeed>, + #[cfg(not(feature = "timer-func"))] pub stackmat_rx: AnyPin<'static>, #[cfg(feature = "v4")] @@ -54,16 +54,14 @@ pub struct Board { #[cfg(feature = "v3")] pub battery: esp_hal::peripherals::GPIO2<'static>, #[cfg(feature = "v3")] + pub adc1: esp_hal::peripherals::ADC1<'static>, + #[cfg(feature = "v3")] pub button_input: Input<'static>, #[cfg(feature = "v3")] pub buttons_shifter: adv_shift_registers::wrappers::ShifterValue, #[cfg(feature = "v3")] pub lcd: adv_shift_registers::wrappers::ShifterValue, - - // usb pins - pub usb_dp: AnyPin<'static>, - pub usb_dm: AnyPin<'static>, } #[cfg(feature = "v4")] @@ -71,21 +69,12 @@ impl Board { pub fn init(peripherals: Peripherals) -> Board { let timg0 = TimerGroup::new(peripherals.TIMG0); let timg1 = TimerGroup::new(peripherals.TIMG1); - let rng = Rng::new(); - let uart1 = peripherals.UART1; - let spi2 = peripherals.SPI2; - let spi_dma = peripherals.DMA_CH0; - let adc1 = peripherals.ADC1; let wifi = peripherals.WIFI; let bt = peripherals.BT; let aes = peripherals.AES; let flash = peripherals.FLASH; let sw_interrupt = peripherals.FROM_CPU_INTR0; - let stackmat_rx = peripherals.GPIO20.degrade(); - let usb_dp = peripherals.GPIO19.degrade(); - let usb_dm = peripherals.GPIO18.degrade(); - let display_rst = Output::new( peripherals.GPIO7, esp_hal::gpio::Level::Low, @@ -104,7 +93,10 @@ impl Board { frequency: esp_hal::time::Rate::from_hz(2_500), }); } - let lstimer0: &'static _ = alloc::boxed::Box::leak(alloc::boxed::Box::new(lstimer0)); + static LSTIMER0: static_cell::StaticCell< + esp_hal::ledc::timer::Timer<'static, esp_hal::ledc::LowSpeed>, + > = static_cell::StaticCell::new(); + let lstimer0: &'static _ = LSTIMER0.init(lstimer0); let mut buzzer = ledc.channel::( esp_hal::ledc::channel::Number::Channel0, peripherals.GPIO4, @@ -171,11 +163,8 @@ impl Board { Board { timg0, timg1, - rng, - uart1, - spi2, - spi_dma, - adc1, + #[cfg(not(feature = "timer-func"))] + uart1: peripherals.UART1, wifi, bt, aes, @@ -186,14 +175,12 @@ impl Board { display_rst, buzzer, - stackmat_rx, + #[cfg(not(feature = "timer-func"))] + stackmat_rx: peripherals.GPIO20.degrade(), buttons: [b1, b2, b3, b4], #[cfg(feature = "timer-func")] pads: (pad1, pad2), - - usb_dp, - usb_dm, } } } @@ -206,7 +193,6 @@ impl Board { let timg0 = TimerGroup::new(peripherals.TIMG0); let timg1 = TimerGroup::new(peripherals.TIMG1); - let rng = Rng::new(); let uart1 = peripherals.UART1; let spi2 = peripherals.SPI2; let spi_dma = peripherals.DMA_CH0; @@ -222,8 +208,6 @@ impl Board { let mosi = peripherals.GPIO6.degrade(); let battery = peripherals.GPIO2; let stackmat_rx = peripherals.GPIO20.degrade(); - let usb_dp = peripherals.GPIO19.degrade(); - let usb_dm = peripherals.GPIO18.degrade(); let button_input = Input::new( peripherals.GPIO3, @@ -240,8 +224,13 @@ impl Board { shifter_latch_pin, 0, ); - let adv_shift_reg = alloc::boxed::Box::new(adv_shift_reg); - let adv_shift_reg = alloc::boxed::Box::leak(adv_shift_reg); + static SHIFT_REG: static_cell::StaticCell< + adv_shift_registers::AdvancedShiftRegister< + 2, + Output<'static>, + >, + > = static_cell::StaticCell::new(); + let adv_shift_reg = SHIFT_REG.init(adv_shift_reg); let mut backlight = adv_shift_reg.get_pin_mut(1, 1, false); _ = backlight.set_high(); @@ -255,7 +244,6 @@ impl Board { Board { timg0, timg1, - rng, uart1, spi2, spi_dma, @@ -277,8 +265,6 @@ impl Board { buttons_shifter, lcd, - usb_dp, - usb_dm, } } } diff --git a/src/main.rs b/src/main.rs index 87f376a..348a846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -180,7 +180,7 @@ async fn main(spawner: Spawner) { &spawner, "lcd_v4::lcd_task", lcd_v4::lcd_task( - board.i2c.clone(), + board.i2c, board.display_rst, global_state, &WIFI_SETUP_SIG, @@ -197,7 +197,7 @@ async fn main(spawner: Spawner) { spawn_task( &spawner, "battery_v4::battery_read_task", - battery_v4::battery_read_task(board.i2c.clone(), global_state), + battery_v4::battery_read_task(board.i2c, global_state), ); spawn_task( @@ -231,7 +231,7 @@ async fn main(spawner: Spawner) { "rfid::rfid_task", rfid::rfid_task( #[cfg(feature = "v4")] - board.i2c.clone(), + board.i2c, #[cfg(feature = "v4")] board.buzzer, #[cfg(feature = "v3")] diff --git a/src/utils/shared_i2c.rs b/src/utils/shared_i2c.rs index 30d6d81..532a6c0 100644 --- a/src/utils/shared_i2c.rs +++ b/src/utils/shared_i2c.rs @@ -1,10 +1,11 @@ -use alloc::rc::Rc; use embassy_sync::{blocking_mutex::raw::NoopRawMutex, mutex::Mutex}; use esp_hal::{Async, i2c::master::I2c}; -#[derive(Clone)] +type Bus = Mutex>; + +#[derive(Clone, Copy)] pub struct SharedI2C { - inner: Option>>>, + inner: Option<&'static Bus>, } impl embedded_hal::i2c::ErrorType for SharedI2C { @@ -17,7 +18,7 @@ impl embedded_hal_async::i2c::I2c for SharedI2C { address: u8, operations: &mut [embedded_hal::i2c::Operation<'_>], ) -> Result<(), Self::Error> { - let Some(ref i2c) = self.inner else { + let Some(i2c) = self.inner else { return Err(esp_hal::i2c::master::Error::Timeout); }; @@ -38,11 +39,9 @@ impl embedded_hal_async::i2c::I2c for SharedI2C { impl SharedI2C { pub fn new(i2c: Option>) -> Self { - match i2c { - Some(i2c) => SharedI2C { - inner: Some(Rc::new(Mutex::new(i2c))), - }, - None => SharedI2C { inner: None }, + static BUS: static_cell::StaticCell = static_cell::StaticCell::new(); + SharedI2C { + inner: i2c.and_then(|i2c| BUS.try_init(Mutex::new(i2c)).map(|bus| &*bus)), } } } From f0a12667f9d445825701e8f3151912210690ad6c Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:18:46 +0200 Subject: [PATCH 027/132] chore: size-optimized release profile --- .cargo/config.toml | 1 + Cargo.toml | 9 +++++++-- host-tests/Cargo.toml | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 7151a08..9f42c9c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,3 +12,4 @@ ESP_LOG="INFO" [unstable] build-std = ["alloc", "core"] +build-std-features = ["optimize_for_size"] diff --git a/Cargo.toml b/Cargo.toml index 290d620..fb9b101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,5 @@ +cargo-features = ["profile-rustflags"] + [package] name = "fkm-firmware" version = "0.1.0" @@ -8,7 +10,7 @@ edition = "2024" esp-backtrace = { version = "0.20.0", features = [ "panic-handler", "println", "custom-pre-backtrace", "custom-halt", "esp32c3" ] } esp-hal = { version = "1.2.2", features = [ "esp32c3" ] } esp-println = { version = "0.18.0", features = ["log-04", "esp32c3"] } -log = { version = "0.4.34" } +log = { version = "0.4.34", features = ["release_max_level_info"] } esp-radio = { version = "1.0.0-beta.1", features = ["coex", "esp32c3"] } esp-rtos = { version = "0.4.0", features = ["esp32c3", "embassy", "esp-radio"] } embassy-executor = { version = "0.10.0", features = ["nightly"] } @@ -88,8 +90,11 @@ debug = 2 debug-assertions = false incremental = false lto = 'fat' -opt-level = 's' +# size first; the radio and HAL drivers (I2C/SPI used by the display) stay at speed below +opt-level = 'z' overflow-checks = false +# panics keep their message; crash reports use the backtrace addresses (addr2line), not file:line +rustflags = ["-Zlocation-detail=none"] [profile.dev.package.esp-radio] opt-level = 3 diff --git a/host-tests/Cargo.toml b/host-tests/Cargo.toml index 60b05d0..e2d5107 100644 --- a/host-tests/Cargo.toml +++ b/host-tests/Cargo.toml @@ -14,3 +14,6 @@ serde-json-core = { version = "0.7.0", default-features = false } e2e = [] v3 = [] v4 = [] + +# standalone: the firmware manifest uses nightly-only cargo-features +[workspace] From b773ee4299833fdb8b971c7fe7bde34de8db9886 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:24:08 +0200 Subject: [PATCH 028/132] fix: ws watchdog heartbeat --- src/ws.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ws.rs b/src/ws.rs index 0be7995..41a7a8e 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -221,6 +221,7 @@ pub async fn ws_task( }; loop { + crate::watchdog::heartbeat(crate::watchdog::TaskId::Ws); crate::state::set_trust_server(false); crate::state::set_secure_rfid(false); crate::state::set_auto_setup(false); @@ -340,6 +341,8 @@ async fn run( }; loop { + // socket reads time out after 15 s, so this beats well within the watchdog window + crate::watchdog::heartbeat(crate::watchdog::TaskId::Ws); flush(&mut socket, &mut tx, &mut owed).await?; match select3(socket.read(rx.read_buf()), WAKE.wait(), wifi_conn_sig.wait()).await { From 0893eff44892291052225400daabe8f36d4a555d Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:20:27 +0200 Subject: [PATCH 029/132] refactor: shared screen model and no-alloc diffing display drivers --- host-tests/Cargo.lock | 77 ++ host-tests/Cargo.toml | 5 + host-tests/src/lib.rs | 6 +- src/lcd_v3.rs | 1126 ++++++++++---------------- src/lcd_v4.rs | 1510 +++++++++++------------------------ src/utils/lcd_abstract.rs | 170 ---- src/utils/lcd_resourcese.rs | 213 ----- src/utils/lcd_text.rs | 171 ++++ src/utils/mod.rs | 9 +- src/utils/oled_ui.rs | 555 +++++++++++++ src/utils/screen.rs | 148 ++++ src/utils/ssd1309.rs | 85 ++ src/utils/stackmat.rs | 16 - src/utils/text.rs | 23 + 14 files changed, 1957 insertions(+), 2157 deletions(-) delete mode 100644 src/utils/lcd_abstract.rs delete mode 100644 src/utils/lcd_resourcese.rs create mode 100644 src/utils/lcd_text.rs create mode 100644 src/utils/oled_ui.rs create mode 100644 src/utils/screen.rs create mode 100644 src/utils/ssd1309.rs diff --git a/host-tests/Cargo.lock b/host-tests/Cargo.lock index e1474ff..2c18a3b 100644 --- a/host-tests/Cargo.lock +++ b/host-tests/Cargo.lock @@ -2,22 +2,69 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "embedded-graphics" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8da660bb0c829b34a56a965490597f82a55e767b91f9543be80ce8ccb416fe" +dependencies = [ + "az", + "byteorder", + "embedded-graphics-core", + "float-cmp", + "micromath", +] + +[[package]] +name = "embedded-graphics-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95743bef3ff70fcba3930246c4e6872882bbea0dcc6da2ca860112e0cd4bd09f" +dependencies = [ + "az", + "byteorder", +] + [[package]] name = "fkm-host-tests" version = "0.0.0" dependencies = [ + "embedded-graphics", "heapless", + "profont", + "qrcodegen-no-heap", "serde", "serde-json-core", "serde_json", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "hash32" version = "0.3.1" @@ -49,6 +96,21 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromath" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -58,6 +120,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profont" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016681370a9dd6e7ddb4c1a959922fd59dc45e5ebaa5ff5b13090267898ced34" +dependencies = [ + "embedded-graphics", +] + +[[package]] +name = "qrcodegen-no-heap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c" + [[package]] name = "quote" version = "1.0.47" diff --git a/host-tests/Cargo.toml b/host-tests/Cargo.toml index e2d5107..6004ad3 100644 --- a/host-tests/Cargo.toml +++ b/host-tests/Cargo.toml @@ -9,11 +9,16 @@ serde = { version = "1", features = ["derive", "alloc"] } serde_json = "1" heapless = "0.9.3" serde-json-core = { version = "0.7.0", default-features = false } +embedded-graphics = "0.8.2" +profont = "0.7.0" +qrcodegen-no-heap = "1.8.1" [features] e2e = [] v3 = [] v4 = [] +timer-func = [] +qa = [] # standalone: the firmware manifest uses nightly-only cargo-features [workspace] diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index 59a38e3..703be83 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -10,8 +10,12 @@ pub mod bounded; pub mod ring_log; #[path = "../../src/utils/text.rs"] pub mod text; +#[path = "../../src/utils/lcd_text.rs"] +pub mod lcd_text; +#[path = "../../src/utils/oled_ui.rs"] +pub mod oled_ui; pub mod utils { - pub use crate::bounded; + pub use crate::{bounded, text}; } #[path = "../../src/structs.rs"] pub mod structs; diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 0f4f1dc..0b18f89 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -1,791 +1,491 @@ -use ag_lcd_async::LcdDisplay; -use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; -use embassy_time::{Delay, Duration, Instant, Timer}; -use embedded_hal::digital::OutputPin; -use embedded_hal_async::delay::DelayNs; - use crate::{ - consts::{ - DEEPER_SLEEP_AFTER_MS, INSPECTION_TIME_PLUS2, LCD_INSPECTION_FRAME_TIME, - SCROLL_TICKER_INVERVAL_MS, SLEEP_AFTER_MS, - }, - state::{ - GlobalState, MenuScene, Scene, SignaledGlobalStateInner, deeper_sleep_state, sleep_state, - }, + consts::{LCD_INSPECTION_FRAME_TIME, SCROLL_TICKER_INVERVAL_MS}, + state::{GlobalState, MenuScene, Scene, SignaledGlobalStateInner, sleep_state}, translations::{TranslationKey, get_translation, get_translation_params}, utils::{ - lcd_abstract::{LcdAbstract, PrintAlign}, - stackmat::ms_to_time_str, + error_log::{self, ErrorLogEntry}, + lcd_text::{Align, LcdText}, + screen::{Screen, screen}, + text::time_str, }, }; +use adv_shift_registers::wrappers::{ShifterPin, ShifterValue}; +use ag_lcd_async::LcdDisplay; +use core::fmt::Write; +use embassy_futures::select::{Either, Either3, select, select3}; +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; +use embassy_time::{Delay, Duration, Instant, Timer}; -pub async fn lcd_show_critical_code( - lcd_shifter: adv_shift_registers::wrappers::ShifterValue, - code: u8, -) { - let mut lcd = { - let bl_pin = lcd_shifter.get_pin_mut(1, true); - let rs_pin = lcd_shifter.get_pin_mut(2, true); - let en_pin = lcd_shifter.get_pin_mut(3, true); - let d4_pin = lcd_shifter.get_pin_mut(4, false); - let d5_pin = lcd_shifter.get_pin_mut(5, false); - let d6_pin = lcd_shifter.get_pin_mut(6, false); - let d7_pin = lcd_shifter.get_pin_mut(7, false); - LcdDisplay::new(rs_pin, en_pin, Delay) - .with_display(ag_lcd_async::Display::On) - .with_blink(ag_lcd_async::Blink::Off) - .with_cursor(ag_lcd_async::Cursor::Off) - .with_size(ag_lcd_async::Size::Dots5x8) - .with_cols(16) - .with_lines(ag_lcd_async::Lines::TwoLines) - .with_half_bus(d4_pin, d5_pin, d6_pin, d7_pin) - .with_backlight(bl_pin) - .build() - .await - }; +type Lcd = LcdDisplay; +type Text = heapless::String<96>; + +const SIGN_RESULT_MS: u64 = 300; + +async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { + let mut lcd = LcdDisplay::new( + lcd_shifter.get_pin_mut(2, true), + lcd_shifter.get_pin_mut(3, true), + Delay, + ) + .with_display(ag_lcd_async::Display::On) + .with_blink(ag_lcd_async::Blink::Off) + .with_cursor(ag_lcd_async::Cursor::Off) + .with_size(ag_lcd_async::Size::Dots5x8) + .with_cols(16) + .with_lines(ag_lcd_async::Lines::TwoLines) + .with_half_bus( + lcd_shifter.get_pin_mut(4, false), + lcd_shifter.get_pin_mut(5, false), + lcd_shifter.get_pin_mut(6, false), + lcd_shifter.get_pin_mut(7, false), + ) + .with_backlight(lcd_shifter.get_pin_mut(1, true)) + .build() + .await; lcd.clear().await; lcd.backlight_on(); + lcd +} + +/// Writes the changed characters to the LCD. +async fn show(text: &mut LcdText, lcd: &mut Lcd) { + let mut runs: heapless::Vec<(u8, u8, heapless::String<16>), 2> = heapless::Vec::new(); + text.changes(|row, col, s| _ = runs.push((row, col, s.try_into().unwrap_or_default()))); + for (row, col, s) in runs { + lcd.set_position(col, row).await; + lcd.print(&s).await; + } +} + +pub async fn lcd_show_critical_code(lcd_shifter: ShifterValue, code: u8) { + let mut lcd = new_lcd(lcd_shifter).await; + let mut line = Text::new(); + _ = write!(line, " E{code}"); lcd.set_position(0, 0).await; lcd.print(" CRITICAL ERROR ").await; lcd.set_position(0, 1).await; - lcd.print(&alloc::format!(" E{code}")).await; + lcd.print(&line).await; +} + +#[derive(Clone, Copy, PartialEq)] +enum Live { + /// nothing live: the scroll ticker (and sleep check) runs + Idle, + Signing, + WifiConnect, + Inspection(Instant), + Timer, + Update, +} + +#[derive(Default)] +struct Local { + timer_ms: Option, + progress: Option, + sign_result: Option<(bool, Instant)>, } #[embassy_executor::task] pub async fn lcd_task( - lcd_shifter: adv_shift_registers::wrappers::ShifterValue, + lcd_shifter: ShifterValue, global_state: GlobalState, wifi_setup_sig: &'static Signal, ) { - let mut lcd = { - let bl_pin = lcd_shifter.get_pin_mut(1, true); - let rs_pin = lcd_shifter.get_pin_mut(2, true); - let en_pin = lcd_shifter.get_pin_mut(3, true); - let d4_pin = lcd_shifter.get_pin_mut(4, false); - let d5_pin = lcd_shifter.get_pin_mut(5, false); - let d6_pin = lcd_shifter.get_pin_mut(6, false); - let d7_pin = lcd_shifter.get_pin_mut(7, false); - LcdDisplay::new(rs_pin, en_pin, Delay) - .with_display(ag_lcd_async::Display::On) - .with_blink(ag_lcd_async::Blink::Off) - .with_cursor(ag_lcd_async::Cursor::Off) - .with_size(ag_lcd_async::Size::Dots5x8) - .with_cols(16) - .with_lines(ag_lcd_async::Lines::TwoLines) - .with_half_bus(d4_pin, d5_pin, d6_pin, d7_pin) - .with_backlight(bl_pin) - .build() - .await - }; + let mut lcd = new_lcd(lcd_shifter).await; + let mut text = LcdText::new(); - lcd.clear().await; - lcd.backlight_on(); + let mut line = Text::new(); + _ = write!(line, "{:X}", crate::utils::get_efuse_u32()); + text.print(0, &line, Align::Left, true); + text.print(1, crate::version::VERSION, Align::Center, true); + show(&mut text, &mut lcd).await; - let mut lcd_driver: LcdAbstract<80, 16, 2, 3> = LcdAbstract::new(); - - _ = lcd_driver.print( - 0, - &alloc::format!("{:X}", crate::utils::get_efuse_u32()), - PrintAlign::Left, - true, - ); - _ = lcd_driver.print(1, crate::version::VERSION, PrintAlign::Center, true); - lcd_driver.display_on_lcd(&mut lcd).await; - - _ = lcd_driver.print( - 0, - &alloc::format!("{}%", global_state.show_battery.wait().await), - PrintAlign::Right, - false, - ); - lcd_driver.display_on_lcd(&mut lcd).await; + line.clear(); + _ = write!(line, "{}%", global_state.show_battery.wait().await); + text.print(0, &line, Align::Right, false); + show(&mut text, &mut lcd).await; #[cfg(not(feature = "bat_dev_lcd"))] Timer::after_millis(2500).await; - _ = lcd_driver.clear_all(); - let mut last_update; + let mut local = Local::default(); + let mut live = Live::Idle; + let mut last_update = Instant::now(); + let (mut state_changed, mut rerender) = (true, true); loop { - let current_state = global_state.state.value().await.clone(); - log::debug!("lcd current_state: {current_state:?}"); - last_update = Instant::now(); - - if sleep_state() { - lcd.backlight_on(); - - crate::state::set_sleep_state(false); + if state_changed { + state_changed = false; + rerender = true; + last_update = Instant::now(); + if sleep_state() { + lcd.backlight_on(); + crate::state::set_sleep_state(false); + } } - let current_scene = current_state.scene.clone(); - let fut = async { - let _ = process_lcd( - current_state, - &global_state, - &mut lcd_driver, - &mut lcd, - &wifi_setup_sig, - ) - .await; - lcd_driver.display_on_lcd(&mut lcd).await; - - let mut scroll_ticker = - embassy_time::Ticker::every(Duration::from_millis(SCROLL_TICKER_INVERVAL_MS)); - loop { - scroll_ticker.next().await; - let changed = lcd_driver.scroll_step(); - if changed.is_ok_and(|c| c) { - lcd_driver.display_on_lcd(&mut lcd).await; - } - - #[cfg(not(any(feature = "e2e", feature = "qa")))] - if !sleep_state() - && (Instant::now() - last_update).as_millis() > SLEEP_AFTER_MS - && current_scene.can_sleep() - { - _ = lcd_driver.print(0, "Sleep", PrintAlign::Center, true); - _ = lcd_driver.print(1, "Press any key", PrintAlign::Center, true); - lcd_driver.display_on_lcd(&mut lcd).await; - lcd.backlight_off(); - - { - global_state.state.lock().await.server_connected = Some(false); - } - - crate::state::set_sleep_state(true); - crate::state::set_trust_server(false); - - global_state.state.signal_reset(); - } - - #[cfg(not(any(feature = "e2e", feature = "qa")))] - if sleep_state() - && !deeper_sleep_state() - && (Instant::now() - last_update).as_millis() > DEEPER_SLEEP_AFTER_MS - { - _ = lcd_driver.print(0, "Deep Sleep", PrintAlign::Center, true); - _ = lcd_driver.print(1, "Press any key", PrintAlign::Center, true); - lcd_driver.display_on_lcd(&mut lcd).await; - crate::utils::deeper_sleep(); - } + if rerender { + rerender = false; + let mut state = global_state.state.lock().await; + if state.menu_scene == Some(MenuScene::BtDisplay) + && state.selected_bluetooth_item > state.discovered_bluetooth_devices.len() + 1 + { + state.selected_bluetooth_item = 0; } - }; + live = render(&state, &mut text, &local); + drop(state); + if live != Live::Timer { + local.timer_ms = None; + } + show(&mut text, &mut lcd).await; + } - let res = embassy_futures::select::select(fut, global_state.state.wait()).await; - match res { - embassy_futures::select::Either::First(_) => {} - embassy_futures::select::Either::Second(_) => { - continue; + match next_event(global_state, wifi_setup_sig, live, &local).await { + Event::State => state_changed = true, + Event::TimerValue(ms) => { + local.timer_ms = Some(ms); + text.print(0, &time_str(ms), Align::Center, true); + show(&mut text, &mut lcd).await; + } + Event::InspectionFrame(start) => { + let elapsed = start.elapsed().as_millis(); + crate::state::set_inspection_time(Some(elapsed)); + text.print(0, &time_str(elapsed), Align::Center, true); + show(&mut text, &mut lcd).await; + } + Event::Progress(p) => { + local.progress = Some(p); + rerender = true; + } + Event::SignResult(ok) => { + local.sign_result = Some((ok, Instant::now())); + rerender = true; + } + Event::SignResultEnd => { + local.sign_result = None; + rerender = true; + } + Event::WifiSetup => { + global_state.state.lock().await.scene = Scene::AutoSetupWait; + } + Event::ScrollTick => { + if text.scroll_step() { + show(&mut text, &mut lcd).await; + } + sleep_check(&mut text, &mut lcd, global_state, last_update).await; } } } } -async fn process_lcd( - current_state: SignaledGlobalStateInner, - global_state: &GlobalState, - lcd_driver: &mut LcdAbstract<80, 16, 2, 3>, - lcd: &mut LcdDisplay, +enum Event { + State, + TimerValue(u64), + InspectionFrame(Instant), + Progress(u8), + SignResult(bool), + SignResultEnd, + WifiSetup, + ScrollTick, +} + +async fn next_event( + gs: GlobalState, wifi_setup_sig: &Signal, -) -> Option<()> { - #[cfg(feature = "bat_dev_lcd")] - { - let battery_read = current_state.current_bat_read.unwrap_or(-1.0); - lcd_driver - .print( - 0, - &alloc::format!("BAT: {battery_read}"), - PrintAlign::Left, - true, - ) - .ok()?; - - if let Some(avg) = current_state.avg_bat_read { - lcd_driver - .print(1, &alloc::format!("AVG: {avg}"), PrintAlign::Left, true) - .ok()?; + live: Live, + local: &Local, +) -> Event { + let other = async { + match live { + Live::Timer => Event::TimerValue(gs.timer_signal.wait().await), + Live::Inspection(start) => { + Timer::after_millis(LCD_INSPECTION_FRAME_TIME).await; + Event::InspectionFrame(start) + } + Live::Update => Event::Progress(gs.update_progress.wait().await), + Live::WifiConnect => { + wifi_setup_sig.wait().await; + Event::WifiSetup + } + Live::Signing => { + let result_end = async { + match local.sign_result { + Some((_, at)) => Timer::at(at + Duration::from_millis(SIGN_RESULT_MS)).await, + None => core::future::pending().await, + } + }; + match select3(gs.sign_unsign_progress.wait(), result_end, scroll_tick()).await { + Either3::First(ok) => Event::SignResult(ok), + Either3::Second(()) => Event::SignResultEnd, + Either3::Third(()) => Event::ScrollTick, + } + } + Live::Idle => { + scroll_tick().await; + Event::ScrollTick + } } + }; - return Some(()); - } - - if let Some(error_text) = current_state.error_text { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::ERROR_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print(1, &error_text, PrintAlign::Center, true) - .ok()?; - - return Some(()); + match select(gs.state.wait(), other).await { + Either::First(()) => Event::State, + Either::Second(event) => event, } +} - // display custom message on top of everything! - if let Some((line1, line2)) = ¤t_state.custom_message { - _ = lcd_driver.print(0, line1, PrintAlign::Center, true); - _ = lcd_driver.print(1, line2, PrintAlign::Center, true); +async fn scroll_tick() { + Timer::after_millis(SCROLL_TICKER_INVERVAL_MS).await; +} - return Some(()); +/// Sleep after `SLEEP_AFTER_MS` without state changes, "deeper sleep" after +/// `DEEPER_SLEEP_AFTER_MS` (logic unchanged; WP5's power module takes it over later). +#[cfg(not(any(feature = "e2e", feature = "qa")))] +async fn sleep_check(text: &mut LcdText, lcd: &mut Lcd, global_state: GlobalState, last_update: Instant) { + use crate::consts::{DEEPER_SLEEP_AFTER_MS, SLEEP_AFTER_MS}; + + let idle_ms = last_update.elapsed().as_millis(); + let can_sleep = global_state.state.value().await.scene.can_sleep(); + if !sleep_state() && idle_ms > SLEEP_AFTER_MS && can_sleep { + text.print(0, "Sleep", Align::Center, true); + text.print(1, "Press any key", Align::Center, true); + show(text, lcd).await; + lcd.backlight_off(); + + global_state.state.lock().await.server_connected = Some(false); + crate::state::set_sleep_state(true); + crate::state::set_trust_server(false); + global_state.state.signal_reset(); } - if let Some(sel) = current_state.selected_config_menu { - lcd_driver.clear_all().ok()?; - lcd_driver.print(0, "<", PrintAlign::Left, false).ok()?; - lcd_driver.print(0, ">", PrintAlign::Right, false).ok()?; - - lcd_driver - .print(0, "Config Menu", PrintAlign::Center, false) - .ok()?; - - lcd_driver - .print( - 1, - &alloc::format!("{}. {}", sel + 1, crate::structs::CONFIG_MENU_ITEMS[sel]), - PrintAlign::Left, - true, - ) - .ok()?; - - return Some(()); + if sleep_state() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { + text.print(0, "Deep Sleep", Align::Center, true); + text.print(1, "Press any key", Align::Center, true); + show(text, lcd).await; + crate::utils::deeper_sleep(); } +} - match current_state.menu_scene { - Some(MenuScene::Signing) | Some(MenuScene::Unsigning) => { - let prefix = if current_state.menu_scene == Some(MenuScene::Signing) { - "S" - } else { - "Uns" - }; +#[cfg(any(feature = "e2e", feature = "qa"))] +async fn sleep_check(_: &mut LcdText, _: &mut Lcd, _: GlobalState, _: Instant) {} - lcd_driver.clear_all().ok()?; - lcd_driver - .print( - 0, - &alloc::format!("{prefix}igning | Submit To Exit"), - PrintAlign::Left, - true, - ) - .ok()?; - - if global_state.sign_unsign_progress.signaled() { - let status = if global_state.sign_unsign_progress.wait().await { - "OK" - } else { - "FAIL" - }; +fn tr(text: &mut LcdText, header: usize, footer: usize) { + text.print(0, &get_translation(header), Align::Center, true); + text.print(1, &get_translation(footer), Align::Center, true); +} - lcd_driver - .print( - 1, - &alloc::format!("Operation {}", status), - PrintAlign::Center, - true, - ) - .ok()?; - lcd_driver.display_on_lcd(lcd).await; - - Timer::after_millis(300).await; - lcd_driver - .print(1, "Scan the card", PrintAlign::Center, true) - .ok()?; - } else { - lcd_driver - .print(1, "Scan the card", PrintAlign::Center, true) - .ok()?; - } +/// Lays out both lines for the current state (no I/O, no heap). +fn render(s: &SignaledGlobalStateInner, text: &mut LcdText, local: &Local) -> Live { + text.clear(); + let mut line = Text::new(); - return Some(()); + #[cfg(feature = "bat_dev_lcd")] + { + _ = write!(line, "BAT: {}", s.current_bat_read.unwrap_or(-1.0)); + text.print(0, &line, Align::Left, true); + if let Some(avg) = s.avg_bat_read { + line.clear(); + _ = write!(line, "AVG: {avg}"); + text.print(1, &line, Align::Left, true); } - Some(crate::state::MenuScene::BtDisplay) => { - lcd_driver.clear_all().ok()?; - if current_state.selected_bluetooth_item - == current_state.discovered_bluetooth_devices.len() - { - lcd_driver - .print(0, "Unpair", PrintAlign::Center, true) - .ok()?; - } else if current_state.selected_bluetooth_item - == current_state.discovered_bluetooth_devices.len() + 1 - { - lcd_driver.print(0, "Exit", PrintAlign::Center, true).ok()?; - } else if current_state.selected_bluetooth_item - < current_state.discovered_bluetooth_devices.len() - { - if let Some(display_dev) = current_state - .discovered_bluetooth_devices - .get(current_state.selected_bluetooth_item) - { - lcd_driver - .print(0, &display_dev.name, PrintAlign::Center, true) - .ok()?; - - lcd_driver - .print( - 1, - &alloc::format!("{:x?}", display_dev.addr), - PrintAlign::Center, - true, - ) - .ok()?; + return Live::Idle; + } + + match screen(s) { + Screen::Error(error) => { + text.print(0, &get_translation(TranslationKey::ERROR_HEADER), Align::Center, true); + text.print(1, error, Align::Center, true); + } + Screen::Custom(l1, l2) => { + text.print(0, l1, Align::Center, true); + text.print(1, l2, Align::Center, true); + } + Screen::ConfigMenu(sel) => { + text.print(0, "<", Align::Left, false); + text.print(0, ">", Align::Right, false); + text.print(0, "Config Menu", Align::Center, false); + let item = crate::structs::CONFIG_MENU_ITEMS.get(sel).copied().unwrap_or(""); + _ = write!(line, "{}. {item}", sel + 1); + text.print(1, &line, Align::Left, true); + } + Screen::Signing(sign) => { + let title = if sign { "Signing" } else { "Unsigning" }; + _ = write!(line, "{title} | Submit To Exit"); + text.print(0, &line, Align::Left, true); + match local.sign_result { + Some((ok, at)) if at.elapsed().as_millis() < SIGN_RESULT_MS => { + let status = if ok { "Operation OK" } else { "Operation FAIL" }; + text.print(1, status, Align::Center, true); } - } else { - global_state.state.lock().await.selected_bluetooth_item = 0; + _ => text.print(1, "Scan the card", Align::Center, true), } - - return Some(()); + return Live::Signing; } - Some(MenuScene::ErrorLog) => { - lcd_driver.clear_all().ok()?; - - if let Some(entry_idx) = current_state.selected_error_log_entry { - if let Some(entry) = crate::utils::error_log::entry(entry_idx).as_ref() { - match entry { - crate::utils::error_log::ErrorLogEntry::Code { timestamp, code } => { - lcd_driver - .print(0, &alloc::format!("Error E{code}"), PrintAlign::Left, true) - .ok()?; - lcd_driver - .print( - 1, - &crate::utils::error_log::format_timestamp_full(*timestamp), - PrintAlign::Left, - true, - ) - .ok()?; - } - crate::utils::error_log::ErrorLogEntry::Stacktrace { - timestamp, .. - } => { - lcd_driver - .print(0, "Panic (view logs for details)", PrintAlign::Left, true) - .ok()?; - - lcd_driver - .print( - 1, - &crate::utils::error_log::format_timestamp_full(*timestamp), - PrintAlign::Left, - true, - ) - .ok()?; - } - } - } else { - lcd_driver - .print(0, "Invalid log entry", PrintAlign::Center, true) - .ok()?; - lcd_driver - .print(1, "Submit to return", PrintAlign::Center, true) - .ok()?; + Screen::BtDevices(sel) => { + let devices = &s.discovered_bluetooth_devices; + match devices.get(sel) { + Some(dev) => { + text.print(0, &dev.name, Align::Center, true); + _ = write!(line, "{:x?}", dev.addr); + text.print(1, &line, Align::Center, true); } + None if sel == devices.len() => text.print(0, "Unpair", Align::Center, true), + None => text.print(0, "Exit", Align::Center, true), + } + } + Screen::ErrorLogEntry { idx, .. } => match error_log::entry(idx) { + Some(ErrorLogEntry::Code { timestamp, code }) => { + _ = write!(line, "Error E{code}"); + text.print(0, &line, Align::Left, true); + text.print(1, &error_log::format_timestamp_full(timestamp), Align::Left, true); + } + Some(ErrorLogEntry::Stacktrace { timestamp, .. }) => { + text.print(0, "Panic (view logs for details)", Align::Left, true); + text.print(1, &error_log::format_timestamp_full(timestamp), Align::Left, true); + } + None => { + text.print(0, "Invalid log entry", Align::Center, true); + text.print(1, "Submit to return", Align::Center, true); + } + }, + Screen::ErrorLogList(sel) => { + if sel == error_log::entry_count() { + text.print(0, "Exit", Align::Center, true); + text.print(1, "Submit to return", Align::Center, true); + } else if let Some(entry) = error_log::entry(sel) { + text.print(0, &entry.list_label(), Align::Center, true); + text.print(1, "Submit for details", Align::Center, true); } else { - let exit_idx = crate::utils::error_log::entry_count(); - if current_state.selected_error_log_item == exit_idx { - lcd_driver.print(0, "Exit", PrintAlign::Center, true).ok()?; - lcd_driver - .print(1, "Submit to return", PrintAlign::Center, true) - .ok()?; - } else if let Some(entry) = - crate::utils::error_log::entry(current_state.selected_error_log_item) - { - lcd_driver - .print(0, &entry.list_label(), PrintAlign::Center, true) - .ok()?; - lcd_driver - .print(1, "Submit for details", PrintAlign::Center, true) - .ok()?; - } else { - lcd_driver - .print(0, "No entries", PrintAlign::Center, true) - .ok()?; - lcd_driver.print(1, "Exit", PrintAlign::Center, true).ok()?; - } + text.print(0, "No entries", Align::Center, true); + text.print(1, "Exit", Align::Center, true); } - - return Some(()); } - #[cfg(feature = "v4")] - Some(crate::state::MenuScene::BuzzerVolume) => {} - None => {} - } - - let overwritten = process_lcd_overwrite(¤t_state, global_state, lcd_driver).await; - if overwritten { - return Some(()); - } - - lcd_driver.clear_all().ok()?; - if let Some(time) = current_state.delegate_hold { - let delegate_remaining = 3 - time; - - if delegate_remaining == 0 { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::WAITING_FOR_DELEGATE_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print( - 1, - &get_translation(TranslationKey::WAITING_FOR_DELEGATE_FOOTER), - PrintAlign::Center, - true, - ) - .ok()?; - } else { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::CALLING_FOR_DELEGATE_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print( - 1, - &get_translation_params( - TranslationKey::CALLING_FOR_DELEGATE_FOOTER, - &[delegate_remaining], - ), - PrintAlign::Center, - true, - ) - .ok()?; + Screen::WifiLost => { + text.print(0, "Wi-Fi", Align::Center, true); + text.print(1, "Connection lost", Align::Center, true); } - - return Some(()); - } - - match current_state.scene { - Scene::WifiConnect => { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::WAITING_FOR_WIFI_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print( - 1, - &get_translation(TranslationKey::WAITING_FOR_WIFI_FOOTER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver.display_on_lcd(lcd).await; - wifi_setup_sig.wait().await; - global_state.state.lock().await.scene = Scene::AutoSetupWait; + Screen::ServerLost => tr( + text, + TranslationKey::SERVER_DISCONNECTED_HEADER, + TranslationKey::SERVER_DISCONNECTED_FOOTER, + ), + Screen::DeviceNotAdded => { + #[cfg(not(feature = "e2e"))] + tr( + text, + TranslationKey::DEVICE_NOT_ADDED_HEADER, + TranslationKey::DEVICE_NOT_ADDED_FOOTER, + ); + #[cfg(feature = "e2e")] + { + text.print(0, "Press submit", Align::Center, true); + text.print(1, "To start HIL", Align::Center, true); + } } - Scene::AutoSetupWait => { - let wifi_ssid = alloc::format!("FKM-{:X}", crate::utils::get_efuse_u32()); - lcd_driver - .print( - 0, - &get_translation(TranslationKey::WIFI_SETUP_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print(1, &wifi_ssid, PrintAlign::Center, true) - .ok()?; + Screen::StackmatLost => tr( + text, + TranslationKey::STACKMAT_DISCONNECTED_HEADER, + TranslationKey::STACKMAT_DISCONNECTED_FOOTER, + ), + Screen::Delegate(0) => tr( + text, + TranslationKey::WAITING_FOR_DELEGATE_HEADER, + TranslationKey::WAITING_FOR_DELEGATE_FOOTER, + ), + Screen::Delegate(remaining) => { + let header = get_translation(TranslationKey::CALLING_FOR_DELEGATE_HEADER); + text.print(0, &header, Align::Center, true); + let footer = + get_translation_params(TranslationKey::CALLING_FOR_DELEGATE_FOOTER, &[remaining]); + text.print(1, &footer, Align::Center, true); } - Scene::MdnsWait => { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::WAITING_FOR_MDNS_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - lcd_driver - .print( - 1, - &get_translation(TranslationKey::WAITING_FOR_MDNS_FOOTER), - PrintAlign::Center, - true, - ) - .ok()?; + Screen::WifiConnect => { + tr( + text, + TranslationKey::WAITING_FOR_WIFI_HEADER, + TranslationKey::WAITING_FOR_WIFI_FOOTER, + ); + return Live::WifiConnect; } - Scene::GroupSelect => { - lcd_driver.clear_all().ok()?; - lcd_driver.print(0, "<", PrintAlign::Left, false).ok()?; - lcd_driver.print(1, "<", PrintAlign::Left, false).ok()?; - lcd_driver.print(0, ">", PrintAlign::Right, false).ok()?; - lcd_driver.print(1, ">", PrintAlign::Right, false).ok()?; - - lcd_driver - .print( - 0, - &get_translation(TranslationKey::SELECT_GROUP), - PrintAlign::Center, - false, - ) - .ok()?; - - lcd_driver - .print( - 1, - ¤t_state.possible_groups[current_state.group_selected_idx].name, - PrintAlign::Center, - false, - ) - .ok()?; + Screen::AutoSetup => { + text.print(0, &get_translation(TranslationKey::WIFI_SETUP_HEADER), Align::Center, true); + _ = write!(line, "FKM-{:X}", crate::utils::get_efuse_u32()); + text.print(1, &line, Align::Center, true); } - Scene::WaitingForCompetitor => { - lcd_driver - .print( - 0, - &get_translation(TranslationKey::SCAN_COMPETITOR_CARD_HEADER), - PrintAlign::Center, - true, - ) - .ok()?; - - if let Some(solve_time) = current_state.solve_time { - let time_str = ms_to_time_str(solve_time); - lcd_driver - .print( - 1, - &get_translation_params( - TranslationKey::SCAN_COMPETITOR_CARD_WITH_TIME_FOOTER, - &[time_str], - ), - PrintAlign::Center, - true, - ) - .ok()?; - } else { - lcd_driver - .print( - 1, - &get_translation(TranslationKey::SCAN_COMPETITOR_CARD_FOOTER), - PrintAlign::Center, - true, - ) - .ok()?; + Screen::MdnsWait => tr( + text, + TranslationKey::WAITING_FOR_MDNS_HEADER, + TranslationKey::WAITING_FOR_MDNS_FOOTER, + ), + Screen::GroupSelect(name) => { + for row in 0..2 { + text.print(row, "<", Align::Left, false); + text.print(row, ">", Align::Right, false); } + text.print(0, &get_translation(TranslationKey::SELECT_GROUP), Align::Center, false); + text.print(1, name, Align::Center, false); } - Scene::CompetitorInfo => { - lcd_driver - .print( - 0, - current_state - .competitor_display - .as_deref() - .unwrap_or("??????"), - PrintAlign::Center, - true, - ) - .ok()?; - - if let Some(group) = current_state.solve_group { - lcd_driver - .print(1, &group.name, PrintAlign::Center, true) - .ok()?; + Screen::WaitingForCompetitor(solve_time) => { + let header = get_translation(TranslationKey::SCAN_COMPETITOR_CARD_HEADER); + text.print(0, &header, Align::Center, true); + let footer = match solve_time { + Some(time) => get_translation_params( + TranslationKey::SCAN_COMPETITOR_CARD_WITH_TIME_FOOTER, + &[time_str(time)], + ), + None => get_translation(TranslationKey::SCAN_COMPETITOR_CARD_FOOTER), + }; + text.print(1, &footer, Align::Center, true); + } + Screen::CompetitorInfo => { + let name = s.competitor_display.as_deref().unwrap_or("??????"); + text.print(0, name, Align::Center, true); + if let Some(ref group) = s.solve_group { + text.print(1, &group.name, Align::Center, true); } } - Scene::Inspection => { - let inspection_start = global_state - .state - .value() - .await - .inspection_start - .unwrap_or(Instant::now()); - - lcd_driver - .print(1, "Inspection", PrintAlign::Center, true) - .ok()?; - - loop { - let elapsed = (Instant::now() - inspection_start).as_millis(); - crate::state::set_inspection_time(Some(elapsed)); - - let time_str = ms_to_time_str(elapsed); - lcd_driver - .print(0, &time_str, PrintAlign::Center, true) - .ok()?; - - lcd_driver.display_on_lcd(lcd).await; - Timer::after_millis(LCD_INSPECTION_FRAME_TIME).await; + Screen::Inspection(start) => { + text.print(1, "Inspection", Align::Center, true); + text.print(0, &time_str(start.elapsed().as_millis()), Align::Center, true); + return Live::Inspection(start); + } + Screen::Timer => { + // keep the last value on re-render so unrelated state changes don't blank the time + if let Some(ms) = local.timer_ms { + text.print(0, &time_str(ms), Align::Center, true); } + return Live::Timer; } - Scene::Timer => loop { - let time = global_state.timer_signal.wait().await; - let time_str = ms_to_time_str(time); - lcd_driver - .print(0, &time_str, PrintAlign::Center, true) - .ok()?; - - lcd_driver.display_on_lcd(lcd).await; - }, - Scene::Finished => { - let solve_time = current_state.solve_time.unwrap_or(0); - let time_str = if solve_time > 0 { - ms_to_time_str(solve_time) - } else { - heapless::String::new() + Screen::Finished(fin) => { + let time = if fin.time > 0 { time_str(fin.time) } else { heapless::String::new() }; + _ = match fin.inspection { + Some(ms) => write!(line, "{time} ({}s)", ms / 1000), + None => write!(line, "{time}"), }; - - let inspection_time = - match (current_state.inspection_start, current_state.inspection_end) { - (Some(start), Some(end)) => { - Some(end.saturating_duration_since(start).as_millis()) - } - _ => None, - }; - - if current_state.use_inspection() - && inspection_time.unwrap_or(0) > INSPECTION_TIME_PLUS2 - { - let inspections_seconds = inspection_time.unwrap_or(0) / 1000; - lcd_driver - .print( - 0, - &alloc::format!("{time_str} ({inspections_seconds}s)"), - PrintAlign::Left, - true, - ) - .ok()?; - } else { - lcd_driver - .print(0, &time_str, PrintAlign::Left, true) - .ok()?; - } - - let penalty = current_state.penalty.unwrap_or(0); - let penalty_str = match penalty { - -2 => "DNS", - -1 => "DNF", - 1.. => &alloc::format!("+{penalty}"), - _ => "", + text.print(0, &line, Align::Left, true); + + line.clear(); + _ = match fin.penalty { + -2 => write!(line, "DNS"), + -1 => write!(line, "DNF"), + p @ 1.. => write!(line, "+{p}"), + _ => Ok(()), }; + text.print(0, &line, Align::Right, false); - lcd_driver - .print(0, penalty_str, PrintAlign::Right, false) - .ok()?; - - if !current_state.time_confirmed { - lcd_driver - .print( - 1, - &get_translation(TranslationKey::CONFIRM_TIME), - PrintAlign::Right, - true, - ) - .ok()?; - } else if current_state.current_judge.is_none() { - lcd_driver - .print( - 1, - &get_translation(TranslationKey::SCAN_JUDGE_CARD), - PrintAlign::Right, - true, - ) - .ok()?; - } else if current_state.current_competitor.is_some() - && current_state.current_judge.is_some() - { - lcd_driver - .print( - 1, - &get_translation(TranslationKey::SCAN_COMPETITOR_CARD), - PrintAlign::Right, - true, - ) - .ok()?; + if let Some(status) = fin.status { + text.print(1, &get_translation(status), Align::Right, true); } } - Scene::Update => { - _ = lcd_driver.print(0, "Updating...", PrintAlign::Center, true); - loop { - let progress = global_state.update_progress.wait().await; - _ = lcd_driver.print(1, &alloc::format!("{progress}%"), PrintAlign::Center, true); - - lcd_driver.display_on_lcd(lcd).await; + Screen::Update => { + text.print(0, "Updating...", Align::Center, true); + if let Some(p) = local.progress { + _ = write!(line, "{p}%"); + text.print(1, &line, Align::Center, true); } + return Live::Update; } } - Some(()) -} - -async fn process_lcd_overwrite( - current_state: &SignaledGlobalStateInner, - _global_state: &GlobalState, - lcd_driver: &mut LcdAbstract<80, 16, 2, 3>, -) -> bool { - if !current_state.scene.can_be_lcd_overwritten() { - return false; - } - - if current_state.server_connected == Some(false) { - if current_state.wifi_connected == Some(false) { - // TODO: maybe add to this translation - _ = lcd_driver.print(0, "Wi-Fi", PrintAlign::Center, true); - _ = lcd_driver.print(1, "Connection lost", PrintAlign::Center, true); - } else { - _ = lcd_driver.print( - 0, - &get_translation(TranslationKey::SERVER_DISCONNECTED_HEADER), - PrintAlign::Center, - true, - ); - _ = lcd_driver.print( - 1, - &get_translation(TranslationKey::SERVER_DISCONNECTED_FOOTER), - PrintAlign::Center, - true, - ); - } - } else if current_state.device_added == Some(false) { - #[cfg(not(feature = "e2e"))] - let lines = ( - &get_translation(TranslationKey::DEVICE_NOT_ADDED_HEADER), - &get_translation(TranslationKey::DEVICE_NOT_ADDED_FOOTER), - ); - - #[cfg(feature = "e2e")] - let lines = ("Press submit", "To start HIL"); - - _ = lcd_driver.print(0, lines.0, PrintAlign::Center, true); - _ = lcd_driver.print(1, lines.1, PrintAlign::Center, true); - } else if current_state.stackmat_connected == Some(false) { - _ = lcd_driver.print( - 0, - &get_translation(TranslationKey::STACKMAT_DISCONNECTED_HEADER), - PrintAlign::Center, - true, - ); - _ = lcd_driver.print( - 1, - &get_translation(TranslationKey::STACKMAT_DISCONNECTED_FOOTER), - PrintAlign::Center, - true, - ); - } else { - return false; - } - - true + Live::Idle } diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index 2e50ca0..95b66a8 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -1,1156 +1,582 @@ use crate::{ - consts::{ - DEEPER_SLEEP_AFTER_MS, INSPECTION_TIME_PLUS2, LCD_INSPECTION_FRAME_TIME, SLEEP_AFTER_MS, - }, + consts::LCD_INSPECTION_FRAME_TIME, state::{ - ErrorLogEntryStage, GlobalState, MenuScene, Scene, SignaledGlobalStateInner, - deeper_sleep_state, sleep_state, + ErrorLogEntryStage, GlobalState, MenuScene, Scene, SignaledGlobalStateInner, sleep_state, }, translations::{TranslationKey, get_translation, get_translation_params}, utils::{ - lcd_resourcese::{CrossedIcon, Resources}, + error_log::{self, ErrorLogEntry, codes}, + oled_ui::{self as ui, Frame, Line64, QrData, TopBar}, + screen::{Screen, screen}, shared_i2c::SharedI2C, - stackmat::ms_to_time_str, + ssd1309::Ssd1309, }, }; -use alloc::{ - format, - string::{String, ToString}, - vec::Vec, -}; -use anyhow::{Result, anyhow}; -use display_interface_i2c::I2CInterface; +use core::fmt::Write; +use embassy_futures::select::{Either, Either3, select, select3}; use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; -use embassy_time::{Delay, Instant, Timer}; -use embedded_graphics::{ - Drawable, - mono_font::MonoTextStyle, - pixelcolor::BinaryColor, - prelude::*, - primitives::{Line, PrimitiveStyle, Rectangle}, - text::{Alignment, Baseline, Text, TextStyle, TextStyleBuilder}, -}; -use embedded_graphics_framebuf::FrameBuf; -use embedded_layout::{ - layout::linear::{FixedMargin, Horizontal, LinearLayout}, - prelude::*, - view_group::ViewGroup, -}; -use embedded_text::{ - TextBox, - alignment::{HorizontalAlignment, VerticalAlignment}, - style::TextBoxStyleBuilder, -}; +use embassy_time::{Duration, Instant, Timer, with_timeout}; use esp_hal::gpio::Output; -use oled_async::{displays::ssd1309::Ssd1309_128_64, mode::GraphicsMode}; - -pub const FBUF_WIDTH: usize = 128; -pub const FBUF_HEIGHT: usize = 64; -pub const FBUF_SIZE: usize = FBUF_WIDTH * FBUF_HEIGHT; - -pub struct OledData<'a> { - pub fbuf: FrameBuf, - pub disp: GraphicsMode>, -} - -impl OledData<'_> { - pub async fn flush(&mut self) -> Result<()> { - self.disp - .draw_iter(&self.fbuf) - .map_err(|e| anyhow!("{e:?}"))?; - self.disp.flush().await.map_err(|e| anyhow!("{e:?}"))?; - - Ok(()) - } - - pub fn clear_main(&mut self) -> Result<()> { - self.fbuf.fill_solid(&MAIN_RECT, BinaryColor::Off); - Ok(()) - } -} - -pub const MAIN_RECT: Rectangle = Rectangle::new(Point::new(0, 11), Size::new(128, 53)); -pub const TOPBAR_RECT: Rectangle = Rectangle::new(Point::new(0, 0), Size::new(128, 10)); -pub const DETAILS_VISIBLE_LINES: usize = 5; -pub const NORMAL_FONT: MonoTextStyle<'_, BinaryColor> = MonoTextStyle::new( - &embedded_graphics::mono_font::ascii::FONT_7X13, - BinaryColor::On, -); -pub const SMALL_FONT: MonoTextStyle<'_, BinaryColor> = MonoTextStyle::new( - &embedded_graphics::mono_font::ascii::FONT_6X9, - BinaryColor::On, -); -pub const TIMER_FONT: MonoTextStyle<'_, BinaryColor> = - MonoTextStyle::new(&profont::PROFONT_14_POINT, BinaryColor::On); -pub const SMALL_TIMER_FONT: MonoTextStyle<'_, BinaryColor> = - MonoTextStyle::new(&profont::PROFONT_7_POINT, BinaryColor::On); -pub const TEXT_CENTER: TextStyle = TextStyleBuilder::new() - .alignment(Alignment::Center) - .baseline(Baseline::Middle) - .build(); -pub const TEXT_TOPBAR: TextStyle = TextStyleBuilder::new() - .alignment(Alignment::Right) - .baseline(Baseline::Top) - .build(); +pub use ui::DETAILS_VISIBLE_LINES; -fn center_screen( - content: VG, -) -> LinearLayout, VG> { - LinearLayout::horizontal(content) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange() - .align_to( - &Rectangle::new(Point::new(0, 0), Size::new(128, 64)), - embedded_layout::align::horizontal::Center, - embedded_layout::align::vertical::Center, - ) -} +type Text = heapless::String<256>; -fn center_layout( - content: VG, -) -> LinearLayout, VG> { - LinearLayout::horizontal(content) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange() - .align_to( - &MAIN_RECT, - embedded_layout::align::horizontal::Center, - embedded_layout::align::vertical::Center, - ) +/// Which live updates the shown screen needs besides state changes. +#[derive(Clone, Copy, PartialEq)] +enum Live { + /// nothing live: the 1 s sleep-check tick runs + Idle, + Signing, + WifiConnect, + Inspection(Instant), + Timer, + Update, } -fn center_text_layout(text: &str) -> TextBox<'_, MonoTextStyle<'_, BinaryColor>> { - let textbox_style = TextBoxStyleBuilder::new() - .alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Middle) - .build(); - - TextBox::with_textbox_style(text, MAIN_RECT, NORMAL_FONT, textbox_style) +/// Values that only the display keeps (they arrive as signals, not state). +#[derive(Default)] +struct Local { + /// last timer value of the current solve (Timer scene) + timer_ms: Option, + progress: u8, + sign_result: Option<(bool, Instant)>, } -fn draw_scrollable_menu(target: &mut D, items: &[&str], selected: usize) -where - D: DrawTarget, -{ - const LINE_HEIGHT: i32 = 10; - const VISIBLE: usize = 5; - const PADDING_X: i32 = 4; - - let menu_font = MonoTextStyle::new( - &embedded_graphics::mono_font::ascii::FONT_6X9, - BinaryColor::On, - ); - let menu_font_inv = MonoTextStyle::new( - &embedded_graphics::mono_font::ascii::FONT_6X9, - BinaryColor::Off, - ); - - let total = items.len(); - let scroll_start = if selected + 1 >= VISIBLE { - (selected + 1 - VISIBLE).min(total.saturating_sub(VISIBLE)) - } else { - 0 - }; +const SIGN_RESULT_MS: u64 = 300; - let start_y = MAIN_RECT.top_left.y; - - for (row, item) in items[scroll_start..].iter().take(VISIBLE).enumerate() { - let item_idx = scroll_start + row; - let y = start_y + row as i32 * LINE_HEIGHT; - let text_y = y + LINE_HEIGHT / 2; - - if item_idx == selected { - let bar = Rectangle::new(Point::new(0, y), Size::new(128, LINE_HEIGHT as u32)); - bar.into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) - .draw(target) - .ok(); - Text::with_text_style( - item, - Point::new(PADDING_X, text_y), - menu_font_inv, - TextStyleBuilder::new() - .alignment(Alignment::Left) - .baseline(Baseline::Middle) - .build(), - ) - .draw(target) - .ok(); - } else { - Text::with_text_style( - item, - Point::new(PADDING_X, text_y), - menu_font, - TextStyleBuilder::new() - .alignment(Alignment::Left) - .baseline(Baseline::Middle) - .build(), - ) - .draw(target) - .ok(); - } - - if scroll_start > 0 && row == 0 { - Text::with_text_style("^", Point::new(122, text_y), menu_font, TEXT_CENTER) - .draw(target) - .ok(); - } - if scroll_start + VISIBLE < total && row == VISIBLE - 1 { - Text::with_text_style("v", Point::new(122, text_y), menu_font, TEXT_CENTER) - .draw(target) - .ok(); +async fn flush(oled: &mut Ssd1309) { + match with_timeout(Duration::from_millis(1500), oled.flush()).await { + Ok(Ok(())) => {} + Ok(Err(e)) => log::error!("OLED flush error: {e:?}"), + Err(_) => { + oled.frame.invalidate(); + log::error!("OLED FLUSH TIMEOUT"); + error_log::add_error(codes::LCD_FLUSH_TIMEOUT); } } } -fn draw_scrollable_details_text(target: &mut D, text: &str, scroll: usize) -where - D: DrawTarget, -{ - const LINE_HEIGHT: i32 = 10; - const VISIBLE: usize = DETAILS_VISIBLE_LINES; - const PADDING_X: i32 = 2; - - let font = MonoTextStyle::new( - &embedded_graphics::mono_font::ascii::FONT_6X9, - BinaryColor::On, - ); - - let lines: alloc::vec::Vec<&str> = text.split('\n').collect(); - let total = lines.len(); - let start_y = MAIN_RECT.top_left.y; - - for (row, line) in lines.iter().skip(scroll).take(VISIBLE).enumerate() { - let y = start_y + row as i32 * LINE_HEIGHT; - let text_y = y + LINE_HEIGHT / 2; - Text::with_text_style( - line, - Point::new(PADDING_X, text_y), - font, - TextStyleBuilder::new() - .alignment(Alignment::Left) - .baseline(Baseline::Middle) - .build(), - ) - .draw(target) - .ok(); - } - - if scroll > 0 { - Text::with_text_style( - "^", - Point::new(122, start_y + LINE_HEIGHT / 2), - font, - TEXT_CENTER, - ) - .draw(target) - .ok(); - } - if scroll + VISIBLE < total { - let last_row_y = start_y + (VISIBLE as i32 - 1) * LINE_HEIGHT + LINE_HEIGHT / 2; - Text::with_text_style("v", Point::new(122, last_row_y), font, TEXT_CENTER) - .draw(target) - .ok(); - } -} - -enum QrCodeData<'a> { - Text(&'a str), - Binary(&'a [u8]), -} - -fn draw_qr_code(display: &mut D, data: QrCodeData<'_>, module_size: u32) -> Result<(), D::Error> -where - D: DrawTarget, -{ - let max_version = match module_size { - 1 => qrcodegen_no_heap::Version::new(6), - 2 => qrcodegen_no_heap::Version::new(3), - 3 => qrcodegen_no_heap::Version::new(2), - _ => qrcodegen_no_heap::Version::new(1), - }; - - let mut outbuffer = [0u8; qrcodegen_no_heap::Version::new(6).buffer_len()]; - let mut tempbuffer = [0u8; qrcodegen_no_heap::Version::new(6).buffer_len()]; - - let qr = match data { - QrCodeData::Text(text) => qrcodegen_no_heap::QrCode::encode_text( - text, - &mut tempbuffer, - &mut outbuffer, - qrcodegen_no_heap::QrCodeEcc::Quartile, - qrcodegen_no_heap::Version::MIN, - max_version, - None, - true, - ), - QrCodeData::Binary(data) => { - tempbuffer[..data.len()].copy_from_slice(data); - qrcodegen_no_heap::QrCode::encode_binary( - &mut tempbuffer, - data.len(), - &mut outbuffer, - qrcodegen_no_heap::QrCodeEcc::Quartile, - qrcodegen_no_heap::Version::MIN, - max_version, - None, - true, - ) - } - }; - - if let Ok(qr) = qr { - let filled = PrimitiveStyle::with_fill(BinaryColor::On); - - let qr_pixel_size = qr.size() as u32 * module_size; - let origin = Point::new( - ((128 - qr_pixel_size) / 2) as i32, - ((64 - qr_pixel_size) / 2) as i32, - ); - - for y in 0..qr.size() { - for x in 0..qr.size() { - if qr.get_module(x, y) { - let top_left = - origin + Point::new(x * module_size as i32, y * module_size as i32); - Rectangle::new(top_left, Size::new(module_size, module_size)) - .into_styled(filled) - .draw(display)?; - } - } - } - } - - Ok(()) -} - -pub async fn lcd_show_critical_code(i2c: SharedI2C, mut display_rst: Output<'static>, code: u8) { - let di = display_interface_i2c::I2CInterface::new(i2c, 0x3C, 0x40); - let raw_disp = - oled_async::builder::Builder::new(oled_async::displays::ssd1309::Ssd1309_128_64 {}) - .with_rotation(oled_async::prelude::DisplayRotation::Rotate180) - .connect(di); - - let mut disp: oled_async::mode::GraphicsMode<_, _> = raw_disp.into(); - disp.reset(&mut display_rst, &mut Delay); - - let disp_init = async { - disp.init().await?; - disp.clear(); - disp.flush().await?; - - anyhow::Result::<(), display_interface::DisplayError>::Ok(()) - } - .await; - - if let Err(e) = disp_init { +async fn init(i2c: SharedI2C, mut display_rst: Output<'static>) -> Ssd1309 { + let mut oled = Ssd1309::new(i2c); + if let Err(e) = oled.init(&mut display_rst).await { log::error!("Disp init error: {e:?} (but continuing i guess)"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED); + error_log::add_error(codes::LCD_INIT_FAILED); } + flush(&mut oled).await; // clear the panel's power-up content + oled +} - let text = format!("CRITICAL ERROR\nCODE: {code}",); - let text = Text::with_text_style(&text, Point::zero(), NORMAL_FONT, TEXT_CENTER); - - _ = center_layout(Chain::new(text)).draw(&mut disp); - _ = disp.flush().await; +pub async fn lcd_show_critical_code(i2c: SharedI2C, display_rst: Output<'static>, code: u8) { + let mut oled = init(i2c, display_rst).await; + let mut text = Text::new(); + _ = write!(text, "CRITICAL ERROR\nCODE: {code}"); + ui::centered_main(&mut oled.frame, &text); + flush(&mut oled).await; } #[embassy_executor::task] pub async fn lcd_task( i2c: SharedI2C, - mut display_rst: Output<'static>, + display_rst: Output<'static>, global_state: GlobalState, wifi_setup_sig: &'static Signal, ) { - let di = display_interface_i2c::I2CInterface::new(i2c, 0x3C, 0x40); - let raw_disp = - oled_async::builder::Builder::new(oled_async::displays::ssd1309::Ssd1309_128_64 {}) - .with_rotation(oled_async::prelude::DisplayRotation::Rotate180) - .connect(di); - - let mut disp: oled_async::mode::GraphicsMode<_, _> = raw_disp.into(); - disp.reset(&mut display_rst, &mut Delay); - - let disp_init = async { - disp.init().await?; - disp.clear(); - disp.flush().await?; - - anyhow::Result::<(), display_interface::DisplayError>::Ok(()) - } - .await; - - if let Err(e) = disp_init { - log::error!("Disp init error: {e:?} (but continuing i guess)"); - crate::utils::error_log::add_error(crate::utils::error_log::codes::LCD_INIT_FAILED); - } - - let mut data = alloc::vec![embedded_graphics::pixelcolor::BinaryColor::Off; FBUF_SIZE]; - let Some(data): Option<&mut [BinaryColor; FBUF_SIZE]> = data.as_mut_array() else { - log::error!("Disp framebuffer data alloc failed!"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::LCD_FRAMEBUFFER_ALLOC_FAILED, - ); - return; - }; - let fbuf = embedded_graphics_framebuf::FrameBuf::new(data, FBUF_WIDTH, FBUF_HEIGHT); - - let mut oled = OledData { fbuf, disp }; + let mut oled = init(i2c, display_rst).await; global_state.show_battery.wait().await; - _ = process_top_bar( - &global_state.state.value().await.clone(), - &global_state, - &mut oled, - ) - .await; - - let text = format!( + top_bar(&mut oled.frame, &*global_state.state.value().await); + let mut text = Text::new(); + _ = write!( + text, "S/N: {:X}\nVER: {}", crate::utils::get_efuse_u32(), crate::version::VERSION ); - let text = Text::with_text_style(&text, Point::zero(), NORMAL_FONT, TEXT_CENTER); - - center_layout(Chain::new(text)).draw(&mut oled.fbuf); - _ = oled.flush().await; - + ui::centered_main(&mut oled.frame, &text); + flush(&mut oled).await; Timer::after_millis(2500).await; - let mut last_update; + let mut local = Local::default(); + let mut live = Live::Idle; + let mut last_update = Instant::now(); + let (mut state_changed, mut rerender) = (true, true); loop { - let current_state = global_state.state.value().await.clone(); - log::debug!("lcd current_state: {current_state:?}"); - last_update = Instant::now(); + if state_changed { + state_changed = false; + rerender = true; + last_update = Instant::now(); + if sleep_state() { + crate::state::set_sleep_state(false); + log::warn!("Sleep wakeup!"); + } + } - if sleep_state() { - crate::state::set_sleep_state(false); - log::warn!("Sleep wakeup!"); + if rerender { + rerender = false; + let mut state = global_state.state.lock().await; + clamp_selection(&mut state); + live = render(&state, &mut oled.frame, &local); + drop(state); + if live != Live::Timer { + local.timer_ms = None; + } + flush(&mut oled).await; } - let current_scene = current_state.scene.clone(); - let fut = async { - oled.fbuf.clear(BinaryColor::Off); - _ = process_top_bar(¤t_state, &global_state, &mut oled).await; - _ = process_main(¤t_state, &global_state, &wifi_setup_sig, &mut oled).await; - if embassy_time::with_timeout(embassy_time::Duration::from_millis(1500), oled.flush()) - .await - .is_err() - { - log::error!("OLED FLUSH TIMEOUT"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ); + match next_event(global_state, wifi_setup_sig, live, &local).await { + Event::State => state_changed = true, + Event::TimerValue(ms) => { + local.timer_ms = Some(ms); + ui::digits(&mut oled.frame, ms); + flush(&mut oled).await; + } + Event::InspectionFrame(start) => { + let elapsed = start.elapsed().as_millis(); + crate::state::set_inspection_time(Some(elapsed)); + ui::digits(&mut oled.frame, elapsed); + flush(&mut oled).await; + } + Event::Progress(p) => { + local.progress = p; + rerender = true; } + Event::SignResult(ok) => { + local.sign_result = Some((ok, Instant::now())); + rerender = true; + } + Event::SignResultEnd => { + local.sign_result = None; + rerender = true; + } + Event::WifiSetup => { + global_state.state.lock().await.scene = Scene::AutoSetupWait; + } + Event::SleepTick => sleep_check(&mut oled, global_state, last_update).await, + } + } +} - loop { - Timer::after_millis(1000).await; - if global_state.show_battery.signaled() { - let state_snapshot = global_state.state.value().await.clone(); - let qr_open = state_snapshot.menu_scene == Some(MenuScene::ErrorLog) - && state_snapshot.error_log_entry_stage == Some(ErrorLogEntryStage::Qr); - if qr_open { - continue; - } +enum Event { + State, + TimerValue(u64), + InspectionFrame(Instant), + Progress(u8), + SignResult(bool), + SignResultEnd, + WifiSetup, + SleepTick, +} - oled.fbuf.fill_solid(&TOPBAR_RECT, BinaryColor::Off); - _ = process_top_bar(&state_snapshot, &global_state, &mut oled).await; - if embassy_time::with_timeout( - embassy_time::Duration::from_millis(1500), - oled.flush(), - ) - .await - .is_err() - { - log::error!("OLED FLUSH TIMEOUT"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ); +async fn next_event( + gs: GlobalState, + wifi_setup_sig: &Signal, + live: Live, + local: &Local, +) -> Event { + let other = async { + match live { + Live::Timer => Event::TimerValue(gs.timer_signal.wait().await), + Live::Inspection(start) => { + Timer::after_millis(LCD_INSPECTION_FRAME_TIME).await; + Event::InspectionFrame(start) + } + Live::Update => Event::Progress(gs.update_progress.wait().await), + Live::WifiConnect => { + wifi_setup_sig.wait().await; + Event::WifiSetup + } + Live::Signing => { + let result_end = async { + match local.sign_result { + Some((_, at)) => Timer::at(at + Duration::from_millis(SIGN_RESULT_MS)).await, + None => core::future::pending().await, } - - global_state.show_battery.reset(); + }; + match select3(gs.sign_unsign_progress.wait(), result_end, sleep_tick()).await { + Either3::First(ok) => Event::SignResult(ok), + Either3::Second(()) => Event::SignResultEnd, + Either3::Third(()) => Event::SleepTick, } + } + Live::Idle => { + sleep_tick().await; + Event::SleepTick + } + } + }; - #[cfg(not(any(feature = "e2e", feature = "qa")))] - if !sleep_state() - && (Instant::now() - last_update).as_millis() > SLEEP_AFTER_MS - && current_scene.can_sleep() - { - oled.fbuf.clear(BinaryColor::Off); - let text = - Text::with_text_style("Sleep", Point::zero(), SMALL_FONT, TEXT_CENTER); - - center_screen(Chain::new(text)).draw(&mut oled.fbuf); - if embassy_time::with_timeout( - embassy_time::Duration::from_millis(1500), - oled.flush(), - ) - .await - .is_err() - { - log::error!("OLED FLUSH TIMEOUT"); - crate::utils::error_log::add_error( - crate::utils::error_log::codes::LCD_FLUSH_TIMEOUT, - ); - } + match select(gs.state.wait(), other).await { + Either::First(()) => Event::State, + Either::Second(event) => event, + } +} - { - let mut state = global_state.state.lock().await; - state.server_connected = Some(false); - state.wifi_connected = Some(false); - } +async fn sleep_tick() { + Timer::after_millis(1000).await; +} - crate::state::set_sleep_state(true); - crate::state::set_trust_server(false); +/// Sleep after `SLEEP_AFTER_MS` without state changes, deep sleep after `DEEPER_SLEEP_AFTER_MS`. +#[cfg(not(any(feature = "e2e", feature = "qa")))] +async fn sleep_check(oled: &mut Ssd1309, global_state: GlobalState, last_update: Instant) { + use crate::consts::{DEEPER_SLEEP_AFTER_MS, SLEEP_AFTER_MS}; - global_state.state.signal_reset(); - } + let idle_ms = last_update.elapsed().as_millis(); + let can_sleep = global_state.state.value().await.scene.can_sleep(); + if !sleep_state() && idle_ms > SLEEP_AFTER_MS && can_sleep { + enter_sleep(oled, global_state).await; + } + if sleep_state() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { + deep_sleep(oled).await; + } +} - #[cfg(not(any(feature = "e2e", feature = "qa")))] - if sleep_state() - && !deeper_sleep_state() - && (Instant::now() - last_update).as_millis() > DEEPER_SLEEP_AFTER_MS - { - use esp_hal::gpio::{Event, Input, InputConfig, Pull, WakeupConfig}; - use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; +#[cfg(any(feature = "e2e", feature = "qa"))] +async fn sleep_check(_: &mut Ssd1309, _: GlobalState, _: Instant) {} - oled.fbuf.clear(BinaryColor::Off); - let text = - Text::with_text_style("Sleep", Point::zero(), SMALL_FONT, TEXT_CENTER); +/// Sleep screen + connection flags (logic unchanged; WP5's power module takes it over later). +#[cfg(not(any(feature = "e2e", feature = "qa")))] +async fn enter_sleep(oled: &mut Ssd1309, global_state: GlobalState) { + oled.frame.clear(); + ui::centered_screen(&mut oled.frame, "Sleep", ui::SMALL_FONT); + flush(oled).await; - center_screen(Chain::new(text)).draw(&mut oled.fbuf); - _ = oled.flush().await; + { + let mut state = global_state.state.lock().await; + state.server_connected = Some(false); + state.wifi_connected = Some(false); + } - unsafe { - // pull-down like the old RtcioWakeupSource did for a high-level wake - let input_config = InputConfig::default().with_pull(Pull::Down); - let wakeup_config = WakeupConfig::default().with_low_power_path(true); - let mut wakeup_pins = [ - Input::new(esp_hal::peripherals::GPIO0::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO1::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO2::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO3::steal(), input_config), - ]; - for pin in &mut wakeup_pins { - pin.apply_wakeup_config(&wakeup_config).unwrap(); - pin.listen(Event::HighLevel); - } - LowPower::new(esp_hal::peripherals::LPWR::steal()) - .sleep_deep(RtcSleepConfig::deep()); - } - } - } - }; + crate::state::set_sleep_state(true); + crate::state::set_trust_server(false); + global_state.state.signal_reset(); +} - let res = embassy_futures::select::select(fut, global_state.state.wait()).await; - match res { - embassy_futures::select::Either::First(_) => {} - embassy_futures::select::Either::Second(_) => { - continue; +/// Deep sleep until a button (GPIO0-3) goes high; returns only if the wakeup can't be armed. +#[cfg(not(any(feature = "e2e", feature = "qa")))] +async fn deep_sleep(oled: &mut Ssd1309) { + use esp_hal::gpio::{Event, Input, InputConfig, Pull, WakeupConfig}; + use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; + + oled.frame.clear(); + ui::centered_screen(&mut oled.frame, "Sleep", ui::SMALL_FONT); + flush(oled).await; + + // SAFETY: the buttons task is idle while sleeping and the chip never returns from deep sleep + unsafe { + // pull-down like the old RtcioWakeupSource did for a high-level wake + let input_config = InputConfig::default().with_pull(Pull::Down); + let wakeup_config = WakeupConfig::default().with_low_power_path(true); + let mut wakeup_pins = [ + Input::new(esp_hal::peripherals::GPIO0::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO1::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO2::steal(), input_config), + Input::new(esp_hal::peripherals::GPIO3::steal(), input_config), + ]; + for pin in &mut wakeup_pins { + if let Err(e) = pin.apply_wakeup_config(&wakeup_config) { + // without a wake source the chip would never wake up again + log::error!("Deep sleep wakeup config failed: {e:?}"); + return; } + pin.listen(Event::HighLevel); } + LowPower::new(esp_hal::peripherals::LPWR::steal()).sleep_deep(RtcSleepConfig::deep()); } } -fn battery_layout( - content: VG, -) -> LinearLayout, VG> { - LinearLayout::horizontal(content) - .with_alignment(embedded_layout::align::vertical::Center) - .with_spacing(FixedMargin(1)) - .arrange() - .align_to( - &Rectangle::new(Point::new(0, 0), Size::new(128, 10)), - embedded_layout::align::horizontal::Right, - embedded_layout::align::vertical::Center, - ) -} - -fn topbar_icons_layout( - content: VG, -) -> LinearLayout, VG> { - LinearLayout::horizontal(content) - .with_alignment(embedded_layout::align::vertical::Center) - .with_spacing(FixedMargin(2)) - .arrange() - .align_to( - &Rectangle::new(Point::new(0, 0), Size::new(128, 10)), - embedded_layout::align::horizontal::Left, - embedded_layout::align::vertical::Center, - ) -} - -async fn process_top_bar( - current_state: &SignaledGlobalStateInner, - _global_state: &GlobalState, - oled: &mut OledData<'_>, -) -> Result<()> { - let text = format!("{}%", current_state.battery_status.0); - let text = Text::with_text_style(&text, Point::zero(), NORMAL_FONT, TEXT_TOPBAR); - if current_state.battery_status.1 { - battery_layout(Chain::new(Resources::CHARGING).append(text)).draw(&mut oled.fbuf)?; - } else { - battery_layout(Chain::new(text)).draw(&mut oled.fbuf)?; +/// Out-of-range menu selections reset to the first item (was done mid-render before). +fn clamp_selection(state: &mut crate::utils::signaled_mutex::SignaledMutexGuard<'_, CriticalSectionRawMutex, SignaledGlobalStateInner>) { + let error_log_items = error_log::entry_count() + 1; + if state.menu_scene == Some(MenuScene::ErrorLog) + && state.selected_error_log_entry.is_none() + && state.selected_error_log_item >= error_log_items + { + state.selected_error_log_item = 0; } + let bt_items = state.discovered_bluetooth_devices.len() + 2; + if state.menu_scene == Some(MenuScene::BtDisplay) && state.selected_bluetooth_item >= bt_items { + state.selected_bluetooth_item = 0; + } +} - let topbar_chain = Chain::new(CrossedIcon::new( - Resources::WIFI, - !current_state.wifi_connected.unwrap_or(false), - 9, - )) - .append(CrossedIcon::new( - Resources::SERVER, - !current_state.server_connected.unwrap_or(false), - 9, - )); - - #[cfg(not(feature = "timer-func"))] - let topbar_chain = topbar_chain.append(CrossedIcon::new( - Resources::TIMER, - !current_state.stackmat_connected.unwrap_or(false), - 9, - )); - - topbar_icons_layout(topbar_chain).draw(&mut oled.fbuf)?; - - let text = if current_state.selected_config_menu.is_some() { +fn top_bar(f: &mut Frame, s: &SignaledGlobalStateInner) { + let title = if s.selected_config_menu.is_some() { Some("CONFIG") - } else if let Some(ref menu_scene) = current_state.menu_scene { - match menu_scene { - MenuScene::Signing => Some("SIGN"), - MenuScene::Unsigning => Some("UNSIGN"), - MenuScene::BtDisplay => Some("BTDISP"), - MenuScene::ErrorLog => Some("ERRLOG"), - MenuScene::BuzzerVolume => Some("BUZZER"), - } - } else if let Some(ref group) = current_state.solve_group - && current_state.scene == Scene::CompetitorInfo + } else if let Some(ref menu_scene) = s.menu_scene { + Some(match menu_scene { + MenuScene::Signing => "SIGN", + MenuScene::Unsigning => "UNSIGN", + MenuScene::BtDisplay => "BTDISP", + MenuScene::ErrorLog => "ERRLOG", + MenuScene::BuzzerVolume => "BUZZER", + }) + } else if let Some(ref group) = s.solve_group + && s.scene == Scene::CompetitorInfo { Some(group.name.as_str()) } else { None }; - if let Some(text) = text { - Text::with_text_style(text, Point::new(64, 5), SMALL_FONT, TEXT_CENTER) - .draw(&mut oled.fbuf)?; - } - - Line::new(Point::new(0, 10), Point::new(128, 10)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(&mut oled.fbuf)?; - Ok(()) + ui::top_bar( + f, + &TopBar { + battery: s.battery_status, + wifi: s.wifi_connected.unwrap_or(false), + server: s.server_connected.unwrap_or(false), + stackmat: s.stackmat_connected.unwrap_or(false), + title, + }, + ); } -async fn process_main( - current_state: &SignaledGlobalStateInner, - global_state: &GlobalState, - wifi_setup_sig: &Signal, - oled: &mut OledData<'_>, -) -> Result<()> { - if let Some(ref error_text) = current_state.error_text { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::ERROR_HEADER), - error_text - )) - .draw(&mut oled.fbuf)?; - - return Ok(()); - } +/// `a\nb` into a stack buffer. +fn two_lines(a: &str, b: &str) -> Text { + let mut t = Text::new(); + _ = write!(t, "{a}\n{b}"); + t +} - // display custom message on top of everything! - if let Some((line1, line2)) = ¤t_state.custom_message { - center_text_layout(&format!("{line1}\n{line2}")).draw(&mut oled.fbuf)?; - return Ok(()); - } +fn tr_lines(header: usize, footer: usize) -> Text { + two_lines(&get_translation(header), &get_translation(footer)) +} - if let Some(sel) = current_state.selected_config_menu { - let items: alloc::vec::Vec = crate::structs::CONFIG_MENU_ITEMS - .iter() - .enumerate() - .map(|(i, name)| alloc::format!("{}. {}", i + 1, name)) - .collect(); - let item_refs: alloc::vec::Vec<&str> = items.iter().map(|s| s.as_str()).collect(); - draw_scrollable_menu(&mut oled.fbuf, &item_refs, sel); - return Ok(()); - } +/// Draws the whole frame for the current state (no I/O, no heap). +fn render(s: &SignaledGlobalStateInner, f: &mut Frame, local: &Local) -> Live { + f.clear(); + top_bar(f, s); - match current_state.menu_scene { - Some(MenuScene::BuzzerVolume) => { - center_text_layout(&format!( + let mut text = Text::new(); + match screen(s) { + Screen::Error(error) => { + ui::text_main(f, &two_lines(&get_translation(TranslationKey::ERROR_HEADER), error)) + } + Screen::Custom(l1, l2) => ui::text_main(f, &two_lines(l1, l2)), + Screen::ConfigMenu(sel) => { + let items = &crate::structs::CONFIG_MENU_ITEMS; + ui::menu(f, items.len(), sel, |i, buf| { + _ = write!(buf, "{}. {}", i + 1, items[i]); + }); + } + Screen::BuzzerVolume => { + _ = write!( + text, "Buzzer Volume\n< {} >\nSubmit to save", crate::state::buzzer_volume() - )) - .draw(&mut oled.fbuf)?; - return Ok(()); + ); + ui::text_main(f, &text); } - Some(MenuScene::ErrorLog) => { - if let Some(entry_idx) = current_state.selected_error_log_entry { - if let Some(entry) = crate::utils::error_log::entry(entry_idx).as_ref() { - if current_state.error_log_entry_stage == Some(ErrorLogEntryStage::Qr) { - match entry { - crate::utils::error_log::ErrorLogEntry::Code { timestamp: _, code } => { - let url = format!("https://docs.fkmtime.com/debugging/e{}", code); - draw_qr_code(&mut oled.fbuf, QrCodeData::Text(&url), 1)?; - } - crate::utils::error_log::ErrorLogEntry::Stacktrace { - timestamp: _, - version, - addrs, - } => { - let mut tmp = Vec::new(); - tmp.push(version.len() as u8); - tmp.extend_from_slice(version.as_bytes()); - for addr in addrs { - tmp.extend_from_slice(&addr.to_be_bytes()); - } - - draw_qr_code(&mut oled.fbuf, QrCodeData::Binary(&tmp), 1)?; - } - }; - - return Ok(()); - } - - draw_scrollable_details_text( - &mut oled.fbuf, - &error_log_entry_details_text(entry), - current_state.error_log_details_scroll, - ); - return Ok(()); - } - - center_text_layout("Invalid entry\nSubmit to return").draw(&mut oled.fbuf)?; - return Ok(()); - } - - let mut items = (0..crate::utils::error_log::entry_count()) - .filter_map(crate::utils::error_log::entry) - .map(|entry| alloc::string::String::from(entry.list_label().as_str())) - .collect::>(); - items.push("Exit".into()); - - let sel = current_state.selected_error_log_item; - if sel >= items.len() { - global_state.state.lock().await.selected_error_log_item = 0; - } else { - let item_refs = items - .iter() - .map(|s| s.as_str()) - .collect::>(); - draw_scrollable_menu(&mut oled.fbuf, &item_refs, sel); - } - return Ok(()); + Screen::ErrorLogEntry { idx, stage, scroll } => match error_log::entry(idx) { + Some(entry) if stage == Some(&ErrorLogEntryStage::Qr) => draw_qr(f, &entry), + Some(entry) => ui::details(f, &error_log_entry_details_text(&entry), scroll), + None => ui::text_main(f, "Invalid entry\nSubmit to return"), + }, + Screen::ErrorLogList(sel) => { + let count = error_log::entry_count(); + ui::menu(f, count + 1, sel, |i, buf| match error_log::entry(i) { + Some(entry) if i < count => _ = buf.push_str(&entry.list_label()), + _ if i < count => _ = buf.push('?'), + _ => _ = buf.push_str("Exit"), + }); } - Some(MenuScene::Signing) | Some(MenuScene::Unsigning) => { - let prefix = if current_state.menu_scene == Some(MenuScene::Signing) { - "S" - } else { - "Uns" - }; - - let main_text = format!("{prefix}igning"); - - if global_state.sign_unsign_progress.signaled() { - let status = if global_state.sign_unsign_progress.wait().await { - "OK" - } else { - "FAIL" - }; - - center_text_layout(&format!("{main_text}\nOperation: {status}")) - .draw(&mut oled.fbuf)?; - oled.flush().await?; - - Timer::after_millis(300).await; - oled.clear_main()?; - center_text_layout(&format!("{main_text}\nScan the card\n\nSubmit to exit")) - .draw(&mut oled.fbuf)?; - } else { - center_text_layout(&format!("{main_text}\nScan the card\n\nSubmit to exit")) - .draw(&mut oled.fbuf)?; + Screen::Signing(sign) => { + let title = if sign { "Signing" } else { "Unsigning" }; + match local.sign_result { + Some((ok, at)) if at.elapsed().as_millis() < SIGN_RESULT_MS => { + let status = if ok { "OK" } else { "FAIL" }; + _ = write!(text, "{title}\nOperation: {status}"); + } + _ => _ = write!(text, "{title}\nScan the card\n\nSubmit to exit"), } - - return Ok(()); + ui::text_main(f, &text); + return Live::Signing; } - Some(crate::state::MenuScene::BtDisplay) => { - let mut items: alloc::vec::Vec = current_state - .discovered_bluetooth_devices - .iter() - .map(|dev| alloc::format!("{} [{:X?}]", dev.name, dev.addr)) - .collect(); - items.push("Unpair".into()); - items.push("Exit".into()); - - let sel = current_state.selected_bluetooth_item; - if sel >= items.len() { - global_state.state.lock().await.selected_bluetooth_item = 0; - } else { - let item_refs: alloc::vec::Vec<&str> = items.iter().map(|s| s.as_str()).collect(); - draw_scrollable_menu(&mut oled.fbuf, &item_refs, sel); - } - return Ok(()); + Screen::BtDevices(sel) => { + let devices = &s.discovered_bluetooth_devices; + ui::menu(f, devices.len() + 2, sel, |i, buf| match devices.get(i) { + Some(dev) => _ = write!(buf, "{} [{:X?}]", dev.name, dev.addr), + None if i == devices.len() => _ = buf.push_str("Unpair"), + None => _ = buf.push_str("Exit"), + }); } - None => {} - } - - let overwritten = process_main_overwrite(current_state, global_state, oled).await; - if overwritten { - return Ok(()); - } - - if let Some(time) = current_state.delegate_hold { - let delegate_remaining = 3 - time; - - if delegate_remaining == 0 { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::WAITING_FOR_DELEGATE_HEADER), - get_translation(TranslationKey::WAITING_FOR_DELEGATE_FOOTER) - )) - .draw(&mut oled.fbuf)?; - } else { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::CALLING_FOR_DELEGATE_HEADER), - get_translation_params( - TranslationKey::CALLING_FOR_DELEGATE_FOOTER, - &[delegate_remaining], - ) - )) - .draw(&mut oled.fbuf)?; + Screen::WifiLost => ui::text_main(f, "Wi-Fi\nConnection lost"), + Screen::ServerLost => ui::text_main( + f, + &tr_lines( + TranslationKey::SERVER_DISCONNECTED_HEADER, + TranslationKey::SERVER_DISCONNECTED_FOOTER, + ), + ), + Screen::DeviceNotAdded => { + #[cfg(not(feature = "e2e"))] + let lines = tr_lines( + TranslationKey::DEVICE_NOT_ADDED_HEADER, + TranslationKey::DEVICE_NOT_ADDED_FOOTER, + ); + #[cfg(feature = "e2e")] + let lines = two_lines("Press submit", "To start HIL"); + ui::text_main(f, &lines); } - - return Ok(()); - } - - match current_state.scene { - Scene::WifiConnect => { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::WAITING_FOR_WIFI_HEADER), - get_translation(TranslationKey::WAITING_FOR_WIFI_FOOTER) - )) - .draw(&mut oled.fbuf)?; - oled.flush().await?; - - wifi_setup_sig.wait().await; - global_state.state.lock().await.scene = Scene::AutoSetupWait; + Screen::StackmatLost => ui::text_main( + f, + &tr_lines( + TranslationKey::STACKMAT_DISCONNECTED_HEADER, + TranslationKey::STACKMAT_DISCONNECTED_FOOTER, + ), + ), + Screen::Delegate(0) => ui::text_main( + f, + &tr_lines( + TranslationKey::WAITING_FOR_DELEGATE_HEADER, + TranslationKey::WAITING_FOR_DELEGATE_FOOTER, + ), + ), + Screen::Delegate(remaining) => ui::text_main( + f, + &two_lines( + &get_translation(TranslationKey::CALLING_FOR_DELEGATE_HEADER), + &get_translation_params(TranslationKey::CALLING_FOR_DELEGATE_FOOTER, &[remaining]), + ), + ), + Screen::WifiConnect => { + ui::text_main( + f, + &tr_lines( + TranslationKey::WAITING_FOR_WIFI_HEADER, + TranslationKey::WAITING_FOR_WIFI_FOOTER, + ), + ); + return Live::WifiConnect; } - Scene::AutoSetupWait => { - let wifi_ssid = alloc::format!("FKM-{:X}", crate::utils::get_efuse_u32()); - - center_text_layout(&format!( - "{}\n{wifi_ssid}", + Screen::AutoSetup => { + _ = write!( + text, + "{}\nFKM-{:X}", get_translation(TranslationKey::WIFI_SETUP_HEADER), - )) - .draw(&mut oled.fbuf)?; - } - Scene::MdnsWait => { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::WAITING_FOR_MDNS_HEADER), - get_translation(TranslationKey::WAITING_FOR_MDNS_FOOTER) - )) - .draw(&mut oled.fbuf)?; - } - Scene::GroupSelect => { - let lt = Text::with_text_style("<", Point::zero(), TIMER_FONT, TEXT_CENTER); - let gt = Text::with_text_style(">", Point::zero(), TIMER_FONT, TEXT_CENTER); - LinearLayout::horizontal(Chain::new(lt)) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange() - .align_to( - &MAIN_RECT, - embedded_layout::align::horizontal::Left, - embedded_layout::align::vertical::Center, - ) - .draw(&mut oled.fbuf)?; - - LinearLayout::horizontal(Chain::new(gt)) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange() - .align_to( - &MAIN_RECT, - embedded_layout::align::horizontal::Right, - embedded_layout::align::vertical::Center, - ) - .draw(&mut oled.fbuf)?; - - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::SELECT_GROUP), - current_state.possible_groups[current_state.group_selected_idx].name - )) - .draw(&mut oled.fbuf)?; + crate::utils::get_efuse_u32() + ); + ui::text_main(f, &text); } - Scene::WaitingForCompetitor => { - if let Some(solve_time) = current_state.solve_time { - let time_str = ms_to_time_str(solve_time); - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::SCAN_COMPETITOR_CARD_HEADER), - get_translation_params( - TranslationKey::SCAN_COMPETITOR_CARD_WITH_TIME_FOOTER, - &[time_str], - ) - )) - .draw(&mut oled.fbuf)?; - } else { - center_text_layout(&format!( - "{}\n{}", - get_translation(TranslationKey::SCAN_COMPETITOR_CARD_HEADER), - get_translation(TranslationKey::SCAN_COMPETITOR_CARD_FOOTER), - )) - .draw(&mut oled.fbuf)?; - } + Screen::MdnsWait => ui::text_main( + f, + &tr_lines( + TranslationKey::WAITING_FOR_MDNS_HEADER, + TranslationKey::WAITING_FOR_MDNS_FOOTER, + ), + ), + Screen::GroupSelect(name) => ui::group_select( + f, + &two_lines(&get_translation(TranslationKey::SELECT_GROUP), name), + ), + Screen::WaitingForCompetitor(solve_time) => { + let header = get_translation(TranslationKey::SCAN_COMPETITOR_CARD_HEADER); + let footer = match solve_time { + Some(time) => get_translation_params( + TranslationKey::SCAN_COMPETITOR_CARD_WITH_TIME_FOOTER, + &[crate::utils::text::time_str(time)], + ), + None => get_translation(TranslationKey::SCAN_COMPETITOR_CARD_FOOTER), + }; + ui::text_main(f, &two_lines(&header, &footer)); } - Scene::CompetitorInfo => { - let mut text = alloc::string::String::from( - current_state - .competitor_display - .as_deref() - .unwrap_or("------"), - ); - - if let Some(ref group) = current_state.solve_group - && let Some(ref secondary_text) = group.secondary_text - { - text += &format!("\n{}", secondary_text); + Screen::CompetitorInfo => { + _ = text.push_str(s.competitor_display.as_deref().unwrap_or("------")); + if let Some(secondary) = s.solve_group.as_ref().and_then(|g| g.secondary_text.as_ref()) { + _ = write!(text, "\n{secondary}"); } - - center_text_layout(&text).draw(&mut oled.fbuf)?; + ui::text_main(f, &text); } - Scene::Inspection => { - oled.clear_main()?; - oled.flush().await?; - let inspection_start = global_state - .state - .value() - .await - .inspection_start - .unwrap_or(Instant::now()); - - _ = Text::with_text_style( - &get_translation(TranslationKey::INSPECTION), - Point::new(64, 50), - NORMAL_FONT, - TEXT_CENTER, - ) - .draw(&mut oled.disp); - - let text_rect = Rectangle::new(Point::new(0, 28), Size::new(128, 17)); - loop { - let elapsed = (Instant::now() - inspection_start).as_millis(); - crate::state::set_inspection_time(Some(elapsed)); - - let time_str = ms_to_time_str(elapsed); - _ = oled.disp.fill_solid(&text_rect, BinaryColor::Off); - _ = Text::with_text_style(&time_str, Point::new(64, 36), TIMER_FONT, TEXT_CENTER) - .draw(&mut oled.disp); - _ = oled.disp.flush().await; - - Timer::after_millis(LCD_INSPECTION_FRAME_TIME).await; - } + Screen::Inspection(start) => { + ui::inspection_label(f, &get_translation(TranslationKey::INSPECTION)); + ui::digits(f, start.elapsed().as_millis()); + return Live::Inspection(start); } - Scene::Timer => { - oled.clear_main()?; - oled.flush().await?; - let text_rect = Rectangle::new(Point::new(0, 28), Size::new(128, 17)); - - loop { - let time = global_state.timer_signal.wait().await; - let time_str = ms_to_time_str(time); - - _ = oled.disp.fill_solid(&text_rect, BinaryColor::Off); - _ = Text::with_text_style(&time_str, Point::new(64, 36), TIMER_FONT, TEXT_CENTER) - .draw(&mut oled.disp); - _ = oled.disp.flush().await; + Screen::Timer => { + // keep the last value on re-render so unrelated state changes don't blank the time + if let Some(ms) = local.timer_ms { + ui::digits(f, ms); } + return Live::Timer; } - Scene::Finished => { - let solve_time = current_state.solve_time.unwrap_or(0); - let time_str = ms_to_time_str(solve_time); - let inspection_time = - match (current_state.inspection_start, current_state.inspection_end) { - (Some(start), Some(end)) => { - Some(end.saturating_duration_since(start).as_millis()) - } - _ => None, - }; - - let show_inspection = current_state.use_inspection() - && inspection_time.unwrap_or(0) > INSPECTION_TIME_PLUS2; - let inspection_display = - show_inspection.then(|| ms_to_time_str(inspection_time.unwrap_or(0))); - - let penalty = current_state.penalty.unwrap_or(0); - let main_time_display: String = match penalty { - -2 if solve_time == 0 => "DNS".into(), - -2 => format!("DNS ({time_str})"), - -1 if solve_time == 0 => "DNF".into(), - -1 => format!("DNF ({time_str})"), - 1.. => format!("{time_str} +{penalty}"), - _ => time_str.to_string(), + Screen::Finished(fin) => { + let time = crate::utils::text::time_str(fin.time); + let mut main = Line64::new(); + _ = match fin.penalty { + -2 if fin.time == 0 => write!(main, "DNS"), + -2 => write!(main, "DNS ({time})"), + -1 if fin.time == 0 => write!(main, "DNF"), + -1 => write!(main, "DNF ({time})"), + p @ 1.. => write!(main, "{time} +{p}"), + _ => write!(main, "{time}"), }; - let font = if main_time_display.len() > 13 { - NORMAL_FONT - } else { - TIMER_FONT - }; - - let main_time_text = - Text::with_text_style(&main_time_display, Point::zero(), font, TEXT_CENTER); - - if let Some(insp_str) = inspection_display { - let insp_str = format!( - "{}: {insp_str}", - get_translation(TranslationKey::INSPECTION) - ); - let insp_text = - Text::with_text_style(&insp_str, Point::zero(), SMALL_TIMER_FONT, TEXT_CENTER); + let inspection = fin.inspection.map(|ms| { + let mut s = Line64::new(); + _ = write!(s, "{}: {}", get_translation(TranslationKey::INSPECTION), crate::utils::text::time_str(ms)); + s + }); + let status = fin.status.map(get_translation); + ui::finished(f, &main, inspection.as_deref(), status.as_deref()); + } + Screen::Update => { + _ = write!(text, "Updating\n{}%", local.progress); + ui::text_main(f, &text); + return Live::Update; + } + } - LinearLayout::vertical( - Chain::new( - LinearLayout::horizontal(Chain::new(main_time_text)) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange(), - ) - .append( - LinearLayout::horizontal(Chain::new(insp_text)) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange(), - ), - ) - .with_alignment(embedded_layout::align::horizontal::Center) - .arrange() - .align_to( - &MAIN_RECT, - embedded_layout::align::horizontal::Center, - embedded_layout::align::vertical::Top, - ) - .translate(Point::new(0, 2)) - .draw(&mut oled.fbuf)?; - } else { - LinearLayout::horizontal(Chain::new(main_time_text)) - .with_alignment(embedded_layout::align::vertical::Center) - .arrange() - .align_to( - &MAIN_RECT, - embedded_layout::align::horizontal::Center, - embedded_layout::align::vertical::Top, - ) - .translate(Point::new(0, 10)) - .draw(&mut oled.fbuf)?; - } + Live::Idle +} - let status = if !current_state.time_confirmed { - Some(get_translation(TranslationKey::CONFIRM_TIME)) - } else if current_state.current_judge.is_none() { - Some(get_translation(TranslationKey::SCAN_JUDGE_CARD)) - } else if current_state.current_competitor.is_some() - && current_state.current_judge.is_some() - { - Some(get_translation(TranslationKey::SCAN_COMPETITOR_CARD)) - } else { - None - }; - if let Some(status_str) = status { - let textbox_style = TextBoxStyleBuilder::new() - .alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Bottom) - .build(); - TextBox::with_textbox_style(&status_str, MAIN_RECT, NORMAL_FONT, textbox_style) - .draw(&mut oled.fbuf)?; +fn draw_qr(f: &mut Frame, entry: &ErrorLogEntry) { + match entry { + ErrorLogEntry::Code { code, .. } => { + let mut url = Line64::new(); + _ = write!(url, "https://docs.fkmtime.com/debugging/e{code}"); + ui::qr(f, QrData::Text(&url)); + } + ErrorLogEntry::Stacktrace { version, addrs, .. } => { + let mut data: heapless::Vec = heapless::Vec::new(); + _ = data.push(version.len() as u8); + _ = data.extend_from_slice(version.as_bytes()); + for addr in addrs { + _ = data.extend_from_slice(&addr.to_be_bytes()); } + ui::qr(f, QrData::Binary(&data)); } - Scene::Update => loop { - let progress = global_state.update_progress.wait().await; - oled.clear_main()?; - center_text_layout(&format!("Updating\n{progress}%")).draw(&mut oled.fbuf)?; - oled.flush().await?; - }, } - - Ok(()) } -pub fn error_log_entry_details_text(entry: &crate::utils::error_log::ErrorLogEntry) -> String { - match entry { - crate::utils::error_log::ErrorLogEntry::Code { timestamp, code } => format!( +/// Lines of the error log details view (also used by the buttons to limit scrolling). +pub fn error_log_entry_details_text(entry: &ErrorLogEntry) -> Text { + let mut t = Text::new(); + _ = match entry { + ErrorLogEntry::Code { timestamp, code } => write!( + t, "Error E{code}\n{}\nSubmit to return", - crate::utils::error_log::format_timestamp_full(*timestamp) + error_log::format_timestamp_full(*timestamp) ), - crate::utils::error_log::ErrorLogEntry::Stacktrace { + ErrorLogEntry::Stacktrace { timestamp, version, addrs, - } => { - let mut details = format!( + } => (|| { + write!( + t, "Panic {}\nVER: {}\n", - crate::utils::error_log::format_timestamp_compact(*timestamp), + error_log::format_timestamp_compact(*timestamp), version - ); + )?; if addrs.is_empty() { - details.push_str("No addrs"); - } else { - for (idx, addr) in addrs.iter().enumerate() { - if idx > 0 { - details.push('\n'); - } - details.push_str(&format!("0x{addr:X}")); - } + write!(t, "No addrs")?; } - details.push_str("\nSubmit to return"); - details - } - } -} - -async fn process_main_overwrite( - current_state: &SignaledGlobalStateInner, - _global_state: &GlobalState, - oled: &mut OledData<'_>, -) -> bool { - if !current_state.scene.can_be_lcd_overwritten() { - return false; - } - - if current_state.server_connected == Some(false) { - if current_state.wifi_connected == Some(false) { - _ = center_text_layout("Wi-Fi\nConnection lost").draw(&mut oled.fbuf); - } else { - let text = format!( - "{}\n{}", - get_translation(TranslationKey::SERVER_DISCONNECTED_HEADER), - get_translation(TranslationKey::SERVER_DISCONNECTED_FOOTER) - ); - _ = center_text_layout(&text).draw(&mut oled.fbuf); - } - } else if current_state.device_added == Some(false) { - #[cfg(not(feature = "e2e"))] - let lines = ( - &get_translation(TranslationKey::DEVICE_NOT_ADDED_HEADER), - &get_translation(TranslationKey::DEVICE_NOT_ADDED_FOOTER), - ); - - #[cfg(feature = "e2e")] - let lines = ("Press submit", "To start HIL"); - - let text = format!("{}\n{}", lines.0, lines.1); - _ = center_text_layout(&text).draw(&mut oled.fbuf); - } else if current_state.stackmat_connected == Some(false) { - let text = format!( - "{}\n{}", - get_translation(TranslationKey::STACKMAT_DISCONNECTED_HEADER), - get_translation(TranslationKey::STACKMAT_DISCONNECTED_FOOTER) - ); - _ = center_text_layout(&text).draw(&mut oled.fbuf); - } else { - return false; - } - - true + for (idx, addr) in addrs.iter().enumerate() { + let sep = if idx > 0 { "\n" } else { "" }; + write!(t, "{sep}0x{addr:X}")?; + } + write!(t, "\nSubmit to return") + })(), + }; + t } diff --git a/src/utils/lcd_abstract.rs b/src/utils/lcd_abstract.rs deleted file mode 100644 index 615b56a..0000000 --- a/src/utils/lcd_abstract.rs +++ /dev/null @@ -1,170 +0,0 @@ -use ag_lcd_async::LcdDisplay; -use embedded_hal::digital::OutputPin; -use embedded_hal_async::delay::DelayNs; - -pub enum PrintAlign { - Left, - Center, - Right, -} - -#[derive(Debug)] -pub enum LcdError { - OutOfRange, - Other, -} - -pub type LcdDisplayData<'a, const X: usize, const Y: usize> = - ([(&'a [u8], bool); Y], &'a mut [[u8; X]; Y]); - -pub struct LcdAbstract< - const LINE_SIZE: usize, - const X: usize, - const Y: usize, - const SCROLLER_WT: usize, -> { - pub lines: [[u8; LINE_SIZE]; Y], - pub sizes: [usize; Y], - - old_display: [[u8; X]; Y], - scroll_wait_ticks: usize, - current_scroll: usize, - scroll_dir: i8, -} - -impl - LcdAbstract -{ - pub fn new() -> Self { - Self { - lines: [[b' '; LINE_SIZE]; Y], - sizes: [0; Y], - - old_display: [[0; X]; Y], - scroll_wait_ticks: 0, - current_scroll: 0, - scroll_dir: 0, - } - } - - pub fn display_data(&mut self) -> LcdDisplayData<'_, X, Y> { - let mut tmp: [(&[u8], bool); Y] = [(&[], false); Y]; - - for (y, tmp) in tmp.iter_mut().enumerate() { - let scroll_max = self.sizes[y].saturating_sub(X); - let scroll_offset = self.current_scroll.min(scroll_max); - let scrolled_data = &self.lines[y][scroll_offset..X + scroll_offset]; - - *tmp = (scrolled_data, scrolled_data != self.old_display[y]); - } - - (tmp, &mut self.old_display) - } - - pub fn scroll_step(&mut self) -> Result { - let max_size = *self.sizes.iter().max().ok_or(LcdError::Other)?; - let max_scroll = max_size.saturating_sub(X); - if max_scroll == 0 { - return Ok(false); - } - - if self.scroll_wait_ticks > 0 { - self.scroll_wait_ticks = self.scroll_wait_ticks.saturating_sub(1); - return Ok(false); - } - - self.current_scroll = self - .current_scroll - .saturating_add_signed(self.scroll_dir as isize) - .min(max_scroll); - - if self.current_scroll == 0 || self.current_scroll == max_scroll { - self.scroll_wait_ticks = SCROLLER_WT - 1; - - self.scroll_dir = if self.current_scroll == 0 { - 1 - } else if self.current_scroll == max_scroll { - -1 - } else { - return Err(LcdError::Other); - }; - } - - Ok(true) - } - - pub fn print( - &mut self, - line: usize, - text: &str, - align: PrintAlign, - pad: bool, - ) -> Result<(), LcdError> { - if line > Y || text.len() > LINE_SIZE { - return Err(LcdError::OutOfRange); - } - - self.current_scroll = 0; - self.scroll_wait_ticks = 0; - - let x_offset = if text.len() < X { - match align { - PrintAlign::Left => 0, - PrintAlign::Center => (X - text.len()) / 2, - PrintAlign::Right => X - text.len(), - } - } else { - 0 - }; - - if pad && text.len() < X { - let mut tmp_line = [b' '; X]; - let end_offset = (x_offset + text.len()).min(X); - tmp_line[x_offset..end_offset] - .copy_from_slice(&text.as_bytes()[..(end_offset - x_offset)]); - - self.lines[line][..X].copy_from_slice(&tmp_line); - self.sizes[line] = X; - } else { - self.lines[line][x_offset..x_offset + text.len()].copy_from_slice(text.as_bytes()); - self.sizes[line] = text.len(); - } - - self.scroll_wait_ticks = SCROLLER_WT - 1; - Ok(()) - } - - pub fn clear(&mut self, line: usize) -> Result<(), LcdError> { - if line > Y { - return Err(LcdError::OutOfRange); - } - - self.lines[line][..X].fill(b' '); - self.sizes[line] = 0; - self.old_display[line].fill(0xFF); - Ok(()) - } - - pub fn clear_all(&mut self) -> Result<(), LcdError> { - for y in 0..Y { - self.clear(y)?; - } - - Ok(()) - } - - pub async fn display_on_lcd(&mut self, lcd: &mut LcdDisplay) { - let display_data = self.display_data(); - for (y, line) in display_data.0.iter().enumerate() { - if line.1 { - display_data.1[y].fill(0xFF); - - lcd.set_position(0, y as u8).await; - lcd.print(unsafe { core::str::from_utf8_unchecked(line.0) }) - .await; - - display_data.1[y].copy_from_slice(line.0); - } - } - } -} diff --git a/src/utils/lcd_resourcese.rs b/src/utils/lcd_resourcese.rs deleted file mode 100644 index c42dc04..0000000 --- a/src/utils/lcd_resourcese.rs +++ /dev/null @@ -1,213 +0,0 @@ -use embedded_graphics::{ - pixelcolor::BinaryColor, - prelude::*, - primitives::{Line, PrimitiveStyle, Rectangle}, -}; - -macros::load_lcd_resources!("src/resources"); - -#[derive(Clone, Copy)] -pub struct PixelArt { - data: &'static [u8], // packed bits, MSB first, row by row - width: u32, - height: u32, - top_left: Point, -} - -impl PixelArt { - pub const fn new(data: &'static [u8], width: u32, height: u32) -> Self { - Self { - data, - width, - height, - top_left: Point::zero(), - } - } - - fn bytes_per_row(&self) -> u32 { - self.width.div_ceil(8) - } -} - -impl Dimensions for PixelArt { - fn bounding_box(&self) -> Rectangle { - Rectangle::new(self.top_left, Size::new(self.width, self.height)) - } -} - -impl Transform for PixelArt { - fn translate(&self, by: Point) -> Self { - Self { - top_left: self.top_left + by, - ..*self - } - } - fn translate_mut(&mut self, by: Point) -> &mut Self { - self.top_left += by; - self - } -} - -impl Drawable for PixelArt { - type Color = BinaryColor; - type Output = (); - - fn draw>(&self, target: &mut D) -> Result<(), D::Error> { - let origin = self.top_left; - let bpr = self.bytes_per_row(); - let (w, h) = (self.width, self.height); - let data = self.data; - - target.draw_iter((0..h).flat_map(move |y| { - (0..w).map(move |x| { - let byte = data[(y * bpr + x / 8) as usize]; - let on = byte & (0x80 >> (x % 8)) != 0; - Pixel( - origin + Point::new(x as i32, y as i32), - if on { - BinaryColor::On - } else { - BinaryColor::Off - }, - ) - }) - })) - } -} - -#[allow(dead_code)] -pub struct Overlay { - base: A, - overlay: B, - top_left: Point, -} - -#[allow(dead_code)] -impl Overlay { - pub fn new(base: A, overlay: B) -> Self { - let base_box = base.bounding_box(); - let overlay_box = overlay.bounding_box(); - - // center overlay over base - let offset = Point::new( - (base_box.size.width as i32 - overlay_box.size.width as i32) / 2, - (base_box.size.height as i32 - overlay_box.size.height as i32) / 2, - ); - let overlay = overlay.translate(base_box.top_left + offset); - - Self { - top_left: base_box.top_left, - base, - overlay, - } - } -} - -impl Dimensions for Overlay { - fn bounding_box(&self) -> Rectangle { - Rectangle::new(self.top_left, self.base.bounding_box().size) - } -} - -impl Transform for Overlay { - fn translate(&self, by: Point) -> Self { - Self { - top_left: self.top_left + by, - base: self.base.translate(by), - overlay: self.overlay.translate(by), - } - } - fn translate_mut(&mut self, by: Point) -> &mut Self { - self.top_left += by; - self.base.translate_mut(by); - self.overlay.translate_mut(by); - self - } -} - -impl Drawable for Overlay -where - A: Drawable, - B: Drawable, -{ - type Color = BinaryColor; - type Output = (); - - fn draw>(&self, target: &mut D) -> Result<(), D::Error> { - self.base.draw(target)?; - self.overlay.draw(target)?; - Ok(()) - } -} - -pub struct CrossedIcon { - base: A, - top_left: Point, - crossed: bool, - cross_size: u32, -} - -impl CrossedIcon { - pub fn new(base: A, crossed: bool, cross_size: u32) -> Self { - let base_box = base.bounding_box(); - Self { - base, - top_left: base_box.top_left, - crossed, - cross_size, - } - } -} - -impl Dimensions for CrossedIcon { - fn bounding_box(&self) -> Rectangle { - Rectangle::new(self.top_left, self.base.bounding_box().size) - } -} - -impl Transform for CrossedIcon { - fn translate(&self, by: Point) -> Self { - Self { - top_left: self.top_left + by, - base: self.base.translate(by), - crossed: self.crossed, - cross_size: self.cross_size, - } - } - fn translate_mut(&mut self, by: Point) -> &mut Self { - self.top_left += by; - self.base.translate_mut(by); - self - } -} - -impl Drawable for CrossedIcon -where - A: Drawable + Dimensions, -{ - type Color = BinaryColor; - type Output = (); - fn draw>(&self, target: &mut D) -> Result<(), D::Error> { - self.base.draw(target)?; - if self.crossed { - let thin_stroke = PrimitiveStyle::with_stroke(BinaryColor::On, 1); - let bb = self.bounding_box(); - - // Center the cross over the bounding box - let offset = Point::new( - (bb.size.width as i32 - self.cross_size as i32) / 2, - (bb.size.height as i32 - self.cross_size as i32) / 2, - ); - let tl = bb.top_left + offset; - let s = self.cross_size as i32 - 1; - - Line::new(tl, tl + Point::new(s, s)) - .into_styled(thin_stroke) - .draw(target)?; - Line::new(tl + Point::new(0, s), tl + Point::new(s, 0)) - .into_styled(thin_stroke) - .draw(target)?; - } - Ok(()) - } -} diff --git a/src/utils/lcd_text.rs b/src/utils/lcd_text.rs new file mode 100644 index 0000000..293960a --- /dev/null +++ b/src/utils/lcd_text.rs @@ -0,0 +1,171 @@ +//! v3 16×2 character LCD text buffer (pure, host-tested): lines up to 80 chars that scroll +//! back and forth, and a copy of what the LCD shows so only changed characters are written. + +pub const COLS: usize = 16; +const ROWS: usize = 2; +const LINE: usize = 80; +/// scroll ticks to wait at each end +const SCROLL_WAIT: usize = 3; + +#[derive(Clone, Copy)] +pub enum Align { + Left, + Center, + Right, +} + +pub struct LcdText { + lines: [[u8; LINE]; ROWS], + sizes: [usize; ROWS], + /// what the LCD shows (0xFF = unknown → rewritten) + shown: [[u8; COLS]; ROWS], + scroll: usize, + wait: usize, + dir: isize, +} + +impl LcdText { + pub const fn new() -> Self { + Self { + lines: [[b' '; LINE]; ROWS], + sizes: [0; ROWS], + shown: [[0xFF; COLS]; ROWS], + scroll: 0, + wait: 0, + dir: 0, + } + } + + /// Blank both lines (the LCD is updated by the next `changes`). + pub fn clear(&mut self) { + self.lines = [[b' '; LINE]; ROWS]; + self.sizes = [0; ROWS]; + } + + /// Writes `text` to `row`. With `pad` the visible line is replaced, otherwise the text is + /// laid over it. Non-ASCII chars become `?` (the HD44780 has no glyphs for them), text + /// longer than a line is cut. + pub fn print(&mut self, row: usize, text: &str, align: Align, pad: bool) { + let Some(line) = self.lines.get_mut(row) else { + return; + }; + let mut chars = [b' '; LINE]; + let mut len = 0; + for (dst, c) in chars.iter_mut().zip(text.chars()) { + *dst = if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }; + len += 1; + } + + let x = match align { + _ if len >= COLS => 0, + Align::Left => 0, + Align::Center => (COLS - len) / 2, + Align::Right => COLS - len, + }; + if pad && len < COLS { + line[..COLS].fill(b' '); + self.sizes[row] = COLS; + } else { + self.sizes[row] = len; + } + line[x..x + len].copy_from_slice(&chars[..len]); + + self.scroll = 0; + self.wait = SCROLL_WAIT - 1; + } + + /// One scroll tick (every 500 ms); `true` if the visible text moved. + pub fn scroll_step(&mut self) -> bool { + let max_scroll = self.sizes.iter().max().map_or(0, |s| s.saturating_sub(COLS)); + if max_scroll == 0 { + return false; + } + if self.wait > 0 { + self.wait -= 1; + return false; + } + + self.scroll = self.scroll.saturating_add_signed(self.dir).min(max_scroll); + if self.scroll == 0 || self.scroll == max_scroll { + self.wait = SCROLL_WAIT - 1; + self.dir = if self.scroll == 0 { 1 } else { -1 }; + } + true + } + + /// Calls `write(row, col, text)` for the changed run of each row and marks it shown. + pub fn changes(&mut self, mut write: impl FnMut(u8, u8, &str)) { + for row in 0..ROWS { + let offset = self.scroll.min(self.sizes[row].saturating_sub(COLS)); + let visible = &self.lines[row][offset..offset + COLS]; + let shown = &mut self.shown[row]; + + let Some(start) = visible.iter().zip(shown.iter()).position(|(a, b)| a != b) else { + continue; + }; + let end = COLS - visible.iter().zip(shown.iter()).rev().position(|(a, b)| a != b).unwrap_or(0); + // only ASCII is ever stored, so this can't fail + if let Ok(text) = core::str::from_utf8(&visible[start..end]) { + write(row as u8, start as u8, text); + } + shown[start..end].copy_from_slice(&visible[start..end]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{string::String, vec::Vec}; + + fn flush(t: &mut LcdText) -> Vec<(u8, u8, String)> { + let mut v = Vec::new(); + t.changes(|r, c, s| v.push((r, c, s.into()))); + v + } + + #[test] + fn writes_only_changed_runs() { + let mut t = LcdText::new(); + t.print(0, "12.34", Align::Center, true); + t.print(1, "Inspection", Align::Center, true); + assert_eq!( + flush(&mut t), + [(0, 0, " 12.34 ".into()), (1, 0, " Inspection ".into())] + ); + assert!(flush(&mut t).is_empty()); + + t.print(0, "12.35", Align::Center, true); + assert_eq!(flush(&mut t), [(0, 9, "5".into())]); + + // overlay without pad keeps the rest of the line + t.print(0, ">", Align::Right, false); + assert_eq!(flush(&mut t), [(0, 15, ">".into())]); + } + + #[test] + fn sanitizes_and_cuts() { + let mut t = LcdText::new(); + t.print(0, "zażółć", Align::Left, true); + t.print(5, "out of range row", Align::Left, true); + t.print(1, &"x".repeat(200), Align::Left, true); + let out = flush(&mut t); + assert_eq!(out[0], (0, 0, "za???? ".into())); + assert_eq!(out[1], (1, 0, "x".repeat(16))); + } + + #[test] + fn long_lines_scroll_back_and_forth() { + let mut t = LcdText::new(); + t.print(0, "Signing | Submit To Exit", Align::Left, true); // 24 chars + flush(&mut t); + let mut offsets = Vec::new(); + for _ in 0..30 { + if t.scroll_step() { + offsets.push(t.scroll); + } + } + assert_eq!(&offsets[..10], [0, 1, 2, 3, 4, 5, 6, 7, 8, 7]); + assert!(!flush(&mut t).is_empty()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a1be299..ad70a34 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -74,13 +74,18 @@ pub fn spawn_task( } #[cfg(feature = "v3")] -pub mod lcd_abstract; +pub mod lcd_text; #[cfg(feature = "v4")] pub mod shared_i2c; +pub mod screen; + +#[cfg(feature = "v4")] +pub mod oled_ui; + #[cfg(feature = "v4")] -pub mod lcd_resourcese; +pub mod ssd1309; pub fn set_brownout_detection(state: bool) { unsafe { diff --git a/src/utils/oled_ui.rs b/src/utils/oled_ui.rs new file mode 100644 index 0000000..a34f424 --- /dev/null +++ b/src/utils/oled_ui.rs @@ -0,0 +1,555 @@ +//! v4 OLED drawing primitives (pure: no hardware, no heap) — host-testable. +//! Positions replicate the old embedded-layout / embedded-text output pixel for pixel. + +use core::fmt::Write; +use embedded_graphics::{ + Drawable, + geometry::AnchorPoint, + image::{Image, ImageRaw}, + mono_font::{MonoFont, MonoTextStyle, ascii}, + pixelcolor::BinaryColor, + prelude::*, + primitives::{Line, PrimitiveStyle, Rectangle}, + text::{Alignment, Baseline, Text, TextStyle, TextStyleBuilder}, +}; + +pub const MAIN_RECT: Rectangle = Rectangle::new(Point::new(0, 11), Size::new(128, 53)); +#[cfg_attr(any(feature = "e2e", feature = "qa"), allow(dead_code))] +const SCREEN_RECT: Rectangle = Rectangle::new(Point::zero(), Size::new(128, 64)); +/// Timer/inspection digits area (cleared before every digit redraw). +const DIGITS_RECT: Rectangle = Rectangle::new(Point::new(0, 28), Size::new(128, 17)); +pub const DETAILS_VISIBLE_LINES: usize = 5; + +pub const NORMAL_FONT: MonoTextStyle<'_, BinaryColor> = + MonoTextStyle::new(&ascii::FONT_7X13, BinaryColor::On); +pub const SMALL_FONT: MonoTextStyle<'_, BinaryColor> = + MonoTextStyle::new(&ascii::FONT_6X9, BinaryColor::On); +const SMALL_FONT_INV: MonoTextStyle<'_, BinaryColor> = + MonoTextStyle::new(&ascii::FONT_6X9, BinaryColor::Off); +pub const TIMER_FONT: MonoTextStyle<'_, BinaryColor> = + MonoTextStyle::new(&profont::PROFONT_14_POINT, BinaryColor::On); +pub const SMALL_TIMER_FONT: MonoTextStyle<'_, BinaryColor> = + MonoTextStyle::new(&profont::PROFONT_7_POINT, BinaryColor::On); + +const TEXT_CENTER: TextStyle = TextStyleBuilder::new() + .alignment(Alignment::Center) + .baseline(Baseline::Middle) + .build(); +const TEXT_LEFT_MIDDLE: TextStyle = TextStyleBuilder::new() + .alignment(Alignment::Left) + .baseline(Baseline::Middle) + .build(); +const TEXT_TOP: TextStyle = TextStyleBuilder::new() + .alignment(Alignment::Left) + .baseline(Baseline::Top) + .build(); + +// 1bpp, MSB first, rows padded to bytes (generated once from the old src/resources/*.png) +const ICON_CHARGING: ImageRaw = + ImageRaw::new(&[0x08, 0x10, 0x20, 0x40, 0xF8, 0x10, 0xA0, 0xC0, 0xE0], 5); +const ICON_SERVER: ImageRaw = + ImageRaw::new(&[0xFE, 0x82, 0x8A, 0xFE, 0x82, 0x8A, 0xFE], 7); +#[cfg_attr(feature = "timer-func", allow(dead_code))] +const ICON_TIMER: ImageRaw = + ImageRaw::new(&[0x1E, 0x3C, 0x42, 0x85, 0x99, 0x91, 0x81, 0x42, 0x3C], 8); +const ICON_WIFI: ImageRaw = ImageRaw::new( + &[ + 0x1E, 0x00, 0x61, 0x80, 0x8C, 0x40, 0x33, 0x00, 0x40, 0x80, 0x1E, 0x00, 0x21, 0x00, 0x0C, + 0x00, 0x0C, 0x00, + ], + 10, +); + +pub type Line64 = heapless::String<64>; + +/// 128×64 1bpp frame in SSD1309 page layout plus a copy of what the panel shows, so a flush +/// only sends the changed column span of each page. +pub struct Frame { + buf: [u8; 1024], + shown: [u8; 1024], + /// `false` = panel content unknown (after init or a failed flush): resend everything + synced: bool, +} + +impl Frame { + pub const fn new() -> Self { + Self { + buf: [0; 1024], + shown: [0; 1024], + synced: false, + } + } + + pub fn clear(&mut self) { + self.buf.fill(0); + } + + pub fn invalidate(&mut self) { + self.synced = false; + } + + /// Changed column range `[start, end)` of `page` with its new bytes. + pub fn dirty(&self, page: usize) -> Option<(u8, &[u8])> { + let range = page * 128..page * 128 + 128; + let (new, old) = (&self.buf[range.clone()], &self.shown[range]); + let (start, end) = if self.synced { + let start = new.iter().zip(old).position(|(a, b)| a != b)?; + let end = 128 - new.iter().zip(old).rev().position(|(a, b)| a != b)?; + (start, end) + } else { + (0, 128) + }; + Some((start as u8, &new[start..end])) + } + + /// Records that `dirty(page)` reached the panel. + pub fn sent(&mut self, page: usize) { + let range = page * 128..page * 128 + 128; + self.shown[range.clone()].copy_from_slice(&self.buf[range]); + } + + /// After every page was sent. + pub fn mark_synced(&mut self) { + self.synced = true; + } +} + +impl OriginDimensions for Frame { + fn size(&self) -> Size { + Size::new(128, 64) + } +} + +impl DrawTarget for Frame { + type Color = BinaryColor; + type Error = core::convert::Infallible; + + fn draw_iter>>(&mut self, pixels: I) -> Result<(), Self::Error> { + for Pixel(p, color) in pixels { + if let (Ok(x @ 0..128), Ok(y @ 0..64)) = (usize::try_from(p.x), usize::try_from(p.y)) { + let (byte, bit) = (&mut self.buf[y / 8 * 128 + x], 1 << (y % 8)); + if color.is_on() { + *byte |= bit; + } else { + *byte &= !bit; + } + } + } + Ok(()) + } +} + +/// Offset that centers `bb` inside `r` (same math as embedded-layout's Center alignment). +fn center_offset(bb: Rectangle, r: Rectangle) -> Point { + r.anchor_point(AnchorPoint::Center) - bb.anchor_point(AnchorPoint::Center) +} + +/// Draws `text` (may contain `\n`) as one block centered in `r`. +pub fn centered>( + d: &mut D, + text: &str, + style: MonoTextStyle<'_, BinaryColor>, + r: Rectangle, +) { + let t = Text::with_text_style(text, Point::zero(), style, TEXT_CENTER); + let by = center_offset(t.bounding_box(), r); + _ = t.translate(by).draw(d); +} + +pub fn centered_main>(d: &mut D, text: &str) { + centered(d, text, NORMAL_FONT, MAIN_RECT); +} + +#[cfg_attr(any(feature = "e2e", feature = "qa"), allow(dead_code))] +pub fn centered_screen>( + d: &mut D, + text: &str, + style: MonoTextStyle<'_, BinaryColor>, +) { + centered(d, text, style, SCREEN_RECT); +} + +#[derive(Clone, Copy, PartialEq)] +pub enum VAlign { + Middle, + Bottom, +} + +/// Greedy word wrap into lines of at most `cols` chars (`\n` forces a break, words longer +/// than a line are split). Yields trimmed slices of `text`. +pub fn wrap<'a>(text: &'a str, cols: usize, mut line: impl FnMut(&'a str)) { + for para in text.split('\n') { + let mut cur: Option<(usize, usize)> = None; // byte range of the current line in `para` + let mut cur_len = 0; + let mut pos = 0; + for word in para.split(' ') { + let start = pos; + pos += word.len() + 1; + if word.is_empty() { + continue; + } + let wlen = word.chars().count(); + if let Some((s, _)) = cur { + if cur_len + 1 + wlen <= cols { + cur = Some((s, start + word.len())); + cur_len += 1 + wlen; + continue; + } + line(¶[s..cur.map_or(s, |c| c.1)]); + } + // word starts a new line; split it when it is longer than a line + let mut rest = word; + let mut rest_start = start; + while rest.chars().count() > cols { + let cut = rest.char_indices().nth(cols).map_or(rest.len(), |(i, _)| i); + line(&rest[..cut]); + rest = &rest[cut..]; + rest_start += cut; + } + cur = Some((rest_start, rest_start + rest.len())); + cur_len = rest.chars().count(); + } + line(cur.map_or("", |(s, e)| ¶[s..e])); + } +} + +/// Word-wrapped text, each line horizontally centered, block aligned in `r` +/// (replaces embedded-text's centered `TextBox`). +pub fn text_box>( + d: &mut D, + text: &str, + r: Rectangle, + style: MonoTextStyle<'_, BinaryColor>, + valign: VAlign, +) { + let font: &MonoFont = style.font; + let cw = font.character_size.width + font.character_spacing; + let lh = font.character_size.height as i32; + let cols = (r.size.width / cw) as usize; + + let mut n = 0; + wrap(text, cols, |_| n += 1); + let text_h = n * lh; + let mut y = r.top_left.y + + match valign { + VAlign::Middle => (r.size.height as i32 - text_h) / 2, + VAlign::Bottom => r.size.height as i32 - text_h, + }; + + // like embedded-text: the block is aligned as a whole, only fully visible rows are drawn + let bottom = r.top_left.y + r.size.height as i32; + wrap(text, cols, |l| { + if y >= r.top_left.y && y + lh <= bottom { + let w = l.chars().count() as i32 * cw as i32; + let x = r.top_left.x + (r.size.width as i32 - w + 1) / 2; + _ = Text::with_text_style(l, Point::new(x, y), style, TEXT_TOP).draw(d); + } + y += lh; + }); +} + +pub fn text_main>(d: &mut D, text: &str) { + text_box(d, text, MAIN_RECT, NORMAL_FONT, VAlign::Middle); +} + +pub struct TopBar<'a> { + pub battery: (u8, bool), + pub wifi: bool, + pub server: bool, + #[cfg_attr(feature = "timer-func", allow(dead_code))] + pub stackmat: bool, + pub title: Option<&'a str>, +} + +fn crossed_icon>( + d: &mut D, + icon: &ImageRaw, + at: Point, + crossed: bool, +) { + _ = Image::new(icon, at).draw(d); + if crossed { + let size = icon.size(); + let tl = at + Point::new((size.width as i32 - 9) / 2, (size.height as i32 - 9) / 2); + let stroke = PrimitiveStyle::with_stroke(BinaryColor::On, 1); + _ = Line::new(tl, tl + Point::new(8, 8)).into_styled(stroke).draw(d); + _ = Line::new(tl + Point::new(0, 8), tl + Point::new(8, 0)) + .into_styled(stroke) + .draw(d); + } +} + +pub fn top_bar>(d: &mut D, bar: &TopBar) { + let mut pct: heapless::String<5> = heapless::String::new(); + _ = write!(pct, "{}%", bar.battery.0); + + // battery: right aligned "[charging icon] NN%" + let text = Text::with_text_style(&pct, Point::zero(), NORMAL_FONT, TEXT_TOP); + let tb = text.bounding_box(); + let text_x = 128 - tb.size.width as i32; + _ = text + .translate(Point::new(text_x, -2) - tb.top_left) + .draw(d); + if bar.battery.1 { + _ = Image::new(&ICON_CHARGING, Point::new(text_x - 1 - 5, 0)).draw(d); + } + + // connection icons, left aligned with 2 px spacing + crossed_icon(d, &ICON_WIFI, Point::new(0, 0), !bar.wifi); + crossed_icon(d, &ICON_SERVER, Point::new(12, 1), !bar.server); + #[cfg(not(feature = "timer-func"))] + crossed_icon(d, &ICON_TIMER, Point::new(21, 0), !bar.stackmat); + + if let Some(title) = bar.title { + _ = Text::with_text_style(title, Point::new(64, 5), SMALL_FONT, TEXT_CENTER).draw(d); + } + + _ = Line::new(Point::new(0, 10), Point::new(128, 10)) + .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) + .draw(d); +} + +/// Scrollable list in the main area; `item(i, buf)` writes item `i` (only visible ones +/// are requested). +pub fn menu>( + d: &mut D, + total: usize, + selected: usize, + mut item: impl FnMut(usize, &mut Line64), +) { + const LINE_HEIGHT: i32 = 10; + const VISIBLE: usize = 5; + const PADDING_X: i32 = 4; + + let scroll_start = if selected + 1 >= VISIBLE { + (selected + 1 - VISIBLE).min(total.saturating_sub(VISIBLE)) + } else { + 0 + }; + + let mut buf = Line64::new(); + for row in 0..VISIBLE.min(total.saturating_sub(scroll_start)) { + let idx = scroll_start + row; + let y = MAIN_RECT.top_left.y + row as i32 * LINE_HEIGHT; + let text_y = y + LINE_HEIGHT / 2; + buf.clear(); + item(idx, &mut buf); + + let style = if idx == selected { + _ = Rectangle::new(Point::new(0, y), Size::new(128, LINE_HEIGHT as u32)) + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) + .draw(d); + SMALL_FONT_INV + } else { + SMALL_FONT + }; + _ = Text::with_text_style(&buf, Point::new(PADDING_X, text_y), style, TEXT_LEFT_MIDDLE) + .draw(d); + + if scroll_start > 0 && row == 0 { + _ = Text::with_text_style("^", Point::new(122, text_y), SMALL_FONT, TEXT_CENTER) + .draw(d); + } + if scroll_start + VISIBLE < total && row == VISIBLE - 1 { + _ = Text::with_text_style("v", Point::new(122, text_y), SMALL_FONT, TEXT_CENTER) + .draw(d); + } + } +} + +/// Multi-line text scrolled by `scroll` lines. +pub fn details>(d: &mut D, text: &str, scroll: usize) { + const LINE_HEIGHT: i32 = 10; + const PADDING_X: i32 = 2; + + let start_y = MAIN_RECT.top_left.y; + for (row, line) in text + .split('\n') + .skip(scroll) + .take(DETAILS_VISIBLE_LINES) + .enumerate() + { + let text_y = start_y + row as i32 * LINE_HEIGHT + LINE_HEIGHT / 2; + _ = Text::with_text_style(line, Point::new(PADDING_X, text_y), SMALL_FONT, TEXT_LEFT_MIDDLE) + .draw(d); + } + + if scroll > 0 { + let y = start_y + LINE_HEIGHT / 2; + _ = Text::with_text_style("^", Point::new(122, y), SMALL_FONT, TEXT_CENTER).draw(d); + } + if scroll + DETAILS_VISIBLE_LINES < text.split('\n').count() { + let y = start_y + (DETAILS_VISIBLE_LINES as i32 - 1) * LINE_HEIGHT + LINE_HEIGHT / 2; + _ = Text::with_text_style("v", Point::new(122, y), SMALL_FONT, TEXT_CENTER).draw(d); + } +} + +pub enum QrData<'a> { + Text(&'a str), + Binary(&'a [u8]), +} + +/// Version 6 (41×41 modules) at 1 px per module, centered on the screen. +pub fn qr>(d: &mut D, data: QrData<'_>) { + use qrcodegen_no_heap::{QrCode, QrCodeEcc, Version}; + const MAX: Version = Version::new(6); + + let mut out = [0u8; MAX.buffer_len()]; + let mut tmp = [0u8; MAX.buffer_len()]; + let qr = match data { + QrData::Text(text) => QrCode::encode_text( + text, + &mut tmp, + &mut out, + QrCodeEcc::Quartile, + Version::MIN, + MAX, + None, + true, + ), + QrData::Binary(data) => { + let Some(dst) = tmp.get_mut(..data.len()) else { + return; + }; + dst.copy_from_slice(data); + QrCode::encode_binary( + &mut tmp, + data.len(), + &mut out, + QrCodeEcc::Quartile, + Version::MIN, + MAX, + None, + true, + ) + } + }; + let Ok(qr) = qr else { + return; + }; + + let size = qr.size(); + let origin = Point::new((128 - size) / 2, (64 - size) / 2); + let on = PrimitiveStyle::with_fill(BinaryColor::On); + for y in 0..size { + for x in 0..size { + if qr.get_module(x, y) { + _ = Rectangle::new(origin + Point::new(x, y), Size::new(1, 1)) + .into_styled(on) + .draw(d); + } + } + } +} + +pub fn group_select>(d: &mut D, text: &str) { + for (s, right) in [("<", false), (">", true)] { + let t = Text::with_text_style(s, Point::zero(), TIMER_FONT, TEXT_CENTER); + let bb = t.bounding_box(); + let x = if right { + MAIN_RECT.anchor_point(AnchorPoint::BottomRight).x - bb.anchor_point(AnchorPoint::BottomRight).x + } else { + MAIN_RECT.top_left.x - bb.top_left.x + }; + let y = center_offset(bb, MAIN_RECT).y; + _ = t.translate(Point::new(x, y)).draw(d); + } + text_main(d, text); +} + +/// Finished screen: big time (optionally inspection time below) at the top, status at the bottom. +pub fn finished>( + d: &mut D, + time: &str, + inspection: Option<&str>, + status: Option<&str>, +) { + let font = if time.len() > 13 { NORMAL_FONT } else { TIMER_FONT }; + let main = Text::with_text_style(time, Point::zero(), font, TEXT_CENTER); + let mb = main.bounding_box(); + + match inspection { + Some(insp) => { + let it = Text::with_text_style(insp, Point::zero(), SMALL_TIMER_FONT, TEXT_CENTER); + let ib = it.bounding_box(); + let width = mb.size.width.max(ib.size.width); + let group = Rectangle::new(Point::zero(), Size::new(width, mb.size.height + ib.size.height)); + let gx = center_offset(group, MAIN_RECT).x; + let top = MAIN_RECT.top_left.y + 2; + let mx = gx + (width as i32 - mb.size.width as i32) / 2; + let ix = gx + (width as i32 - ib.size.width as i32) / 2; + _ = main.translate(Point::new(mx, top) - mb.top_left).draw(d); + _ = it + .translate(Point::new(ix, top + mb.size.height as i32) - ib.top_left) + .draw(d); + } + None => { + let x = center_offset(mb, MAIN_RECT).x; + let y = MAIN_RECT.top_left.y + 10 - mb.top_left.y; + _ = main.translate(Point::new(x, y)).draw(d); + } + } + + if let Some(status) = status { + text_box(d, status, MAIN_RECT, NORMAL_FONT, VAlign::Bottom); + } +} + +/// Timer / inspection digits (fast path: only this area changes between frames). +pub fn digits>(d: &mut D, ms: u64) { + _ = d.fill_solid(&DIGITS_RECT, BinaryColor::Off); + let time = crate::utils::text::time_str(ms); + _ = Text::with_text_style(&time, Point::new(64, 36), TIMER_FONT, TEXT_CENTER).draw(d); +} + +pub fn inspection_label>(d: &mut D, label: &str) { + _ = Text::with_text_style(label, Point::new(64, 50), NORMAL_FONT, TEXT_CENTER).draw(d); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wrapped(text: &str, cols: usize) -> std::vec::Vec { + let mut v = std::vec::Vec::new(); + wrap(text, cols, |l| v.push(l.into())); + v + } + + #[test] + fn wrap_breaks_words_and_lines() { + assert_eq!(wrapped("Scan the card", 18), ["Scan the card"]); + assert_eq!(wrapped("a\nb", 18), ["a", "b"]); + assert_eq!(wrapped("hello world foo", 11), ["hello world", "foo"]); + assert_eq!(wrapped("abcdefghij", 4), ["abcd", "efgh", "ij"]); + assert_eq!(wrapped("x\n\ny", 4), ["x", "", "y"]); + assert_eq!(wrapped("zażółć gęślą", 6), ["zażółć", "gęślą"]); + } + + #[test] + fn frame_sends_only_changed_spans() { + let mut f = Frame::new(); + // unknown panel content: every page is sent in full + assert!((0..8).all(|p| f.dirty(p).is_some_and(|(c, b)| c == 0 && b.len() == 128))); + (0..8).for_each(|p| f.sent(p)); + f.mark_synced(); + assert!((0..8).all(|p| f.dirty(p).is_none())); + + _ = Pixel(Point::new(10, 20), BinaryColor::On).draw(&mut f); + _ = Pixel(Point::new(13, 23), BinaryColor::On).draw(&mut f); + _ = Pixel(Point::new(200, 20), BinaryColor::On).draw(&mut f); // off screen: ignored + assert_eq!(f.dirty(2), Some((10, &[0x10, 0, 0, 0x80][..]))); + assert!(f.dirty(1).is_none()); + f.sent(2); + assert!(f.dirty(2).is_none()); + + // redrawing identical content sends nothing + f.clear(); + _ = Pixel(Point::new(10, 20), BinaryColor::On).draw(&mut f); + _ = Pixel(Point::new(13, 23), BinaryColor::On).draw(&mut f); + assert!((0..8).all(|p| f.dirty(p).is_none())); + + f.invalidate(); + assert!(f.dirty(5).is_some_and(|(c, b)| c == 0 && b.len() == 128)); + } +} diff --git a/src/utils/screen.rs b/src/utils/screen.rs new file mode 100644 index 0000000..7ff04ba --- /dev/null +++ b/src/utils/screen.rs @@ -0,0 +1,148 @@ +//! What the display shows, decided once for both hardware revisions; the per-revision +//! renderers (`lcd_v3`, `lcd_v4`) only draw it. + +use crate::{ + consts::INSPECTION_TIME_PLUS2, + state::{ErrorLogEntryStage, MenuScene, Scene, SignaledGlobalStateInner}, + translations::TranslationKey, +}; +use embassy_time::Instant; + +pub enum Screen<'a> { + Error(&'a str), + Custom(&'a str, &'a str), + ConfigMenu(usize), + #[cfg(feature = "v4")] + BuzzerVolume, + ErrorLogList(usize), + ErrorLogEntry { + idx: usize, + #[cfg_attr(feature = "v3", allow(dead_code))] + stage: Option<&'a ErrorLogEntryStage>, + #[cfg_attr(feature = "v3", allow(dead_code))] + scroll: usize, + }, + /// `true` = signing, `false` = unsigning + Signing(bool), + BtDevices(usize), + WifiLost, + ServerLost, + DeviceNotAdded, + StackmatLost, + /// seconds remaining until the delegate is called (0 = waiting for the delegate) + Delegate(u8), + WifiConnect, + AutoSetup, + MdnsWait, + /// selected group name (empty if the selection is out of range) + GroupSelect(&'a str), + WaitingForCompetitor(Option), + CompetitorInfo, + Inspection(Instant), + Timer, + Finished(Finished), + Update, +} + +pub struct Finished { + pub time: u64, + pub penalty: i8, + /// inspection time, only when it is worth showing (over the +2 threshold) + pub inspection: Option, + /// translation key of the status line + pub status: Option, +} + +/// The priority chain: error > custom message > config menu > menu scene > connection +/// overwrites > delegate > scene. +pub fn screen(s: &SignaledGlobalStateInner) -> Screen<'_> { + if let Some(ref error) = s.error_text { + return Screen::Error(error); + } + if let Some((ref l1, ref l2)) = s.custom_message { + return Screen::Custom(l1, l2); + } + if let Some(sel) = s.selected_config_menu { + return Screen::ConfigMenu(sel); + } + + match s.menu_scene { + Some(MenuScene::Signing | MenuScene::Unsigning) => { + return Screen::Signing(s.menu_scene == Some(MenuScene::Signing)); + } + Some(MenuScene::BtDisplay) => return Screen::BtDevices(s.selected_bluetooth_item), + Some(MenuScene::ErrorLog) => { + return match s.selected_error_log_entry { + Some(idx) => Screen::ErrorLogEntry { + idx, + stage: s.error_log_entry_stage.as_ref(), + scroll: s.error_log_details_scroll, + }, + None => Screen::ErrorLogList(s.selected_error_log_item), + }; + } + #[cfg(feature = "v4")] + Some(MenuScene::BuzzerVolume) => return Screen::BuzzerVolume, + None => {} + } + + if s.scene.can_be_lcd_overwritten() { + if s.server_connected == Some(false) { + return if s.wifi_connected == Some(false) { + Screen::WifiLost + } else { + Screen::ServerLost + }; + } + if s.device_added == Some(false) { + return Screen::DeviceNotAdded; + } + if s.stackmat_connected == Some(false) { + return Screen::StackmatLost; + } + } + + if let Some(held) = s.delegate_hold { + return Screen::Delegate(3u8.saturating_sub(held)); + } + + match s.scene { + Scene::WifiConnect => Screen::WifiConnect, + Scene::AutoSetupWait => Screen::AutoSetup, + Scene::MdnsWait => Screen::MdnsWait, + Scene::GroupSelect => Screen::GroupSelect( + s.possible_groups + .get(s.group_selected_idx) + .map_or("", |g| g.name.as_str()), + ), + Scene::WaitingForCompetitor => Screen::WaitingForCompetitor(s.solve_time), + Scene::CompetitorInfo => Screen::CompetitorInfo, + Scene::Inspection => Screen::Inspection(s.inspection_start.unwrap_or_else(Instant::now)), + Scene::Timer => Screen::Timer, + Scene::Finished => { + let inspection = match (s.inspection_start, s.inspection_end) { + (Some(start), Some(end)) => Some(end.saturating_duration_since(start).as_millis()), + _ => None, + }; + + let status = if !s.time_confirmed { + Some(TranslationKey::CONFIRM_TIME) + } else if s.current_judge.is_none() { + Some(TranslationKey::SCAN_JUDGE_CARD) + } else if s.current_competitor.is_some() { + Some(TranslationKey::SCAN_COMPETITOR_CARD) + } else { + None + }; + + Screen::Finished(Finished { + time: s.solve_time.unwrap_or(0), + penalty: s.penalty.unwrap_or(0), + inspection: inspection + .filter(|&t| s.use_inspection() && t > INSPECTION_TIME_PLUS2), + status, + }) + } + Scene::Update => Screen::Update, + } +} diff --git a/src/utils/ssd1309.rs b/src/utils/ssd1309.rs new file mode 100644 index 0000000..8157ab5 --- /dev/null +++ b/src/utils/ssd1309.rs @@ -0,0 +1,85 @@ +//! Minimal async SSD1309 (128×64, I2C) driver: sends only the changed span of each page. +//! Init sequence and rotation are the ones oled_async 0.2.1 used (Ssd1309_128_64, Rotate180). + +use super::oled_ui::Frame; +use embassy_time::Timer; +use embedded_hal::digital::OutputPin; +use embedded_hal_async::i2c::I2c; + +const ADDR: u8 = 0x3C; +const CMD: u8 = 0x00; +const DATA: u8 = 0x40; + +const INIT: &[u8] = &[ + CMD, 0xAE, // display off + 0xD5, 0x80, // clock div + 0xA8, 0x3F, // multiplex 64 + 0x40, // start line 0 + 0xAD, 0x8B, // charge pump + 0x81, 0x80, // contrast + 0xD9, 0xF1, // pre-charge + 0xDB, 0x40, // vcomh auto + 0xA4, // resume from RAM + 0xA6, // normal (not inverted) + 0xAF, // display on + 0xD3, 0x00, // display offset 0 +]; +const INIT2: &[u8] = &[ + CMD, 0xDA, 0x12, // alternative COM pins + 0xA0, 0xC0, // Rotate180: segment remap off, COM scan normal +]; + +pub struct Ssd1309 { + i2c: I, + pub frame: Frame, +} + +impl Ssd1309 { + pub const fn new(i2c: I) -> Self { + Self { + i2c, + frame: Frame::new(), + } + } + + pub async fn init(&mut self, rst: &mut impl OutputPin) -> Result<(), I::Error> { + _ = rst.set_high(); + Timer::after_millis(1).await; + _ = rst.set_low(); + Timer::after_millis(10).await; + _ = rst.set_high(); + + self.i2c.write(ADDR, INIT).await?; + self.i2c.write(ADDR, INIT2).await?; + self.frame.invalidate(); + Ok(()) + } + + /// Sends what changed since the last flush (nothing if the frame is unchanged). + pub async fn flush(&mut self) -> Result<(), I::Error> { + let mut data = [0u8; 129]; + data[0] = DATA; + + for page in 0..8 { + let Some((col, bytes)) = self.frame.dirty(page) else { + continue; + }; + let n = bytes.len(); + data[1..=n].copy_from_slice(bytes); + + let addr = [CMD, 0xB0 | page as u8, col & 0x0F, 0x10 | (col >> 4)]; + let res = match self.i2c.write(ADDR, &addr).await { + Ok(()) => self.i2c.write(ADDR, &data[..=n]).await, + err => err, + }; + if let Err(e) = res { + self.frame.invalidate(); // panel content unknown now + return Err(e); + } + self.frame.sent(page); + } + + self.frame.mark_synced(); + Ok(()) + } +} diff --git a/src/utils/stackmat.rs b/src/utils/stackmat.rs index 70f7b17..4c64eb1 100644 --- a/src/utils/stackmat.rs +++ b/src/utils/stackmat.rs @@ -82,19 +82,3 @@ pub fn generate_stackmat_data(state: &StackmatTimerState, time_ms: u64) -> [u8; data[7] = 64 + data[1..7].iter().fold(0u8, |acc, &x| acc + (x - b'0')); data } - -pub fn ms_to_time_str(ms: u64) -> heapless::String<12> { - let minutes: u8 = (ms / 60000) as u8; - let seconds: u8 = ((ms % 60000) / 1000) as u8; - let ms: u16 = (ms % 1000) as u16; - - let mut time_str = heapless::String::<12>::new(); - let ms2 = ms / 10; - if minutes > 0 { - _ = time_str.push_str(&alloc::format!("{minutes}:{seconds:02}.{ms2:02}")); - } else { - _ = time_str.push_str(&alloc::format!("{seconds:01}.{ms2:02}")); - } - - time_str -} diff --git a/src/utils/text.rs b/src/utils/text.rs index ea8d6b6..c9b4c88 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -28,6 +28,20 @@ pub fn fill_placeholders(template: &str, params: &[T], out: &mut imp _ = out.write_str(rest); } +/// Solve time as `m:ss.cc` / `s.cc` (centiseconds truncated), no heap. +pub fn time_str(ms: u64) -> heapless::String<12> { + let minutes = ms / 60000; + let seconds = (ms % 60000) / 1000; + let cs = (ms % 1000) / 10; + let mut s = heapless::String::new(); + _ = if minutes > 0 { + write!(s, "{minutes}:{seconds:02}.{cs:02}") + } else { + write!(s, "{seconds}.{cs:02}") + }; + s +} + #[cfg(test)] mod tests { use super::*; @@ -46,4 +60,13 @@ mod tests { assert_eq!(fill("", &["a"]), ""); assert_eq!(fill("ó{0}ł", &["ż"]), "óżł"); } + + #[test] + fn time_format() { + assert_eq!(time_str(0), "0.00"); + assert_eq!(time_str(9_999), "9.99"); + assert_eq!(time_str(12_345), "12.34"); + assert_eq!(time_str(61_009), "1:01.00"); + assert_eq!(time_str(600_000), "10:00.00"); + } } From 3b93afc101ec0e151f8a4569074d3d303af5ad66 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:20:27 +0200 Subject: [PATCH 030/132] chore: drop replaced display dependencies --- Cargo.lock | 897 +------------------------------------ Cargo.toml | 8 +- macros/Cargo.toml | 2 - macros/src/lib.rs | 6 - macros/src/pixelart.rs | 152 ------- src/resources/CHARGING.png | Bin 154 -> 0 bytes src/resources/SERVER.png | Bin 162 -> 0 bytes src/resources/TIMER.png | Bin 171 -> 0 bytes src/resources/WIFI.png | Bin 182 -> 0 bytes 9 files changed, 7 insertions(+), 1058 deletions(-) delete mode 100644 macros/src/pixelart.rs delete mode 100644 src/resources/CHARGING.png delete mode 100644 src/resources/SERVER.png delete mode 100644 src/resources/TIMER.png delete mode 100644 src/resources/WIFI.png diff --git a/Cargo.lock b/Cargo.lock index f465116..0c73c4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,24 +94,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "allocator-api2" version = "0.3.1" @@ -124,44 +106,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "arraydeque" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "askama" version = "0.14.0" @@ -205,49 +155,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - [[package]] name = "az" version = "1.2.1" @@ -266,12 +173,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitfield" version = "0.19.5" @@ -315,15 +216,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "bitvec" version = "1.1.1" @@ -404,18 +296,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - [[package]] name = "bytemuck" version = "1.25.2" @@ -428,12 +308,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "cc" version = "1.8.0" @@ -441,8 +315,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -490,12 +362,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "const-default" version = "1.0.0" @@ -596,37 +462,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -964,23 +799,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "display-interface" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba2aab1ef3793e6f7804162debb5ac5edb93b3d650fbcc5aeb72fcd0e6c03a0" - -[[package]] -name = "display-interface-i2c" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d964fa85bbbb5a6ecd06e58699407ac5dc3e3ad72dac0ab7e6b0d00a1cd262d" -dependencies = [ - "display-interface", - "embedded-hal 1.0.0", - "embedded-hal-async", -] - [[package]] name = "docsplay" version = "0.1.3" @@ -1347,15 +1165,6 @@ dependencies = [ "nb 1.1.0", ] -[[package]] -name = "embedded-dma" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994f7e5b5cb23521c22304927195f236813053eb9c065dd2226a32ba64695446" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "embedded-graphics" version = "0.8.2" @@ -1379,16 +1188,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "embedded-graphics-framebuf" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22354420f68727fa24d1e2741dae1e9a041065e80fb63b35a8d19c647a85be76" -dependencies = [ - "embedded-dma", - "embedded-graphics", -] - [[package]] name = "embedded-hal" version = "0.2.7" @@ -1458,27 +1257,6 @@ dependencies = [ "embedded-io 0.7.1", ] -[[package]] -name = "embedded-layout" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a90553247f2b05c59ac7894ea13d830636c2b1203fa03bff400eddbd1fa9f52" -dependencies = [ - "embedded-graphics", - "embedded-layout-macros", -] - -[[package]] -name = "embedded-layout-macros" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f6e621fe4c7e05b695274b722dc0a60bacd1c8696b58191baa0154713d52400" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "embedded-nal" version = "0.9.0" @@ -1513,17 +1291,6 @@ dependencies = [ "embedded-storage", ] -[[package]] -name = "embedded-text" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba" -dependencies = [ - "az", - "embedded-graphics", - "object-chain", -] - [[package]] name = "embedded-tls" version = "0.19.0" @@ -1573,26 +1340,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.3" @@ -2087,38 +1834,6 @@ dependencies = [ "vcell", ] -[[package]] -name = "exr" -version = "1.74.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide 0.8.9", - "num-complex", - "pulp", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "ff" version = "0.13.1" @@ -2154,8 +1869,6 @@ dependencies = [ "anyhow", "bq27441", "critical-section", - "display-interface", - "display-interface-i2c", "dotenvy", "dyn-smooth", "embassy-crypto", @@ -2166,14 +1879,11 @@ dependencies = [ "embassy-sync 0.8.0", "embassy-time", "embedded-graphics", - "embedded-graphics-framebuf", "embedded-hal 1.0.0", "embedded-hal-async", "embedded-hal-bus", "embedded-io-async 0.7.0", - "embedded-layout", "embedded-storage", - "embedded-text", "embedded-tls", "esp-alloc", "esp-backtrace", @@ -2188,12 +1898,11 @@ dependencies = [ "esp-rtos", "esp-storage", "esp32c3 0.33.0", - "getrandom 0.2.15", + "getrandom", "heapless 0.9.3", "log", "macros", "nb 1.1.0", - "oled_async", "portable-atomic", "profont", "qrcodegen-no-heap", @@ -2213,7 +1922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide 0.9.1", + "miniz_oxide", "zlib-rs", ] @@ -2366,29 +2075,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - [[package]] name = "ghash" version = "0.6.0" @@ -2398,16 +2084,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "group" version = "0.13.0" @@ -2430,17 +2106,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - [[package]] name = "hash32" version = "0.3.1" @@ -2560,46 +2225,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" - [[package]] name = "include-minifier" version = "0.2.0" @@ -2662,17 +2287,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2725,50 +2339,18 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "linked_list_allocator" version = "0.10.6" @@ -2809,22 +2391,11 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "macros" version = "0.1.0" dependencies = [ - "anyhow", "convert_case 0.12.0", - "image", "proc-macro2", "quote", "serde", @@ -2847,16 +2418,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2869,16 +2430,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2889,16 +2440,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -2920,36 +2461,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2959,26 +2470,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "bytemuck", - "num-traits", -] - [[package]] name = "num-derive" version = "0.4.2" @@ -2990,26 +2481,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3040,24 +2511,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "object-chain" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd" - -[[package]] -name = "oled_async" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459f0aec254d6506e17928b6fee53bbf294bc37af9dbab75652fc74dc2c06b6c" -dependencies = [ - "defmt 0.3.101", - "display-interface", - "embedded-graphics-core", - "embedded-hal 1.0.0", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3093,12 +2546,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3111,19 +2558,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - [[package]] name = "polyval" version = "0.7.3" @@ -3242,25 +2676,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "profont" version = "0.7.0" @@ -3270,56 +2685,12 @@ dependencies = [ "embedded-graphics", ] -[[package]] -name = "pulp" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" -dependencies = [ - "bytemuck", - "cfg-if", - "libm", - "num-complex", - "paste", - "pulp-wasm-simd-flag", - "raw-cpuid", - "reborrow", - "version_check", -] - -[[package]] -name = "pulp-wasm-simd-flag" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "qrcodegen-no-heap" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c" -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quote" version = "1.0.47" @@ -3329,18 +2700,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "radium" version = "0.7.0" @@ -3356,16 +2715,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -3376,16 +2725,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -3397,9 +2736,6 @@ name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] [[package]] name = "rand_core" @@ -3407,91 +2743,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.5", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "reborrow" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" - [[package]] name = "regex-automata" version = "0.4.18" @@ -3519,12 +2770,6 @@ dependencies = [ "hmac 0.13.0", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "riscv" version = "0.15.0" @@ -3772,15 +3017,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -3946,20 +3182,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "toml" version = "0.8.23" @@ -4109,8 +3331,8 @@ dependencies = [ "futures", "heapless 0.9.3", "p256 0.13.2", - "rand 0.8.8", - "rand_chacha 0.3.1", + "rand", + "rand_chacha", "rand_core 0.6.4", "static_cell", "trouble-host-macros", @@ -4195,18 +3417,7 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", + "getrandom", ] [[package]] @@ -4239,66 +3450,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "winapi-util" version = "0.1.11" @@ -4350,12 +3501,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wnaf" version = "0.14.1" @@ -4374,7 +3519,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff96a9b275d0beb2ccdbca7faec38c4fb84fdab77a931867205338cd2466404" dependencies = [ - "getrandom 0.2.15", + "getrandom", "httparse", "itoa", ] @@ -4429,12 +3574,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yaml-rust2" version = "0.9.0" @@ -4483,27 +3622,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index fb9b101..ee97706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,15 +53,9 @@ esp-bootloader-esp-idf = { version = "0.6.0", features = ["log-04", "esp32c3"] } trouble-host = { version = "0.7.0", features = ["scan", "security"] } # v4 only -display-interface = { version = "0.5.0", optional = true } -display-interface-i2c = { version = "0.5.0", optional = true } embedded-graphics = { version = "0.8.2", optional = true } -embedded-graphics-framebuf = { version = "0.5.0", optional = true } -oled_async = { version = "0.2.1", features = ["i2c"], optional = true } bq27441 = { version = "0.3.0", features = ["async", "embassy"], optional = true } profont = { version = "0.7.0", optional = true } -embedded-layout = { version = "0.4.2", optional = true } -embedded-text = { version = "0.7.3", optional = true } qrcodegen-no-heap = { version = "1.8.1", optional = true } #v3 only @@ -79,7 +73,7 @@ timer-func = [] sleep = [] auto_add = [] v3 = ["dep:dyn-smooth", "dep:ag-lcd-async"] -v4 = ["dep:display-interface", "dep:display-interface-i2c", "dep:embedded-graphics", "dep:embedded-graphics-framebuf", "dep:oled_async", "dep:bq27441", "dep:profont", "dep:embedded-layout", "dep:embedded-text", "dep:qrcodegen-no-heap"] +v4 = ["dep:embedded-graphics", "dep:bq27441", "dep:profont", "dep:qrcodegen-no-heap"] [profile.dev] opt-level = 3 diff --git a/macros/Cargo.toml b/macros/Cargo.toml index fc276a5..d99291e 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -7,9 +7,7 @@ edition = "2021" proc-macro = true [dependencies] -anyhow = "1.0.104" convert_case = "0.12.0" -image = "0.25.10" proc-macro2 = "1.0.107" quote = "1.0.47" serde = { version = "1.0.229", features = ["derive"] } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index c3619fc..80ff700 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,6 +1,5 @@ #![feature(proc_macro_span)] -mod pixelart; mod translations; use proc_macro::TokenStream; @@ -205,11 +204,6 @@ pub fn load_default_translations(args: TokenStream) -> TokenStream { translations::load_translations_macro(args) } -#[proc_macro] -pub fn load_lcd_resources(args: TokenStream) -> TokenStream { - pixelart::load_lcd_resources(args) -} - // Maybe not useful, most variables in functions that can fail are owned :( /* struct MacroInput { diff --git a/macros/src/pixelart.rs b/macros/src/pixelart.rs deleted file mode 100644 index cc367db..0000000 --- a/macros/src/pixelart.rs +++ /dev/null @@ -1,152 +0,0 @@ -use convert_case::Casing; -use image::{GenericImageView, ImageReader}; -use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use std::path::PathBuf; -use syn::{ - parse::{Parse, ParseStream}, - LitStr, -}; - -#[derive(Debug)] -#[allow(dead_code)] -struct PixelArtHandler { - path: String, -} - -impl Parse for PixelArtHandler { - fn parse(input: ParseStream) -> Result { - let path = input.parse::()?; - - Ok(PixelArtHandler { path: path.value() }) - } -} - -pub fn load_lcd_resources(args: TokenStream) -> TokenStream { - let PixelArtHandler { path } = syn::parse_macro_input!(args as PixelArtHandler); - - let mut resources = Vec::new(); - let mut res = std::fs::read_dir(path).expect("Cannot read specified path"); - while let Some(Ok(e)) = res.next() { - let metadata = e.metadata().unwrap(); - if metadata.is_file() { - if let Ok((width, height, data)) = parse_img(e.path()) { - let name = format_ident!( - "{}", - e.file_name() - .to_string_lossy() - .split(".") - .next() - .unwrap() - .to_case(convert_case::Case::Constant) - ); - let data_tokens = quote! { &[#(#data),*] }; - - resources.push(quote! { - pub const #name: PixelArt = PixelArt::new(#data_tokens, #width, #height); - }); - } - } - } - - quote! { - pub struct Resources {} - - impl Resources { - #(#resources)* - } - } - .into() -} - -fn parse_img(path: PathBuf) -> anyhow::Result<(u32, u32, Vec)> { - let img = ImageReader::open(path)?.decode()?; - let (w, h) = img.dimensions(); - - let mut bounding_box_left = 0; - let mut bounding_box_right = w; - let mut bounding_box_top = h; - let mut bounding_box_bottom = 0; - - for x in 0..w { - let mut all_clear = true; - for y in 0..h { - let px = img.get_pixel(x, y).0; - if px == [0, 0, 0, 255] { - all_clear = false; - break; - } - } - - if !all_clear { - bounding_box_right = x; - } - } - - for x in (0..w).rev() { - let mut all_clear = true; - for y in 0..h { - let px = img.get_pixel(x, y).0; - if px == [0, 0, 0, 255] { - all_clear = false; - break; - } - } - - if !all_clear { - bounding_box_left = x; - } - } - - for y in 0..h { - let mut all_clear = true; - for x in 0..w { - let px = img.get_pixel(x, y).0; - if px == [0, 0, 0, 255] { - all_clear = false; - break; - } - } - - if !all_clear { - bounding_box_top = y; - } - } - - for y in (0..h).rev() { - let mut all_clear = true; - for x in 0..w { - let px = img.get_pixel(x, y).0; - if px == [0, 0, 0, 255] { - all_clear = false; - break; - } - } - - if !all_clear { - bounding_box_bottom = y; - } - } - - let x_size = bounding_box_right - bounding_box_left + 1; - let y_size = bounding_box_top - bounding_box_bottom + 1; - - let bytes_per_row = (x_size + 7) / 8; - let mut data = vec![0u8; (bytes_per_row * y_size) as usize]; - for y in bounding_box_bottom..=bounding_box_top { - for x in bounding_box_left..=bounding_box_right { - let px = img.get_pixel(x, y).0; - - if px == [0, 0, 0, 255] { - let relative_x = x - bounding_box_left; - let relative_y = y - bounding_box_bottom; - - let byte_idx = (relative_y * bytes_per_row + relative_x / 8) as usize; - let bit_idx = 7 - (relative_x % 8); - data[byte_idx] |= 1 << bit_idx; - } - } - } - - Ok((x_size, y_size, data)) -} diff --git a/src/resources/CHARGING.png b/src/resources/CHARGING.png deleted file mode 100644 index 5288b1cb6710304b48a036ed964ccdaecb153f7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CJ!3HGRcAO0XQvRMUjv*C{J?9v?4k!pPAKdo; ze{m{{qhULjV%Da4_lx8H$5=9+dat`-YU9FVdQ I&MBb@0E7ZJsQ>@~ diff --git a/src/resources/TIMER.png b/src/resources/TIMER.png deleted file mode 100644 index f5f10be645e926c18fd4166726ad85059eb624d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CJ!3HGRcAO0XQVE_ejv*C{C4G#12NXD%@BaJ$ zymxVy)Dc$$>vgZJf4%2^Bf38-mLV|stNZLKUIpcfgDLLc8JLe8sMb9;?*cPtn)8BJ zjE}_>%1<1KUNAMAp)>u!-h?xgu1ZTNR2&Ri_7`Zy|Nmt!YhHt_1lh&Rpz&Uaf2N>V Q0#Jm()78&qol`;+09O(_82|tP diff --git a/src/resources/WIFI.png b/src/resources/WIFI.png deleted file mode 100644 index 82d22a442606fbb98104734bc4db7e51018dd7a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmeAS@N?(olHy`uVBq!ia0vp^JV4CJ!3HGRcAO0XQrVs^jv*C{HRl<*8VqldTJOY}Hor1n*ZoI=0J3DuMv#VhVGw@hl`sebX~ zIe$wNn33w>sHGlLe!Ah1M>A+!PC{xWt~$(695xwMJoUR From f61757a3ba9e234e8b37216a9976b73f80e15481 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:27:13 +0200 Subject: [PATCH 031/132] refactor: display sleep through power module, lcd heartbeat --- src/lcd_v3.rs | 26 +++++++++++++-------- src/lcd_v4.rs | 62 ++++++++++++++++----------------------------------- src/power.rs | 7 +++--- 3 files changed, 39 insertions(+), 56 deletions(-) diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 0b18f89..5e9e5cc 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -1,6 +1,7 @@ use crate::{ consts::{LCD_INSPECTION_FRAME_TIME, SCROLL_TICKER_INVERVAL_MS}, - state::{GlobalState, MenuScene, Scene, SignaledGlobalStateInner, sleep_state}, + power, + state::{GlobalState, MenuScene, Scene, SignaledGlobalStateInner}, translations::{TranslationKey, get_translation, get_translation_params}, utils::{ error_log::{self, ErrorLogEntry}, @@ -14,12 +15,13 @@ use ag_lcd_async::LcdDisplay; use core::fmt::Write; use embassy_futures::select::{Either, Either3, select, select3}; use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; -use embassy_time::{Delay, Duration, Instant, Timer}; +use embassy_time::{Delay, Duration, Instant, Timer, with_timeout}; type Lcd = LcdDisplay; type Text = heapless::String<96>; const SIGN_RESULT_MS: u64 = 300; +const EVENT_TIMEOUT: Duration = Duration::from_secs(30); async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { let mut lcd = LcdDisplay::new( @@ -119,9 +121,9 @@ pub async fn lcd_task( state_changed = false; rerender = true; last_update = Instant::now(); - if sleep_state() { + if power::is_sleeping() { lcd.backlight_on(); - crate::state::set_sleep_state(false); + power::wake(); } } @@ -141,7 +143,13 @@ pub async fn lcd_task( show(&mut text, &mut lcd).await; } - match next_event(global_state, wifi_setup_sig, live, &local).await { + crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); + // bounded so the heartbeat keeps beating while a live screen waits for its signal + let event = with_timeout(EVENT_TIMEOUT, next_event(global_state, wifi_setup_sig, live, &local)); + let Ok(event) = event.await else { + continue; + }; + match event { Event::State => state_changed = true, Event::TimerValue(ms) => { local.timer_ms = Some(ms); @@ -246,23 +254,23 @@ async fn sleep_check(text: &mut LcdText, lcd: &mut Lcd, global_state: GlobalStat let idle_ms = last_update.elapsed().as_millis(); let can_sleep = global_state.state.value().await.scene.can_sleep(); - if !sleep_state() && idle_ms > SLEEP_AFTER_MS && can_sleep { + if !power::is_sleeping() && idle_ms > SLEEP_AFTER_MS && can_sleep { text.print(0, "Sleep", Align::Center, true); text.print(1, "Press any key", Align::Center, true); show(text, lcd).await; lcd.backlight_off(); global_state.state.lock().await.server_connected = Some(false); - crate::state::set_sleep_state(true); + power::request_sleep(); crate::state::set_trust_server(false); global_state.state.signal_reset(); } - if sleep_state() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { + if power::is_sleeping() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { text.print(0, "Deep Sleep", Align::Center, true); text.print(1, "Press any key", Align::Center, true); show(text, lcd).await; - crate::utils::deeper_sleep(); + power::enter_deep_sleep(); } } diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index 95b66a8..d54c494 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -1,8 +1,7 @@ use crate::{ consts::LCD_INSPECTION_FRAME_TIME, - state::{ - ErrorLogEntryStage, GlobalState, MenuScene, Scene, SignaledGlobalStateInner, sleep_state, - }, + power, + state::{ErrorLogEntryStage, GlobalState, MenuScene, Scene, SignaledGlobalStateInner}, translations::{TranslationKey, get_translation, get_translation_params}, utils::{ error_log::{self, ErrorLogEntry, codes}, @@ -44,6 +43,7 @@ struct Local { } const SIGN_RESULT_MS: u64 = 300; +const EVENT_TIMEOUT: Duration = Duration::from_secs(30); async fn flush(oled: &mut Ssd1309) { match with_timeout(Duration::from_millis(1500), oled.flush()).await { @@ -106,8 +106,8 @@ pub async fn lcd_task( state_changed = false; rerender = true; last_update = Instant::now(); - if sleep_state() { - crate::state::set_sleep_state(false); + if power::is_sleeping() { + power::wake(); log::warn!("Sleep wakeup!"); } } @@ -124,7 +124,13 @@ pub async fn lcd_task( flush(&mut oled).await; } - match next_event(global_state, wifi_setup_sig, live, &local).await { + crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); + // bounded so the heartbeat keeps beating while a live screen waits for its signal + let event = with_timeout(EVENT_TIMEOUT, next_event(global_state, wifi_setup_sig, live, &local)); + let Ok(event) = event.await else { + continue; + }; + match event { Event::State => state_changed = true, Event::TimerValue(ms) => { local.timer_ms = Some(ms); @@ -223,11 +229,14 @@ async fn sleep_check(oled: &mut Ssd1309, global_state: GlobalState, l let idle_ms = last_update.elapsed().as_millis(); let can_sleep = global_state.state.value().await.scene.can_sleep(); - if !sleep_state() && idle_ms > SLEEP_AFTER_MS && can_sleep { + if !power::is_sleeping() && idle_ms > SLEEP_AFTER_MS && can_sleep { enter_sleep(oled, global_state).await; } - if sleep_state() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { - deep_sleep(oled).await; + if power::is_sleeping() && !crate::state::deeper_sleep_state() && idle_ms > DEEPER_SLEEP_AFTER_MS { + oled.frame.clear(); + ui::centered_screen(&mut oled.frame, "Sleep", ui::SMALL_FONT); + flush(oled).await; + power::enter_deep_sleep(); } } @@ -247,44 +256,11 @@ async fn enter_sleep(oled: &mut Ssd1309, global_state: GlobalState) { state.wifi_connected = Some(false); } - crate::state::set_sleep_state(true); + power::request_sleep(); crate::state::set_trust_server(false); global_state.state.signal_reset(); } -/// Deep sleep until a button (GPIO0-3) goes high; returns only if the wakeup can't be armed. -#[cfg(not(any(feature = "e2e", feature = "qa")))] -async fn deep_sleep(oled: &mut Ssd1309) { - use esp_hal::gpio::{Event, Input, InputConfig, Pull, WakeupConfig}; - use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; - - oled.frame.clear(); - ui::centered_screen(&mut oled.frame, "Sleep", ui::SMALL_FONT); - flush(oled).await; - - // SAFETY: the buttons task is idle while sleeping and the chip never returns from deep sleep - unsafe { - // pull-down like the old RtcioWakeupSource did for a high-level wake - let input_config = InputConfig::default().with_pull(Pull::Down); - let wakeup_config = WakeupConfig::default().with_low_power_path(true); - let mut wakeup_pins = [ - Input::new(esp_hal::peripherals::GPIO0::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO1::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO2::steal(), input_config), - Input::new(esp_hal::peripherals::GPIO3::steal(), input_config), - ]; - for pin in &mut wakeup_pins { - if let Err(e) = pin.apply_wakeup_config(&wakeup_config) { - // without a wake source the chip would never wake up again - log::error!("Deep sleep wakeup config failed: {e:?}"); - return; - } - pin.listen(Event::HighLevel); - } - LowPower::new(esp_hal::peripherals::LPWR::steal()).sleep_deep(RtcSleepConfig::deep()); - } -} - /// Out-of-range menu selections reset to the first item (was done mid-render before). fn clamp_selection(state: &mut crate::utils::signaled_mutex::SignaledMutexGuard<'_, CriticalSectionRawMutex, SignaledGlobalStateInner>) { let error_log_items = error_log::entry_count() + 1; diff --git a/src/power.rs b/src/power.rs index 276ef75..1e9d4cc 100644 --- a/src/power.rs +++ b/src/power.rs @@ -17,19 +17,18 @@ pub fn is_sleeping() -> bool { crate::state::sleep_state() } -#[allow(dead_code)] // called by the display code once its sleep entry moves here +#[cfg_attr(any(feature = "e2e", feature = "qa"), allow(dead_code))] // sleep is off in e2e/qa pub fn request_sleep() { crate::state::set_sleep_state(true); } -#[allow(dead_code)] // called by the display/buttons code once its wake path moves here pub fn wake() { crate::state::set_sleep_state(false); } /// Powers the chip down until one of the four buttons is pressed (wake = reset). #[cfg(feature = "v4")] -#[allow(dead_code)] // called by the display code once its deep sleep entry moves here +#[cfg_attr(any(feature = "e2e", feature = "qa"), allow(dead_code))] pub fn enter_deep_sleep() -> ! { use esp_hal::gpio::{Event, Input, InputConfig, Pin, Pull, WakeupConfig}; use esp_hal::rtc_cntl::sleep::{LowPower, RtcSleepConfig}; @@ -63,7 +62,7 @@ pub fn enter_deep_sleep() -> ! { /// v3 has no wakeable deep sleep: the cpu clock drops to 10 MHz until the next reset. #[cfg(feature = "v3")] -#[allow(dead_code)] // called by the display code once its deeper sleep entry moves here +#[cfg_attr(any(feature = "e2e", feature = "qa"), allow(dead_code))] pub fn enter_deep_sleep() { crate::utils::deeper_sleep(); } From 66a0238649b13873b29c417caa48ac5d19ebe005 Mon Sep 17 00:00:00 2001 From: filipton Date: Fri, 16 Oct 2026 19:09:25 +0200 Subject: [PATCH 032/132] refactor: plain button handler table instead of proc macros --- macros/src/lib.rs | 273 ------------------------------------------- src/buttons.rs | 179 +++++++++++----------------- src/utils/buttons.rs | 272 +++++++++++++++++++----------------------- 3 files changed, 193 insertions(+), 531 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 80ff700..fa8ce81 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,281 +1,8 @@ -#![feature(proc_macro_span)] - mod translations; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{ - parse::{Parse, ParseStream}, - Ident, Item, Meta, Token, -}; - -#[derive(Debug)] -#[allow(dead_code)] -struct KeyValue { - key: Ident, - value_type: syn::Type, -} - -#[derive(Debug)] -#[allow(dead_code)] -struct GenerateHandler { - values: Vec, -} - -impl Parse for GenerateHandler { - fn parse(input: ParseStream) -> Result { - let mut values = Vec::new(); - while !input.is_empty() { - let key = input.parse::()?; - input.parse::()?; - let value_type = input.parse::()?; - values.push(KeyValue { key, value_type }); - if !input.peek(Token![,]) { - break; - } - input.parse::()?; - } - - Ok(GenerateHandler { values }) - } -} - -#[proc_macro] -pub fn generate_button_handler_enum(args: TokenStream) -> TokenStream { - let input_cloned = args.clone(); - let input_parsed = syn::parse_macro_input!(input_cloned as GenerateHandler); - - let input = proc_macro2::TokenStream::from(args); - let input_untyped_idents: Vec<_> = input_parsed - .values - .iter() - .map(|kv| { - let key = &kv.key; - - quote! { - #key, - } - }) - .collect(); - - let span = proc_macro::Span::call_site(); - let source_file = span.source().local_file(); - let Some(path) = source_file else { - panic!("File path not found!"); - }; - - let Ok(read) = std::fs::read_to_string(&path) else { - return quote! { - #[doc(hidden)] - pub enum HandlersDerive {} - } - .into(); - }; - - let input_file = syn::parse_file(&read).expect("SYN Cannot parse file"); - let mut output_ty: Option = None; - let mut output_enum = Vec::new(); - let mut output_enum_impl = Vec::new(); - - for item in input_file.items { - if let Item::Fn(func) = item { - let mut button_handler_macro = false; - if func.attrs.len() == 1 { - let attr = &func.attrs[0]; - if let Meta::Path(path) = &attr.meta { - for seg in &path.segments { - let ident_str = seg.ident.to_string(); - if ident_str == "button_handler" { - button_handler_macro = true; - } - } - } - } - - if button_handler_macro { - if func.sig.asyncness.is_none() { - continue; - } - let output = match func.sig.output { - syn::ReturnType::Default => quote! { () }, - syn::ReturnType::Type(_, tp) => quote! { #tp }, - }; - - if let Some(output_ty) = output_ty { - if output_ty.to_string() != output.to_string() { - panic!("Handlers result types mismatch!"); - } - } - - output_ty = Some(output); - - let function_name = func.sig.ident.to_string(); - let function_name = format_ident!("_button_handler_{function_name}"); - // _button_handler_name(_button_handler_name), - output_enum.push(quote! { - #function_name(#function_name), - }); - - // _button_handler_enum::_button_handler_name(_button_handler_name) => _button_handler_name.execute().await, - output_enum_impl.push(quote! { - Self::#function_name(#function_name) => #function_name.execute(#(#input_untyped_idents)*).await, - }); - } - } - } - - let output = output_ty.unwrap_or(quote! {()}); - quote! { - #[doc(hidden)] - pub enum HandlersDerive { - #(#output_enum)* - } - - impl HandlersDerive { - pub async fn execute(&self, #input) -> #output { - match self { - #(#output_enum_impl)* - } - } - } - } - .into() -} - -#[proc_macro_attribute] -pub fn button_handler(_args: TokenStream, item: TokenStream) -> TokenStream { - let f = syn::parse_macro_input!(item as syn::ItemFn); - if f.sig.asyncness.is_none() { - panic!("Function has to by async!"); - } - - let inputs = f.sig.inputs; - let output = match f.sig.output { - syn::ReturnType::Default => quote! { () }, - syn::ReturnType::Type(_, tp) => quote! { #tp }, - }; - - let name = f.sig.ident; - let handler_name = format_ident!("_button_handler_{}", name.to_string()); - - let vis = f.vis; - let block = f.block; - - quote! { - #[allow(non_camel_case_types)] - #vis struct #handler_name; - - impl #handler_name { - pub async fn execute(&self, #inputs) -> #output - #block - - } - - #vis const fn #name() -> HandlersDerive { - HandlersDerive::#handler_name(#handler_name) - } - } - .into() -} - -#[proc_macro] -pub fn nb_to_fut(item: TokenStream) -> TokenStream { - let item = syn::parse_macro_input!(item as syn::Expr); - - quote! { - async { - loop { - match #item { - Ok(val) => return Ok(val), - Err(nb::Error::WouldBlock) => { - Timer::after_micros(10).await; - continue; - }, - Err(nb::Error::Other(e)) => return Err(e) - } - } - } - } - .into() -} #[proc_macro] pub fn load_default_translations(args: TokenStream) -> TokenStream { translations::load_translations_macro(args) } - -// Maybe not useful, most variables in functions that can fail are owned :( -/* -struct MacroInput { - function_call: syn::Expr, - retry_count: syn::LitInt, - retry_interval: syn::LitInt, - print_error: syn::LitBool, - retrun_err: syn::LitBool, -} - -impl Parse for MacroInput { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let function_call: syn::Expr = input.parse()?; - input.parse::()?; - let retry_count: syn::LitInt = input.parse()?; - input.parse::()?; - let retry_interval: syn::LitInt = input.parse()?; - input.parse::()?; - let print_error: syn::LitBool = input.parse()?; - input.parse::()?; - let retrun_err: syn::LitBool = input.parse()?; - - Ok(MacroInput { - function_call, - retry_count, - retry_interval, - print_error, - retrun_err, - }) - } -} - -/// Macro to easily manage function calls that return Result and you want to retry them -/// -/// Usage: -/// retry_call!(function_call, retry_count(int), retry_interval(int ms), print_error(bool), return_err(bool)) -/// If return_err is false, this will panic on final error -#[proc_macro] -pub fn retry_call(item: TokenStream) -> TokenStream { - let MacroInput { - function_call, - retry_count, - retry_interval, - print_error, - retrun_err, - } = syn::parse_macro_input!(item as MacroInput); - - let mut i = 0; - let dsa = loop { - if i % 2 == 0 { - break i; - } - - i += 1; - }; - - quote! { - { - let mut i = 0; - loop { - let res = #function_call; - match res { - Ok(o) => {}, - Err(e) => { - break Some(i); - } - } - - i += 1; - }; - } - } - .into() -} -*/ diff --git a/src/buttons.rs b/src/buttons.rs index 6bc8fe4..667e9ea 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -1,15 +1,72 @@ +// qa builds report button edges to the QA flow and never dispatch these handlers +#![cfg_attr(feature = "qa", allow(dead_code))] + + use crate::{ consts::{NVS_BONDING_KEY, NVS_ERROR_LOG, NVS_SIGN_KEY}, state::{ BleAction, ErrorLogEntryStage, GlobalState, MenuScene, Scene, current_epoch, deeper_sleep_state, sleep_state, }, - utils::buttons::{Button, ButtonTrigger, ButtonsHandler}, + utils::buttons::{Button, ButtonTrigger, ButtonsHandler, HandlerTable}, }; use embassy_time::{Instant, Timer}; use esp_hal::gpio::Input; -macros::generate_button_handler_enum!(triggered: &ButtonTrigger, hold_time: u64, state: &GlobalState); +/// Every button action; dispatched from [`HANDLERS`] by [`ButtonsHandler`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Handler { + Wakeup, + SelLeft, + SelRight, + SubmitUp, + SubmitResetCompetitor, + SubmitConfigMenu, + InspectionStart, + InspectionHoldStop, + Dnf, + Penalty, + DelegateHold, +} + +impl Handler { + pub async fn execute( + self, + triggered: &ButtonTrigger, + hold_time: u64, + state: &GlobalState, + ) -> Result { + match self { + Self::Wakeup => wakeup_button(state).await, + Self::SelLeft => sel_left(state).await, + Self::SelRight => sel_right(state).await, + Self::SubmitUp => submit_up(state).await, + Self::SubmitResetCompetitor => submit_reset_competitor(state).await, + Self::SubmitConfigMenu => submit_config_menu(state).await, + Self::InspectionStart => inspection_start(state).await, + Self::InspectionHoldStop => inspection_hold_stop(state).await, + Self::Dnf => dnf_button(state).await, + Self::Penalty => penalty_button(state).await, + Self::DelegateHold => delegate_hold(triggered, hold_time, state).await, + } + } +} + +/// Per button, in execution order. +const HANDLERS: &HandlerTable = &[ + (Button::Third, ButtonTrigger::Up, Handler::SubmitUp), + (Button::Third, ButtonTrigger::HoldOnce(3000), Handler::SubmitResetCompetitor), + (Button::Third, ButtonTrigger::HoldOnce(10000), Handler::SubmitConfigMenu), + (Button::First, ButtonTrigger::Down, Handler::SelLeft), + (Button::First, ButtonTrigger::Down, Handler::InspectionStart), + (Button::First, ButtonTrigger::HoldOnce(1000), Handler::InspectionHoldStop), + (Button::Fourth, ButtonTrigger::Down, Handler::SelRight), + (Button::Fourth, ButtonTrigger::HoldOnce(1000), Handler::Dnf), + (Button::Fourth, ButtonTrigger::Up, Handler::Penalty), + (Button::Second, ButtonTrigger::HoldTimed(0, 1000), Handler::DelegateHold), + (Button::Second, ButtonTrigger::HoldOnce(3000), Handler::DelegateHold), + (Button::Second, ButtonTrigger::Up, Handler::DelegateHold), +]; #[cfg(feature = "v3")] @@ -31,42 +88,7 @@ pub async fn buttons_task( #[cfg(feature = "v3")] button_input: Input<'static>, #[cfg(feature = "v3")] button_reg: adv_shift_registers::wrappers::ShifterValue, ) { - let mut handler = ButtonsHandler::new(Some(wakeup_button())); - handler.add_handler(Button::Third, ButtonTrigger::Up, submit_up()); - handler.add_handler( - Button::Third, - ButtonTrigger::HoldOnce(3000), - submit_reset_competitor(), - ); - handler.add_handler( - Button::Third, - ButtonTrigger::HoldOnce(10000), - submit_config_menu(), - ); - - handler.add_handler(Button::First, ButtonTrigger::Down, sel_left()); - handler.add_handler(Button::First, ButtonTrigger::Down, inspection_start()); - handler.add_handler( - Button::First, - ButtonTrigger::HoldOnce(1000), - inspection_hold_stop(), - ); - - handler.add_handler(Button::Fourth, ButtonTrigger::Down, sel_right()); - handler.add_handler(Button::Fourth, ButtonTrigger::HoldOnce(1000), dnf_button()); - handler.add_handler(Button::Fourth, ButtonTrigger::Up, penalty_button()); - - handler.add_handler( - Button::Second, - ButtonTrigger::HoldTimed(0, 1000), - delegate_hold(), - ); - handler.add_handler( - Button::Second, - ButtonTrigger::HoldOnce(3000), - delegate_hold(), - ); - handler.add_handler(Button::Second, ButtonTrigger::Up, delegate_hold()); + let mut handler = ButtonsHandler::new(Some(Handler::Wakeup), HANDLERS); #[cfg(feature = "v3")] handler.run(&state, &button_input, &button_reg).await; @@ -74,22 +96,7 @@ pub async fn buttons_task( handler.run(&state, &button_inputs).await; } -#[macros::button_handler] -async fn button_test( - triggered: &ButtonTrigger, - hold_time: u64, - _state: &GlobalState, -) -> Result { - log::info!("Triggered: {triggered:?} - {hold_time}"); - Ok(false) -} - -#[macros::button_handler] -async fn wakeup_button( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn wakeup_button(state: &GlobalState) -> Result { if sleep_state() { state.state.signal(); if deeper_sleep_state() { @@ -102,12 +109,7 @@ async fn wakeup_button( Ok(false) } -#[macros::button_handler] -async fn sel_left( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn sel_left(state: &GlobalState) -> Result { let mut state_val = state.state.lock().await; #[cfg(feature = "v4")] if state_val.menu_scene == Some(MenuScene::BuzzerVolume) { @@ -171,12 +173,7 @@ async fn sel_left( Ok(false) } -#[macros::button_handler] -async fn sel_right( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn sel_right(state: &GlobalState) -> Result { let mut state_val = state.state.lock().await; #[cfg(feature = "v4")] if state_val.menu_scene == Some(MenuScene::BuzzerVolume) { @@ -250,12 +247,7 @@ async fn sel_right( Ok(false) } -#[macros::button_handler] -async fn submit_up( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn submit_up(state: &GlobalState) -> Result { let mut state_val = state.state.value().await; // Clear error (text) @@ -531,12 +523,7 @@ async fn submit_up( Ok(false) } -#[macros::button_handler] -async fn inspection_start( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn inspection_start(state: &GlobalState) -> Result { let mut state_val = state.state.value().await; if !state_val.use_inspection() || state_val.should_skip_other_actions() { return Ok(false); @@ -561,12 +548,7 @@ async fn inspection_start( Ok(false) } -#[macros::button_handler] -async fn inspection_hold_stop( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn inspection_hold_stop(state: &GlobalState) -> Result { let mut state_val = state.state.value().await; if state_val.should_skip_other_actions() { return Ok(false); @@ -589,12 +571,7 @@ async fn inspection_hold_stop( Ok(false) } -#[macros::button_handler] -async fn dnf_button( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn dnf_button(state: &GlobalState) -> Result { let mut state_val = state.state.value().await; if state_val.should_skip_other_actions() { return Ok(false); @@ -625,12 +602,7 @@ async fn dnf_button( Ok(false) } -#[macros::button_handler] -async fn penalty_button( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn penalty_button(state: &GlobalState) -> Result { let mut state_val = state.state.value().await; if state_val.should_skip_other_actions() { return Ok(false); @@ -651,12 +623,7 @@ async fn penalty_button( Ok(false) } -#[macros::button_handler] -async fn submit_reset_competitor( - _triggered: &ButtonTrigger, - _hold_time: u64, - global_state: &GlobalState, -) -> Result { +async fn submit_reset_competitor(global_state: &GlobalState) -> Result { let mut state = global_state.state.lock().await; if state.should_skip_other_actions() { return Ok(false); @@ -667,12 +634,7 @@ async fn submit_reset_competitor( Ok(false) } -#[macros::button_handler] -async fn submit_config_menu( - _triggered: &ButtonTrigger, - _hold_time: u64, - state: &GlobalState, -) -> Result { +async fn submit_config_menu(state: &GlobalState) -> Result { { let mut state = state.state.lock().await; state.selected_config_menu = Some(0); @@ -681,7 +643,6 @@ async fn submit_config_menu( Ok(true) } -#[macros::button_handler] async fn delegate_hold( triggered: &ButtonTrigger, hold_time: u64, diff --git a/src/utils/buttons.rs b/src/utils/buttons.rs index 104235e..32935e8 100644 --- a/src/utils/buttons.rs +++ b/src/utils/buttons.rs @@ -1,66 +1,74 @@ -use crate::{buttons::HandlersDerive, state::GlobalState}; -use alloc::vec::Vec; +// qa builds report button edges to the QA flow and never dispatch these handlers +#![cfg_attr(feature = "qa", allow(dead_code))] + + +use crate::{buttons::Handler, state::GlobalState}; use embassy_time::{Instant, Timer}; use esp_hal::gpio::Input; #[cfg(feature = "v3")] use crate::consts::BUTTON_DEBOUNCE_MS; -#[allow(dead_code)] -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Copy)] pub enum ButtonTrigger { Down, Up, + /// Once per press, after the button was held for `ms`. HoldOnce(u64), + /// Repeatedly while held: first after `offset` ms, then every `gap` ms. HoldTimed(u64, u64), - Hold, } -#[allow(dead_code)] -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone, Copy)] pub enum Button { First, Second, Third, Fourth, - Unknown, } -impl From for Button { - fn from(value: u8) -> Self { - match value { - 0b00000001 => Self::First, - 0b00000010 => Self::Second, - 0b00000100 => Self::Third, - 0b00001000 => Self::Fourth, - _ => Self::Unknown, +impl Button { + /// Single-button mask to button (`None` for no or multiple buttons). + fn from_mask(mask: u8) -> Option { + match mask { + 0b0001 => Some(Self::First), + 0b0010 => Some(Self::Second), + 0b0100 => Some(Self::Third), + 0b1000 => Some(Self::Fourth), + _ => None, } } } -pub struct ButtonHandler { - button: Button, - handlers: Vec<(ButtonTrigger, bool, HandlersDerive)>, -} +pub type HandlerTable = [(Button, ButtonTrigger, Handler)]; +/// Dispatches button edges/holds to a const table of handlers. +/// +/// Handlers of a button run in table order; a handler returning `Ok(true)` stops the rest +/// for this press. pub struct ButtonsHandler { - default_handler: Option, + default_handler: Option, + table: &'static HandlerTable, - handlers: Vec, press_time: Instant, last_hold_execute: Instant, - current_handler_down: Option, + current_button: Option + + + + +
+

Hold a button as long as you'd hold the real one (e.g. Submit 10 s = config menu).

+ +
+

Stackmat

+
0:00.000
+
+ + + + +
+

Space = start / stop, R = reset.

+
+ + +
+
+

Cards

+
+ + + + + +
+
+
+

Power & network

+
+ + + 100% +
+
+ + + +
+
+
+

State

+
+
+
+

Log

+

+    
+
+ + + + diff --git a/sim/web.py b/sim/web.py new file mode 100644 index 0000000..255bf7c --- /dev/null +++ b/sim/web.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""The sim device in a browser: OLED, buttons, cards, stackmat, battery, Wi-Fi/server/power. + + python3 sim/web.py [--mc ~/projects/micro-connector] [--no-server] [--port 8000] + +Builds the sim image, starts esp-emu, the fake stackmat and (unless --no-server) a local +micro-connector, then serves http://127.0.0.1:8000. Stdlib only: events go out as SSE, +commands come in as POSTs. +""" +import argparse +import json +import re +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import monkey + +JOB = 9 # monkey jobs use 0..; keep ports apart so a campaign can run next to this +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +class Hub: + """Collects events for the page: OLED frames, log lines, state.""" + + def __init__(self): + self.events, self.cond = [], threading.Condition() + self.oled = None + + def push(self, kind: str, data): + with self.cond: + self.events.append((kind, data)) + if kind == "oled": + self.oled = data + self.cond.notify_all() + + def watch(self, emu: monkey.Emu): + """Turns emulator output into events (the OLED arrives as 32 half-block text rows).""" + seen, rows = 0, None + while True: + with emu.lock: + new = [l for _, l in emu.lines[seen:]] + seen += len(new) + for line in new: + if line.startswith("┌"): + rows = [] + elif rows is not None and line.startswith("│"): + rows.append(line[1:129]) + elif rows is not None and line.startswith("└"): + if len(rows) == 32: + self.push("oled", rows) + rows = None + elif line.startswith("@state "): + self.push("state", dict(kv.split("=", 1) for kv in line[7:].split() if "=" in kv)) + else: + self.push("log", ANSI.sub("", line)) + time.sleep(0.05) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--mc", default="~/projects/micro-connector") + p.add_argument("--no-server", action="store_true") + p.add_argument("--port", type=int, default=8000) + args = p.parse_args() + + out = monkey.ROOT / "sim/out/web" + out.mkdir(parents=True, exist_ok=True) + srv_port = monkey.SRV_PORT + JOB + print("building the sim image…") + bin_, elf = monkey.build(srv_port) + + server = None + if not args.no_server: + server = monkey.Server(Path(args.mc).expanduser(), srv_port, out) + server.start() + base = 20000 + JOB * 10 + ports = dict(http=base, uart1=base + 1, control=base + 2) + stackmat = monkey.Stackmat(ports["uart1"]) + emu = monkey.Emu(bin_, elf, ports, out / "firmware.log") + hub = Hub() + threading.Thread(target=hub.watch, args=(emu,), daemon=True).start() + + def poll_state(): + while True: + emu.send("state") + time.sleep(1) + + threading.Thread(target=poll_state, daemon=True).start() + + def command(target: str, arg: str): + if target == "emu": + emu.send(arg) + elif target == "stk": + stackmat.cmd(arg) + elif target == "power": + emu.power_cycle() + elif target == "server" and server: + server.stop() if arg == "stop" else server.start() + + page = (monkey.ROOT / "sim/web.html").read_bytes() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_GET(self): + if self.path == "/": + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(page) + elif self.path == "/events": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + with hub.cond: + cursor = len(hub.events) + first = [("oled", hub.oled)] if hub.oled else [] + # recent log so a reload isn't empty + first += [e for e in hub.events[-300:] if e[0] == "log"] + try: + for kind, data in first: + self.wfile.write(f"event: {kind}\ndata: {json.dumps(data)}\n\n".encode()) + while True: + with hub.cond: + hub.cond.wait_for(lambda: len(hub.events) > cursor, timeout=15) + batch, cursor = hub.events[cursor:], len(hub.events) + msg = "".join(f"event: {k}\ndata: {json.dumps(d)}\n\n" for k, d in batch) + self.wfile.write((msg or ": ping\n\n").encode()) + self.wfile.flush() + except OSError: + pass + else: + self.send_error(404) + + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + try: + req = json.loads(body) + command(str(req["target"]), str(req.get("arg", ""))) + self.send_response(204) + except (ValueError, KeyError, OSError): + self.send_response(400) + self.end_headers() + + http = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + http.daemon_threads = True + print(f"device on http://127.0.0.1:{args.port} (firmware log: {out / 'firmware.log'})") + try: + http.serve_forever() + except KeyboardInterrupt: + pass + finally: + stackmat.alive = False + emu.stop() + if server: + server.stop() + + +if __name__ == "__main__": + main() From c8765630cf7176690c188d3445025fdb56b215f7 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 16:28:44 +0200 Subject: [PATCH 093/132] feat: raw oled frames at 20 ms for the web sim --- sim/web.html | 26 +++++++++++++++----------- sim/web.py | 25 ++++++++++++++----------- src/sim.rs | 22 ++++++++++++++++++++-- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/sim/web.html b/sim/web.html index 39ff321..dfa97a3 100644 --- a/sim/web.html +++ b/sim/web.html @@ -128,17 +128,21 @@

Log

const send = (target, arg = "") => fetch("/cmd", { method: "POST", body: JSON.stringify({ target, arg }) }).catch(() => {}); -// OLED: 32 rows of 128 half-block chars, each char = 2 vertical pixels +// OLED: 1024 bytes hex, 8 pages × 128 columns, bit n of a byte = row page*8+n const ctx = $("oled").getContext("2d"); -const TOP = { "▀": 1, "█": 1 }, BOTTOM = { "▄": 1, "█": 1 }; -function drawOled(rows) { - const css = getComputedStyle(document.documentElement); - ctx.fillStyle = css.getPropertyValue("--oled-bg"); ctx.fillRect(0, 0, 128, 64); - ctx.fillStyle = css.getPropertyValue("--oled-px"); - rows.forEach((row, y) => [...row].forEach((ch, x) => { - if (TOP[ch]) ctx.fillRect(x, y * 2, 1, 1); - if (BOTTOM[ch]) ctx.fillRect(x, y * 2 + 1, 1, 1); - })); +const img = ctx.createImageData(128, 64); +function rgb(v) { const c = document.createElement("canvas").getContext("2d"); c.fillStyle = v; c.fillRect(0, 0, 1, 1); return c.getImageData(0, 0, 1, 1).data; } +let on, off; +function drawOled(hex) { + if (!on) { const css = getComputedStyle(document.documentElement); on = rgb(css.getPropertyValue("--oled-px")); off = rgb(css.getPropertyValue("--oled-bg")); } + for (let i = 0; i < 1024; i++) { + const b = hex ? parseInt(hex.substr(i * 2, 2), 16) : 0, x = i % 128, page = i >> 7; + for (let bit = 0; bit < 8; bit++) { + const c = b >> bit & 1 ? on : off, p = ((page * 8 + bit) * 128 + x) * 4; + img.data[p] = c[0]; img.data[p + 1] = c[1]; img.data[p + 2] = c[2]; img.data[p + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); } // hardware buttons: held until released (the firmware times the hold itself) @@ -233,7 +237,7 @@

Log

es.addEventListener("oled", (e) => drawOled(JSON.parse(e.data))); es.addEventListener("state", (e) => showState(JSON.parse(e.data))); es.addEventListener("log", (e) => addLog(JSON.parse(e.data))); -drawOled([]); +drawOled(""); diff --git a/sim/web.py b/sim/web.py index 255bf7c..0d455e4 100644 --- a/sim/web.py +++ b/sim/web.py @@ -10,6 +10,7 @@ import argparse import json import re +import signal import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -36,26 +37,22 @@ def push(self, kind: str, data): self.cond.notify_all() def watch(self, emu: monkey.Emu): - """Turns emulator output into events (the OLED arrives as 32 half-block text rows).""" - seen, rows = 0, None + """Turns emulator output into events (OLED frames as `@oled `, page-major like the + SSD1309 GRAM: byte = 8 vertical pixels).""" + seen = 0 while True: with emu.lock: new = [l for _, l in emu.lines[seen:]] seen += len(new) for line in new: - if line.startswith("┌"): - rows = [] - elif rows is not None and line.startswith("│"): - rows.append(line[1:129]) - elif rows is not None and line.startswith("└"): - if len(rows) == 32: - self.push("oled", rows) - rows = None + if line.startswith("@oled "): + if len(line) == 6 + 2048: + self.push("oled", line[6:]) elif line.startswith("@state "): self.push("state", dict(kv.split("=", 1) for kv in line[7:].split() if "=" in kv)) else: self.push("log", ANSI.sub("", line)) - time.sleep(0.05) + time.sleep(0.01) def main(): @@ -83,6 +80,8 @@ def main(): threading.Thread(target=hub.watch, args=(emu,), daemon=True).start() def poll_state(): + time.sleep(1) + emu.send("oled raw") while True: emu.send("state") time.sleep(1) @@ -146,6 +145,10 @@ def do_POST(self): self.send_response(400) self.end_headers() + def on_term(*_): + raise KeyboardInterrupt # same cleanup as Ctrl-C + + signal.signal(signal.SIGTERM, on_term) http = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) http.daemon_threads = True print(f"device on http://127.0.0.1:{args.port} (firmware log: {out / 'firmware.log'})") diff --git a/src/sim.rs b/src/sim.rs index 7e9a0a4..ccf5591 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -23,6 +23,8 @@ use esp_hal::{ pub static CARD: Signal = Signal::new(); /// `wifi off|on`: `true` stops the radio (driver torn down), `false` restarts it pub static WIFI: Signal = Signal::new(); +/// `oled raw`: frames as `@oled ` every 20 ms (sim/web.py) instead of text art every 200 ms +static OLED_RAW: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false); static TX: CriticalSectionMutex>>> = CriticalSectionMutex::new(RefCell::new(None)); @@ -171,6 +173,16 @@ fn print_oled() { return; }; + if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { + let mut line = heapless::String::<{ 6 + 2048 }>::new(); + _ = line.push_str("@oled "); + for b in gram.gram { + _ = write!(line, "{b:02x}"); + } + print(format_args!("{line}")); + return; + } + print(format_args!("┌{:─<128}┐", "")); for row in 0..32 { let mut line = heapless::String::<{ 128 * 3 + 8 }>::new(); @@ -253,7 +265,7 @@ fn print_state(state: GlobalState) { print(format_args!("{line}")); } -/// Reads stdin commands and prints the OLED when it changes (at most every 200 ms). +/// Reads stdin commands and prints the OLED when it changes (every 200 ms, 20 ms in raw mode). #[embassy_executor::task] pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { use embassy_futures::select::{Either, select}; @@ -266,7 +278,9 @@ pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { loop { match select( rx.read_async(&mut buf), - Timer::after(Duration::from_millis(200)), + Timer::after(Duration::from_millis( + if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { 20 } else { 200 }, + )), ) .await { @@ -305,6 +319,10 @@ fn command(line: &str, state: GlobalState) { print_oled(); } (Some("state"), _) => print_state(state), + (Some("oled"), _) if a == Some("raw") => { + OLED_RAW.store(true, portable_atomic::Ordering::Relaxed); + OLED.lock(|o| o.borrow_mut().dirty = true); + } (Some("wifi"), _) if a == Some("off") => WIFI.signal(true), (Some("wifi"), _) if a == Some("on") => WIFI.signal(false), _ => print(format_args!("? {line}\r\n{HELP}")), From 3e1e47d864f016cad1945c9538360d74c79aa4d5 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 17:01:16 +0200 Subject: [PATCH 094/132] feat: monkey competition mode with several stations --- sim/README.md | 27 ++ sim/competition.py | 864 +++++++++++++++++++++++++++++++++++++++++++++ sim/make-efuse.py | 204 +++++++++++ sim/monkey.py | 23 +- src/sim.rs | 11 +- 5 files changed, 1122 insertions(+), 7 deletions(-) create mode 100644 sim/competition.py create mode 100644 sim/make-efuse.py diff --git a/sim/README.md b/sim/README.md index 6f6cdde..5809d33 100644 --- a/sim/README.md +++ b/sim/README.md @@ -96,6 +96,33 @@ for f in sim/scenarios/*.json; do python3 sim/monkey.py --replay-file $f; done `sim/scenarios/` holds scripted regressions for bugs the monkey found (same format as `actions.json`, plus `expect` steps that wait for a `state` field to reach a value). +### Competition day (`--mode competition`) + +A seeded, realistic competition instead of abuse: 20–40 competitors (some in several events, so +they pick their group), staff and competitor judges, the first group of 3–4 events (one +blindfolded: no inspection, 25 s time limit), each cut to a share of `--max-attempts`. Every attempt +is acted out like a person would (scan, group select, inspection 5–12 s, sometimes 15–17 s (+2) or +over 17 s (DNF), solve, judge's penalty, confirm, judge and competitor cards), with clumsy slips at +a few % each (wrong card first, double taps, forgotten/restarted inspection, fumbled timer, +2 +applied and corrected, DNF hold, long/double submit, wrong button, scrolling past the group, +delegate call, Wi-Fi blip, abandoned attempt, solve over the limit) plus server restarts mid-group +and a draining battery. Each step's effect is checked on the device; after each attempt the +server's `EnterAttempt` log (`srv/logs/unix.log`) must hold exactly one solve with the intended +station, competitor, time, penalty, judge, group and inspection; at the end the whole list must +match. `--devices N` runs N stations (own esp-emu, eFuse MAC from `sim/make-efuse.py`, stackmat) +on one server, round by round, a competitor never at two stations at once. + +```sh +python3 sim/monkey.py --mode competition --seeds 1-6 --jobs 3 --job-base 20 --max-attempts 36 +python3 sim/monkey.py --mode competition --seed 4 --devices 3 --job-base 20 --slip-rate 2 +python3 sim/monkey.py --replay-file sim/out/comp-d1/4/day.json # same day again +``` + +`--job-base N` moves ports (emulators 20000+10·N.., servers 18100+N..) away from other runs; +`--time-scale` shortens solves (never inspection or holds). Logs: `sim/out/comp-d//` +(`day.json` = plan, intended results, mismatches, executed steps). Not covered: sleep (off in sim +builds), delegate answers (micro-connector's e2e never resolves an incident), BLE. + Failures land in `sim/out//` (actions, firmware log with timestamps, server log). Needs a built micro-connector (`cargo build --bins` in it; `--mc` path). The `state` sim command prints the machine-readable line the monkey reads. Not covered: pads (`timer-func`), BLE, Wi-Fi drops (esp-emu diff --git a/sim/competition.py b/sim/competition.py new file mode 100644 index 0000000..68addcf --- /dev/null +++ b/sim/competition.py @@ -0,0 +1,864 @@ +"""Competition day for sim/monkey.py (`--mode competition`): clumsy but well-meaning competitors +and judges run a seeded competition on one or more stations (each its own esp-emu, all on one +micro-connector), and the server must get exactly the intended results. + +Every human action is a planned step with a defined outcome. The oracle models the firmware rules +(src/buttons.rs, src/rfid.rs, src/stackmat.rs) independently, checks the device state after each +step, and after each attempt checks the server's EnterAttempt log (srv/logs/unix.log): exactly one +solve (per session) with the right station, competitor, time, penalty, judge, group and inspection. +""" +import json +import random +import re +import shutil +import subprocess +import threading +import time +from collections import Counter +from pathlib import Path + +import monkey as M + +PLUS2_MS, DNF_MS = 15000, 17000 # src/consts.rs INSPECTION_TIME_* +RESET_HOLD, DNF_HOLD, INSPECT_STOP_HOLD, DELEGATE_HOLD = 3000, 1000, 1000, 3000 # src/buttons.rs +DELEGATE_TIMEOUT = 5 * 60_000 # src/consts.rs: a pending delegate call blocks new ones this long +EVENTS = [ # id, name, inspection, time limit (ms), attempts, solve mean/sd (s, before --time-scale) + ("333", "3x3x3 Cube", True, None, 5, 13.0, 3.0), + ("222", "2x2x2 Cube", True, None, 5, 6.0, 2.0), + ("pyram", "Pyraminx", True, None, 5, 7.0, 2.5), + ("skewb", "Skewb", True, None, 5, 8.0, 2.5), + ("333bf", "3x3x3 Blindfolded", False, 25_000, 3, 14.0, 3.0), +] +# per attempt (the realistic base rates; --slip-rate multiplies them) +SLIPS = { + "judge_card_first": 0.03, # judge's card on the reader before the competitor's + "other_card_first": 0.03, # someone else's card first, judge resets (submit hold) + "scan_twice": 0.04, # competitor taps twice + "forgot_inspection": 0.03, # starts the timer without inspection + "inspect_twice": 0.04, # second inspect press during inspection: no effect + "inspection_restart": 0.02, # judge cancels inspection (inspect hold) and starts it again + "fumble_before_scan": 0.03, # plays with the timer (start, stop?, reset) before scanning + "timer_reset_running": 0.01, # resets a running timer after scanning: DNF (0) + "stackmat_reset_early": 0.05, # judge resets the timer before confirming: no effect + "plus2_fix": 0.03, # applies +2 by mistake, then corrects (wrap-around / DNF hold) + "wrong_judge_card": 0.03, # another person's card as judge, then the right judge + "competitor_before_judge": 0.04, # competitor scans before the judge ("cards the same") + "double_submit": 0.04, + "submit_long": 0.04, # 1.3-2.6 s (reset is at 3 s) + "wrong_button": 0.04, # short delegate / inspect press while finished: no effect + "group_scroll": 0.3, # multi-group competitors: scroll past their group and back + "delegate": 0.03, # judge calls the delegate (3.5 s hold) + "wifi_blip": 0.02, # radio off 1-4 s on this station only + "abandon": 0.01, # attempt reset before inspection, redone right away + "over_limit": 0.10, # (limited events) solve runs past the time limit: DNF + "plus2": 0.05, # legit judge decisions + "dnf": 0.02, +} + + +# ── generation (pure: same seed and devices ⇒ same day) ──────────────────────────────────── + +def generate_day(seed: int, devices: int, max_attempts: int, slip_rate: float = 1.0, + time_scale: float = 0.5) -> dict: + r = random.Random(seed * 1000 + devices) + uids = set() + + def uid(): + while True: + u = r.randint(10_000, 2**32 - 1) + if u not in uids and u not in M.KNOWN_CARDS: + uids.add(u) + return u + + events = [EVENTS[0]] + r.sample(EVENTS[1:4], r.randint(1, 2)) + [EVENTS[4]] + n = r.randint(20, 40) + comps = [uid() for _ in range(n)] + regs = {c: [e[0] for e in events if r.random() < (0.9 if e[0] == "333" else 0.35)] or ["333"] + for c in comps} + groups, comp_groups, schedule_groups = [], {c: [] for c in comps}, [] + for ev, name, insp, limit, attempts, mean, sd in events: + people = [c for c in comps if ev in regs[c]] + if not people: + continue + k = 2 if len(people) > 14 else 1 + for g in range(k): + gid = f"{ev}-r1-g{g + 1}" + members = people[g::k] + groups.append(dict(groupId=gid, name=f"{name} R1", useInspection=insp, + secondaryText=f"Group {g + 1}", limit=limit)) + for c in members: + comp_groups[c].append(gid) + schedule_groups.append((gid, members, insp, limit, attempts, mean, sd)) + staff = [uid() for _ in range(r.randint(3, 5))] + cards = {} + for i, c in enumerate(comps): + cards[str(c)] = dict(registrantId=i + 1, name=f"Competitor {i + 1}", wcaId=f"2026COMP{i + 1:02}", + canCompete=True, groups=comp_groups[c]) + for i, s in enumerate(staff): + cards[str(s)] = dict(registrantId=100 + i, name=f"Staff {i + 1}", wcaId="", canCompete=False, + groups=[]) + + # the whole competition is registered; the day plays the first group of each event, each + # cut to a share of --max-attempts (whole attempts of the first few competitors) + played = [g for i, g in enumerate(schedule_groups) + if g[0].endswith("-g1") or i == len(schedule_groups) - 1][:4] + budget = max(1, max_attempts // len(played)) + schedule, total = [], 0 + for gid, members, insp, limit, attempts, mean, sd in played: + judges = staff + [c for c in comps if c not in members] + r.shuffle(judges) + members = members[:max(devices, -(-budget // attempts))] + rounds = plan_rounds(r, members, attempts, devices) + specs = [] + jrot, in_group = 0, 0 + for rd in rounds: + row = [] + busy = {x[0] for x in rd if x} + for c, k in [x or (None, None) for x in rd]: + if c is None or in_group >= budget or total >= max_attempts: + row.append(None) + continue + total += 1 + in_group += 1 + judge = judges[jrot % len(judges)] + jrot += 1 + others = [x for x in members if x not in busy] + row.append(attempt_spec(r, c, judge, gid, k, insp, limit, mean, sd, time_scale, + len(comp_groups[c]) > 1, slip_rate, others, staff, comps)) + if any(row): + specs.append(row) + restart = None + if specs and r.random() < 0.35: + restart = dict(round=r.randrange(len(specs)), delay=round(r.uniform(2, 25), 1), + down=round(r.uniform(1.5, 6), 1)) + schedule.append(dict(group=gid, rounds=specs, restart=restart, + sync=[r.random() < 0.3 for _ in specs], + break_s=round(r.choice([5, 10, 20, 40, 90]) * min(1.0, time_scale * 2), 1))) + # battery drains over the day + i = 0 + for g in schedule: + for row in g["rounds"]: + for sp in row: + if sp: + sp["bat"] = max(5, 100 - int(85 * i / max(1, total)) - r.randint(0, 2)) + i += 1 if any(row) else 0 + return dict(seed=seed, devices=devices, time_scale=time_scale, groups=groups, cards=cards, + schedule=schedule, attempts=total) + + +def plan_rounds(r, members, attempts, devices): + """Rounds of up to `devices` distinct competitors: (competitor, attempt no). Competitors + usually stay at their station for the next attempt; sometimes they move.""" + left = {c: attempts for c in members} + queue = list(members) + seats = [None] * devices + rounds = [] + while any(left.values()): + row = [None] * devices + used = set() + for s in range(devices): + c = seats[s] + if c and left[c] and c not in used and r.random() < 0.85: + row[s] = c + used.add(c) + for s in range(devices): + if row[s] is None: + cand = next((c for c in queue if left[c] and c not in used), None) + if cand: + row[s] = cand + used.add(cand) + queue.remove(cand) + queue.append(cand) + if devices > 1 and r.random() < 0.15: + r.shuffle(row) # people moved to other stations + seats = row + out = [] + for c in row: + if c: + out.append((c, attempts - left[c] + 1)) + left[c] -= 1 + else: + out.append(None) + rounds.append(out) + return rounds + + +def attempt_spec(r, comp, judge, gid, k, insp, limit, mean, sd, scale, multi, rate, others, staff, + comps): + def slip(name): + return r.random() < SLIPS[name] * rate + + sl = {} + first = None + if slip("judge_card_first"): + first = judge + elif slip("other_card_first"): + first = r.choice([x for x in others if x != comp] or staff) + if first: + sl["wrong_first"] = first + for name in ("scan_twice", "fumble_before_scan", "stackmat_reset_early", "double_submit", + "submit_long", "competitor_before_judge", "abandon"): + if slip(name): + sl[name] = True + if "scan_twice" in sl: + sl["scan_twice"] = r.choice([0.2, 1.5]) + if "fumble_before_scan" in sl: + sl["fumble_before_scan"] = r.random() < 0.5 # with a stop before the reset + if "submit_long" in sl: + sl["submit_long"] = r.randint(1300, 2600) + if multi and slip("group_scroll"): + sl["group_scroll"] = True + if slip("timer_reset_running"): + sl["timer_reset_running"] = True + elif insp and slip("forgot_inspection"): + sl["forgot_inspection"] = True + elif insp: + if slip("inspect_twice"): + sl["inspect_twice"] = True + if slip("inspection_restart"): + sl["inspection_restart"] = True + if limit and "timer_reset_running" not in sl and slip("over_limit"): + sl["over_limit"] = True + if slip("plus2_fix"): + sl["plus2_fix"] = r.choice(["wrap", "dnf_hold"]) + if slip("delegate"): + sl["delegate"] = True + if slip("wrong_judge_card"): + sl["wrong_judge_card"] = r.choice([x for x in comps + staff if x not in (comp, judge)]) + if slip("wrong_button"): + sl["wrong_button"] = r.choice([2, 1]) + if slip("wifi_blip"): + sl["wifi_blip"] = dict(at=r.choice(["before_scan", "after_stop"]), ms=r.randint(1000, 4000)) + decision = "dnf" if slip("dnf") else "plus2" if slip("plus2") else None + if decision: + sl["judge_" + decision] = True + + x = r.random() + insp_ms = None + if insp: # firmware thresholds are never scaled; targets keep ≥ 0.6 s away from them + insp_ms = (r.randint(5000, 12000) if x < 0.90 else + r.randint(15600, 16400) if x < 0.97 else r.randint(17800, 19000)) + solve = max(0.8, r.gauss(mean, sd)) * scale + if limit: + solve = min(solve, limit / 1000 - 3) + return dict(comp=comp, judge=judge, group=gid, attempt=k, insp=insp_ms, solve=round(solve, 2), + gap=round(r.choice([0.5, 1, 2, 3, 5]) * r.uniform(0.8, 1.2), 2), slips=sl) + + +# ── server log ─────────────────────────────────────────────────────────────────────────────── + +ENTRY = re.compile( + r'EnterAttempt \{ value: \d+, value_ms: (\d+), penalty: (-?\d+), solved_at: "[^"]*", ' + r'esp_id: (\d+), judge_id: "(\d*)", competitor_id: "(\d+)", is_delegate: (\w+), ' + r'session_id: "([^"]*)", inspection_time: (-?\d+), group_id: "([^"]*)"') + + +def entries(run_dir: Path) -> list: + """Every EnterAttempt the backend forwarded (appended across server restarts).""" + try: + text = (run_dir / "srv" / "logs" / "unix.log").read_text(errors="replace") + except FileNotFoundError: + return [] + return [dict(time=int(m[1]), pen=int(m[2]), esp=int(m[3]), judge=int(m[4] or 0), comp=int(m[5]), + delegate=m[6] == "true", session=m[7], insp=int(m[8]), group=m[9]) + for m in re.finditer(ENTRY, text)] + + +# ── penalty model (independent of the firmware code, from its documented rules) ───────────── + +def press_penalty(p): # 0 → +2 → … → +16 → 0, DNF → 0 + return 0 if p >= 16 or p == -1 else p + 2 + + +def hold_penalty(p): # DNF toggle + return 0 if p == -1 else -1 + + +class Mismatch(Exception): + pass + + +# ── one station ────────────────────────────────────────────────────────────────────────────── + +class Station(M.Run): + def __init__(self, n, run_dir, server, image, ports, args, restart_lock): + self.n, self.dir, self.server, self.args, self.ports = n, run_dir, server, args, ports + self.expected_boots, self.settings_resets, self.heap_peak = 1, 0, 0 + self.executed, self.t0 = [], time.monotonic() + self.restart_lock = restart_lock + efuse = run_dir / f"dev{n}.efuse" + subprocess.run(["python3", str(M.ROOT / "sim/make-efuse.py"), "--chip", "esp32c3", + "--chip-rev", "1.3", "--mac", f"24:0A:C4:00:00:{n + 1:02X}", "-o", str(efuse)], + check=True, stdout=subprocess.DEVNULL) + bin_ = run_dir / f"dev{n}.bin" + shutil.copy(image[0], bin_) + self.emu = M.Emu(bin_, image[1], ports, run_dir / f"firmware{n}.log", efuse) + self.stackmat = M.Stackmat(ports["uart1"]) + self.esp = None + self.delegate_until = -10**9 # emulated ms until which a pending delegate blocks new calls + self.disturbed = False # a Wi-Fi blip / server restart touched the current attempt + + def note(self, *what): + self.executed.append([round(time.monotonic() - self.t0, 3), self.n, *what]) + + def st(self): + return self.state(timeout=10) + + def poll(self, pred, emu_ms, what=None): + """State once `pred` holds within `emu_ms` of emulated time (None if not, or Failure if + `what` is given). A real-time cap catches a hung emulator.""" + s = self.st() + end, cap = int(s["up"]) + emu_ms, time.monotonic() + emu_ms / 1000 * 8 + 15 + while True: + if pred(s): + return s + if int(s["up"]) >= end or time.monotonic() > cap: + if what: + raise Mismatch(f"{what}: not reached; state {brief(s)}") + return None + time.sleep(0.15) + self.check_alive() + s = self.st() + + def connected(self): + self.wait_for(lambda s: s.get("srv") == "Some(true)" and s.get("stk") == "Some(true)", + 120, "server and stackmat connected") + + def btn(self, b, ms): + self.note("btn", b, ms) + self.press(b, ms) + time.sleep(0.1) + + def tap(self, uid): + self.note("card", uid) + self.emu.send(f"card {uid}") + + def stk(self, c): + self.note("stk", c) + self.stackmat.cmd(c) + + def dismiss(self, s): + """An error screen (e.g. a lookup lost to a server restart) is dismissed with submit.""" + if s.get("err") == "1": + self.btn(3, 120) + time.sleep(0.5) + + def scan(self, uid, pred, what, wait=14000, tries=4): + """Tap a card until the screen shows it worked (no beep / an error ⇒ tap again). `wait` + outlasts a request timeout (10 s), so a retap never queues behind a pending answer.""" + for _ in range(tries): + self.connected() + self.tap(uid) + s = self.poll(pred, wait) + if s: + return s + s = self.st() + self.disturbed = True + self.dismiss(s) + raise Mismatch(f"{what}: card {uid} had no effect after {tries} taps; state {brief(self.st())}") + + def press_check(self, b, ms, pred, what, tries=3): + for _ in range(tries): + self.connected() + self.btn(b, ms) + s = self.poll(pred, 2500) + if s: + return s + s = self.st() + if s.get("err") != "1": + raise Mismatch(f"{what}: btn {b} {ms} ms gave {brief(s)}") + self.dismiss(s) + raise Mismatch(f"{what}: btn {b} ignored {tries}x") + + def idle(self, s): + return s.get("scene") == "WaitingForCompetitor" and s.get("comp") == "-1" + + def to_idle(self): + """Reset competitor (submit hold) and wait until idle.""" + self.btn(3, RESET_HOLD + 400) + self.poll(self.idle, 8000, "idle after the reset hold") + + def wifi_blip(self, ms): + self.disturbed = True + self.note("wifi", ms) + self.emu.send("wifi off") + time.sleep(ms / 1000) + self.emu.send("wifi on") + + # ── one attempt ───────────────────────────────────────────────────────────────────────── + + def attempt(self, sp, day, rec): + C, J, G, sl = sp["comp"], sp["judge"], sp["group"], sp["slips"] + group = next(g for g in day["groups"] if g["groupId"] == G) + mine = [g["groupId"] for g in day["groups"] if g["groupId"] in day["cards"][str(C)]["groups"]] + self.disturbed = False + self.emu.send(f"bat {sp['bat']}") + s = self.poll(lambda s: self.idle(s) and s.get("time") == "-1", 12000) + if not s: + raise Mismatch(f"not idle before the attempt: {brief(self.st())}") + blip = sl.get("wifi_blip") + if blip and blip["at"] == "before_scan": + self.wifi_blip(blip["ms"]) + + if "fumble_before_scan" in sl: + self.connected() + self.stk("start") + time.sleep(0.7) + if sl["fumble_before_scan"]: + self.stk("stop") + time.sleep(0.6) + self.stk("reset") + self.poll(lambda s: self.idle(s) and s.get("time") == "-1" and s.get("timer") == "Some(0)", + 5000, "fumbled timer cleared") + + if "wrong_first" in sl: + W = sl["wrong_first"] + if day["cards"][str(W)]["canCompete"]: + self.scan(W, lambda s: s.get("comp") == str(W), "wrong card shows that person") + self.to_idle() + else: + self.connected() + self.tap(W) + time.sleep(2) + if not self.idle(self.st()): + raise Mismatch(f"staff card {W} changed the idle screen: {brief(self.st())}") + + def scan_competitor(): + return self.scan(C, lambda s: s.get("comp") == str(C) and s.get("scene") in + ("CompetitorInfo", "GroupSelect"), "competitor scanned") + + def select_group(): + if len(mine) < 2: + return self.poll(lambda s: s.get("group") == G, 3000, "single group selected") + self.poll(lambda s: s.get("scene") == "GroupSelect" and s.get("groups") == str(len(mine)), + 3000, "group select with the competitor's groups") + k, t, sel = len(mine), mine.index(G), 0 + steps = [4] * t + if "group_scroll" in sl: + steps = [4] * (t + 1) + [1] + for b in steps: + sel = (sel + 1) % k if b == 4 else (sel - 1) % k + self.press_check(b, 120, lambda s, sel=sel: s.get("sel") == str(sel), "group cursor") + return self.press_check(3, 120, lambda s: s.get("scene") == "CompetitorInfo" and s.get("group") == G, + "group chosen") + + s = scan_competitor() + if "scan_twice" in sl: + time.sleep(sl["scan_twice"]) + self.tap(C) + time.sleep(1.5) + s2 = self.st() + if s2.get("comp") != str(C) or s2.get("scene") != s.get("scene"): + raise Mismatch(f"second tap of the competitor changed the screen: {brief(s2)}") + select_group() + if "abandon" in sl: + self.to_idle() + rec["abandoned"] += 1 + scan_competitor() + select_group() + + conf, insp_expect, limit = False, None, group["limit"] + if "timer_reset_running" in sl: + self.connected() + self.stk("start") + self.poll(lambda s: s.get("scene") == "Timer", 5000, "fumble starts the timer") + time.sleep(0.6) + self.stk("reset") + s = self.poll(lambda s: s.get("scene") == "Finished", 5000, "reset running timer ⇒ DNF") + exp_time, pen, conf, insp_expect = 0, -1, True, 0 + else: + target = sp["insp"] + if group["useInspection"] and "forgot_inspection" not in sl: + t0 = self.start_inspection() + if "inspect_twice" in sl: + time.sleep(1.5) + self.press_check(1, 120, lambda s: s.get("scene") == "Inspection", "2nd inspect press") + if "inspection_restart" in sl: + time.sleep(1.5) + self.press_check(1, INSPECT_STOP_HOLD + 300, lambda s: s.get("scene") == "CompetitorInfo", + "inspection cancelled") + t0 = self.start_inspection() + self.poll(lambda s: False, max(0, t0 + target - int(self.st()["up"]))) + insp_expect = target + else: + self.connected() + time.sleep(1) + insp_expect = 0 + target = 0 + self.stk("start") + self.poll(lambda s: s.get("scene") == "Timer", 5000, "timer running") + if "over_limit" in sl: + time.sleep(limit / 1000 + 1.5) + else: + time.sleep(sp["solve"]) + self.stk("stop") + shown = self.stackmat.shown + if "over_limit" in sl: + exp_time, pen = limit, -1 + else: + exp_time = shown + pen = -1 if target >= DNF_MS else 2 if target >= PLUS2_MS else 0 + s = self.poll(lambda s: s.get("scene") == "Finished", 5000, "finished") + rec["solve_ms"] = exp_time + if int(s["time"]) != exp_time or norm_pen(s) != pen: + raise Mismatch(f"after the stop: time {s['time']} pen {s['pen']}, expected {exp_time} ({pen})") + + if "stackmat_reset_early" in sl: + self.stk("reset") + time.sleep(1) + s = self.st() + if s.get("scene") != "Finished" or int(s["time"]) != exp_time: + raise Mismatch(f"timer reset before confirming changed the solve: {brief(s)}") + if blip and blip["at"] == "after_stop": + self.wifi_blip(blip["ms"]) + + if "delegate" in sl: + self.connected() + up = int(self.st()["up"]) + # a call within the pending window is ignored; near its end, either is fine + exp = "no" if up < self.delegate_until - 15000 else "maybe" if up < self.delegate_until + 15000 else "yes" + log_mark = len(self.emu.since(0)) + self.btn(2, DELEGATE_HOLD + 500) + if any("Server not trusted" in l for l in self.emu.since(0)[log_mark:]): + exp = "no" # just reconnected, trust handshake not done yet + if exp != "no": + self.delegate_until = up + DELEGATE_TIMEOUT + if self.disturbed and exp == "yes": + exp = "maybe" + rec["delegates"].append(dict(expect=exp, time=exp_time, pen=pen)) + + def pen_step(b, ms, newpen): + nonlocal pen + if conf: + newpen = pen + self.press_check(b, ms, lambda s: norm_pen(s) == newpen, f"penalty {pen} → {newpen}") + pen = newpen + + if "plus2_fix" in sl and not conf: + p0 = pen + pen_step(4, 120, press_penalty(pen)) + if sl["plus2_fix"] == "dnf_hold" and p0 == 0: + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + elif p0 == -1: + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + else: # wrap-around back to where it was + while pen != p0: + pen_step(4, 120, press_penalty(pen)) + if "judge_plus2" in sl: + pen_step(4, 120, press_penalty(pen)) + if "judge_dnf" in sl: + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + if "wrong_button" in sl: + before = self.st() + self.btn(sl["wrong_button"], 300) + s = self.st() + if any(s.get(k) != before.get(k) for k in ("scene", "time", "pen", "conf", "comp")): + raise Mismatch(f"wrong button {sl['wrong_button']} changed the solve: {brief(s)}") + + hold = sl.get("submit_long", 120) + self.press_check(3, hold, lambda s: s.get("conf") == "1", "time confirmed") + if "double_submit" in sl: + time.sleep(0.2) + self.btn(3, 120) + s = self.st() + if int(s["time"]) != exp_time or norm_pen(s) != pen or s.get("scene") != "Finished": + raise Mismatch(f"after confirming: {brief(s)}, expected {exp_time} ({pen})") + + if "competitor_before_judge" in sl: + self.scan(C, lambda s: s.get("msg") == "1", "competitor first: 'cards the same' message") + if "wrong_judge_card" in sl: + X = sl["wrong_judge_card"] + self.scan(X, lambda s: s.get("judge") == str(X), "wrong judge card taken as judge") + self.scan(J, lambda s: s.get("judge") == str(J), "judge scanned", wait=16000) + self.scan(C, self.idle, "solve sent (back to idle)", wait=16000) + rec.update(time=exp_time, pen=pen, insp=insp_expect) + self.stk("reset") + + def start_inspection(self): + """Inspect press; returns the emulated ms it started at (just before the press).""" + for _ in range(3): + self.connected() + s = self.st() + if s.get("timer") not in ("Some(0)", "None"): + raise Mismatch(f"stackmat not at 0 before inspection: {brief(s)}") + t0 = int(s["up"]) + 30 + self.note("btn", 1, 120) + self.emu.send("btn 1 120") + if self.poll(lambda s: s.get("scene") == "Inspection", 2500): + return t0 + self.dismiss(self.st()) + raise Mismatch(f"inspection did not start: {brief(self.st())}") + + +def norm_pen(s): + p = int(s.get("pen", "-99")) + return 0 if p == -99 else p + + +def brief(s): + keys = ("scene", "comp", "judge", "time", "pen", "conf", "group", "groups", "sel", "err", "msg", "srv") + return " ".join(f"{k}={s.get(k)}" for k in keys) + + +# ── a whole day ────────────────────────────────────────────────────────────────────────────── + +def check_attempt(st, rec, new): + """New log entries of this station since the attempt began vs. the intended result.""" + errs = [] + sessions = {} + for e in new: + if e["delegate"]: + continue + key = (e["comp"], e["time"], e["pen"], e["judge"], e["group"]) + if sessions.setdefault(e["session"], key) != key: + errs.append(f"session {e['session']} sent twice with different data") + got = list(sessions.values()) + want = (rec["comp"], rec["time"], rec["pen"], rec["judge"], rec["group"]) + if got != [want]: + errs.append(f"server got {got}, intended {[want]}") + for e in new: + if not e["delegate"] and e["comp"] == rec["comp"]: + if rec["insp"] and abs(e["insp"] - rec["insp"]) > 900: + errs.append(f"inspection {e['insp']} ms, intended ~{rec['insp']}") + if not rec["insp"] and e["insp"] != 0: + errs.append(f"inspection {e['insp']} ms sent for an attempt without inspection") + dels = [e for e in new if e["delegate"]] + want_d = [d for d in rec["delegates"] if d["expect"] != "no"] + sure = [d for d in want_d if d["expect"] == "yes"] + if not len(sure) <= len(dels) <= len(want_d): + errs.append(f"delegate calls: server got {len(dels)}, intended {rec['delegates']}") + for e, d in zip(dels, want_d): + if (e["comp"], e["group"], e["time"], e["pen"], e["judge"]) != (rec["comp"], rec["group"], d["time"], d["pen"], 0): + errs.append(f"delegate call {e} != intended {d}") + return errs + + +class Day: + def __init__(self, seed, day, job, args, image): + self.seed, self.day, self.args = seed, day, args + self.dir = M.OUT / f"comp-d{day['devices']}" / str(seed) + if self.dir.exists(): + shutil.rmtree(self.dir) + self.dir.mkdir(parents=True) + self.server = M.Server(Path(args.mc).expanduser(), M.SRV_PORT + args.job_base + job, self.dir) + tests = json.load(open(self.dir / "srv" / "tests.json")) + tests.update(groups=day["groups"], cards=day["cards"]) + json.dump(tests, open(self.dir / "srv" / "tests.json", "w")) + self.job, self.image = job, image + self.records, self.mismatches, self.lock = [], [], threading.Lock() + self.restart_lock = threading.Lock() + self.restarts, self.timers, self.heap = 0, [], 0 + + def restart_server(self, down, stations): + with self.restart_lock: + for st in stations: + st.disturbed = True + self.server.stop() + time.sleep(down) + self.server.start() + self.restarts += 1 + + def run_station(self, st, stations, barrier, failures): + try: + for gi, g in enumerate(self.day["schedule"]): + for ri, row in enumerate(g["rounds"]): + barrier.wait(timeout=3600) + if st.n == 0 and g["restart"] and g["restart"]["round"] == ri: + t = threading.Timer(g["restart"]["delay"], self.restart_server, + (g["restart"]["down"], stations)) + self.timers.append(t) + t.start() + sp = row[st.n] if st.n < len(row) else None + if not sp: + continue + if not g["sync"][ri]: + time.sleep(sp["gap"]) + self.one(st, sp) + barrier.wait(timeout=3600) + if st.n == 0: + st.note("break", g["break_s"]) + time.sleep(g["break_s"]) + except threading.BrokenBarrierError: + pass + except M.Failure as f: + failures.append(f"station {st.n}: {f}") + barrier.abort() + except Exception as e: # harness bug: stop the day, keep the logs + failures.append(f"station {st.n}: harness error {e!r}") + barrier.abort() + + def one(self, st, sp): + rec = dict(station=st.n, esp=st.esp, comp=sp["comp"], judge=sp["judge"], group=sp["group"], + attempt=sp["attempt"], slips=sorted(sp["slips"]), delegates=[], abandoned=0, + time=None, pen=None, insp=None) + mark = len(entries(self.dir)) + st.note("attempt", sp["comp"], sp["group"], sp["attempt"]) + try: + st.attempt(sp, self.day, rec) + time.sleep(1.0) + new = [e for e in entries(self.dir)[mark:] if e["esp"] == st.esp] + errs = check_attempt(st, rec, new) + except Mismatch as m: + errs = [str(m)] + rec["ok"] = not errs + with self.lock: + self.records.append(rec) + for e in errs: + self.mismatches.append(f"station {st.n} {sp['comp']} {sp['group']} #{sp['attempt']}: {e}") + print(f" [{self.seed}] MISMATCH station {st.n} {sp['comp']} {sp['group']} #{sp['attempt']}: {e}", + flush=True) + if errs: + st.stackmat.cmd("stop") + st.recover() + rec["recovered"] = True + st.check_alive() + + def execute(self): + n = self.day["devices"] + self.server.start() + stations, failures = [], [] + try: + for i in range(n): + base = 20000 + (self.args.job_base + self.job * n + i) * 10 + stations.append(Station(i, self.dir, self.server, self.image, + dict(http=base, uart1=base + 1, control=base + 2), self.args, + self.restart_lock)) + for st in stations: + st.boot() + st.esp = int(st.st()["id"], 16) + if len({st.esp for st in stations}) != n: + raise M.Failure(f"stations share a device id: {[st.esp for st in stations]}") + barrier = threading.Barrier(n) + threads = [threading.Thread(target=self.run_station, args=(st, stations, barrier, failures)) + for st in stations] + for t in threads: + t.start() + for t in threads: + t.join() + if not failures: + for st in stations: + st.check_alive() + st.check_errors(st.st()) + self.final_check(stations) + except M.Failure as f: + failures.append(str(f)) + finally: + for t in self.timers: + t.cancel() + for st in stations: + self.heap = max(self.heap, st.heap_peak) + st.stackmat.alive = False + st.emu.stop() + with self.restart_lock: + self.server.stop() + self.save(stations) + return failures + + def final_check(self, stations): + """The server's whole list (deduplicated by session) equals the intended list.""" + esps = {st.esp: st.n for st in stations} + by_session = {} + for e in entries(self.dir): + if e["esp"] not in esps: + self.mismatches.append(f"final: solve from unknown device {e['esp']}: {e}") + if not e["delegate"]: + by_session.setdefault(e["session"], (esps.get(e["esp"]), e["comp"], e["time"], e["pen"], e["judge"], e["group"])) + got = Counter(by_session.values()) + want = Counter((r["station"], r["comp"], r["time"], r["pen"], r["judge"], r["group"]) + for r in self.records if r.get("time") is not None) + missing, extra = want - got, got - want + for k in missing.elements(): + self.mismatches.append(f"final: intended solve missing on the server: {k}") + for k in extra.elements(): + self.mismatches.append(f"final: unintended solve on the server: {k}") + + def save(self, stations): + json.dump(dict(seed=self.seed, mode="competition", day=self.day, intended=self.records, + mismatches=self.mismatches, + executed=sorted(sum((st.executed for st in stations), []))), + open(self.dir / "day.json", "w"), indent=0) + + def summary(self, failures): + slips = Counter(s for r in self.records for s in r["slips"]) + return dict(seed=self.seed, attempts=len(self.records), + ok=sum(1 for r in self.records if r["ok"]), slips=dict(slips), + mismatches=self.mismatches, failures=failures, restarts=self.restarts, heap=self.heap) + + +def run_day(seed, day, job, args, image): + d = Day(seed, day, job, args, image) + return d.summary(d.execute()) + + +def main(args): + if args.replay_file: + data = json.load(open(args.replay_file)) + work = [(data["seed"], data["day"])] + else: + if args.seed is not None or args.replay is not None: + seeds = [args.seed if args.seed is not None else args.replay] + else: + seeds = [] + for part in args.seeds.split(","): + lo, _, hi = part.partition("-") + seeds += range(int(lo), int(hi or lo) + 1) + work = [(s, generate_day(s, args.devices, args.max_attempts, args.slip_rate, args.time_scale)) + for s in seeds] + jobs = max(1, min(args.jobs, len(work))) + images = {} + for j in range(jobs): + port = M.SRV_PORT + args.job_base + j + t = M.ROOT / "target/sim" + images[j] = ((t / f"monkey-{port}.bin", t / f"monkey-{port}.elf") if args.no_build + else M.build(port)) + results, queue, lock = [], list(work), threading.Lock() + + def worker(j): + while True: + with lock: + if not queue: + return + seed, day = queue.pop(0) + print(f"seed {seed}: {day['attempts']} attempts in {len(day['schedule'])} groups, " + f"{day['devices']} station(s)", flush=True) + s = run_day(seed, day, j, args, images[j]) + print(f"seed {seed}: {'FAIL' if s['failures'] or s['mismatches'] else 'ok'} " + f"({s['ok']}/{s['attempts']} attempts clean)", flush=True) + with lock: + results.append(s) + + threads = [threading.Thread(target=worker, args=(j,)) for j in range(jobs)] + for t in threads: + t.start() + for t in threads: + t.join() + + print() + bad = 0 + for s in sorted(results, key=lambda s: s["seed"]): + bad += bool(s["failures"] or s["mismatches"]) + print(f"seed {s['seed']}: {s['ok']}/{s['attempts']} attempts ok, {s['restarts']} server restarts, " + f"heap peak {s['heap']}") + print(f" slips: {', '.join(f'{k} {v}' for k, v in sorted(s['slips'].items())) or '-'}") + for m in s["mismatches"] + s["failures"]: + print(f" ! {m}") + print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp-d{args.devices}//)") + return 1 if bad else 0 + + +if __name__ == "__main__": + # self-check: generation is deterministic, distinct people, attempts capped + a, b = generate_day(3, 2, 40), generate_day(3, 2, 40) + assert a == b and generate_day(4, 2, 40) != a and a["attempts"] <= 40 + assert len(a["cards"]) == len(set(a["cards"])) + for g in a["schedule"]: + for row in g["rounds"]: + people = [x["comp"] for x in row if x] + [x["judge"] for x in row if x] + assert len(people) == len(set(people)), row + rec = dict(comp=1, time=900, pen=2, judge=3, group="g", insp=16000, delegates=[]) + e = dict(comp=1, time=900, pen=2, judge=3, group="g", insp=16100, delegate=False, session="s") + assert check_attempt(None, rec, [e, e]) == [] and check_attempt(None, dict(rec, judge=4), [e]) + assert check_attempt(None, rec, []) and check_attempt(None, rec, [e, dict(e, session="t")]) + assert press_penalty(16) == 0 and press_penalty(-1) == 0 and hold_penalty(2) == -1 + print("ok") diff --git a/sim/make-efuse.py b/sim/make-efuse.py new file mode 100644 index 0000000..8a506a5 --- /dev/null +++ b/sim/make-efuse.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# +# Generate an eFuse blob for esp-emu's --efuse with a chosen silicon revision. +# +# Usage: +# ./tools/make-efuse.py --chip esp32c3 --chip-rev 1.1 -o c3_rev1p1.efuse +# esp-emu --chip esp32c3 --firmware merged_flash.bin --efuse c3_rev1p1.efuse +# +# --chip-rev takes either MAJOR.MINOR (1.1) or ESP-IDF's integer form (101, as +# in CONFIG_ESP32C3_REV_MIN_101). +# +# ESP-IDF derives the revision as efuse_hal_chip_revision() = +# major * 100 + minor, reading the two halves through the per-chip +# efuse_ll_get_chip_wafer_version_{major,minor}() accessors. CHIPS below +# mirrors those bit positions from components/efuse//esp_efuse_table.csv; +# The inject entries mirror EfuseChipConfig::default_wafer_* in +# src/periph//efuse.rs, so the script rejects any revision the emulator +# would override rather than emitting a blob that boots as something else. + +import argparse +import struct +import sys + +# Field chunks are (block, bit-offset-within-block, width), least-significant +# chunk first: C3 splits WAFER_VERSION_MINOR into LO+HI, P4 splits MAJOR. +# minor / major: the fields ESP-IDF's efuse_ll accessors read. +# calib: (block, bit, width, value) — the chip's BLK_VERSION default, +# matching EFUSE_CONFIG.calib_version_default. A blob replaces +# esp-emu's defaults wholesale, so without this the bootloader +# reports "efuse block revision: v0.0" and ADC calibration +# stops matching a real devkit. +# inject: BLK1 (word, mask) pairs covering every wafer field, and +# inject_bits the (word, bits) pairs esp-emu ORs in when all +# the masked words read zero. +# max_rev: _REV_MAX_FULL from IDF's Kconfig.hw_support. Above it +# the 2nd-stage bootloader rejects every image, so a request +# that high is almost certainly a typo. +CHIPS = { + "esp32c3": { + "max_rev": 199, + "minor": [(1, 114, 3), (1, 183, 1)], + "major": [(1, 184, 2)], + "calib": (2, 128, 2, 1), # BLK_VERSION_MAJOR = 1 + "inject": [(3, 0x7 << 18), (5, 0x0380_0000)], "inject_bits": [(3, 3 << 18)], + }, + "esp32c5": { + "max_rev": 199, + "minor": [(1, 64, 4)], + "major": [(1, 68, 2)], + "calib": (1, 72, 3, 1), # BLK_VERSION_MINOR = 1 + "inject": [(2, 0x3F)], "inject_bits": [(2, 1 << 4)], + }, + "esp32c6": { + "max_rev": 99, + "minor": [(1, 114, 4)], + "major": [(1, 118, 2)], + "calib": (1, 123, 3, 1), + "inject": [(3, 0x3F << 18)], "inject_bits": [(3, 3 << 18)], + }, + "esp32h2": { + "max_rev": 199, + "minor": [(1, 114, 3)], + "major": [(1, 117, 2)], + "calib": (2, 130, 3, 1), + "inject": [(3, 0x1F << 18)], "inject_bits": [(3, 2 << 18)], + }, + "esp32p4": { + "max_rev": 399, + "minor": [(1, 64, 4)], + "major": [(1, 68, 2), (1, 87, 1)], + "calib": None, + "inject": [(2, 0x0080003F)], "inject_bits": [(2, (1 << 0) | (3 << 4))], + }, + "esp32s31": { + "max_rev": 99, + "minor": [(1, 114, 4)], + "major": [(1, 118, 2)], + "calib": None, + "inject": [(3, 0x3F << 18)], "inject_bits": [], + }, +} + +# File word index of each block, per the QEMU-compatible layout esp-emu reads +# (Efuse::load_from_binary): BLK0 6 words, BLK1 6, BLK2 8, BLK3 8, keys, BLK10. +BLK_WORD_BASE = {0: 0, 1: 6, 2: 12, 3: 20} +NWORDS = 84 # 336 bytes + + +def put(words, chunks, value): + """Scatter `value` across `chunks`, least-significant chunk first.""" + for blk, bit, width in chunks: + if bit % 32 + width > 32: + raise AssertionError(f"field at BLK{blk} bit {bit} straddles a word") + words[BLK_WORD_BASE[blk] + bit // 32] |= (value & ((1 << width) - 1)) << (bit % 32) + value >>= width + if value: + raise ValueError("value too wide for the field") + + +def get(words, chunks): + """Reassemble a value scattered across `chunks`.""" + value, shift = 0, 0 + for blk, bit, width in chunks: + field = (words[BLK_WORD_BASE[blk] + bit // 32] >> (bit % 32)) & ((1 << width) - 1) + value |= field << shift + shift += width + return value + + +def parse_rev(text): + if "." in text: + major, minor = (int(x) for x in text.split(".", 1)) + else: + major, minor = divmod(int(text), 100) + return major, minor + + +def reachable(chip, major): + """Minor values for `major` that survive esp-emu's default injection.""" + spec = CHIPS[chip] + out = [] + for minor in range(1 << sum(w for _, _, w in spec["minor"])): + probe = [0] * NWORDS + put(probe, spec["minor"], minor) + put(probe, spec["major"], major) + if effective(chip, probe) == (major, minor): + out.append(minor) + return out + + +def effective(chip, words): + """The (major, minor) esp-emu will report for this blob, after injection.""" + spec = CHIPS[chip] + words = list(words) + blank = all(words[BLK_WORD_BASE[1] + w] & m == 0 for w, m in spec["inject"]) + if blank: + for w, bits in spec["inject_bits"]: + words[BLK_WORD_BASE[1] + w] |= bits + return get(words, spec["major"]), get(words, spec["minor"]) + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--chip", required=True, choices=sorted(CHIPS)) + p.add_argument("--chip-rev", required=True, metavar="MAJOR.MINOR", + help="silicon revision, e.g. 1.1 or IDF's integer form 101") + p.add_argument("--mac", default="24:0A:C4:00:00:01", + help="factory MAC (default: esp-emu's own default)") + p.add_argument("--no-calib", action="store_true", + help="leave BLK_VERSION at v0.0 instead of the chip default") + p.add_argument("-o", "--output", required=True) + a = p.parse_args() + + spec = CHIPS[a.chip] + major, minor = parse_rev(a.chip_rev) + words = [0] * NWORDS + + # MAC_FACTORY is read back in reverse byte order, so BLK1 word 0 holds + # mac[2..5] and word 1 mac[0..1]. A blob supplies the whole eFuse image, so + # skipping this would boot the firmware with MAC 00:00:00:00:00:00. + mac = bytes(int(b, 16) for b in a.mac.split(":")) + if len(mac) != 6: + p.error("--mac must be 6 colon-separated hex bytes") + words[BLK_WORD_BASE[1] + 0] = int.from_bytes(mac[2:6], "big") + words[BLK_WORD_BASE[1] + 1] = int.from_bytes(mac[0:2], "big") + + try: + put(words, spec["minor"], minor) + put(words, spec["major"], major) + except ValueError: + p.error(f"v{major}.{minor} does not fit {a.chip}'s wafer-version fields") + + if spec["calib"] and not a.no_calib: + blk, bit, width, val = spec["calib"] + put(words, [(blk, bit, width)], val) + + # esp-emu re-injects its default wafer bits when every masked wafer field + # reads zero, so a blob asking for v0.0 comes back as the chip default. + # Refuse rather than emit a blob that boots as some other revision. + got = effective(a.chip, words) + if got != (major, minor): + alts = reachable(a.chip, major) + print(f"error: esp-emu would report v{got[0]}.{got[1]}, not v{major}.{minor}.\n" + f" Its default wafer bits are re-injected when every wafer field reads zero\n" + f" (EfuseChipConfig::default_wafer_* in src/periph/{a.chip}/efuse.rs).\n" + f" Reachable minors for major {major}: " + f"{', '.join(str(m) for m in alts) if alts else '(none)'}", + file=sys.stderr) + return 1 + + if major * 100 + minor > spec["max_rev"]: + ceiling = divmod(spec["max_rev"], 100) + print(f"warning: ESP-IDF supports {a.chip} only up to v{ceiling[0]}.{ceiling[1]} " + f"(_REV_MAX_FULL); the bootloader will reject every image " + f"on a v{major}.{minor} part.", file=sys.stderr) + + with open(a.output, "wb") as f: + f.write(b"".join(struct.pack(", state: GlobalState) { match select( rx.read_async(&mut buf), Timer::after(Duration::from_millis( - if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { 20 } else { 200 }, + if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { + 20 + } else { + 200 + }, )), ) .await From 2710af91b328f15db48a1f3ddb80d6d99b1bdb78 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 17:19:02 +0200 Subject: [PATCH 095/132] fix: keep the sign key on a repeated device add --- src/buttons.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/buttons.rs b/src/buttons.rs index dbc70a4..d444bc9 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -448,14 +448,21 @@ async fn submit(state: &GlobalState) { } } -/// Registers this device on the server with a fresh sign key. +/// Registers this device on the server, with a fresh sign key if it has none yet. +/// +/// An existing key is kept: the server ignores an `Add` for a device it already knows, so a +/// second `Add` with a new key (submit pressed twice, or a stale "not added" push that crossed +/// the first `Add`) would leave the device with a key the server rejects. pub async fn add_device(state: &GlobalState) { - let mut sign_key = [0; 4]; - _ = getrandom::getrandom(&mut sign_key); - let sign_key = u32::from_be_bytes(sign_key) >> 1; - - _ = state.nvs.set(&NVS_SIGN_KEY, sign_key).await; - crate::state::SETTINGS.write(|s| s.sign_key = sign_key); + let mut sign_key = crate::state::SETTINGS.read(|s| s.sign_key); + if sign_key == 0 { + let mut bytes = [0; 4]; + _ = getrandom::getrandom(&mut bytes); + sign_key = (u32::from_be_bytes(bytes) >> 1).max(1); + + _ = state.nvs.set(&NVS_SIGN_KEY, sign_key).await; + crate::state::SETTINGS.write(|s| s.sign_key = sign_key); + } crate::state::SESSION.write(|s| s.trust_server = true); let packet = crate::structs::TimerPacket { From 6a7d4de40fa760627e0e6d74980760f357605f18 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 17:19:03 +0200 Subject: [PATCH 096/132] test: monkey re-add race scenario, retry presses while disconnected --- sim/competition.py | 5 ++++- sim/scenarios/readd-sign-key.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 sim/scenarios/readd-sign-key.json diff --git a/sim/competition.py b/sim/competition.py index 68addcf..e816e82 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -365,6 +365,9 @@ def press_check(self, b, ms, pred, what, tries=3): if s: return s s = self.st() + if s.get("srv") != "Some(true)" or s.get("stk") != "Some(true)": + self.disturbed = True # the disconnected screen ignores buttons: press again + continue if s.get("err") != "1": raise Mismatch(f"{what}: btn {b} {ms} ms gave {brief(s)}") self.dismiss(s) @@ -843,7 +846,7 @@ def worker(j): print(f" slips: {', '.join(f'{k} {v}' for k, v in sorted(s['slips'].items())) or '-'}") for m in s["mismatches"] + s["failures"]: print(f" ! {m}") - print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp-d{args.devices}//)") + print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp-d{work[0][1]['devices']}//)") return 1 if bad else 0 diff --git a/sim/scenarios/readd-sign-key.json b/sim/scenarios/readd-sign-key.json new file mode 100644 index 0000000..2672e2a --- /dev/null +++ b/sim/scenarios/readd-sign-key.json @@ -0,0 +1 @@ +{"seed": 90003, "note": "regression: stations re-added at once after a server restart; a stale \"not added\" push made one Add twice with a new key, the server kept the first, every lookup failed with Wrong sign key", "day": {"seed": 90003, "devices": 3, "time_scale": 0.5, "groups": [{"groupId": "333-r1-g1", "name": "3x3x3 Cube R1", "useInspection": true, "secondaryText": "Group 1", "limit": null}, {"groupId": "333-r1-g2", "name": "3x3x3 Cube R1", "useInspection": true, "secondaryText": "Group 2", "limit": null}, {"groupId": "222-r1-g1", "name": "2x2x2 Cube R1", "useInspection": true, "secondaryText": "Group 1", "limit": null}, {"groupId": "333bf-r1-g1", "name": "3x3x3 Blindfolded R1", "useInspection": false, "secondaryText": "Group 1", "limit": 25000}], "cards": {"287189434": {"registrantId": 1, "name": "Competitor 1", "wcaId": "2026COMP01", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1"]}, "3666356328": {"registrantId": 2, "name": "Competitor 2", "wcaId": "2026COMP02", "canCompete": true, "groups": ["333-r1-g2", "333bf-r1-g1"]}, "37990345": {"registrantId": 3, "name": "Competitor 3", "wcaId": "2026COMP03", "canCompete": true, "groups": ["333-r1-g1"]}, "1371121841": {"registrantId": 4, "name": "Competitor 4", "wcaId": "2026COMP04", "canCompete": true, "groups": ["333-r1-g2", "222-r1-g1", "333bf-r1-g1"]}, "2182945475": {"registrantId": 5, "name": "Competitor 5", "wcaId": "2026COMP05", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1", "333bf-r1-g1"]}, "1181161137": {"registrantId": 6, "name": "Competitor 6", "wcaId": "2026COMP06", "canCompete": true, "groups": ["333-r1-g2", "333bf-r1-g1"]}, "117094282": {"registrantId": 7, "name": "Competitor 7", "wcaId": "2026COMP07", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1", "333bf-r1-g1"]}, "4057200016": {"registrantId": 8, "name": "Competitor 8", "wcaId": "2026COMP08", "canCompete": true, "groups": ["333-r1-g2"]}, "4042335171": {"registrantId": 9, "name": "Competitor 9", "wcaId": "2026COMP09", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1", "333bf-r1-g1"]}, "2631022212": {"registrantId": 10, "name": "Competitor 10", "wcaId": "2026COMP10", "canCompete": true, "groups": ["222-r1-g1"]}, "2488215895": {"registrantId": 11, "name": "Competitor 11", "wcaId": "2026COMP11", "canCompete": true, "groups": ["333-r1-g2", "222-r1-g1"]}, "2999766711": {"registrantId": 12, "name": "Competitor 12", "wcaId": "2026COMP12", "canCompete": true, "groups": ["333-r1-g1"]}, "1012332382": {"registrantId": 13, "name": "Competitor 13", "wcaId": "2026COMP13", "canCompete": true, "groups": ["333-r1-g2", "222-r1-g1"]}, "3886023573": {"registrantId": 14, "name": "Competitor 14", "wcaId": "2026COMP14", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1"]}, "2456473984": {"registrantId": 15, "name": "Competitor 15", "wcaId": "2026COMP15", "canCompete": true, "groups": ["333-r1-g2", "222-r1-g1"]}, "1174907418": {"registrantId": 16, "name": "Competitor 16", "wcaId": "2026COMP16", "canCompete": true, "groups": ["333-r1-g1", "333bf-r1-g1"]}, "2119974984": {"registrantId": 17, "name": "Competitor 17", "wcaId": "2026COMP17", "canCompete": true, "groups": ["333-r1-g2", "333bf-r1-g1"]}, "2790795643": {"registrantId": 18, "name": "Competitor 18", "wcaId": "2026COMP18", "canCompete": true, "groups": ["333-r1-g1", "333bf-r1-g1"]}, "1723451360": {"registrantId": 19, "name": "Competitor 19", "wcaId": "2026COMP19", "canCompete": true, "groups": ["333-r1-g2"]}, "2969857505": {"registrantId": 20, "name": "Competitor 20", "wcaId": "2026COMP20", "canCompete": true, "groups": ["333-r1-g1", "222-r1-g1", "333bf-r1-g1"]}, "2628392858": {"registrantId": 21, "name": "Competitor 21", "wcaId": "2026COMP21", "canCompete": true, "groups": ["333-r1-g2", "222-r1-g1"]}, "43701714": {"registrantId": 22, "name": "Competitor 22", "wcaId": "2026COMP22", "canCompete": true, "groups": ["333-r1-g1", "333bf-r1-g1"]}, "3481226819": {"registrantId": 23, "name": "Competitor 23", "wcaId": "2026COMP23", "canCompete": true, "groups": ["333-r1-g2", "333bf-r1-g1"]}, "1517392783": {"registrantId": 24, "name": "Competitor 24", "wcaId": "2026COMP24", "canCompete": true, "groups": ["333-r1-g1", "333bf-r1-g1"]}, "860522919": {"registrantId": 25, "name": "Competitor 25", "wcaId": "2026COMP25", "canCompete": true, "groups": ["333-r1-g2"]}, "1043975204": {"registrantId": 26, "name": "Competitor 26", "wcaId": "2026COMP26", "canCompete": true, "groups": ["333-r1-g1", "333bf-r1-g1"]}, "34243095": {"registrantId": 27, "name": "Competitor 27", "wcaId": "2026COMP27", "canCompete": true, "groups": ["333-r1-g2"]}, "3686400090": {"registrantId": 28, "name": "Competitor 28", "wcaId": "2026COMP28", "canCompete": true, "groups": ["333-r1-g1"]}, "1758461112": {"registrantId": 100, "name": "Staff 1", "wcaId": "", "canCompete": false, "groups": []}, "509749111": {"registrantId": 101, "name": "Staff 2", "wcaId": "", "canCompete": false, "groups": []}, "1684534433": {"registrantId": 102, "name": "Staff 3", "wcaId": "", "canCompete": false, "groups": []}, "3727079654": {"registrantId": 103, "name": "Staff 4", "wcaId": "", "canCompete": false, "groups": []}, "1333087271": {"registrantId": 104, "name": "Staff 5", "wcaId": "", "canCompete": false, "groups": []}}, "schedule": [{"group": "333-r1-g1", "rounds": [[{"comp": 287189434, "judge": 509749111, "group": "333-r1-g1", "attempt": 1, "insp": 9552, "solve": 6.88, "gap": 2.41, "slips": {}, "bat": 100}, {"comp": 37990345, "judge": 2119974984, "group": "333-r1-g1", "attempt": 1, "insp": 5560, "solve": 10.74, "gap": 2.21, "slips": {}, "bat": 98}, {"comp": 2182945475, "judge": 3727079654, "group": "333-r1-g1", "attempt": 1, "insp": 10234, "solve": 4.75, "gap": 4.31, "slips": {}, "bat": 99}]], "restart": {"round": 0, "delay": 1.0, "down": 2.0}, "sync": [true], "break_s": 2}, {"group": "222-r1-g1", "rounds": [[{"comp": 287189434, "judge": 43701714, "group": "222-r1-g1", "attempt": 1, "insp": 11743, "solve": 1.85, "gap": 4.19, "slips": {}, "bat": 90}, {"comp": 1371121841, "judge": 1181161137, "group": "222-r1-g1", "attempt": 1, "insp": 5401, "solve": 3.63, "gap": 0.45, "slips": {}, "bat": 91}, {"comp": 2182945475, "judge": 3481226819, "group": "222-r1-g1", "attempt": 1, "insp": 10162, "solve": 3.09, "gap": 1.11, "slips": {}, "bat": 89}]], "restart": {"round": 0, "delay": 1.0, "down": 2.0}, "sync": [true], "break_s": 2}, {"group": "333bf-r1-g1", "rounds": [[{"comp": 2182945475, "judge": 287189434, "group": "333bf-r1-g1", "attempt": 1, "insp": null, "solve": 6.58, "gap": 0.6, "slips": {}, "bat": 82}, {"comp": 3666356328, "judge": 1333087271, "group": "333bf-r1-g1", "attempt": 1, "insp": null, "solve": 5.85, "gap": 1.8, "slips": {}, "bat": 82}, {"comp": 1371121841, "judge": 4057200016, "group": "333bf-r1-g1", "attempt": 1, "insp": null, "solve": 6.27, "gap": 3.17, "slips": {}, "bat": 81}]], "restart": {"round": 0, "delay": 1.0, "down": 2.0}, "sync": [true], "break_s": 2}], "attempts": 9}} \ No newline at end of file From ae91c0642af70033fe94d12b73e5367c2d9796a9 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 17:29:08 +0200 Subject: [PATCH 097/132] fix: cap wifi driver buffers so reconnect bursts can't balloon the heap --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- src/main.rs | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 223a544..fd96f69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,9 +1507,9 @@ dependencies = [ [[package]] name = "esp-hal-wifimanager" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f150a502c6253f3f77c8f9d93981e466f06acabd709608ff9ae9e301b0dd8da0" +checksum = "7659099c1405f5cf949e17acd62fd297e06891185a112d03f1aeac4a95b1c1b5" dependencies = [ "edge-dhcp", "edge-nal", diff --git a/Cargo.toml b/Cargo.toml index f09f1f3..ebf6349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ esp-backtrace = { version = "0.20.0", features = [ "panic-handler", "println", " esp-hal = { version = "1.2.2", features = [ "esp32c3" ] } esp-println = { version = "0.18.0", features = ["log-04", "esp32c3"] } log = { version = "0.4.34", features = ["release_max_level_info"] } -esp-radio = { version = "1.0.0-beta.1", features = ["coex", "esp32c3"] } +esp-radio = { version = "1.0.0-beta.1", features = ["coex", "esp32c3", "unstable"] } esp-rtos = { version = "0.4.0", features = ["esp32c3", "embassy", "esp-radio"] } embassy-executor = { version = "0.10.0", features = ["nightly"] } embassy-net = { version = "0.9.1", features = ["tcp", "udp", "multicast", "dhcpv4", "medium-ethernet", "proto-ipv4", "dns"] } @@ -21,7 +21,7 @@ static_cell = { version = "2.1.1", features = ["nightly"] } heapless = { version = "0.9.3", default-features = false } esp-alloc = { version = "0.11.0", features = ["internal-heap-stats"] } -esp-hal-wifimanager = { version = "0.6.3", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } +esp-hal-wifimanager = { version = "0.6.4", default-features = false, features = ["ap", "ble", "esp32c3", "ota", "custom_panel"] } serde = { version = "1.0.229", features = ["alloc", "derive"], default-features = false } serde_json = { version = "1.0.154", default-features = false, features = ["alloc"] } adv-shift-registers = "0.2.5" diff --git a/src/main.rs b/src/main.rs index 5c1c714..b14c172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -247,6 +247,11 @@ async fn main(spawner: Spawner) { provisioning_signal: Some(&WIFI_SETUP_SIG), // provisioning and the ws buffers share heap: never let both run in one boot esp_restart_after_connection: true, + // our TCP windows (4 KB rx / 2 KB tx) keep ~3 frames in flight; the default 32+32 + // dynamic buffers only let a burst after a reconnect take up to ~100 KB of heap + controller_config: esp_radio::wifi::ControllerConfig::default() + .with_dynamic_rx_buf_num(12) + .with_dynamic_tx_buf_num(12), ..Default::default() }; From 7dee216696e52854189dc7520337f1d2673bb956 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 17:19:02 +0200 Subject: [PATCH 098/132] fix: keep the sign key on a repeated device add --- src/buttons.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/buttons.rs b/src/buttons.rs index 7dc6642..e207c90 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -444,14 +444,21 @@ async fn submit(state: &GlobalState) { } } -/// Registers this device on the server with a fresh sign key. +/// Registers this device on the server, with a fresh sign key if it has none yet. +/// +/// An existing key is kept: the server ignores an `Add` for a device it already knows, so a +/// second `Add` with a new key (submit pressed twice, or a stale "not added" push that crossed +/// the first `Add`) would leave the device with a key the server rejects. pub async fn add_device(state: &GlobalState) { - let mut sign_key = [0; 4]; - _ = getrandom::getrandom(&mut sign_key); - let sign_key = u32::from_be_bytes(sign_key) >> 1; - - _ = state.nvs.set(&NVS_SIGN_KEY, sign_key).await; - crate::state::SETTINGS.write(|s| s.sign_key = sign_key); + let mut sign_key = crate::state::SETTINGS.read(|s| s.sign_key); + if sign_key == 0 { + let mut bytes = [0; 4]; + _ = getrandom::getrandom(&mut bytes); + sign_key = (u32::from_be_bytes(bytes) >> 1).max(1); + + _ = state.nvs.set(&NVS_SIGN_KEY, sign_key).await; + crate::state::SETTINGS.write(|s| s.sign_key = sign_key); + } crate::state::SESSION.write(|s| s.trust_server = true); let packet = crate::structs::TimerPacket { From aa8a21cecb2fa05a862405c898d752df153c4e79 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:28:01 +0200 Subject: [PATCH 099/132] fix: keep a delegate call on its attempt until answered --- src/buttons.rs | 29 +++++++++++++++++++++++++---- src/consts.rs | 2 +- src/state.rs | 5 +++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/buttons.rs b/src/buttons.rs index d444bc9..859c0af 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -32,6 +32,12 @@ static DELEGATE_REQUEST: Signal bool { + DELEGATE_PENDING.load(Ordering::Relaxed) +} + /// The single button being held. Dropped when an action consumes the press, so it gets no /// further hold/up actions. struct Press { @@ -191,7 +197,9 @@ async fn on_up(state: &GlobalState, button: Button) { match button { Button::Third => submit(state).await, Button::Fourth => penalty(state), - Button::Second => state.solve.update(|s| s.delegate_hold = None), + // a sent call keeps its "waiting for delegate" screen until the answer + Button::Second if !delegate_pending() => state.solve.update(|s| s.delegate_hold = None), + Button::Second => {} Button::First => {} } } @@ -675,6 +683,10 @@ fn delegate_send(state: &GlobalState) { async fn delegate_loop(state: GlobalState) { loop { let packet = DELEGATE_REQUEST.wait().await; + let session_id = match &packet { + crate::structs::TimerPacketInner::Solve { session_id, .. } => Some(session_id.clone()), + _ => None, + }; let resp = crate::ws::delegate_request( packet, Duration::from_millis(crate::consts::DELEGATE_TIMEOUT_MS), @@ -684,18 +696,27 @@ async fn delegate_loop(state: GlobalState) { match resp { Ok(resp) => { - state.solve.update(|s| { + // only for the attempt that called (it can't change meanwhile, but a reboot + // restoring another one could) + let applied = state.solve.update(|s| { + s.delegate_hold = None; + if session_id.is_none() || s.session_id != session_id { + return false; + } s.solve_time = Some(resp.solve_time.unwrap_or(s.solve_time.unwrap_or(0))); s.penalty = Some(resp.penalty.unwrap_or(s.penalty.unwrap_or(0) as i64) as i8); s.scene = Scene::Finished; s.time_confirmed = true; s.delegate_used = true; + true }); - if !resp.should_scan_cards { + if !applied { + log::warn!("Delegate answer for another attempt ignored"); + } else if !resp.should_scan_cards { state.reset_solve(true).await; } } - // no answer: drop the "hold" indicator, the solve stays as it was + // no answer: drop the waiting screen, the solve stays as it was Err(_) => state.solve.update(|s| s.delegate_hold = None), } diff --git a/src/consts.rs b/src/consts.rs index b545dcf..02d049c 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -20,7 +20,7 @@ pub const RFID_RETRY_INIT_MS: u64 = 1500; pub const WS_RETRY_MS: u64 = 1000; /// Total time for a request (send + tagged response); was 5 s send + 5 s response. pub const WS_REQUEST_TIMEOUT_MS: u64 = 10_000; -/// The delegate answers on the web panel, so wait long (the buttons task is blocked). +/// The delegate answers on the web panel, so wait long (the attempt takes no input meanwhile). #[cfg_attr(feature = "qa", allow(dead_code))] pub const DELEGATE_TIMEOUT_MS: u64 = 5 * 60_000; diff --git a/src/state.rs b/src/state.rs index edbc423..ef8ba11 100644 --- a/src/state.rs +++ b/src/state.rs @@ -512,9 +512,10 @@ impl GlobalStateInner { }) } - /// Buttons/cards are ignored while an error, a lost connection or a setup scene is shown. + /// Buttons/cards are ignored while an error, a lost connection, a setup scene or a pending + /// delegate call is shown. pub fn should_skip_other_actions(&self) -> bool { - if self.ui.read(|u| u.error_text.is_some()) { + if self.ui.read(|u| u.error_text.is_some()) || crate::buttons::delegate_pending() { return true; } From 6158e8e5d98ca3109b747e9e6fff0e5ea5b8755f Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:28:08 +0200 Subject: [PATCH 100/132] fix: confirming tap is no new competitor lookup --- src/rfid.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rfid.rs b/src/rfid.rs index da5cd6b..a41555a 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -348,9 +348,9 @@ async fn handle_card( } } - let is_competitor = global_state - .solve - .read(|s| s.current_competitor.is_none_or(|c| c == card_uid as u64)); + // only a card starting an attempt: the server refuses it once the round is over, which must + // not hit the competitor's confirming tap (e.g. of a last attempt a delegate resolved) + let is_competitor = global_state.solve.read(|s| s.current_competitor.is_none()); let resp = crate::ws::request::( crate::structs::TimerPacketInner::CardInfoRequest { From 8c41b8373deb80a75c4b98a600425c87e8f9e195 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:44:57 +0200 Subject: [PATCH 101/132] feat: sim v3 with an emulated HD44780 behind the shift registers --- host-tests/src/lib.rs | 2 + sim/README.md | 27 ++- sim/competition.py | 4 +- sim/monkey.py | 41 ++++- sim/run.sh | 6 +- sim/web.html | 40 ++++- sim/web.py | 24 +-- src/battery_v3.rs | 18 +- src/board.rs | 27 ++- src/lcd_v3.rs | 2 + src/sim.rs | 305 +++++++++++++++++++++++++++++--- src/sim/hd44780.rs | 394 ++++++++++++++++++++++++++++++++++++++++++ src/utils/lcd_text.rs | 6 + 13 files changed, 836 insertions(+), 60 deletions(-) create mode 100644 src/sim/hd44780.rs diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index be4fc63..d2309cf 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -14,6 +14,8 @@ pub mod ring_log; pub mod text; #[path = "../../src/utils/lcd_text.rs"] pub mod lcd_text; +#[path = "../../src/sim/hd44780.rs"] +pub mod hd44780; #[path = "../../src/utils/oled_ui.rs"] pub mod oled_ui; #[path = "../../src/utils/stackmat.rs"] diff --git a/sim/README.md b/sim/README.md index 5809d33..d26d699 100644 --- a/sim/README.md +++ b/sim/README.md @@ -1,14 +1,16 @@ # Running the firmware in esp-emu [esp-emu](https://github.com/espressif/esp-emulator) (Espressif, beta, closed source) runs the real -ESP32-C3 image with Wi-Fi. The `sim` feature (v4, UART stackmat) fills the gaps of the emulator: +ESP32-C3 image with Wi-Fi. The `sim` feature (v4 or v3, UART stackmat; `SIM_HW=v3` / `--hw v3`) +fills the gaps of the emulator: | Real device | In `sim` builds | |---|---| -| OLED (I2C) | fake SSD1309 on the I2C bus, printed to the console (half blocks) when it changes | +| OLED (I2C, v4) | fake SSD1309 on the I2C bus, printed to the console (half blocks) when it changes | +| LCD (v3) | the shift register chain is faked at its three GPIOs (data/clock/latch); the real `adv_shift_registers` + `ag-lcd-async` + `lcd_v3` code drives an emulated HD44780 (`src/sim/hd44780.rs`: 8-bit power-on state, 4-bit nibble pairing, DDRAM/CGRAM, entry mode/shift, writes during the busy time are lost), printed as a 16×2 box | | RFID reader | `card ` on stdin | | Buttons | `btn <1-4> [ms]` on stdin (1 inspect, 2 delegate, 3 submit, 4 penalty) | -| Battery gauge | `bat <0-100>` on stdin (starts at 100) | +| Battery gauge | `bat <0-100>` on stdin (starts at 100); v3: the reading of the faked ADC | | Stackmat | the real UART driver; `sim/stackmat.py` plays the timer | | Wi-Fi setup | seeded for the emulator's AP; also adds itself to the server (`auto_add`) | | BLE | starts (patched esp-radio), but esp-emu never answers its HCI commands: no BLE flows yet | @@ -34,13 +36,29 @@ python3 sim/stackmat.py # terminal 3: start | stop | r which doesn't get answers through esp-emu's relay on macOS. Cards in micro-connector's `tests.json`: `69420` (competitor), `3004425529` (judge). +## v3 LCD + +`SIM_HW=v3 sim/run.sh` (or `--hw v3` for `web.py`/`monkey.py`). `@state` gets `lcd=`: how long +the emulated LCD has shown something else than what `lcd_v3` believes it wrote (0 = in sync); +`monkey.py` fails a run when that exceeds 4 s. Fault injection on stdin: + +| Command | Real-world fault | +|---|---| +| `lcd glitch` | one E edge lost (noise): every later byte pairs the wrong nibbles | +| `lcd drop` | one write lost | +| `lcd reset` | module brownout: back to 8-bit, display off, while the MCU keeps running | +| `lcd slow ` | controller execution times ×n. esp-emu bit-bangs the shift registers ~4× slower than a C3, so n = 3–4 puts the short gaps where real hardware has them | +| `lcd stats` | writes lost to the busy time and the least slack seen since the last `stats` | + +The fuzzer injects the first three on v3; `sim/scenarios/lcd-heal.json` checks each heals. + ## In a browser ```sh python3 sim/web.py # builds, starts esp-emu + stackmat + micro-connector, serves :8000 ``` -http://127.0.0.1:8000 shows the OLED and has the four buttons (hold as long as on the device, +http://127.0.0.1:8000 shows the OLED (or with `--hw v3` the 16×2 LCD) and has the four buttons (hold as long as on the device, keys 1–4), cards, the stackmat (space = start/stop, r = reset), battery, Wi-Fi off/on, server off/on, power cycle, the live state and the log. `--no-server` runs without micro-connector. @@ -114,6 +132,7 @@ on one server, round by round, a competitor never at two stations at once. ```sh python3 sim/monkey.py --mode competition --seeds 1-6 --jobs 3 --job-base 20 --max-attempts 36 +python3 sim/monkey.py --mode competition --hw v3 --seeds 1-6 --jobs 3 --job-base 20 python3 sim/monkey.py --mode competition --seed 4 --devices 3 --job-base 20 --slip-rate 2 python3 sim/monkey.py --replay-file sim/out/comp-d1/4/day.json # same day again ``` diff --git a/sim/competition.py b/sim/competition.py index e816e82..f610e01 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -812,9 +812,9 @@ def main(args): images = {} for j in range(jobs): port = M.SRV_PORT + args.job_base + j - t = M.ROOT / "target/sim" + t = M.target(args.hw) images[j] = ((t / f"monkey-{port}.bin", t / f"monkey-{port}.elf") if args.no_build - else M.build(port)) + else M.build(port, args.hw)) results, queue, lock = [], list(work), threading.Lock() def worker(j): diff --git a/sim/monkey.py b/sim/monkey.py index 4ce5f47..0c417cf 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -6,6 +6,7 @@ python3 sim/monkey.py --replay 7 # rerun one seed (same actions) python3 sim/monkey.py --replay-file sim/out/7/actions.json --shrink python3 sim/monkey.py --seed 7 --emit-hil /tmp/tests.json # scenario for micro-connector's HIL + python3 sim/monkey.py --hw v3 --seeds 1-6 # v3 hardware (character LCD) Needs: esp-emu on PATH (or ~/.local/bin), a built micro-connector (--mc, default ~/projects/micro-connector), the sim patches (see sim/README.md). Failures go to sim/out//. @@ -36,13 +37,16 @@ SLOW = 3 # emulated time runs up to ~3x slower than real time with several instances SRV_PORT = 18100 # per-job server port base, away from the default 8080 other sims use HEAP_LIMIT = 0.92 # of the heap size printed by the firmware +# v3: the emulated LCD may differ from what the firmware wrote only this long (emulated ms) +LCD_STALE_MS = 4000 IDLE = dict(scene="WaitingForCompetitor", comp="-1", time="-1", menu="None", cfg="-1", err="0") # ── action generation (pure: same seed ⇒ same list) ───────────────────────────────────────── -def generate(seed: int, count: int) -> list: - """Weighted random actions, biased towards abuse. Each action: [delay_ms, kind, *args].""" +def generate(seed: int, count: int, hw: str = "v4") -> list: + """Weighted random actions, biased towards abuse. Each action: [delay_ms, kind, *args]. + v3 adds LCD faults (lost E edge, lost write, module brownout) the display must heal from.""" r = random.Random(seed) acts = [] @@ -54,6 +58,8 @@ def add(delay, *a): "stk": 14, "stk_chaos": 5, "bat": 2, "wait": 8, "flow": 10, "server": 1, "power": 1, "menu": 3, "wifi": 2, "outage": 1, } + if hw == "v3": + weights["lcd"] = 2 kinds, w = zip(*weights.items()) while len(acts) < count: kind = r.choices(kinds, w)[0] @@ -116,6 +122,8 @@ def add(delay, *a): add(r.choice([0, 100, 500, 3000]), "wifi", r.choice([100, 400, 1500, 3000])) elif kind == "outage": # server gone for minutes add(gap, "server", r.choice([30_000, 90_000, 180_000])) + elif kind == "lcd": + add(gap, "emu", "lcd " + r.choice(["glitch", "drop", "reset"])) return acts[:count] @@ -331,6 +339,9 @@ def state(self, timeout=4.0) -> dict: if line.startswith("@state "): st = dict(kv.split("=", 1) for kv in line[7:].split() if "=" in kv) check_invariants(st) + if int(st.get("lcd", 0)) > LCD_STALE_MS: + self.emu.send("screen") + raise Failure(f"LCD shows other text than the firmware wrote for {st['lcd']} ms") return st time.sleep(0.05) raise Failure(f"no answer to `state` within {timeout}s (hang?)") @@ -395,6 +406,8 @@ def do(self, act): self.stackmat.cmd(a[0]) elif kind == "bat": self.emu.send(f"bat {a[0]}") + elif kind == "emu": # a raw sim command (e.g. `lcd glitch`) + self.emu.send(a[0]) elif kind == "wait": pass elif kind == "expect": # scripted scenarios only: state field reaches a value @@ -555,11 +568,15 @@ def execute(self, deadline): # ── driver ────────────────────────────────────────────────────────────────────────────────── -def build(port: int) -> tuple: - env = dict(os.environ, SIM_BUILD_ONLY="1", SIM_WS_URL=f"ws://192.168.4.1:{port}") +def target(hw: str) -> Path: + return ROOT / ("target/sim-v3" if hw == "v3" else "target/sim") + + +def build(port: int, hw: str = "v4") -> tuple: + env = dict(os.environ, SIM_BUILD_ONLY="1", SIM_WS_URL=f"ws://192.168.4.1:{port}", SIM_HW=hw) subprocess.run([str(ROOT / "sim/run.sh")], cwd=ROOT, env=env, check=True, stdout=subprocess.DEVNULL) - t = ROOT / "target/sim" + t = target(hw) bin_, elf = t / f"monkey-{port}.bin", t / f"monkey-{port}.elf" shutil.copy(t / "sim.bin", bin_) shutil.copy(t / "riscv32imc-unknown-none-elf/release/fkm-firmware", elf) @@ -630,7 +647,8 @@ def main(): p.add_argument("--minutes", type=float, default=4) p.add_argument("--actions", type=int, default=400) p.add_argument("--chunk", type=int, default=40, help="recovery probe every N actions") - p.add_argument("--cfg-exit", type=int, default=6, help="index of Exit in the config menu (v4)") + p.add_argument("--hw", choices=["v3", "v4"], default=os.environ.get("SIM_HW", "v4")) + p.add_argument("--cfg-exit", type=int, help="index of Exit in the config menu (v3: 5, v4: 6)") p.add_argument("--mc", default="~/projects/micro-connector") p.add_argument("--emit-hil") p.add_argument("--shrink", action="store_true") @@ -644,6 +662,10 @@ def main(): p.add_argument("--time-scale", type=float, default=0.5, help="competition: solve lengths") p.add_argument("--slip-rate", type=float, default=1.0, help="competition: slip rate factor") args = p.parse_args() + if args.replay_file: # a scenario written for one hardware revision runs on it + args.hw = json.load(open(args.replay_file)).get("hw", args.hw) + if args.cfg_exit is None: + args.cfg_exit = 5 if args.hw == "v3" else 6 if args.mode == "competition" or (args.replay_file and "day" in json.load(open(args.replay_file))): import competition @@ -660,7 +682,7 @@ def main(): for part in args.seeds.split(","): lo, _, hi = part.partition("-") seeds += range(int(lo), int(hi or lo) + 1) - work = [(s, generate(s, args.actions)) for s in seeds] + work = [(s, generate(s, args.actions, args.hw)) for s in seeds] if args.emit_hil: emit_hil(work[0][1], args.emit_hil) @@ -669,10 +691,10 @@ def main(): jobs = max(1, min(args.jobs, len(work))) images = {} for j in range(jobs): - t = ROOT / "target/sim" + t = target(args.hw) port = SRV_PORT + args.job_base + j images[j] = ((t / f"monkey-{port}.bin", t / f"monkey-{port}.elf") - if args.no_build else build(port)) + if args.no_build else build(port, args.hw)) results, queue, lock = [], list(work), threading.Lock() @@ -708,5 +730,6 @@ def worker(j): a, b = generate(5, 300), generate(5, 300) assert a == b and generate(6, 300) != a assert {x[1] for x in a} >= {"btn", "card", "stk"} + assert "emu" in {x[1] for x in generate(5, 300, "v3")} and "emu" not in {x[1] for x in a} assert Stackmat.frame(b" ", 61_234) == b" 101234" + bytes([64 + 11]) + b"\n\r" main() diff --git a/sim/run.sh b/sim/run.sh index d064186..92af4cf 100755 --- a/sim/run.sh +++ b/sim/run.sh @@ -6,12 +6,14 @@ # SIM_PROVISION=1 sim/run.sh no seeded Wi-Fi: AP provisioning, panel on http://127.0.0.1:18080 # SIM_WIFIMANAGER=~/projects/esp-hal-wifimanager sim/run.sh local wifimanager checkout # SIM_BLE=tcp:localhost:9544 sim/run.sh BLE through a Bumble controller (sim/ble_*.py) +# SIM_HW=v3 sim/run.sh v3 hardware (HD44780 behind the shift registers) instead of v4 # SIM_BUILD_ONLY=1, SIM_HTTP_PORT/SIM_UART1_PORT/SIM_CONTROL_PORT: used by sim/monkey.py # Extra arguments go to esp-emu (e.g. --timeout 60s). set -eu cd "$(dirname "$0")/.." -T=target/sim +HW="${SIM_HW:-v4}" +case "$HW" in v3) T=target/sim-v3 ;; v4) T=target/sim ;; *) echo "SIM_HW must be v3 or v4" >&2; exit 2 ;; esac mkdir -p "$T" PATCHES="" ESP_SIM_PATCHES="${ESP_SIM_PATCHES:-$HOME/tmp/esp-sim-patches}" @@ -33,7 +35,7 @@ if [ -n "${SIM_WIFIMANAGER:-}" ]; then cargo update -q -p esp-hal-wifimanager $PATCHES fi # shellcheck disable=SC2086 -CARGO_TARGET_DIR=$T cargo build -r --no-default-features --features v4,sim $PATCHES +CARGO_TARGET_DIR=$T cargo build -r --no-default-features --features $HW,sim $PATCHES cp "$T/Cargo.lock.bak" Cargo.lock trap - EXIT diff --git a/sim/web.html b/sim/web.html index dfa97a3..c948f86 100644 --- a/sim/web.html +++ b/sim/web.html @@ -9,6 +9,7 @@ --bg: #f4f4f2; --panel: #ffffff; --ink: #1d1d1b; --muted: #6b6b66; --line: #deddd8; --accent: #1f6feb; --oled-bg: #050807; --oled-px: #9ff0ff; --btn: #ecebe7; --btn-down: #c9d8f5; --ok: #1a7f37; --bad: #c62828; + --lcd-bg: #1d3fd8; --lcd-cell: #2a4ce6; --lcd-ink: #eef4ff; --lcd-off-bg: #0d1a5c; --lcd-off-ink: #6c7bb8; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { @@ -33,6 +34,9 @@ .stack > * + * { margin-top: 16px; } #oled { width: 100%; aspect-ratio: 2 / 1; image-rendering: pixelated; background: var(--oled-bg); border-radius: 6px; display: block; border: 6px solid #000; } + #lcd { width: 100%; aspect-ratio: 16 / 3.2; background: var(--lcd-bg); border-radius: 6px; display: block; + border: 10px solid #111; box-shadow: inset 0 0 18px rgba(0,0,0,.45); } + .hidden { display: none !important; } .buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; } button { font: inherit; color: var(--ink); background: var(--btn); border: 1px solid var(--line); border-radius: 8px; padding: 10px 8px; cursor: pointer; touch-action: none; user-select: none; } @@ -67,6 +71,7 @@

FKM device (esp-emu)

+
@@ -145,6 +150,37 @@

Log

ctx.putImageData(img, 0, 0); } +// v3 LCD: {codes: 32 char codes hex (row 0 then row 1), bl: backlight, cur: "r,c" | "-", cg: CGRAM hex} +const lctx = $("lcd").getContext("2d"); +function drawLcd(f) { + const css = getComputedStyle(document.documentElement), v = (n) => css.getPropertyValue(n); + const bl = f.bl === "1", W = 800, H = 160, cw = W / 16, ch = H / 2, pad = 3; + lctx.fillStyle = v(bl ? "--lcd-bg" : "--lcd-off-bg"); lctx.fillRect(0, 0, W, H); + const cg = f.cg || "", cur = f.cur && f.cur !== "-" ? f.cur.split(",").map(Number) : null; + lctx.font = `600 ${ch * 0.62}px ui-monospace, "SF Mono", Menlo, monospace`; + lctx.textAlign = "center"; lctx.textBaseline = "middle"; + for (let i = 0; i < 32; i++) { + const code = parseInt((f.codes || "").substr(i * 2, 2), 16) || 32, x = (i % 16) * cw, y = (i >> 4) * ch; + lctx.fillStyle = v("--lcd-cell"); lctx.globalAlpha = bl ? 0.35 : 0.15; + lctx.fillRect(x + pad, y + pad * 2, cw - pad * 2, ch - pad * 4); lctx.globalAlpha = 1; + lctx.fillStyle = v(bl ? "--lcd-ink" : "--lcd-off-ink"); + if (code < 8) { // CGRAM glyph: 8 rows of 5 dots + const dx = (cw - pad * 2) / 5, dy = (ch - pad * 4) / 8; + for (let r = 0; r < 8; r++) { + const bits = parseInt(cg.substr(((code & 7) * 8 + r) * 2, 2), 16) || 0; + for (let c = 0; c < 5; c++) if (bits >> (4 - c) & 1) lctx.fillRect(x + pad + c * dx, y + pad * 2 + r * dy, dx - 1, dy - 1); + } + } else { + lctx.fillText(code >= 0x20 && code < 0x7f ? String.fromCharCode(code) : "▒", x + cw / 2, y + ch / 2); + } + if (cur && cur[0] === i >> 4 && cur[1] === i % 16) lctx.fillRect(x + pad, y + ch - pad * 4, cw - pad * 2, pad); + } +} +function setHw(hw) { + $("oled").classList.toggle("hidden", hw === "v3"); + $("lcd").classList.toggle("hidden", hw !== "v3"); +} + // hardware buttons: held until released (the firmware times the hold itself) const holding = new Map(); function press(n, el) { @@ -234,10 +270,12 @@

Log

const es = new EventSource("/events"); es.onopen = () => { $("conn").textContent = "connected"; $("conn").className = "ok"; }; es.onerror = () => { $("conn").textContent = "disconnected (is sim/web.py running?)"; $("conn").className = "bad"; }; +es.addEventListener("hw", (e) => setHw(JSON.parse(e.data))); es.addEventListener("oled", (e) => drawOled(JSON.parse(e.data))); +es.addEventListener("lcd", (e) => drawLcd(JSON.parse(e.data))); es.addEventListener("state", (e) => showState(JSON.parse(e.data))); es.addEventListener("log", (e) => addLog(JSON.parse(e.data))); -drawOled(""); +drawOled(""); drawLcd({ bl: "1" }); diff --git a/sim/web.py b/sim/web.py index 0d455e4..51e955b 100644 --- a/sim/web.py +++ b/sim/web.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""The sim device in a browser: OLED, buttons, cards, stackmat, battery, Wi-Fi/server/power. +"""The sim device in a browser: display, buttons, cards, stackmat, battery, Wi-Fi/server/power. - python3 sim/web.py [--mc ~/projects/micro-connector] [--no-server] [--port 8000] + python3 sim/web.py [--hw v3|v4] [--mc ~/projects/micro-connector] [--no-server] [--port 8000] Builds the sim image, starts esp-emu, the fake stackmat and (unless --no-server) a local micro-connector, then serves http://127.0.0.1:8000. Stdlib only: events go out as SSE, @@ -23,22 +23,22 @@ class Hub: - """Collects events for the page: OLED frames, log lines, state.""" + """Collects events for the page: display frames, log lines, state.""" def __init__(self): self.events, self.cond = [], threading.Condition() - self.oled = None + self.frame = None def push(self, kind: str, data): with self.cond: self.events.append((kind, data)) - if kind == "oled": - self.oled = data + if kind in ("oled", "lcd"): + self.frame = (kind, data) self.cond.notify_all() def watch(self, emu: monkey.Emu): """Turns emulator output into events (OLED frames as `@oled `, page-major like the - SSD1309 GRAM: byte = 8 vertical pixels).""" + SSD1309 GRAM: byte = 8 vertical pixels; LCD frames as `@lcd <32 char codes> bl= cur= cg=`).""" seen = 0 while True: with emu.lock: @@ -48,6 +48,9 @@ def watch(self, emu: monkey.Emu): if line.startswith("@oled "): if len(line) == 6 + 2048: self.push("oled", line[6:]) + elif line.startswith("@lcd "): + hexcodes, *kv = line[5:].split() + self.push("lcd", dict(codes=hexcodes, **dict(x.split("=", 1) for x in kv if "=" in x))) elif line.startswith("@state "): self.push("state", dict(kv.split("=", 1) for kv in line[7:].split() if "=" in kv)) else: @@ -60,13 +63,14 @@ def main(): p.add_argument("--mc", default="~/projects/micro-connector") p.add_argument("--no-server", action="store_true") p.add_argument("--port", type=int, default=8000) + p.add_argument("--hw", choices=["v3", "v4"], default="v4") args = p.parse_args() out = monkey.ROOT / "sim/out/web" out.mkdir(parents=True, exist_ok=True) srv_port = monkey.SRV_PORT + JOB print("building the sim image…") - bin_, elf = monkey.build(srv_port) + bin_, elf = monkey.build(srv_port, args.hw) server = None if not args.no_server: @@ -81,7 +85,7 @@ def main(): def poll_state(): time.sleep(1) - emu.send("oled raw") + emu.send("lcd raw" if args.hw == "v3" else "oled raw") while True: emu.send("state") time.sleep(1) @@ -117,7 +121,7 @@ def do_GET(self): self.end_headers() with hub.cond: cursor = len(hub.events) - first = [("oled", hub.oled)] if hub.oled else [] + first = [("hw", args.hw)] + ([hub.frame] if hub.frame else []) # recent log so a reload isn't empty first += [e for e in hub.events[-300:] if e[0] == "log"] try: diff --git a/src/battery_v3.rs b/src/battery_v3.rs index 99c4966..322453f 100644 --- a/src/battery_v3.rs +++ b/src/battery_v3.rs @@ -1,7 +1,9 @@ use crate::consts::BATTERY_SEND_INTERVAL_MS; use embassy_time::{Duration, Instant, Timer}; +#[cfg(not(feature = "sim"))] use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation}; +#[cfg(not(feature = "sim"))] type AdcCal = esp_hal::analog::adc::AdcCalCurve>; #[embassy_executor::task] @@ -10,10 +12,15 @@ pub async fn battery_read_task( adc: esp_hal::peripherals::ADC1<'static>, state: crate::state::GlobalState, ) { - let mut adc_config = AdcConfig::new(); - - let mut adc_pin = adc_config.enable_pin_with_cal::<_, AdcCal>(adc_pin, Attenuation::_11dB); - let mut adc = Adc::new(adc, adc_config).into_async(); + #[cfg(not(feature = "sim"))] + let (mut adc, mut adc_pin) = { + let mut adc_config = AdcConfig::new(); + let pin = adc_config.enable_pin_with_cal::<_, AdcCal>(adc_pin, Attenuation::_11dB); + (Adc::new(adc, adc_config).into_async(), pin) + }; + // esp-emu has no ADC: the sim's `bat` command sets the reading + #[cfg(feature = "sim")] + let _ = (adc_pin, adc); let mut battery_start = Instant::now().saturating_add(Duration::from_millis(300)); @@ -31,7 +38,10 @@ pub async fn battery_read_task( continue; } + #[cfg(not(feature = "sim"))] let read = adc.read_oneshot(&mut adc_pin).await; + #[cfg(feature = "sim")] + let read = crate::sim::battery_adc(); let read = smoother.tick(read as f32); #[cfg(feature = "bat_dev_lcd")] diff --git a/src/board.rs b/src/board.rs index 0609fc4..3109d06 100644 --- a/src/board.rs +++ b/src/board.rs @@ -2,7 +2,7 @@ use esp_hal::gpio::AnyPin; use esp_hal::gpio::Pin; use esp_hal::{ - gpio::{Input, InputConfig, Output, Pull}, + gpio::{Input, InputConfig, Pull}, peripherals::{AES, BT, FLASH, FROM_CPU_INTR0, Peripherals, TIMG0, TIMG1, WIFI}, timer::timg::TimerGroup, }; @@ -37,7 +37,7 @@ pub struct Board { pub cs: adv_shift_registers::wrappers::ShifterPin, #[cfg(feature = "v4")] - pub display_rst: Output<'static>, + pub display_rst: esp_hal::gpio::Output<'static>, #[cfg(feature = "v4")] pub i2c: SharedI2C, #[cfg(feature = "v4")] @@ -76,7 +76,7 @@ impl Board { let flash = peripherals.FLASH; let sw_interrupt = peripherals.FROM_CPU_INTR0; - let display_rst = Output::new( + let display_rst = esp_hal::gpio::Output::new( peripherals.GPIO7, esp_hal::gpio::Level::Low, Default::default(), @@ -175,7 +175,8 @@ impl Board { impl Board { pub fn init(peripherals: Peripherals) -> Board { use embedded_hal::digital::OutputPin; - use esp_hal::gpio::Level; + #[cfg(not(feature = "sim"))] + use esp_hal::gpio::{Level, Output}; let timg0 = TimerGroup::new(peripherals.TIMG0); let timg1 = TimerGroup::new(peripherals.TIMG1); @@ -200,9 +201,19 @@ impl Board { InputConfig::default().with_pull(Pull::Down), ); - let shifter_data_pin = Output::new(peripherals.GPIO10, Level::Low, Default::default()); - let shifter_latch_pin = Output::new(peripherals.GPIO1, Level::Low, Default::default()); - let shifter_clk_pin = Output::new(peripherals.GPIO21, Level::Low, Default::default()); + #[cfg(not(feature = "sim"))] + type ShiftOut = Output<'static>; + #[cfg(not(feature = "sim"))] + let (shifter_data_pin, shifter_clk_pin, shifter_latch_pin) = ( + Output::new(peripherals.GPIO10, Level::Low, Default::default()), + Output::new(peripherals.GPIO21, Level::Low, Default::default()), + Output::new(peripherals.GPIO1, Level::Low, Default::default()), + ); + // esp-emu can't observe GPIOs: the sim decodes the chain (and the LCD behind it) itself + #[cfg(feature = "sim")] + type ShiftOut = crate::sim::ShiftPin; + #[cfg(feature = "sim")] + let (shifter_data_pin, shifter_clk_pin, shifter_latch_pin) = crate::sim::shift_pins(); let adv_shift_reg = adv_shift_registers::AdvancedShiftRegister::<2, _>::new( shifter_data_pin, @@ -211,7 +222,7 @@ impl Board { 0, ); static SHIFT_REG: static_cell::StaticCell< - adv_shift_registers::AdvancedShiftRegister<2, Output<'static>>, + adv_shift_registers::AdvancedShiftRegister<2, ShiftOut>, > = static_cell::StaticCell::new(); let adv_shift_reg = SHIFT_REG.init(adv_shift_reg); diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index ed6ed54..c5518d6 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -56,6 +56,8 @@ async fn show(text: &mut LcdText, lcd: &mut Lcd) { lcd.set_position(col, row).await; lcd.print(&s).await; } + #[cfg(feature = "sim")] + crate::sim::lcd_intended(text.shown()); } pub async fn lcd_show_critical_code(lcd_shifter: ShifterValue, code: u8) { diff --git a/src/sim.rs b/src/sim.rs index ffce03e..4c162c9 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -1,21 +1,27 @@ //! esp-emu simulation build (feature `sim`, see sim/README.md). //! //! The emulator has no I2C devices, no GPIO input and prints only UART0, so this module stands -//! in for them: logs go to UART0, the OLED is a fake I2C SSD1309 printed as text, and stdin -//! (forwarded to UART0 RX) drives the buttons, cards and battery. +//! in for them: logs go to UART0, the display is faked at its bus (v4: an I2C SSD1309, v3: the +//! shift register chain driving an HD44780) and printed as text, and stdin (forwarded to UART0 +//! RX) drives the buttons, cards and battery. -#[cfg(any(feature = "v3", feature = "timer-func"))] -compile_error!("feature `sim` targets v4 with the UART stackmat (--no-default-features)"); +#[cfg(feature = "timer-func")] +compile_error!("feature `sim` needs the UART stackmat (--no-default-features)"); + +#[cfg(feature = "v3")] +mod hd44780; use crate::state::GlobalState; use core::{cell::RefCell, fmt::Write}; use embassy_sync::blocking_mutex::{CriticalSectionMutex, raw::CriticalSectionRawMutex}; use embassy_sync::signal::Signal; use embassy_time::{Duration, Instant, Timer}; +#[cfg(feature = "v4")] use embedded_hal::i2c::Operation; +#[cfg(feature = "v4")] +use esp_hal::i2c::master::{AcknowledgeCheckFailedReason, Error as I2cError}; use esp_hal::{ Async, Blocking, - i2c::master::{AcknowledgeCheckFailedReason, Error as I2cError}, uart::{Config, Uart, UartRx, UartTx}, }; @@ -23,14 +29,16 @@ use esp_hal::{ pub static CARD: Signal = Signal::new(); /// `wifi off|on`: `true` stops the radio (driver torn down), `false` restarts it pub static WIFI: Signal = Signal::new(); -/// `oled raw`: frames as `@oled ` every 20 ms (sim/web.py) instead of text art every 200 ms -static OLED_RAW: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false); +/// `oled raw` / `lcd raw`: frames as `@oled ` / `@lcd ...` every 20 ms (sim/web.py) +/// instead of text art every 200 ms +static RAW: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false); static TX: CriticalSectionMutex>>> = CriticalSectionMutex::new(RefCell::new(None)); /// Release instant (µs) of each held button. static BUTTONS: CriticalSectionMutex> = CriticalSectionMutex::new(RefCell::new([0; 4])); +#[cfg(feature = "v4")] static OLED: CriticalSectionMutex> = CriticalSectionMutex::new(RefCell::new(Oled { gram: [0; 1024], page: 0, @@ -111,6 +119,7 @@ pub async fn seed_wifi(nvs: &esp_hal_wifimanager::Nvs) { } } +#[cfg(feature = "v4")] /// The emulated I2C bus: only the SSD1309 (0x3C) answers. pub fn i2c(address: u8, operations: &mut [Operation<'_>]) -> Result<(), I2cError> { if address != 0x3C { @@ -130,6 +139,7 @@ pub fn i2c(address: u8, operations: &mut [Operation<'_>]) -> Result<(), I2cError Ok(()) } +#[cfg(feature = "v4")] /// Fake SSD1309: page addressing (what the driver uses) into a 1 KB GRAM. struct Oled { gram: [u8; 1024], @@ -138,6 +148,7 @@ struct Oled { dirty: bool, } +#[cfg(feature = "v4")] impl Oled { fn write(&mut self, bytes: &[u8]) { match bytes { @@ -164,6 +175,7 @@ impl Oled { } } +#[cfg(feature = "v4")] /// Prints the OLED (1×2 pixels per character) if it changed since the last print. fn print_oled() { let Some(gram) = OLED.lock(|o| { @@ -173,7 +185,7 @@ fn print_oled() { return; }; - if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { + if RAW.load(portable_atomic::Ordering::Relaxed) { let mut line = heapless::String::<{ 6 + 2048 }>::new(); _ = line.push_str("@oled "); for b in gram.gram { @@ -201,7 +213,233 @@ fn print_oled() { print(format_args!("└{:─<128}┘", "")); } +/// v3: the 74HC595 chain (data, clock, latch GPIOs) the real `adv_shift_registers` code +/// bit-bangs; the second register's latched outputs drive the emulated HD44780. +#[cfg(feature = "v3")] +pub struct ShiftPin(u8); + +#[cfg(feature = "v3")] +const SR_DATA: u8 = 1; +#[cfg(feature = "v3")] +const SR_CLK: u8 = 2; +#[cfg(feature = "v3")] +const SR_LATCH: u8 = 4; + +/// Pin levels (`SR_*` bits) and the shift register contents: plain loads/stores, so a GPIO +/// write costs about what the real one does (all writers run on the one executor). +#[cfg(feature = "v3")] +static SR_PINS: portable_atomic::AtomicU8 = portable_atomic::AtomicU8::new(0); +#[cfg(feature = "v3")] +static SR_BITS: portable_atomic::AtomicU16 = portable_atomic::AtomicU16::new(0); + +#[cfg(feature = "v3")] +static LCD: CriticalSectionMutex> = CriticalSectionMutex::new(RefCell::new(Lcd { + hd: hd44780::Hd44780::new(), + backlight: false, + intended: [[b' '; hd44780::COLS]; 2], + printed: None, + bad_since: None, +})); + +#[cfg(feature = "v3")] +struct Lcd { + hd: hd44780::Hd44780, + backlight: bool, + /// what the firmware believes is shown (lcd_v3's cache after its last write) + intended: [[u8; hd44780::COLS]; 2], + /// last printed frame (visible, cursor, backlight) + printed: Option, + /// since when (ms) the visible text differs from `intended` + bad_since: Option, +} + +#[cfg(feature = "v3")] +type LcdFrame = ([[u8; hd44780::COLS]; 2], Option<(usize, usize)>, bool); + +/// data, clock, latch +#[cfg(feature = "v3")] +pub fn shift_pins() -> (ShiftPin, ShiftPin, ShiftPin) { + (ShiftPin(SR_DATA), ShiftPin(SR_CLK), ShiftPin(SR_LATCH)) +} + +#[cfg(feature = "v3")] +impl embedded_hal::digital::ErrorType for ShiftPin { + type Error = core::convert::Infallible; +} + +#[cfg(feature = "v3")] +impl embedded_hal::digital::OutputPin for ShiftPin { + fn set_low(&mut self) -> Result<(), Self::Error> { + let pins = SR_PINS.load(portable_atomic::Ordering::Relaxed); + SR_PINS.store(pins & !self.0, portable_atomic::Ordering::Relaxed); + Ok(()) + } + + fn set_high(&mut self) -> Result<(), Self::Error> { + use portable_atomic::Ordering::Relaxed; + let pins = SR_PINS.load(Relaxed); + SR_PINS.store(pins | self.0, Relaxed); + if pins & self.0 != 0 { + return Ok(()); + } + if self.0 == SR_CLK { + // 74HC595 chain: the first bit clocked in ends up in bit 0 after 16 clocks + let bits = SR_BITS.load(Relaxed) >> 1 | u16::from(pins & SR_DATA != 0) << 15; + SR_BITS.store(bits, Relaxed); + } else if self.0 == SR_LATCH { + latch(SR_BITS.load(Relaxed) as u8); + } + Ok(()) + } +} + +/// The LCD register's outputs (ShifterPin bit b = mask 0x80 >> b): CS, backlight, RS, E, +/// DB4..DB7. +#[cfg(feature = "v3")] +fn latch(out: u8) { + let now = esp_hal::time::Instant::now() + .duration_since_epoch() + .as_micros(); + LCD.lock(|l| { + let mut l = l.borrow_mut(); + l.backlight = out & 0x40 != 0; + // DB4..DB7 sit on bits 3..0: reversed into DB7..DB4 = bits 3..0 + let db = (out & 0x0F).reverse_bits() >> 4; + l.hd.pins(out & 0x20 != 0, out & 0x10 != 0, db, now); + }); +} + +/// lcd_v3 reports what it wrote; the sim compares that with what the controller shows. +#[cfg(feature = "v3")] +pub fn lcd_intended(rows: &[[u8; hd44780::COLS]; 2]) { + LCD.lock(|l| l.borrow_mut().intended = *rows); +} + +/// ms the visible LCD text has differed from what the firmware wrote (0 = matches). +#[cfg(feature = "v3")] +fn lcd_mismatch_ms() -> u64 { + let now = Instant::now().as_millis(); + LCD.lock(|l| { + let mut l = l.borrow_mut(); + if l.hd.visible() == l.intended { + l.bad_since = None; + 0 + } else { + now - *l.bad_since.get_or_insert(now) + } + }) +} + +/// Prints the LCD if it changed since the last print: `@lcd <32 char codes hex> bl= cur= cg=` +/// in raw mode, a 16×2 box otherwise. +#[cfg(feature = "v3")] +fn print_lcd(force: bool) { + lcd_mismatch_ms(); + let Some((frame, cgram)) = LCD.lock(|l| { + let mut l = l.borrow_mut(); + let frame = (l.hd.visible(), l.hd.cursor_pos(), l.backlight); + (force || l.printed != Some(frame)).then(|| { + l.printed = Some(frame); + (frame, l.hd.cgram) + }) + }) else { + return; + }; + let (rows, cursor, backlight) = frame; + + if RAW.load(portable_atomic::Ordering::Relaxed) { + let mut line = heapless::String::<{ 5 + 64 + 24 + 128 }>::new(); + _ = line.push_str("@lcd "); + for b in rows.as_flattened() { + _ = write!(line, "{b:02x}"); + } + _ = write!(line, " bl={}", u8::from(backlight)); + _ = match cursor { + Some((r, c)) => write!(line, " cur={r},{c}"), + None => write!(line, " cur=-"), + }; + _ = write!(line, " cg="); + for b in cgram { + _ = write!(line, "{b:02x}"); + } + print(format_args!("{line}")); + return; + } + + print(format_args!( + "┌{:─<16}┐{}", + "", + if backlight { "" } else { " (backlight off)" } + )); + for row in rows { + let mut line = heapless::String::<{ 16 * 3 + 8 }>::new(); + _ = line.push('│'); + for c in row { + _ = line.push(match c { + 0x20..=0x7E => char::from(c), + 0x00..=0x07 => '▯', // custom (CGRAM) character + _ => '▒', + }); + } + _ = line.push('│'); + print(format_args!("{line}")); + } + print(format_args!("└{:─<16}┘", "")); +} + +/// `lcd glitch|drop|reset|slow |stats`: faults a real module can suffer, and the bus timing. +#[cfg(feature = "v3")] +fn lcd_command(arg: Option<&str>, n: Option) -> bool { + LCD.lock(|l| { + let hd = &mut l.borrow_mut().hd; + match (arg, n) { + // a lost E edge: every later byte pairs the wrong nibbles + (Some("glitch"), _) => hd.skip_edges = 1, + // one write lost (noise on E) + (Some("drop"), _) => hd.skip_transfers = 1, + // brownout of the module while the MCU keeps running + (Some("reset"), _) => hd.reset(), + // execution times × n: esp-emu runs the bit-banging slower than a real C3 does + (Some("slow"), Some(n @ 1..=100)) => hd.slow = n, + (Some("stats"), _) => { + print(format_args!( + "lcd: {} writes ignored while busy (last: {:?} = (RS, byte, us early)), \ + least slack {} us (since the last stats)", + hd.busy_drops, hd.last_drop, hd.min_slack + )); + hd.busy_drops = 0; + hd.last_drop = None; + hd.min_slack = i64::MAX; + } + _ => return false, + } + true + }) +} + const HELP: &str = "sim commands: btn <1-4> [ms] | card | bat <0-100> | screen | state | wifi off|on | help (btn 3 = submit)"; +#[cfg(feature = "v3")] +const HELP_LCD: &str = + " lcd glitch|drop|reset (faults) | lcd slow <1-100> (controller timing) | lcd stats | lcd raw"; + +/// Prints the display if it changed (`force`: anyway). +fn print_display(force: bool) { + #[cfg(feature = "v4")] + { + if force { + OLED.lock(|o| o.borrow_mut().dirty = true); + } + print_oled(); + } + #[cfg(feature = "v3")] + print_lcd(force); +} + +fn help() { + print(format_args!("{HELP}")); + #[cfg(feature = "v3")] + print(format_args!("{HELP_LCD}")); +} /// One machine-readable line for sim/monkey.py: `@state key=value ...` (`-` = none). fn print_state(state: GlobalState) { @@ -263,6 +501,10 @@ fn print_state(state: GlobalState) { Instant::now().as_millis(), crate::utils::get_efuse_u32() ); + #[cfg(feature = "v3")] + { + _ = write!(line, " lcd={}", lcd_mismatch_ms()); + } print(format_args!("{line}")); } @@ -272,7 +514,7 @@ pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { use embassy_futures::select::{Either, select}; set_battery(state, 100); - print(format_args!("{HELP}")); + help(); let mut line = heapless::Vec::::new(); let mut buf = [0u8; 32]; @@ -280,7 +522,7 @@ pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { match select( rx.read_async(&mut buf), Timer::after(Duration::from_millis( - if OLED_RAW.load(portable_atomic::Ordering::Relaxed) { + if RAW.load(portable_atomic::Ordering::Relaxed) { 20 } else { 200 @@ -302,7 +544,7 @@ pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { } } Either::First(Err(_)) => line.clear(), - Either::Second(()) => print_oled(), + Either::Second(()) => print_display(false), } } } @@ -319,23 +561,46 @@ fn command(line: &str, state: GlobalState) { } (Some("card"), Some(uid)) => CARD.signal(u128::from(uid)), (Some("bat"), Some(pct @ 0..=100)) => set_battery(state, pct as u8), - (Some("screen"), _) => { - OLED.lock(|o| o.borrow_mut().dirty = true); - print_oled(); - } + (Some("screen"), _) => print_display(true), (Some("state"), _) => print_state(state), - (Some("oled"), _) if a == Some("raw") => { - OLED_RAW.store(true, portable_atomic::Ordering::Relaxed); - OLED.lock(|o| o.borrow_mut().dirty = true); + (Some("oled" | "lcd"), _) if a == Some("raw") => { + RAW.store(true, portable_atomic::Ordering::Relaxed); + print_display(true); } + #[cfg(feature = "v3")] + (Some("lcd"), _) if lcd_command(a, num(b)) => {} (Some("wifi"), _) if a == Some("off") => WIFI.signal(true), (Some("wifi"), _) if a == Some("on") => WIFI.signal(false), - _ => print(format_args!("? {line}\r\n{HELP}")), + _ => { + print(format_args!("? {line}")); + help(); + } } } -/// The battery gauge doesn't exist in the emulator; the first value also unblocks the boot screen. +/// v4: the battery gauge doesn't exist in the emulator; the first value also unblocks the boot +/// screen. +#[cfg(feature = "v4")] fn set_battery(state: GlobalState, percent: u8) { state.battery.write(|b| b.percent = percent); state.battery_ready.signal(()); } + +/// v3: the raw ADC reading battery_v3 sees. +#[cfg(feature = "v3")] +static BATTERY_ADC: portable_atomic::AtomicU16 = portable_atomic::AtomicU16::new(0); + +#[cfg(feature = "v3")] +pub fn battery_adc() -> u16 { + BATTERY_ADC.load(portable_atomic::Ordering::Relaxed) +} + +/// v3: the ADC reading for `percent` (the lowest one, plus a margin for the smoother). +#[cfg(feature = "v3")] +fn set_battery(_: GlobalState, percent: u8) { + use crate::utils::battery::{adc_to_mv, percent as pct}; + let raw = (0..4096u16) + .find(|&raw| pct(adc_to_mv(f64::from(raw))) >= percent) + .unwrap_or(4095); + BATTERY_ADC.store(raw.saturating_add(2), portable_atomic::Ordering::Relaxed); +} diff --git a/src/sim/hd44780.rs b/src/sim/hd44780.rs new file mode 100644 index 0000000..d9559a7 --- /dev/null +++ b/src/sim/hd44780.rs @@ -0,0 +1,394 @@ +//! HD44780 character LCD controller as seen from its pins (pure, host-tested): RS/E/DB7–DB4 +//! edges in, DDRAM/CGRAM and the visible 16×2 window out. Models what makes real modules show +//! wrong text: the 8-bit power-on state, 4-bit nibble pairing, and instructions that arrive +//! while the controller is still busy (ignored, as the datasheet allows no writes while BF = 1). + +/// Execution times (datasheet, fosc = 270 kHz). +const EXEC_US: u64 = 37; +const EXEC_DATA_US: u64 = 41; +const EXEC_CLEAR_US: u64 = 1520; + +pub const COLS: usize = 16; +const LINE: usize = 40; + +#[derive(Clone, Copy)] +pub struct Hd44780 { + pub ddram: [[u8; LINE]; 2], + pub cgram: [u8; 64], + /// address counter (DDRAM: 0x00–0x27 / 0x40–0x67, CGRAM: 0x00–0x3F) + pub ac: u8, + pub cgram_mode: bool, + pub eight_bit: bool, + pub two_lines: bool, + pub display_on: bool, + pub cursor: bool, + pub blink: bool, + increment: bool, + shift_on_write: bool, + /// display shift (characters, 0..40) + shift: usize, + /// 4-bit mode: high nibble of the byte in progress, and how early (µs) it came if busy + high: Option<(u8, Option)>, + en: bool, + busy_until: u64, + /// instructions/data ignored because they arrived while busy + pub busy_drops: u32, + /// the last one ignored: (RS, byte, µs too early) + pub last_drop: Option<(bool, u8, u64)>, + /// least time between the controller getting ready and the next transfer (µs, < 0 = early) + pub min_slack: i64, + /// fault injection: E falling edges to ignore (desyncs the nibble pairing) + pub skip_edges: u8, + /// fault injection: complete transfers to ignore + pub skip_transfers: u8, + /// execution times × this (a slower controller, or a faster MCU than the emulated one) + pub slow: u64, +} + +impl Hd44780 { + /// The state after the internal power-on reset: 8-bit, one line, display off, cleared. + pub const fn new() -> Self { + Self { + ddram: [[b' '; LINE]; 2], + cgram: [0; 64], + ac: 0, + cgram_mode: false, + eight_bit: true, + two_lines: false, + display_on: false, + cursor: false, + blink: false, + increment: true, + shift_on_write: false, + shift: 0, + high: None, + en: false, + busy_until: 0, + busy_drops: 0, + last_drop: None, + min_slack: i64::MAX, + skip_edges: 0, + skip_transfers: 0, + slow: 1, + } + } + + /// Power-on (brownout) reset; the statistics survive. + pub fn reset(&mut self) { + *self = Self { + busy_drops: self.busy_drops, + last_drop: self.last_drop, + min_slack: self.min_slack, + slow: self.slow, + ..Self::new() + }; + } + + /// Pin levels at `now_us`; data is latched on E's falling edge. `db` = DB7..DB4 as bits 3..0. + pub fn pins(&mut self, rs: bool, en: bool, db: u8, now_us: u64) { + let falling = self.en && !en; + self.en = en; + if !falling { + return; + } + if self.skip_edges > 0 { + self.skip_edges -= 1; + return; + } + let db = db & 0x0F; + // a transfer (its first E pulse) that starts while busy is ignored: no writes while BF = 1 + let busy = if self.high.is_none() { + self.min_slack = self.min_slack.min(now_us as i64 - self.busy_until as i64); + self.busy_until + .checked_sub(now_us) + .filter(|&early| early > 0) + } else { + None + }; + if self.eight_bit { + // DB3..DB0 of a 4-bit module float high (internal pull-ups) + self.transfer(rs, db << 4 | 0x0F, busy, now_us); + } else if let Some((high, busy)) = self.high.take() { + self.transfer(rs, high << 4 | db, busy, now_us); + } else { + self.high = Some((db, busy)); + } + } + + fn transfer(&mut self, rs: bool, v: u8, busy: Option, now_us: u64) { + if self.skip_transfers > 0 { + self.skip_transfers -= 1; + return; + } + if let Some(early) = busy { + self.busy_drops = self.busy_drops.saturating_add(1); + self.last_drop = Some((rs, v, early)); + return; + } + let exec = if rs { + self.write(v); + EXEC_DATA_US + } else { + self.instruction(v) + }; + self.busy_until = now_us + exec * self.slow; + } + + fn instruction(&mut self, v: u8) -> u64 { + match v { + 0x80.. => { + self.cgram_mode = false; + self.ac = v & 0x7F; + } + 0x40.. => { + self.cgram_mode = true; + self.ac = v & 0x3F; + } + 0x20.. => { + self.eight_bit = v & 0x10 != 0; + self.two_lines = v & 0x08 != 0; + self.high = None; + } + 0x10.. => { + let right = v & 0x04 != 0; + if v & 0x08 != 0 { + self.shift = (self.shift + if right { LINE - 1 } else { 1 }) % LINE; + } else { + self.step(right); + } + } + 0x08.. => { + self.display_on = v & 0x04 != 0; + self.cursor = v & 0x02 != 0; + self.blink = v & 0x01 != 0; + } + 0x04.. => { + self.increment = v & 0x02 != 0; + self.shift_on_write = v & 0x01 != 0; + } + 0x02.. => { + self.home(); + return EXEC_CLEAR_US; + } + 0x01 => { + self.ddram = [[b' '; LINE]; 2]; + self.increment = true; + self.home(); + return EXEC_CLEAR_US; + } + 0x00 => {} + } + EXEC_US + } + + fn home(&mut self) { + self.ac = 0; + self.cgram_mode = false; + self.shift = 0; + } + + fn write(&mut self, v: u8) { + if self.cgram_mode { + self.cgram[usize::from(self.ac & 0x3F)] = v & 0x1F; + self.ac = (self.ac + 1) & 0x3F; + return; + } + if let Some((line, col)) = self.cell(self.ac) { + self.ddram[line][col] = v; + } + self.step(self.increment); + if self.shift_on_write { + self.shift = (self.shift + if self.increment { 1 } else { LINE - 1 }) % LINE; + } + } + + /// DDRAM address → (line, column); unused addresses (0x28–0x3F, 0x68–0x7F) hold nothing. + fn cell(&self, ac: u8) -> Option<(usize, usize)> { + let (line, col) = if self.two_lines { + (usize::from(ac >= 0x40), usize::from(ac & 0x3F)) + } else { + (0, usize::from(ac)) + }; + (col < LINE).then_some((line, col)) + } + + /// Moves the address counter like the controller does (wrapping between the lines). + fn step(&mut self, forward: bool) { + if self.cgram_mode { + self.ac = if forward { + self.ac + 1 + } else { + self.ac.wrapping_sub(1) + } & 0x3F; + return; + } + self.ac = match (self.two_lines, forward, self.ac) { + (true, true, 0x27) => 0x40, + (true, true, 0x67) => 0x00, + (true, false, 0x40) => 0x27, + (true, false, 0x00) => 0x67, + (false, true, 0x4F) => 0x00, + (false, false, 0x00) => 0x4F, + (_, true, ac) => ac.wrapping_add(1) & 0x7F, + (_, false, ac) => ac.wrapping_sub(1) & 0x7F, + }; + } + + /// The visible characters (codes, not glyphs); blank while the display is off. + pub fn visible(&self) -> [[u8; COLS]; 2] { + let mut out = [[b' '; COLS]; 2]; + if !self.display_on { + return out; + } + for (row, line) in out.iter_mut().enumerate() { + if row == 1 && !self.two_lines { + break; + } + for (col, c) in line.iter_mut().enumerate() { + *c = self.ddram[row][(col + self.shift) % LINE]; + } + } + out + } + + /// Visible cursor position (row, col), if the cursor or blink is on. + pub fn cursor_pos(&self) -> Option<(usize, usize)> { + if !(self.display_on && (self.cursor || self.blink)) || self.cgram_mode { + return None; + } + let (line, col) = self.cell(self.ac)?; + let col = (col + LINE - self.shift) % LINE; + (col < COLS).then_some((line, col)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives the pins like ag-lcd-async's 4-bit `send`, one E pulse per nibble, `gap` µs apart. + struct Bus { + lcd: Hd44780, + t: u64, + } + + impl Bus { + fn nibble(&mut self, rs: bool, n: u8, gap: u64) { + self.t += gap; + self.lcd.pins(rs, true, n, self.t); + self.lcd.pins(rs, false, n, self.t); + } + fn byte(&mut self, rs: bool, v: u8, gap: u64) { + self.nibble(rs, v >> 4, gap); + self.nibble(rs, v & 0x0F, 1); + } + fn init(&mut self) { + for n in [3, 3, 3, 2] { + self.nibble(false, n, 5000); + } + for v in [0x28, 0x0C, 0x06, 0x01] { + self.byte(false, v, 5000); + } + } + fn print(&mut self, addr: u8, s: &str, gap: u64) { + self.byte(false, 0x80 | addr, 5000); + for b in s.bytes() { + self.byte(true, b, gap); + } + } + fn row(&self, r: usize) -> String { + String::from_utf8(self.lcd.visible()[r].to_vec()).unwrap() + } + } + + fn bus() -> Bus { + let mut b = Bus { + lcd: Hd44780::new(), + t: 0, + }; + b.init(); + b + } + + #[test] + fn four_bit_init_and_text() { + let mut b = bus(); + b.print(0x00, "Hello", 500); + b.print(0x45, "World", 500); + assert_eq!(b.row(0), "Hello "); + assert_eq!(b.row(1), " World "); + assert_eq!(b.lcd.busy_drops, 0); + } + + #[test] + fn power_on_state_is_blank_and_eight_bit() { + let mut b = Bus { + lcd: Hd44780::new(), + t: 0, + }; + b.print(0x00, "x", 500); + assert_eq!(b.row(0), " ".repeat(16)); + assert!(b.lcd.eight_bit); + } + + #[test] + fn command_while_busy_is_ignored() { + let mut b = bus(); + b.print(0x00, "0123456789abcdef", 500); + // the row-1 address right after the last character: the text lands off screen + b.byte(false, 0xC0, 20); + for c in b"NEW TEXT " { + b.byte(true, *c, 500); + } + assert_eq!(b.lcd.busy_drops, 1); + assert_eq!(b.row(1), " ".repeat(16)); + assert_eq!(b.lcd.ddram[0][16..24], *b"NEW TEXT"); + } + + #[test] + fn lost_nibble_desyncs_until_reinit() { + let mut b = bus(); + b.lcd.skip_edges = 1; + b.print(0x00, "abcd", 500); + assert_ne!(b.row(0), "abcd "); + b.init(); + b.print(0x00, "abcd", 500); + assert_eq!(b.row(0), "abcd "); + } + + #[test] + fn reinit_without_clear_recovers_from_any_nibble_phase() { + for skip in [0, 1] { + let mut b = bus(); + b.print(0x00, "keep", 500); + b.lcd.skip_edges = skip; + b.nibble(false, 0x0, 500); // leaves a half byte pending for skip = 0 + for n in [3, 3, 3, 2] { + b.nibble(false, n, 5000); + } + for v in [0x28, 0x0C, 0x06, 0x02] { + b.byte(false, v, 5000); + } + b.print(0x40, "ok", 500); + assert_eq!(b.row(0), "keep ", "skip {skip}"); + assert_eq!(b.row(1), "ok ", "skip {skip}"); + } + } + + #[test] + fn brownout_blanks_and_eight_bit_garbles() { + let mut b = bus(); + b.print(0x00, "before", 500); + b.lcd.reset(); + b.print(0x00, "after", 500); + assert!(!b.row(0).contains("after")); + } + + #[test] + fn address_wraps_between_lines() { + let mut b = bus(); + b.print(0x26, "xyz", 500); + assert_eq!(b.lcd.ddram[0][0x26..], *b"xy"); + assert_eq!(b.lcd.ddram[1][0], b'z'); + } +} diff --git a/src/utils/lcd_text.rs b/src/utils/lcd_text.rs index 292a39d..4d5090b 100644 --- a/src/utils/lcd_text.rs +++ b/src/utils/lcd_text.rs @@ -101,6 +101,12 @@ impl LcdText { true } + /// What the LCD shows, as far as this buffer knows. + #[cfg_attr(not(feature = "sim"), allow(dead_code))] + pub fn shown(&self) -> &[[u8; COLS]; ROWS] { + &self.shown + } + /// Calls `write(row, col, text)` for the changed run of each row and marks it shown. pub fn changes(&mut self, mut write: impl FnMut(u8, u8, &str)) { for row in 0..ROWS { From 48ae27d2559f7e72e486e8e1e317b9ec94abb2bb Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:45:02 +0200 Subject: [PATCH 102/132] fix: lcd waits out the controller busy time and resyncs periodically --- sim/scenarios/lcd-heal.json | 6 +++ src/lcd_v3.rs | 74 +++++++++++++++++++++++++++++++++---- src/utils/lcd_text.rs | 17 +++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 sim/scenarios/lcd-heal.json diff --git a/sim/scenarios/lcd-heal.json b/sim/scenarios/lcd-heal.json new file mode 100644 index 0000000..eb4ec6c --- /dev/null +++ b/sim/scenarios/lcd-heal.json @@ -0,0 +1,6 @@ +{"seed": 90004, "hw": "v3", "note": "regression (v3): the LCD must show what the firmware wrote again within LCD_STALE_MS after a lost E edge (nibble desync), a lost write, a module brownout, and with a controller slower than the bus (commands sent while busy)", "actions": [ +[1000, "emu", "lcd glitch"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], +[10, "emu", "lcd drop"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [6000, "expect", "lcd", 0], +[10, "emu", "lcd reset"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], +[10, "emu", "lcd slow 4"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd stats"], [10, "emu", "lcd slow 1"] +]} diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index c5518d6..917cb7f 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -20,8 +20,15 @@ type Lcd = LcdDisplay; type Text = heapless::String<96>; const EVENT_TIMEOUT: Duration = Duration::from_secs(30); +/// HD44780 instructions and writes take 37–41 µs (~60 µs on a slow 190 kHz controller) and any +/// transfer while busy is lost. ag-lcd-async waits only before characters, and the next command +/// reaches the controller ~20 µs after a character through the shift registers. +const BUSY_US: u64 = 100; +/// Re-sync and rewrite the LCD this often on idle screens: the write-only bus can't see a lost +/// write, a nibble desync or a module brownout, and the change-only cache would keep them. +const HEAL_INTERVAL: Duration = Duration::from_secs(2); -async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { +async fn new_lcd(lcd_shifter: &ShifterValue) -> Lcd { let mut lcd = LcdDisplay::new( lcd_shifter.get_pin_mut(2, true), lcd_shifter.get_pin_mut(3, true), @@ -43,31 +50,79 @@ async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { .build() .await; + // build() sends its 4-bit function set right after the 8-bit one, while the controller is busy + resync(lcd_shifter).await; lcd.clear().await; lcd.backlight_on(); lcd } +/// Initialisation by instruction (HD44780 datasheet fig. 24) without the clear: brings the +/// controller back to 4-bit, 2 lines, display on from any state (nibble desync, 8-bit mode after +/// a brownout) with the shown text untouched, so it doesn't flicker. +async fn resync(lcd_shifter: &ShifterValue) { + // ShifterPin bit b is mask 0x80 >> b: RS = 2, E = 3, DB4..DB7 = 4..7 (CS and backlight kept) + const BUS: u8 = 0x3F; + const EN: u8 = 0x10; + let nibble = |n: u8| { + let db = (n & 0x0F).reverse_bits() >> 4; + for en in [0, EN, 0] { + let mut v = lcd_shifter.value(); // shifted out on drop + *v = *v & !BUS | db | en; + } + }; + + Timer::after_micros(BUSY_US).await; + for (n, wait_us) in [(0x3, 4100), (0x3, 100), (0x3, BUSY_US), (0x2, BUSY_US)] { + nibble(n); + Timer::after_micros(wait_us).await; + } + // function set 4-bit/2 lines/5x8, display on, entry left-to-right, home (resets any shift) + for (byte, wait_us) in [ + (0x28, BUSY_US), + (0x0C, BUSY_US), + (0x06, BUSY_US), + (0x02, 3500), + ] { + nibble(byte >> 4); + nibble(byte); + Timer::after_micros(wait_us).await; + } +} + +/// Moves the cursor once the previous character is done. +async fn goto(lcd: &mut Lcd, col: u8, row: u8) { + Timer::after_micros(BUSY_US).await; + lcd.set_position(col, row).await; +} + /// Writes the changed characters to the LCD. async fn show(text: &mut LcdText, lcd: &mut Lcd) { let mut runs: heapless::Vec<(u8, u8, heapless::String<16>), 2> = heapless::Vec::new(); text.changes(|row, col, s| _ = runs.push((row, col, s.try_into().unwrap_or_default()))); for (row, col, s) in runs { - lcd.set_position(col, row).await; + goto(lcd, col, row).await; lcd.print(&s).await; } #[cfg(feature = "sim")] crate::sim::lcd_intended(text.shown()); } +/// Re-syncs the controller and rewrites every character. +async fn heal(lcd_shifter: &ShifterValue, text: &mut LcdText, lcd: &mut Lcd) { + resync(lcd_shifter).await; + text.invalidate(); + show(text, lcd).await; +} + pub async fn lcd_show_critical_code(lcd_shifter: ShifterValue, code: u8) { - let mut lcd = new_lcd(lcd_shifter).await; + let mut lcd = new_lcd(&lcd_shifter).await; let mut line = Text::new(); _ = write!(line, " E{code}"); - lcd.set_position(0, 0).await; + goto(&mut lcd, 0, 0).await; lcd.print(" CRITICAL ERROR ").await; - lcd.set_position(0, 1).await; + goto(&mut lcd, 0, 1).await; lcd.print(&line).await; } @@ -77,8 +132,9 @@ pub async fn lcd_task( global_state: GlobalState, wifi_setup_sig: &'static Signal, ) { - let mut lcd = new_lcd(lcd_shifter).await; + let mut lcd = new_lcd(&lcd_shifter).await; let mut text = LcdText::new(); + let mut last_heal = Instant::now(); let mut line = Text::new(); _ = write!(line, "{:X}", crate::utils::get_efuse_u32()); @@ -177,7 +233,11 @@ pub async fn lcd_task( .update(|s| s.scene = Scene::AutoSetupWait); } Event::Tick => { - if text.scroll_step() { + let scrolled = text.scroll_step(); + if last_heal.elapsed() >= HEAL_INTERVAL { + last_heal = Instant::now(); + heal(&lcd_shifter, &mut text, &mut lcd).await; + } else if scrolled { show(&mut text, &mut lcd).await; } sleep_check(&mut text, &mut lcd, global_state, last_update).await; diff --git a/src/utils/lcd_text.rs b/src/utils/lcd_text.rs index 4d5090b..a45655b 100644 --- a/src/utils/lcd_text.rs +++ b/src/utils/lcd_text.rs @@ -101,6 +101,11 @@ impl LcdText { true } + /// Forget what the LCD shows: the next `changes` rewrites everything. + pub fn invalidate(&mut self) { + self.shown = [[0xFF; COLS]; ROWS]; + } + /// What the LCD shows, as far as this buffer knows. #[cfg_attr(not(feature = "sim"), allow(dead_code))] pub fn shown(&self) -> &[[u8; COLS]; ROWS] { @@ -191,4 +196,16 @@ mod tests { assert_eq!(&offsets[..10], [0, 1, 2, 3, 4, 5, 6, 7, 8, 7]); assert!(!flush(&mut t).is_empty()); } + + #[test] + fn invalidate_rewrites_everything() { + let mut t = LcdText::new(); + t.print(0, "abc", Align::Left, true); + flush(&mut t); + t.invalidate(); + assert_eq!( + flush(&mut t), + [(0, 0, "abc ".into()), (1, 0, " ".repeat(16))] + ); + } } From 1e5f247019efc57a64d5713ecca57a93da9a7920 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 19:01:34 +0200 Subject: [PATCH 103/132] fix: lcd heals on live screens too (timer, inspection) --- sim/scenarios/lcd-heal.json | 5 +++-- src/lcd_v3.rs | 35 ++++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/sim/scenarios/lcd-heal.json b/sim/scenarios/lcd-heal.json index eb4ec6c..a3f2970 100644 --- a/sim/scenarios/lcd-heal.json +++ b/sim/scenarios/lcd-heal.json @@ -1,6 +1,7 @@ -{"seed": 90004, "hw": "v3", "note": "regression (v3): the LCD must show what the firmware wrote again within LCD_STALE_MS after a lost E edge (nibble desync), a lost write, a module brownout, and with a controller slower than the bus (commands sent while busy)", "actions": [ +{"seed": 90004, "hw": "v3", "note": "regression (v3): the LCD must show what the firmware wrote again within LCD_STALE_MS after a lost E edge (nibble desync), a lost write, a module brownout (also during a running solve: the timer screen gets no idle ticks), and with a controller slower than the bus (commands sent while busy)", "actions": [ [1000, "emu", "lcd glitch"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd drop"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [6000, "expect", "lcd", 0], [10, "emu", "lcd reset"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], -[10, "emu", "lcd slow 4"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd stats"], [10, "emu", "lcd slow 1"] +[10, "emu", "lcd slow 4"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd stats"], [10, "emu", "lcd slow 1"], +[10, "btn", 1, 150], [10, "expect", "scene", "Inspection"], [1500, "stk", "start"], [10, "expect", "scene", "Timer"], [10, "emu", "lcd glitch"], [7000, "expect", "lcd", 0], [10, "stk", "stop"], [10, "expect", "scene", "Finished"], [10, "stk", "reset"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"] ]} diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 917cb7f..be09591 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -108,10 +108,19 @@ async fn show(text: &mut LcdText, lcd: &mut Lcd) { crate::sim::lcd_intended(text.shown()); } -/// Re-syncs the controller and rewrites every character. -async fn heal(lcd_shifter: &ShifterValue, text: &mut LcdText, lcd: &mut Lcd) { - resync(lcd_shifter).await; - text.invalidate(); +/// Writes the changes, or every `HEAL_INTERVAL` re-syncs the controller and rewrites every +/// character (also on live screens: a solve can outlast many intervals). +async fn update( + lcd_shifter: &ShifterValue, + text: &mut LcdText, + lcd: &mut Lcd, + healed: &mut Instant, +) { + if healed.elapsed() >= HEAL_INTERVAL { + *healed = Instant::now(); + resync(lcd_shifter).await; + text.invalidate(); + } show(text, lcd).await; } @@ -134,7 +143,7 @@ pub async fn lcd_task( ) { let mut lcd = new_lcd(&lcd_shifter).await; let mut text = LcdText::new(); - let mut last_heal = Instant::now(); + let mut healed = Instant::now(); let mut line = Text::new(); _ = write!(line, "{:X}", crate::utils::get_efuse_u32()); @@ -184,7 +193,7 @@ pub async fn lcd_task( if live != Live::Timer { local.timer_ms = None; } - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); @@ -201,6 +210,7 @@ pub async fn lcd_task( ), ); let Ok(event) = event.await else { + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; continue; }; match event { @@ -208,12 +218,12 @@ pub async fn lcd_task( Event::TimerValue(ms) => { local.timer_ms = Some(ms); text.print(0, &time_str(ms), Align::Center, true); - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } Event::InspectionFrame(start) => { let elapsed = start.elapsed().as_millis(); text.print(0, &time_str(elapsed), Align::Center, true); - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } Event::Progress(p) => { local.progress = Some(p); @@ -233,13 +243,8 @@ pub async fn lcd_task( .update(|s| s.scene = Scene::AutoSetupWait); } Event::Tick => { - let scrolled = text.scroll_step(); - if last_heal.elapsed() >= HEAL_INTERVAL { - last_heal = Instant::now(); - heal(&lcd_shifter, &mut text, &mut lcd).await; - } else if scrolled { - show(&mut text, &mut lcd).await; - } + text.scroll_step(); + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; sleep_check(&mut text, &mut lcd, global_state, last_update).await; } } From 2fc2a22fe8c62ee8a95ea1f34d41bfd0bd5373d5 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 19:05:24 +0200 Subject: [PATCH 104/132] test: competition day against e2e playing FKMTime, delegate decisions and server-side refusals --- sim/README.md | 42 +- sim/competition.py | 628 +++++++++++++----- sim/monkey.py | 149 ++++- sim/scenarios/cutoff-not-passed.json | 21 + sim/scenarios/delegate-accept.json | 23 + .../delegate-answer-other-attempt.json | 25 + sim/scenarios/delegate-change.json | 24 + sim/scenarios/delegate-delete.json | 35 + sim/scenarios/delegate-extra.json | 35 + sim/scenarios/delegate-last-attempt.json | 21 + sim/scenarios/delegate-late.json | 32 + sim/scenarios/delegate-no-cards.json | 19 + sim/scenarios/lost-reply.json | 27 + 13 files changed, 875 insertions(+), 206 deletions(-) create mode 100644 sim/scenarios/cutoff-not-passed.json create mode 100644 sim/scenarios/delegate-accept.json create mode 100644 sim/scenarios/delegate-answer-other-attempt.json create mode 100644 sim/scenarios/delegate-change.json create mode 100644 sim/scenarios/delegate-delete.json create mode 100644 sim/scenarios/delegate-extra.json create mode 100644 sim/scenarios/delegate-last-attempt.json create mode 100644 sim/scenarios/delegate-late.json create mode 100644 sim/scenarios/delegate-no-cards.json create mode 100644 sim/scenarios/lost-reply.json diff --git a/sim/README.md b/sim/README.md index 5809d33..3606e81 100644 --- a/sim/README.md +++ b/sim/README.md @@ -105,25 +105,47 @@ is acted out like a person would (scan, group select, inspection 5–12 s, somet over 17 s (DNF), solve, judge's penalty, confirm, judge and competitor cards), with clumsy slips at a few % each (wrong card first, double taps, forgotten/restarted inspection, fumbled timer, +2 applied and corrected, DNF hold, long/double submit, wrong button, scrolling past the group, -delegate call, Wi-Fi blip, abandoned attempt, solve over the limit) plus server restarts mid-group -and a draining battery. Each step's effect is checked on the device; after each attempt the -server's `EnterAttempt` log (`srv/logs/unix.log`) must hold exactly one solve with the intended -station, competitor, time, penalty, judge, group and inspection; at the end the whole list must -match. `--devices N` runs N stations (own esp-emu, eFuse MAC from `sim/make-efuse.py`, stackmat) -on one server, round by round, a competitor never at two stations at once. +delegate call, Wi-Fi blip, abandoned attempt, solve over the limit, lost answer to a solve) plus +server restarts mid-group and a draining battery. Each step's effect is checked on the device. + +micro-connector's e2e plays FKMTime here (`E2E_SIM=1`, only on micro-connector's `sim-e2e` +branch: `--mc `): it keeps attempts by session id like FKMTime, refuses resends +("already entered"), ends a round at its cutoff ("No attempts left" + reset), DNFs at the time +limit, and turns delegate calls into incidents. A seeded delegate answers each call after a few +seconds (`--delegate-rate` multiplies the call rate): accept (cards confirm it), change time/penalty +(cards confirm it), resolve without cards (reset), extra attempt (reset, the competitor solves +again, the server replaces the original), delete (reset, redo), or only after the device's 5 min +window (the device must ignore it; its resend is refused). The device must show "waiting for +delegate" until the answer and then the decision. The oracle keeps the attempts FKMTime should +hold and after each attempt and at the end compares them with e2e's (station, competitor, judge, +group, number, extra, status, value, penalty, replacement, inspection); a session's sends must +carry the same data. `--devices N` runs N stations (own esp-emu, eFuse MAC from +`sim/make-efuse.py`, stackmat) on one server, round by round, a competitor never at two stations +at once. ```sh -python3 sim/monkey.py --mode competition --seeds 1-6 --jobs 3 --job-base 20 --max-attempts 36 +python3 sim/monkey.py --mode competition --seeds 1-6 --jobs 3 --job-base 20 --max-attempts 36 \ + --mc ~/projects/micro-connector # on its sim-e2e branch +python3 sim/monkey.py --mode competition --seeds 1-4 --delegate-rate 8 # every decision a few times python3 sim/monkey.py --mode competition --seed 4 --devices 3 --job-base 20 --slip-rate 2 python3 sim/monkey.py --replay-file sim/out/comp-d1/4/day.json # same day again ``` `--job-base N` moves ports (emulators 20000+10·N.., servers 18100+N..) away from other runs; `--time-scale` shortens solves (never inspection or holds). Logs: `sim/out/comp-d//` -(`day.json` = plan, intended results, mismatches, executed steps). Not covered: sleep (off in sim -builds), delegate answers (micro-connector's e2e never resolves an incident), BLE. +(`day.json` = plan, intended results, mismatches, executed steps; `srv/e2e.jsonl` = every request +with its answer, pushes and delegate decisions). Not covered: sleep (off in sim builds), attendance +stations (the firmware never asks for attendance), BLE. + +e2e's control socket (`Server.ctl`, JSON lines): `attempts`, `resolve` (`sessionId`, `status` +resolved / standard / extra_given, optional `value` cs, `penalty`, `noCards`), `delete`, +`drop_reply` (`espId`: the next attempt is entered, its answer lost). Scenarios use it with +`"sim_e2e": true`, optional `"server"` (e.g. `rounds` with attempts/cutoff/limit in cs) and the +steps `resolve [args]` / `delete` (the open incident), `drop_reply`, `expect_db +[rows]`; `expect [s]` takes an optional timeout. Failures land in `sim/out//` (actions, firmware log with timestamps, server log). Needs a -built micro-connector (`cargo build --bins` in it; `--mc` path). The `state` sim command prints the +built micro-connector (`cargo build --bins` in it; `--mc` path; `--sim-e2e` lets e2e play FKMTime +here too). The `state` sim command prints the machine-readable line the monkey reads. Not covered: pads (`timer-func`), BLE, Wi-Fi drops (esp-emu can't drop the link), real display/RFID timing. diff --git a/sim/competition.py b/sim/competition.py index e816e82..bfcce2f 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -3,10 +3,14 @@ micro-connector), and the server must get exactly the intended results. Every human action is a planned step with a defined outcome. The oracle models the firmware rules -(src/buttons.rs, src/rfid.rs, src/stackmat.rs) independently, checks the device state after each -step, and after each attempt checks the server's EnterAttempt log (srv/logs/unix.log): exactly one -solve (per session) with the right station, competitor, time, penalty, judge, group and inspection. +(src/buttons.rs, src/rfid.rs, src/stackmat.rs) independently and checks the device state after each +step. micro-connector's e2e plays FKMTime (`sim-e2e` branch, Server(sim=True)): a delegate answers +each call with a seeded decision, cutoffs end rounds early, answers get lost. The oracle keeps the +attempts FKMTime should hold (`Ledger`) and compares them with e2e's after each attempt and at the +end: station, competitor, judge, group, attempt number, extra, status, value, penalty, replacement +and inspection; every session's sends must carry the same data. """ +import contextlib import json import random import re @@ -21,7 +25,16 @@ PLUS2_MS, DNF_MS = 15000, 17000 # src/consts.rs INSPECTION_TIME_* RESET_HOLD, DNF_HOLD, INSPECT_STOP_HOLD, DELEGATE_HOLD = 3000, 1000, 1000, 3000 # src/buttons.rs -DELEGATE_TIMEOUT = 5 * 60_000 # src/consts.rs: a pending delegate call blocks new ones this long +DELEGATE_TIMEOUT = 5 * 60_000 # src/consts.rs: the device waits this long for the delegate +# what the delegate does with a call (FKMTime incident page), relative weights +DECISIONS = { + "accept": 3, # resolved, time stands; the cards confirm it + "change": 3, # resolved with another time / penalty; the cards confirm it + "no_cards": 1.5, # resolved, "do not require cards": the device resets + "extra": 2, # extra attempt given: the device resets, the competitor solves again + "delete": 1, # incident deleted: the device resets, the attempt is redone + "late": 0.4, # answers only after the device gave up (5 min): the device ignores it +} EVENTS = [ # id, name, inspection, time limit (ms), attempts, solve mean/sd (s, before --time-scale) ("333", "3x3x3 Cube", True, None, 5, 13.0, 3.0), ("222", "2x2x2 Cube", True, None, 5, 6.0, 2.0), @@ -47,7 +60,8 @@ "submit_long": 0.04, # 1.3-2.6 s (reset is at 3 s) "wrong_button": 0.04, # short delegate / inspect press while finished: no effect "group_scroll": 0.3, # multi-group competitors: scroll past their group and back - "delegate": 0.03, # judge calls the delegate (3.5 s hold) + "delegate": 0.03, # judge calls the delegate (3.5 s hold); x --delegate-rate + "lost_reply": 0.02, # the answer to the solve gets lost: error, resend refused "wifi_blip": 0.02, # radio off 1-4 s on this station only "abandon": 0.01, # attempt reset before inspection, redone right away "over_limit": 0.10, # (limited events) solve runs past the time limit: DNF @@ -59,7 +73,7 @@ # ── generation (pure: same seed and devices ⇒ same day) ──────────────────────────────────── def generate_day(seed: int, devices: int, max_attempts: int, slip_rate: float = 1.0, - time_scale: float = 0.5) -> dict: + time_scale: float = 0.5, delegate_rate: float = 1.0) -> dict: r = random.Random(seed * 1000 + devices) uids = set() @@ -71,6 +85,12 @@ def uid(): return u events = [EVENTS[0]] + r.sample(EVENTS[1:4], r.randint(1, 2)) + [EVENTS[4]] + # FKMTime's rounds (centiseconds): one short event may have a cutoff after 2 attempts + round_info = {f"{e[0]}-r1": dict(attempts=e[4], limit=e[3] // 10 if e[3] else None, cutoff=None) + for e in events} + if r.random() < 0.6: + e = events[1] + round_info[f"{e[0]}-r1"]["cutoff"] = dict(attempts=2, value=int(e[5] * time_scale * 85)) n = r.randint(20, 40) comps = [uid() for _ in range(n)] regs = {c: [e[0] for e in events if r.random() < (0.9 if e[0] == "333" else 0.35)] or ["333"] @@ -124,7 +144,8 @@ def uid(): jrot += 1 others = [x for x in members if x not in busy] row.append(attempt_spec(r, c, judge, gid, k, insp, limit, mean, sd, time_scale, - len(comp_groups[c]) > 1, slip_rate, others, staff, comps)) + len(comp_groups[c]) > 1, slip_rate, others, staff, comps, + delegate_rate)) if any(row): specs.append(row) restart = None @@ -143,7 +164,7 @@ def uid(): sp["bat"] = max(5, 100 - int(85 * i / max(1, total)) - r.randint(0, 2)) i += 1 if any(row) else 0 return dict(seed=seed, devices=devices, time_scale=time_scale, groups=groups, cards=cards, - schedule=schedule, attempts=total) + rounds=round_info, schedule=schedule, attempts=total) def plan_rounds(r, members, attempts, devices): @@ -183,10 +204,23 @@ def plan_rounds(r, members, attempts, devices): return rounds +def timing(r, insp, limit, mean, sd, scale): + """Inspection (ms, None without) and solve (s) of one attempt.""" + x = r.random() + insp_ms = None + if insp: # firmware thresholds are never scaled; targets keep ≥ 0.6 s away from them + insp_ms = (r.randint(5000, 12000) if x < 0.90 else + r.randint(15600, 16400) if x < 0.97 else r.randint(17800, 19000)) + solve = max(0.8, r.gauss(mean, sd)) * scale + if limit: + solve = min(solve, limit / 1000 - 3) + return insp_ms, round(solve, 2) + + def attempt_spec(r, comp, judge, gid, k, insp, limit, mean, sd, scale, multi, rate, others, staff, - comps): + comps, delegate_rate=1.0): def slip(name): - return r.random() < SLIPS[name] * rate + return r.random() < SLIPS[name] * rate * (delegate_rate if name == "delegate" else 1) sl = {} first = None @@ -222,7 +256,16 @@ def slip(name): if slip("plus2_fix"): sl["plus2_fix"] = r.choice(["wrap", "dnf_hold"]) if slip("delegate"): - sl["delegate"] = True + # mostly after the solve (a judging question); sometimes before it (puzzle, scramble) + at = "before_inspection" if r.random() < 0.15 else "after_stop" + opts = DECISIONS if at == "after_stop" else {"extra": 1, "delete": 1} + insp_r, solve_r = timing(r, insp, limit, mean, sd, scale) + sl["delegate"] = dict(at=at, decision=r.choices(list(opts), list(opts.values()))[0], + think=round(r.choice([1, 3, 8, 20, 45]) * r.uniform(0.8, 1.2), 1), + delta=r.randint(-150, 150), pen=r.choice([0, 0, 2, -1]), + redo=dict(insp=insp_r, solve=solve_r)) + if slip("lost_reply"): + sl["lost_reply"] = True if slip("wrong_judge_card"): sl["wrong_judge_card"] = r.choice([x for x in comps + staff if x not in (comp, judge)]) if slip("wrong_button"): @@ -233,35 +276,123 @@ def slip(name): if decision: sl["judge_" + decision] = True - x = r.random() - insp_ms = None - if insp: # firmware thresholds are never scaled; targets keep ≥ 0.6 s away from them - insp_ms = (r.randint(5000, 12000) if x < 0.90 else - r.randint(15600, 16400) if x < 0.97 else r.randint(17800, 19000)) - solve = max(0.8, r.gauss(mean, sd)) * scale - if limit: - solve = min(solve, limit / 1000 - 3) - return dict(comp=comp, judge=judge, group=gid, attempt=k, insp=insp_ms, solve=round(solve, 2), + insp_ms, solve = timing(r, insp, limit, mean, sd, scale) + return dict(comp=comp, judge=judge, group=gid, attempt=k, insp=insp_ms, solve=solve, gap=round(r.choice([0.5, 1, 2, 3, 5]) * r.uniform(0.8, 1.2), 2), slips=sl) -# ── server log ─────────────────────────────────────────────────────────────────────────────── +# ── what FKMTime should hold ────────────────────────────────────────────────────────────────── + +def round_of(group: str) -> str: + return group.split("-g")[0] + + +def requests(log: list) -> list: + """EnterAttempt requests in e2e's log, with their answer (None if lost).""" + out = [] + for e in log: + req = e.get("req") or {} + if req.get("type") != "EnterAttempt": + continue + d = req["data"] + resp = e.get("resp") or {} + out.append(dict(time=d["valueMs"], pen=d["penalty"], esp=d["espId"], judge=int(d["judgeId"] or 0), + comp=int(d["competitorId"]), delegate=d["isDelegate"], session=d["sessionId"], + insp=d["inspectionTime"], group=d["groupId"], + answer=(resp.get("data") or {}).get("message"))) + return out + + +class Ledger: + """The attempts FKMTime should hold (its rules: numbering, extra attempts replacing the + original, time limit DNF, cutoff and finished rounds), built from what the people did.""" + + def __init__(self, day): + self.rows, self.day, self.lock = [], day, threading.Lock() + self.rounds = day.get("rounds", {}) # days saved before rounds existed: unlimited + + def mine(self, comp, rnd): + return [x for x in self.rows if x["comp"] == comp and x["round"] == rnd] + + def enter(self, esp, comp, judge, group, ms, pen, insp, status="standard"): + with self.lock: + rnd = round_of(group) + mine = self.mine(comp, rnd) + orig = next((x for x in mine if x["status"] == "extra_given" and x["replaced_by"] is None), None) + if orig: + number = sum(x["extra"] for x in mine) + 1 + orig["replaced_by"] = number + else: + number = max([x["number"] for x in mine if not x["extra"]], default=0) + 1 + limit = (self.rounds.get(rnd) or {}).get("limit") + row = dict(esp=esp, comp=comp, judge=judge, group=group, round=rnd, number=number, + extra=orig is not None, status=status, value=ms // 10, + pen=-1 if limit and ms // 10 >= limit else pen, insp=insp, replaced_by=None) + self.rows.append(row) + return row + + def remove(self, row): + with self.lock: + self.rows.remove(row) + + def finished(self, comp, rnd): + info = self.rounds.get(rnd) or {} + if not info.get("attempts"): + return False + done = [x for x in self.mine(comp, rnd) if x["status"] != "extra_given"] + cut = info.get("cutoff") + passed = (not cut or len(done) < cut["attempts"] or + any(x["pen"] != -1 and x["value"] + x["pen"] * 100 < cut["value"] for x in done)) + return len(done) >= (info["attempts"] if passed else cut["attempts"]) + + def open_groups(self, comp): + """The competitor's groups whose round isn't over (FKMTime offers only those).""" + mine = self.day["cards"][str(comp)]["groups"] + return [g["groupId"] for g in self.day["groups"] + if g["groupId"] in mine and not self.finished(comp, round_of(g["groupId"]))] -ENTRY = re.compile( - r'EnterAttempt \{ value: \d+, value_ms: (\d+), penalty: (-?\d+), solved_at: "[^"]*", ' - r'esp_id: (\d+), judge_id: "(\d*)", competitor_id: "(\d+)", is_delegate: (\w+), ' - r'session_id: "([^"]*)", inspection_time: (-?\d+), group_id: "([^"]*)"') + def rejects(self, comp): + """FKMTime answers this competitor's card with "No attempts left" (and a reset).""" + return bool(self.day["cards"][str(comp)]["groups"]) and not self.open_groups(comp) -def entries(run_dir: Path) -> list: - """Every EnterAttempt the backend forwarded (appended across server restarts).""" - try: - text = (run_dir / "srv" / "logs" / "unix.log").read_text(errors="replace") - except FileNotFoundError: - return [] - return [dict(time=int(m[1]), pen=int(m[2]), esp=int(m[3]), judge=int(m[4] or 0), comp=int(m[5]), - delegate=m[6] == "true", session=m[7], insp=int(m[8]), group=m[9]) - for m in re.finditer(ENTRY, text)] +def row_key(x): + return (x["esp"], x["comp"], x["judge"], x["group"], x["number"], x["extra"], x["status"], + x["value"], x["pen"], x["replaced_by"]) + + +def db_key(x): + return (x["espId"], x["competitorId"], x["judgeId"], x["groupId"], x["attemptNumber"], x["extra"], + x["status"], x["value"], x["penalty"], x["replacedBy"]) + + +def compare(want_rows, db_rows, what): + errs = [] + want, got = Counter(map(row_key, want_rows)), Counter(map(db_key, db_rows)) + for k in (want - got).elements(): + errs.append(f"{what}: intended attempt missing on the server: {k}") + for k in (got - want).elements(): + errs.append(f"{what}: unintended attempt on the server: {k}") + for x in want_rows: + for d in db_rows: + if db_key(d) == row_key(x) and x["insp"] is not None: + if x["insp"] and abs(d["inspectionTime"] - x["insp"]) > 900: + errs.append(f"{what}: inspection {d['inspectionTime']} ms, intended ~{x['insp']}") + if not x["insp"] and d["inspectionTime"] != 0: + errs.append(f"{what}: inspection {d['inspectionTime']} ms sent for an attempt without inspection") + return errs + + +def same_sessions(reqs): + """A session's (non-delegate) sends must all carry the same solve.""" + errs, seen = [], {} + for e in reqs: + if e["delegate"]: + continue + key = (e["comp"], e["time"], e["pen"], e["judge"], e["group"]) + if seen.setdefault(e["session"], key) != key: + errs.append(f"session {e['session']} sent twice with different data: {seen[e['session']]} vs {key}") + return errs # ── penalty model (independent of the firmware code, from its documented rules) ───────────── @@ -294,8 +425,7 @@ def __init__(self, n, run_dir, server, image, ports, args, restart_lock): shutil.copy(image[0], bin_) self.emu = M.Emu(bin_, image[1], ports, run_dir / f"firmware{n}.log", efuse) self.stackmat = M.Stackmat(ports["uart1"]) - self.esp = None - self.delegate_until = -10**9 # emulated ms until which a pending delegate blocks new calls + self.esp, self.ledger = None, None self.disturbed = False # a Wi-Fi blip / server restart touched the current attempt def note(self, *what): @@ -361,11 +491,17 @@ def press_check(self, b, ms, pred, what, tries=3): for _ in range(tries): self.connected() self.btn(b, ms) - s = self.poll(pred, 2500) + lost = [] # the press may have hit a short disconnect that is over by now + + def seen(s): + if s.get("srv") != "Some(true)" or s.get("stk") != "Some(true)": + lost.append(s) + return pred(s) + s = self.poll(seen, 2500) if s: return s s = self.st() - if s.get("srv") != "Some(true)" or s.get("stk") != "Some(true)": + if lost or s.get("srv") != "Some(true)" or s.get("stk") != "Some(true)": self.disturbed = True # the disconnected screen ignores buttons: press again continue if s.get("err") != "1": @@ -391,9 +527,11 @@ def wifi_blip(self, ms): # ── one attempt ───────────────────────────────────────────────────────────────────────── def attempt(self, sp, day, rec): + """One attempt as planned; returns the spec of a redo the delegate asked for, or None.""" C, J, G, sl = sp["comp"], sp["judge"], sp["group"], sp["slips"] group = next(g for g in day["groups"] if g["groupId"] == G) - mine = [g["groupId"] for g in day["groups"] if g["groupId"] in day["cards"][str(C)]["groups"]] + led = self.ledger + mine = led.open_groups(C) # FKMTime only offers groups whose round isn't over self.disturbed = False self.emu.send(f"bat {sp['bat']}") s = self.poll(lambda s: self.idle(s) and s.get("time") == "-1", 12000) @@ -416,7 +554,9 @@ def attempt(self, sp, day, rec): if "wrong_first" in sl: W = sl["wrong_first"] - if day["cards"][str(W)]["canCompete"]: + if day["cards"][str(W)]["canCompete"] and led.rejects(W): + self.rejected_scan(W) + elif day["cards"][str(W)]["canCompete"]: self.scan(W, lambda s: s.get("comp") == str(W), "wrong card shows that person") self.to_idle() else: @@ -460,6 +600,12 @@ def select_group(): scan_competitor() select_group() + dl = sl.get("delegate") + if dl and dl["at"] == "before_inspection": + out = self.delegate(dl, rec, C, G, 0, 0, 0) + if out and out["kind"] == "redo": + return out["redo"] + conf, insp_expect, limit = False, None, group["limit"] if "timer_reset_running" in sl: self.connected() @@ -515,20 +661,15 @@ def select_group(): if blip and blip["at"] == "after_stop": self.wifi_blip(blip["ms"]) - if "delegate" in sl: - self.connected() - up = int(self.st()["up"]) - # a call within the pending window is ignored; near its end, either is fine - exp = "no" if up < self.delegate_until - 15000 else "maybe" if up < self.delegate_until + 15000 else "yes" - log_mark = len(self.emu.since(0)) - self.btn(2, DELEGATE_HOLD + 500) - if any("Server not trusted" in l for l in self.emu.since(0)[log_mark:]): - exp = "no" # just reconnected, trust handshake not done yet - if exp != "no": - self.delegate_until = up + DELEGATE_TIMEOUT - if self.disturbed and exp == "yes": - exp = "maybe" - rec["delegates"].append(dict(expect=exp, time=exp_time, pen=pen)) + out = None + if dl and dl["at"] == "after_stop": + out = self.delegate(dl, rec, C, G, exp_time, pen, insp_expect) + if out and out["kind"] in ("redo", "idle"): + rec.update(time=exp_time, pen=pen, insp=insp_expect) + self.stk("reset") + return out.get("redo") + if out and out["kind"] == "cards": # the delegate's decision, confirmed + exp_time, pen, conf = out["time"], out["pen"], True def pen_step(b, ms, newpen): nonlocal pen @@ -537,33 +678,35 @@ def pen_step(b, ms, newpen): self.press_check(b, ms, lambda s: norm_pen(s) == newpen, f"penalty {pen} → {newpen}") pen = newpen - if "plus2_fix" in sl and not conf: - p0 = pen - pen_step(4, 120, press_penalty(pen)) - if sl["plus2_fix"] == "dnf_hold" and p0 == 0: - pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + if not out: + if "plus2_fix" in sl and not conf: + p0 = pen + pen_step(4, 120, press_penalty(pen)) + if sl["plus2_fix"] == "dnf_hold" and p0 == 0: + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + elif p0 == -1: + pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) + else: # wrap-around back to where it was + while pen != p0: + pen_step(4, 120, press_penalty(pen)) + if "judge_plus2" in sl: + pen_step(4, 120, press_penalty(pen)) + if "judge_dnf" in sl: pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) - elif p0 == -1: - pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) - else: # wrap-around back to where it was - while pen != p0: - pen_step(4, 120, press_penalty(pen)) - if "judge_plus2" in sl: - pen_step(4, 120, press_penalty(pen)) - if "judge_dnf" in sl: - pen_step(4, DNF_HOLD + 200, hold_penalty(pen)) - if "wrong_button" in sl: - before = self.st() - self.btn(sl["wrong_button"], 300) - s = self.st() - if any(s.get(k) != before.get(k) for k in ("scene", "time", "pen", "conf", "comp")): - raise Mismatch(f"wrong button {sl['wrong_button']} changed the solve: {brief(s)}") - - hold = sl.get("submit_long", 120) - self.press_check(3, hold, lambda s: s.get("conf") == "1", "time confirmed") - if "double_submit" in sl: - time.sleep(0.2) - self.btn(3, 120) + if "wrong_button" in sl: + before = self.st() + self.btn(sl["wrong_button"], 300) + s = self.st() + if any(s.get(k) != before.get(k) for k in ("scene", "time", "pen", "conf", "comp")): + raise Mismatch(f"wrong button {sl['wrong_button']} changed the solve: {brief(s)}") + + if self.st().get("conf") != "1": + hold = sl.get("submit_long", 120) if not out else 120 + self.press_check(3, hold, lambda s: s.get("conf") == "1", "time confirmed") + if "double_submit" in sl and not out: + time.sleep(0.2) + self.btn(3, 120) s = self.st() if int(s["time"]) != exp_time or norm_pen(s) != pen or s.get("scene") != "Finished": raise Mismatch(f"after confirming: {brief(s)}, expected {exp_time} ({pen})") @@ -574,9 +717,163 @@ def pen_step(b, ms, newpen): X = sl["wrong_judge_card"] self.scan(X, lambda s: s.get("judge") == str(X), "wrong judge card taken as judge") self.scan(J, lambda s: s.get("judge") == str(J), "judge scanned", wait=16000) - self.scan(C, self.idle, "solve sent (back to idle)", wait=16000) + + if out and out["kind"] == "late": + # the incident is still open on the server: it refuses the attempt's send + self.connected() + self.tap(C) + self.poll(lambda s: s.get("err") == "1", 16000, "resend of an open incident refused") + self.dismiss(self.st()) + self.to_idle() + self.late_answer(dl, out, rec) + rec.update(time=exp_time, pen=pen, insp=insp_expect) + self.stk("reset") + return None + + row = out["row"] if out else None + self.submit(C, J, G, rec, row, lost="lost_reply" in sl, + entry=dict(ms=exp_time, pen=pen, insp=insp_expect)) rec.update(time=exp_time, pen=pen, insp=insp_expect) self.stk("reset") + return None + + def submit(self, C, J, G, rec, row, lost, entry): + """The competitor's confirming tap sends the attempt (`row`: the resolved incident it + confirms). Returns once the server holds it and the device is idle again.""" + led, rnd = self.ledger, round_of(G) + + def on_server(): + db = [x for x in self.server.attempts() if x["competitorId"] == C and x["roundId"] == rnd] + if row: + return any(x["status"] == "resolved" and x["judgeId"] == J and x["espId"] == self.esp + for x in db) + return len(db) > len(led.mine(C, rnd)) + + with self.restart_lock if lost else contextlib.nullcontext(): + if lost: + self.connected() + self.server.ctl(cmd="drop_reply", espId=self.esp) + rec["lost_reply"] = True + for _ in range(4): + self.connected() + self.tap(C) + s = self.poll(lambda s: self.idle(s) or s.get("err") == "1", 16000) + if s and s.get("err") != "1": + break + if on_server(): + # entered, but the answer got lost: a retap is refused ("already entered") + self.dismiss(self.st()) + self.tap(C) + self.poll(lambda s: s.get("err") == "1", 16000, "resend refused (already entered)") + self.dismiss(self.st()) + self.to_idle() + break + self.disturbed = True # lookup lost to a restart: tap again + self.dismiss(self.st()) + else: + raise Mismatch(f"solve not sent after 4 taps: {brief(self.st())}") + if not on_server(): + raise Mismatch("the attempt is not on the server") + if row: + row["judge"] = J + else: + led.enter(self.esp, C, J, G, entry["ms"], entry["pen"], entry["insp"]) + + def rejected_scan(self, C): + """FKMTime answers "No attempts left" and makes the device reset.""" + for _ in range(4): + self.connected() + self.tap(C) + if self.poll(lambda s: s.get("err") == "1" and self.idle(s), 14000): + break + self.dismiss(self.st()) + else: + raise Mismatch(f"finished competitor {C} was not refused: {brief(self.st())}") + self.dismiss(self.st()) + self.poll(lambda s: self.idle(s) and s.get("err") == "0", 5000, "idle after the refusal") + + def delegate(self, dl, rec, C, G, ms, pen, insp): + """The judge calls the delegate (3.5 s hold); the device waits, the delegate decides after + thinking. Returns None (call ignored) or the outcome: cards (decision shown, confirmed), + idle (resolved without cards), redo (extra attempt / deleted), late (device gave up).""" + led = self.ledger + with self.restart_lock: # no server restart between the call and its incident + self.connected() + mark = len(self.emu.since(0)) + known = {x["sessionId"] for x in self.server.attempts()} + self.btn(2, DELEGATE_HOLD + 500) + if any("Server not trusted" in l for l in self.emu.since(0)[mark:]): + rec["delegates"].append(dict(decision="ignored (server not trusted yet)")) + return None + inc = None + for _ in range(30): + inc = next((x for x in self.server.attempts() if x["sessionId"] not in known + and x["espId"] == self.esp and x["status"] == "unresolved"), None) + if inc: + break + time.sleep(0.5) + if not inc or inc["competitorId"] != C: + raise Mismatch(f"delegate call not on the server: {inc}") + row = led.enter(self.esp, C, None, G, ms, pen, insp, status="unresolved") + s = self.st() + if s.get("dhold") != "3": + raise Mismatch(f"'waiting for delegate' gone while the call is pending: {brief(s)}") + d = dict(decision=dl["decision"], at=dl["at"], session=inc["sessionId"]) + rec["delegates"].append(d) + if dl["decision"] == "late": + # the device gives up after DELEGATE_TIMEOUT; the solve stays as it was + s = self.poll(lambda s: s.get("dhold") == "-1", DELEGATE_TIMEOUT + 30_000, "device stops waiting") + if s.get("scene") != "Finished" or int(s["time"]) != ms: + raise Mismatch(f"the solve changed when the device stopped waiting: {brief(s)}") + return dict(kind="late", row=row, session=inc["sessionId"]) + + time.sleep(dl["think"]) + decision = dl["decision"] + value, newpen = row["value"], row["pen"] + if decision == "change": + value, newpen = max(1, value + dl["delta"]), dl["pen"] + with self.restart_lock: # the decision reaches the device + self.connected() + if decision == "delete": + r = self.server.ctl(cmd="delete", sessionId=inc["sessionId"]) + else: + status = "extra_given" if decision == "extra" else "resolved" + r = self.server.ctl(cmd="resolve", sessionId=inc["sessionId"], status=status, value=value, + penalty=newpen, noCards=decision == "no_cards") + if not r.get("ok") or not r.get("delivered"): + raise Mismatch(f"delegate decision not delivered: {r}") + if decision == "delete": + led.remove(row) + else: + row.update(status=status, value=value, pen=newpen) + if decision in ("accept", "change"): + self.poll(lambda s: s.get("scene") == "Finished" and s.get("conf") == "1" and s.get("dhold") == "-1" + and int(s["time"]) == value * 10 and norm_pen(s) == newpen, 10000, + "delegate's decision on the device") + return dict(kind="cards", time=value * 10, pen=newpen, row=row) + self.poll(lambda s: self.idle(s) and s.get("time") == "-1" and s.get("dhold") == "-1", 10000, + "back to idle after the decision") + if decision == "no_cards": + return dict(kind="idle") + redo = dict(rec["spec"], insp=dl["redo"]["insp"], solve=dl["redo"]["solve"], slips={}, + redo=decision) + return dict(kind="redo", redo=redo) + + def late_answer(self, dl, out, rec): + """The delegate answers after the device gave up: the device must ignore it.""" + row = out["row"] + value, newpen = max(1, row["value"] + dl["delta"]), dl["pen"] + with self.restart_lock: + self.connected() + r = self.server.ctl(cmd="resolve", sessionId=out["session"], status="resolved", value=value, + penalty=newpen) + if not r.get("ok"): + raise Mismatch(f"late decision refused: {r}") + row.update(status="resolved", value=value, pen=newpen) + time.sleep(3) + s = self.st() + if not self.idle(s) or s.get("time") != "-1": + raise Mismatch(f"a late delegate answer changed the device: {brief(s)}") def start_inspection(self): """Inspect press; returns the emulated ms it started at (just before the press).""" @@ -606,37 +903,6 @@ def brief(s): # ── a whole day ────────────────────────────────────────────────────────────────────────────── -def check_attempt(st, rec, new): - """New log entries of this station since the attempt began vs. the intended result.""" - errs = [] - sessions = {} - for e in new: - if e["delegate"]: - continue - key = (e["comp"], e["time"], e["pen"], e["judge"], e["group"]) - if sessions.setdefault(e["session"], key) != key: - errs.append(f"session {e['session']} sent twice with different data") - got = list(sessions.values()) - want = (rec["comp"], rec["time"], rec["pen"], rec["judge"], rec["group"]) - if got != [want]: - errs.append(f"server got {got}, intended {[want]}") - for e in new: - if not e["delegate"] and e["comp"] == rec["comp"]: - if rec["insp"] and abs(e["insp"] - rec["insp"]) > 900: - errs.append(f"inspection {e['insp']} ms, intended ~{rec['insp']}") - if not rec["insp"] and e["insp"] != 0: - errs.append(f"inspection {e['insp']} ms sent for an attempt without inspection") - dels = [e for e in new if e["delegate"]] - want_d = [d for d in rec["delegates"] if d["expect"] != "no"] - sure = [d for d in want_d if d["expect"] == "yes"] - if not len(sure) <= len(dels) <= len(want_d): - errs.append(f"delegate calls: server got {len(dels)}, intended {rec['delegates']}") - for e, d in zip(dels, want_d): - if (e["comp"], e["group"], e["time"], e["pen"], e["judge"]) != (rec["comp"], rec["group"], d["time"], d["pen"], 0): - errs.append(f"delegate call {e} != intended {d}") - return errs - - class Day: def __init__(self, seed, day, job, args, image): self.seed, self.day, self.args = seed, day, args @@ -644,10 +910,10 @@ def __init__(self, seed, day, job, args, image): if self.dir.exists(): shutil.rmtree(self.dir) self.dir.mkdir(parents=True) - self.server = M.Server(Path(args.mc).expanduser(), M.SRV_PORT + args.job_base + job, self.dir) - tests = json.load(open(self.dir / "srv" / "tests.json")) - tests.update(groups=day["groups"], cards=day["cards"]) - json.dump(tests, open(self.dir / "srv" / "tests.json", "w")) + self.server = M.Server(Path(args.mc).expanduser(), M.SRV_PORT + args.job_base + job, self.dir, + sim=True, extra=dict(groups=day["groups"], cards=day["cards"], + rounds=day.get("rounds", {}))) + self.ledger, self.refused = Ledger(day), set() self.job, self.image = job, image self.records, self.mismatches, self.lock = [], [], threading.Lock() self.restart_lock = threading.Lock() @@ -657,9 +923,7 @@ def restart_server(self, down, stations): with self.restart_lock: for st in stations: st.disturbed = True - self.server.stop() - time.sleep(down) - self.server.start() + self.server.restart(down) self.restarts += 1 def run_station(self, st, stations, barrier, failures): @@ -692,30 +956,67 @@ def run_station(self, st, stations, barrier, failures): barrier.abort() def one(self, st, sp): - rec = dict(station=st.n, esp=st.esp, comp=sp["comp"], judge=sp["judge"], group=sp["group"], - attempt=sp["attempt"], slips=sorted(sp["slips"]), delegates=[], abandoned=0, - time=None, pen=None, insp=None) - mark = len(entries(self.dir)) - st.note("attempt", sp["comp"], sp["group"], sp["attempt"]) - try: - st.attempt(sp, self.day, rec) - time.sleep(1.0) - new = [e for e in entries(self.dir)[mark:] if e["esp"] == st.esp] - errs = check_attempt(st, rec, new) - except Mismatch as m: - errs = [str(m)] + led, C, rnd = self.ledger, sp["comp"], round_of(sp["group"]) + if led.finished(C, rnd): + # the round ended early (cutoff): staff tell them, but some come once anyway + if (C, rnd) in self.refused or not led.rejects(C): + st.note("skip (round over)", C, sp["group"], sp["attempt"]) + return + self.refused.add((C, rnd)) + st.note("refused", C, sp["group"], sp["attempt"]) + rec = dict(station=st.n, esp=st.esp, comp=C, group=sp["group"], attempt=sp["attempt"], + slips=["refused (no attempts left)"], delegates=[]) + try: + st.rejected_scan(C) + errs = [] + except Mismatch as m: + errs = [str(m)] + self.record(st, sp, rec, errs) + return + while sp: + rec = dict(station=st.n, esp=st.esp, comp=C, judge=sp["judge"], group=sp["group"], + attempt=sp["attempt"], slips=sorted(sp["slips"]) + ([f"redo ({sp['redo']})"] if + sp.get("redo") else []), delegates=[], abandoned=0, time=None, pen=None, insp=None, + spec=sp) + mark = len(self.server.log()) + st.note("attempt", C, sp["group"], sp["attempt"]) + redo = None + try: + redo = st.attempt(sp, self.day, rec) + time.sleep(1.0) + errs = self.check(st, C, rnd, mark) + except Mismatch as m: + errs = [str(m)] + self.record(st, sp, rec, errs) + if errs: + st.stackmat.cmd("stop") + st.recover() + rec["recovered"] = True + redo = None + with led.lock: # carry on from what the server holds (the mismatch is reported) + led.rows = [x for x in led.rows if not (x["comp"] == C and x["round"] == rnd)] + [ + dict(esp=x["espId"], comp=C, judge=x["judgeId"], group=x["groupId"], round=rnd, + number=x["attemptNumber"], extra=x["extra"], status=x["status"], value=x["value"], + pen=x["penalty"], insp=None, replaced_by=x["replacedBy"]) + for x in self.server.attempts() if x["competitorId"] == C and x["roundId"] == rnd] + st.check_alive() + sp = redo + + def check(self, st, C, rnd, mark): + """What the server holds for this competitor's round, and this attempt's sends.""" + db = [x for x in self.server.attempts() if x["competitorId"] == C and x["roundId"] == rnd] + errs = compare(self.ledger.mine(C, rnd), db, "attempt") + return errs + same_sessions([e for e in requests(self.server.log()[mark:]) if e["esp"] == st.esp]) + + def record(self, st, sp, rec, errs): rec["ok"] = not errs + rec.pop("spec", None) with self.lock: self.records.append(rec) for e in errs: self.mismatches.append(f"station {st.n} {sp['comp']} {sp['group']} #{sp['attempt']}: {e}") print(f" [{self.seed}] MISMATCH station {st.n} {sp['comp']} {sp['group']} #{sp['attempt']}: {e}", flush=True) - if errs: - st.stackmat.cmd("stop") - st.recover() - rec["recovered"] = True - st.check_alive() def execute(self): n = self.day["devices"] @@ -729,7 +1030,7 @@ def execute(self): self.restart_lock)) for st in stations: st.boot() - st.esp = int(st.st()["id"], 16) + st.esp, st.ledger = int(st.st()["id"], 16), self.ledger if len({st.esp for st in stations}) != n: raise M.Failure(f"stations share a device id: {[st.esp for st in stations]}") barrier = threading.Barrier(n) @@ -759,22 +1060,14 @@ def execute(self): return failures def final_check(self, stations): - """The server's whole list (deduplicated by session) equals the intended list.""" - esps = {st.esp: st.n for st in stations} - by_session = {} - for e in entries(self.dir): - if e["esp"] not in esps: - self.mismatches.append(f"final: solve from unknown device {e['esp']}: {e}") - if not e["delegate"]: - by_session.setdefault(e["session"], (esps.get(e["esp"]), e["comp"], e["time"], e["pen"], e["judge"], e["group"])) - got = Counter(by_session.values()) - want = Counter((r["station"], r["comp"], r["time"], r["pen"], r["judge"], r["group"]) - for r in self.records if r.get("time") is not None) - missing, extra = want - got, got - want - for k in missing.elements(): - self.mismatches.append(f"final: intended solve missing on the server: {k}") - for k in extra.elements(): - self.mismatches.append(f"final: unintended solve on the server: {k}") + """The server holds exactly the intended attempts; every session's sends agree.""" + esps = {st.esp for st in stations} + db = self.server.attempts() + for x in db: + if x["espId"] not in esps: + self.mismatches.append(f"final: attempt from unknown device {x['espId']}: {x}") + self.mismatches += compare(self.ledger.rows, db, "final") + self.mismatches += same_sessions(requests(self.server.log())) def save(self, stations): json.dump(dict(seed=self.seed, mode="competition", day=self.day, intended=self.records, @@ -784,7 +1077,8 @@ def save(self, stations): def summary(self, failures): slips = Counter(s for r in self.records for s in r["slips"]) - return dict(seed=self.seed, attempts=len(self.records), + decisions = Counter(d["decision"] for r in self.records for d in r["delegates"]) + return dict(seed=self.seed, attempts=len(self.records), decisions=dict(decisions), ok=sum(1 for r in self.records if r["ok"]), slips=dict(slips), mismatches=self.mismatches, failures=failures, restarts=self.restarts, heap=self.heap) @@ -806,8 +1100,8 @@ def main(args): for part in args.seeds.split(","): lo, _, hi = part.partition("-") seeds += range(int(lo), int(hi or lo) + 1) - work = [(s, generate_day(s, args.devices, args.max_attempts, args.slip_rate, args.time_scale)) - for s in seeds] + work = [(s, generate_day(s, args.devices, args.max_attempts, args.slip_rate, args.time_scale, + args.delegate_rate)) for s in seeds] jobs = max(1, min(args.jobs, len(work))) images = {} for j in range(jobs): @@ -844,6 +1138,7 @@ def worker(j): print(f"seed {s['seed']}: {s['ok']}/{s['attempts']} attempts ok, {s['restarts']} server restarts, " f"heap peak {s['heap']}") print(f" slips: {', '.join(f'{k} {v}' for k, v in sorted(s['slips'].items())) or '-'}") + print(f" delegate: {', '.join(f'{k} {v}' for k, v in sorted(s['decisions'].items())) or '-'}") for m in s["mismatches"] + s["failures"]: print(f" ! {m}") print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp-d{work[0][1]['devices']}//)") @@ -859,9 +1154,22 @@ def worker(j): for row in g["rounds"]: people = [x["comp"] for x in row if x] + [x["judge"] for x in row if x] assert len(people) == len(set(people)), row - rec = dict(comp=1, time=900, pen=2, judge=3, group="g", insp=16000, delegates=[]) - e = dict(comp=1, time=900, pen=2, judge=3, group="g", insp=16100, delegate=False, session="s") - assert check_attempt(None, rec, [e, e]) == [] and check_attempt(None, dict(rec, judge=4), [e]) - assert check_attempt(None, rec, []) and check_attempt(None, rec, [e, dict(e, session="t")]) + # ledger: numbering, extra replacing the original, limit DNF, cutoff ends the round + day = dict(rounds={"x-r1": dict(attempts=3, limit=500, cutoff=dict(attempts=1, value=100))}, + cards={"1": dict(groups=["x-r1-g1"])}, groups=[dict(groupId="x-r1-g1")]) + led = Ledger(day) + first = led.enter(5, 1, 3, "x-r1-g1", 1200, 0, 8000, status="unresolved") + assert led.open_groups(1) == [] and led.rejects(1) # 12.00 misses the 1.00 cutoff + first["status"] = "extra_given" + assert not led.finished(1, "x-r1") + extra = led.enter(5, 1, 3, "x-r1-g1", 6000, 2, 8000) + assert (extra["number"], extra["extra"], extra["pen"], first["replaced_by"]) == (1, True, -1, 1) + db = [dict(espId=5, competitorId=1, judgeId=3, groupId="x-r1-g1", attemptNumber=n, extra=x, status=st, + value=v, penalty=p, replacedBy=rb, inspectionTime=8100) for n, x, st, v, p, rb in + [(1, False, "extra_given", 120, 0, 1), (1, True, "standard", 600, -1, None)]] + assert compare(led.rows, db, "t") == [] and compare(led.rows, db[:1], "t") + assert compare(led.rows, [dict(db[0], inspectionTime=0), db[1]], "t") + e = dict(comp=1, time=900, pen=2, judge=3, group="g", delegate=False, session="s") + assert same_sessions([e, e, dict(e, delegate=True, time=0)]) == [] and same_sessions([e, dict(e, pen=0)]) assert press_penalty(16) == 0 and press_penalty(-1) == 0 and hold_penalty(2) == -1 print("ok") diff --git a/sim/monkey.py b/sim/monkey.py index 4ce5f47..6e2f1c2 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -138,49 +138,94 @@ def check_invariants(s: dict): # ── processes ─────────────────────────────────────────────────────────────────────────────── class Server: - """micro-connector (dev, plain ws) + its e2e backend answering card lookups.""" - - def __init__(self, mc: Path, port: int, workdir: Path, fresh=False): - self.mc, self.port, self.dir = mc, port, workdir - self.procs = [] + """micro-connector (dev, plain ws) + its e2e backend. HIL mode: e2e answers card lookups and + accepts every solve. Sim mode (`sim=True`, needs micro-connector's `sim-e2e` branch): e2e + plays FKMTime (attempts by session, incidents, extra attempts, cutoffs, limits), keeps running + across server restarts like the real FKMTime would, takes delegate decisions on its control + socket (`ctl`) and logs every request with its answer to srv/e2e.jsonl (`log`).""" + + def __init__(self, mc: Path, port: int, workdir: Path, fresh=False, sim=False, extra=None): + self.mc, self.port, self.dir, self.sim = mc, port, workdir, sim + self.e2e, self.backend = None, None + # unix socket paths are limited to 104 bytes on macOS (SUN_LEN): keep them short + self.sock, self.ctl_path = f"/tmp/fkm-monkey-{port}.sock", f"/tmp/fkm-monkey-{port}.ctl" # appended across restarts: solve consistency is checked over the whole run - self.log = open(workdir / "server.log", "a", buffering=1) + self.log_file = open(workdir / "server.log", "a", buffering=1) if fresh: - self.log.write("\n=== fresh server after reset settings ===\n") + self.log_file.write("\n=== fresh server after reset settings ===\n") tests = json.load(open(mc / "tests.json")) tests["tests"] = [{"name": "idle (monkey)", "steps": [{"type": "Sleep", "data": 86_400_000}]}] + tests.update(extra or {}) (workdir / "srv").mkdir(exist_ok=True) json.dump(tests, open(workdir / "srv" / "tests.json", "w")) + if fresh and sim: # a new FKMTime: its attempts start over + (workdir / "srv" / "e2e.jsonl").unlink(missing_ok=True) def start(self): srv = self.dir / "srv" (srv / "fw").mkdir(exist_ok=True) (srv / "logs").mkdir(exist_ok=True) - # unix socket paths are limited to 104 bytes on macOS (SUN_LEN): keep it short - sock = f"/tmp/fkm-monkey-{self.port}.sock" - env = dict(os.environ, SOCKET_PATH=sock) - try: - os.unlink(sock) - except FileNotFoundError: - pass - self.procs.append(subprocess.Popen([self.mc / "target/debug/e2e"], cwd=srv, env=env, - stdout=self.log, stderr=self.log)) - time.sleep(0.8) + env = dict(os.environ, SOCKET_PATH=self.sock) + if self.e2e is None: + os.unlink(self.sock) if os.path.exists(self.sock) else None + if self.sim: + env.update(E2E_SIM="1", E2E_CONTROL=self.ctl_path, E2E_LOG=str(srv / "e2e.jsonl")) + self.e2e = subprocess.Popen([self.mc / "target/debug/e2e"], cwd=srv, env=env, + stdout=self.log_file, stderr=self.log_file) + time.sleep(0.8) env.update(DEV="1", NO_TLS="1", NO_BT="1", NO_MDNS="1", PORT=str(self.port), FIRMWARE_DIR=str(srv / "fw"), DEVICE_LOGS=str(srv / "logs"), RUST_LOG="none,backend=trace") - self.procs.append(subprocess.Popen([self.mc / "target/debug/backend"], cwd=srv, env=env, - stdout=self.log, stderr=subprocess.STDOUT)) + self.backend = subprocess.Popen([self.mc / "target/debug/backend"], cwd=srv, env=env, + stdout=self.log_file, stderr=subprocess.STDOUT) + + @staticmethod + def _end(p): + if p is None: + return + p.terminate() + try: + p.wait(5) + except subprocess.TimeoutExpired: + p.kill() def stop(self): - for p in self.procs: - p.terminate() - for p in self.procs: - try: - p.wait(5) - except subprocess.TimeoutExpired: - p.kill() - self.procs = [] + """Everything (also FKMTime).""" + self._end(self.backend) + self._end(self.e2e) + self.backend = self.e2e = None + + def restart(self, down_s: float): + """The server goes away for `down_s` (in sim mode FKMTime stays up).""" + self._end(self.backend) + if not self.sim: + self._end(self.e2e) + self.e2e = None + time.sleep(down_s) + self.start() + + def ctl(self, **cmd) -> dict: + with socket.socket(socket.AF_UNIX) as s: + s.settimeout(5) + s.connect(self.ctl_path) + s.sendall((json.dumps(cmd) + "\n").encode()) + buf = b"" + while not buf.endswith(b"\n"): + chunk = s.recv(65536) + if not chunk: + break + buf += chunk + return json.loads(buf) + + def attempts(self) -> list: + return self.ctl(cmd="attempts")["attempts"] + + def log(self) -> list: + try: + text = (self.dir / "srv" / "e2e.jsonl").read_text(errors="replace") + except FileNotFoundError: + return [] + return [json.loads(l) for l in text.splitlines() if l.strip()] def solves(self) -> list: """Solve entries the server received: (time, penalty, competitor, session, group, delegate).""" @@ -315,7 +360,9 @@ def __init__(self, seed, actions, job, args, image): job += args.job_base base = 20000 + job * 10 self.ports = dict(http=base, uart1=base + 1, control=base + 2) - self.server = Server(Path(args.mc).expanduser(), SRV_PORT + job, self.dir) + self.server_extra = getattr(args, "server_extra", None) + self.server = Server(Path(args.mc).expanduser(), SRV_PORT + job, self.dir, sim=args.sim_e2e, + extra=self.server_extra) self.image = image self.executed = [] self.expected_boots = 1 @@ -347,7 +394,8 @@ def check_alive(self): self.settings_resets = resets self.expected_boots += 1 self.server.stop() - self.server = Server(self.server.mc, self.server.port, self.dir, fresh=True) + self.server = Server(self.server.mc, self.server.port, self.dir, fresh=True, + sim=self.server.sim, extra=self.server_extra) self.server.start() if boots > self.expected_boots: raise Failure(f"unexpected reboot (#{boots})") @@ -397,16 +445,35 @@ def do(self, act): self.emu.send(f"bat {a[0]}") elif kind == "wait": pass - elif kind == "expect": # scripted scenarios only: state field reaches a value - self.wait_for(lambda s: s.get(a[0]) == str(a[1]), 15, f"{a[0]}={a[1]}") + elif kind == "expect": # scripted scenarios only: state field reaches a value (in a[2] s) + self.wait_for(lambda s: s.get(a[0]) == str(a[1]), a[2] if len(a) > 2 else 15, f"{a[0]}={a[1]}") + elif kind in ("resolve", "delete"): # sim e2e: the delegate decides the open incident + open_ = [x for x in self.server.attempts() if x["status"] == "unresolved"] + if not open_: + raise Failure(f"{kind}: no open incident on the server") + extra = a[0] if a else {} + r = self.server.ctl(cmd=kind, sessionId=open_[-1]["sessionId"], **extra) + if not r.get("ok"): + raise Failure(f"{kind}: {r}") + elif kind == "drop_reply": # sim e2e: the next attempt is entered, its answer lost + self.server.ctl(cmd="drop_reply", espId=int(self.state()["id"], 16)) + elif kind == "expect_db": # sim e2e: the attempts of a[0] (competitor) match a[1] + def db_matches(): + rows = [x for x in self.server.attempts() if x["competitorId"] == a[0]] + return len(rows) == len(a[1]) and all( + all(r.get(k) == v for k, v in want.items()) for r, want in zip(rows, a[1])) + t0 = time.monotonic() + while not db_matches(): + if time.monotonic() - t0 > 15: + rows = [x for x in self.server.attempts() if x["competitorId"] == a[0]] + raise Failure(f"server attempts of {a[0]}: {rows}, expected {a[1]}") + time.sleep(0.5) elif kind == "wifi": # radio torn down for a[0] ms self.emu.send("wifi off") time.sleep(a[0] / 1000) self.emu.send("wifi on") elif kind == "server": - self.server.stop() - time.sleep(a[0] / 1000) - self.server.start() + self.server.restart(a[0] / 1000) elif kind == "power": before = self.state(timeout=6).get("time") self.expected_boots += 1 @@ -506,6 +573,8 @@ def check_errors(self, s): def check_server(self): """Solves the device sent while being abused must still be consistent.""" by_session = {} + # a delegate's decision may change the time the device then sends + decided = {x[3] for x in self.server.solves() if x[5]} for t, pen, comp, session, group, delegate in self.server.solves(): if comp not in KNOWN_CARDS: raise Failure(f"solve for unknown competitor {comp}") @@ -514,7 +583,8 @@ def check_server(self): running = time.monotonic() - st.started if st.state == b" " else 0 # emulated time runs slower than real time: a button stop is within the real run button_stop = t <= max(st.longest_run, running, *[x / 1000 for x in st.stopped_times], 0) * 1000 - if not delegate and t not in st.stopped_times and pen != -1 and not button_stop: + if not delegate and t not in st.stopped_times and pen != -1 and not button_stop \ + and session not in decided: raise Failure(f"solve time {t} never shown by the stackmat {self.stackmat.stopped_times[-8:]}") if delegate: continue # a delegate call is a request; the solve submitted later supersedes it @@ -631,7 +701,10 @@ def main(): p.add_argument("--actions", type=int, default=400) p.add_argument("--chunk", type=int, default=40, help="recovery probe every N actions") p.add_argument("--cfg-exit", type=int, default=6, help="index of Exit in the config menu (v4)") - p.add_argument("--mc", default="~/projects/micro-connector") + p.add_argument("--mc", default="~/projects/micro-connector", + help="micro-connector checkout (competition mode needs its sim-e2e branch)") + p.add_argument("--sim-e2e", action="store_true", + help="fuzz: e2e plays FKMTime (always on in competition mode)") p.add_argument("--emit-hil") p.add_argument("--shrink", action="store_true") p.add_argument("--shrink-runs", type=int, default=12) @@ -643,6 +716,8 @@ def main(): p.add_argument("--max-attempts", type=int, default=40, help="competition: attempts in the day") p.add_argument("--time-scale", type=float, default=0.5, help="competition: solve lengths") p.add_argument("--slip-rate", type=float, default=1.0, help="competition: slip rate factor") + p.add_argument("--delegate-rate", type=float, default=1.0, + help="competition: delegate call rate factor (on top of --slip-rate)") args = p.parse_args() if args.mode == "competition" or (args.replay_file and "day" in json.load(open(args.replay_file))): @@ -652,6 +727,8 @@ def main(): if args.replay_file: d = json.load(open(args.replay_file)) work = [(d["seed"], d["actions"])] + args.sim_e2e = args.sim_e2e or d.get("sim_e2e", False) + args.server_extra = d.get("server") else: if args.seed is not None or args.replay is not None: seeds = [args.seed if args.seed is not None else args.replay] diff --git a/sim/scenarios/cutoff-not-passed.json b/sim/scenarios/cutoff-not-passed.json new file mode 100644 index 0000000..46da1ee --- /dev/null +++ b/sim/scenarios/cutoff-not-passed.json @@ -0,0 +1,21 @@ +{"seed": 90109, "note": "cutoff missed: the next scan answers No attempts left and resets the device", "sim_e2e": true, "server": {"rounds": {"333-bf": {"attempts": 3, "cutoff": {"attempts": 1, "value": 100}}}}, "actions": [ +[500, "card", 3004425529], +[10, "expect", "scene", "CompetitorInfo"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[500, "card", 1264825046], +[10, "expect", "judge", 1264825046], +[500, "card", 3004425529], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[1000, "card", 3004425529], +[10, "expect", "err", 1], +[10, "expect", "comp", -1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[10, "expect_db", 3004425529, [{"status": "standard"}]] +]} diff --git a/sim/scenarios/delegate-accept.json b/sim/scenarios/delegate-accept.json new file mode 100644 index 0000000..6897f6a --- /dev/null +++ b/sim/scenarios/delegate-accept.json @@ -0,0 +1,23 @@ +{"seed": 90100, "note": "delegate accepts the time: device confirms it, cards add the judge on the server", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "resolve", {"status": "resolved"}], +[10, "expect", "conf", 1], +[10, "expect", "dhold", -1], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "resolved", "judgeId": 3004425529}]] +]} diff --git a/sim/scenarios/delegate-answer-other-attempt.json b/sim/scenarios/delegate-answer-other-attempt.json new file mode 100644 index 0000000..af9b537 --- /dev/null +++ b/sim/scenarios/delegate-answer-other-attempt.json @@ -0,0 +1,25 @@ +{"seed": 90106, "note": "regression: the judge moved on while a delegate call was pending and the answer was applied to the next competitor's attempt", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[300, "btn", 3, 3400], +[500, "card", 3004425529], +[2000, "resolve", {"status": "resolved", "value": 777}], +[1000, "card", 1264825046], +[1000, "card", 3004425529], +[3000, "expect_db", 3004425529, []], +[10, "expect", "comp", 69420], +[10, "expect", "time", 7770], +[10, "card", 69420], +[10, "expect", "comp", -1], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "resolved", "value": 777}]] +]} diff --git a/sim/scenarios/delegate-change.json b/sim/scenarios/delegate-change.json new file mode 100644 index 0000000..a16744f --- /dev/null +++ b/sim/scenarios/delegate-change.json @@ -0,0 +1,24 @@ +{"seed": 90101, "note": "delegate changes time and penalty: the device shows and confirms the decision", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "resolve", {"status": "resolved", "value": 1234, "penalty": 2}], +[10, "expect", "time", 12340], +[10, "expect", "pen", 2], +[10, "expect", "conf", 1], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "resolved", "value": 1234, "penalty": 2, "judgeId": 3004425529}]] +]} diff --git a/sim/scenarios/delegate-delete.json b/sim/scenarios/delegate-delete.json new file mode 100644 index 0000000..286cbe3 --- /dev/null +++ b/sim/scenarios/delegate-delete.json @@ -0,0 +1,35 @@ +{"seed": 90104, "note": "delegate deletes the incident: idle, the redo takes the same attempt number", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "delete"], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "standard", "attemptNumber": 2}]] +]} diff --git a/sim/scenarios/delegate-extra.json b/sim/scenarios/delegate-extra.json new file mode 100644 index 0000000..60ab46c --- /dev/null +++ b/sim/scenarios/delegate-extra.json @@ -0,0 +1,35 @@ +{"seed": 90103, "note": "delegate gives an extra attempt: idle, the redo is extra 1 replacing the attempt", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "resolve", {"status": "extra_given"}], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "extra_given", "replacedBy": 1}, {"status": "standard", "extra": true, "attemptNumber": 1}]] +]} diff --git a/sim/scenarios/delegate-last-attempt.json b/sim/scenarios/delegate-last-attempt.json new file mode 100644 index 0000000..85167db --- /dev/null +++ b/sim/scenarios/delegate-last-attempt.json @@ -0,0 +1,21 @@ +{"seed": 90107, "note": "regression: confirming a resolved last attempt was refused (No attempts left) and reset the device", "sim_e2e": true, "server": {"rounds": {"333-bf": {"attempts": 1}}}, "actions": [ +[500, "card", 3004425529], +[10, "expect", "scene", "CompetitorInfo"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "resolve", {"status": "resolved"}], +[10, "expect", "conf", 1], +[500, "card", 1264825046], +[10, "expect", "judge", 1264825046], +[500, "card", 3004425529], +[10, "expect", "comp", -1], +[10, "expect", "scene", "WaitingForCompetitor"], +[10, "expect", "err", 0], +[10, "expect_db", 3004425529, [{"status": "resolved", "judgeId": 1264825046}]] +]} diff --git a/sim/scenarios/delegate-late.json b/sim/scenarios/delegate-late.json new file mode 100644 index 0000000..939be94 --- /dev/null +++ b/sim/scenarios/delegate-late.json @@ -0,0 +1,32 @@ +{"seed": 90105, "note": "no answer within the device's 5 min window: the solve stays, its resend is refused, the late answer is ignored", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "expect", "dhold", -1, 330], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[10, "resolve", {"status": "resolved", "value": 999}], +[3000, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "resolved", "value": 999, "judgeId": null}]] +]} diff --git a/sim/scenarios/delegate-no-cards.json b/sim/scenarios/delegate-no-cards.json new file mode 100644 index 0000000..699c09d --- /dev/null +++ b/sim/scenarios/delegate-no-cards.json @@ -0,0 +1,19 @@ +{"seed": 90102, "note": "delegate resolves without cards: the device goes back to idle", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 2, 3600], +[10, "expect", "held", 0], +[10, "expect", "dhold", 3], +[3000, "expect", "dhold", 3], +[10, "resolve", {"status": "resolved", "noCards": true}], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "resolved", "judgeId": null}]] +]} diff --git a/sim/scenarios/lost-reply.json b/sim/scenarios/lost-reply.json new file mode 100644 index 0000000..fb81ffd --- /dev/null +++ b/sim/scenarios/lost-reply.json @@ -0,0 +1,27 @@ +{"seed": 90108, "note": "the answer to a solve is lost: error, the resend is refused (already entered), the judge resets", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "standard"}]] +]} From 8de06035d21043b0d172e36da18b4ce7b7748a16 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 19:58:29 +0200 Subject: [PATCH 105/132] test: scenario for a device connection dropped after a slow answer --- sim/monkey.py | 2 + sim/scenarios/lost-reply-heartbeat.json | 153 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 sim/scenarios/lost-reply-heartbeat.json diff --git a/sim/monkey.py b/sim/monkey.py index 6e2f1c2..052b383 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -729,6 +729,8 @@ def main(): work = [(d["seed"], d["actions"])] args.sim_e2e = args.sim_e2e or d.get("sim_e2e", False) args.server_extra = d.get("server") + if any(str(a[1]).startswith("expect") for a in d["actions"]): + args.chunk = 10**9 # scripted: no recovery probe between its steps else: if args.seed is not None or args.replay is not None: seeds = [args.seed if args.seed is not None else args.replay] diff --git a/sim/scenarios/lost-reply-heartbeat.json b/sim/scenarios/lost-reply-heartbeat.json new file mode 100644 index 0000000..8d31f4b --- /dev/null +++ b/sim/scenarios/lost-reply-heartbeat.json @@ -0,0 +1,153 @@ +{"seed": 90110, "note": "regression: an answer slower than the 5 s heartbeat (here: lost, 7.5 s timeout) made micro-connector close the device's connection (missed heartbeat ticks fired in a burst), so its error never arrived", "sim_e2e": true, "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "drop_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[10, "expect", "err", 0], +[500, "card", 69420], +[10, "expect", "err", 1], +[300, "btn", 3, 150], +[300, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "dhold", -1], +[300, "stk", "reset"], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "standard"}, {"status": "standard"}, {"status": "standard"}, {"status": "standard"}, {"status": "standard"}, {"status": "standard"}]] +]} From 0885c62c2de0dcec2e8715b85654dc0d3ea17a38 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:00:51 +0200 Subject: [PATCH 106/132] test: more late and no-cards delegate decisions --- sim/competition.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sim/competition.py b/sim/competition.py index bfcce2f..646d417 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -30,10 +30,10 @@ DECISIONS = { "accept": 3, # resolved, time stands; the cards confirm it "change": 3, # resolved with another time / penalty; the cards confirm it - "no_cards": 1.5, # resolved, "do not require cards": the device resets + "no_cards": 2, # resolved, "do not require cards": the device resets "extra": 2, # extra attempt given: the device resets, the competitor solves again "delete": 1, # incident deleted: the device resets, the attempt is redone - "late": 0.4, # answers only after the device gave up (5 min): the device ignores it + "late": 1, # answers only after the device gave up (5 min): the device ignores it } EVENTS = [ # id, name, inspection, time limit (ms), attempts, solve mean/sd (s, before --time-scale) ("333", "3x3x3 Cube", True, None, 5, 13.0, 3.0), From 590bc8d4f4fcd9b591701100ed68fed258ae48d0 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:04:13 +0200 Subject: [PATCH 107/132] refactor: sim devices on the host behind the esp-hal bus tap --- host-tests/src/lib.rs | 2 - sim/README.md | 46 ++-- sim/bus.py | 542 +++++++++++++++++++++++++++++++++++++++ sim/competition.py | 11 +- sim/devices.py | 439 +++++++++++++++++++++++++++++++ sim/monkey.py | 86 ++----- sim/run.sh | 24 +- sim/web.py | 4 +- src/battery_v3.rs | 18 +- src/board.rs | 23 +- src/buttons.rs | 4 - src/main.rs | 17 +- src/rfid.rs | 18 +- src/sim.rs | 555 +++------------------------------------- src/sim/hd44780.rs | 394 ---------------------------- src/utils/buttons.rs | 5 - src/utils/logger.rs | 9 - src/utils/shared_i2c.rs | 3 - 18 files changed, 1112 insertions(+), 1088 deletions(-) create mode 100644 sim/bus.py create mode 100644 sim/devices.py delete mode 100644 src/sim/hd44780.rs diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index d2309cf..be4fc63 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -14,8 +14,6 @@ pub mod ring_log; pub mod text; #[path = "../../src/utils/lcd_text.rs"] pub mod lcd_text; -#[path = "../../src/sim/hd44780.rs"] -pub mod hd44780; #[path = "../../src/utils/oled_ui.rs"] pub mod oled_ui; #[path = "../../src/utils/stackmat.rs"] diff --git a/sim/README.md b/sim/README.md index d26d699..89851ca 100644 --- a/sim/README.md +++ b/sim/README.md @@ -1,27 +1,34 @@ # Running the firmware in esp-emu [esp-emu](https://github.com/espressif/esp-emulator) (Espressif, beta, closed source) runs the real -ESP32-C3 image with Wi-Fi. The `sim` feature (v4 or v3, UART stackmat; `SIM_HW=v3` / `--hw v3`) -fills the gaps of the emulator: +ESP32-C3 image with Wi-Fi. The firmware runs its real drivers: the bus tap in the patched esp-hal +(esp-sim-patches, `ESP_SIM_TAP=1`) hands every GPIO, I2C, SPI and ADC access to the host over +esp-emu's UART0 socket, where `sim/bus.py` wires the device models of `sim/devices.py` like the +PCB does (`SIM_HW=v3|v4`, `--hw v3|v4`; v4 is the default): -| Real device | In `sim` builds | +| Real device | Model on the host | |---|---| -| OLED (I2C, v4) | fake SSD1309 on the I2C bus, printed to the console (half blocks) when it changes | -| LCD (v3) | the shift register chain is faked at its three GPIOs (data/clock/latch); the real `adv_shift_registers` + `ag-lcd-async` + `lcd_v3` code drives an emulated HD44780 (`src/sim/hd44780.rs`: 8-bit power-on state, 4-bit nibble pairing, DDRAM/CGRAM, entry mode/shift, writes during the busy time are lost), printed as a 16×2 box | -| RFID reader | `card ` on stdin | -| Buttons | `btn <1-4> [ms]` on stdin (1 inspect, 2 delegate, 3 submit, 4 penalty) | -| Battery gauge | `bat <0-100>` on stdin (starts at 100); v3: the reading of the faked ADC | -| Stackmat | the real UART driver; `sim/stackmat.py` plays the timer | -| Wi-Fi setup | seeded for the emulator's AP; also adds itself to the server (`auto_add`) | +| v4 OLED (I2C 0x3C) | SSD1309 page-addressing GRAM, printed to the console (half blocks) when it changes | +| v3 LCD | 74HC595 chain decoded from the GPIO10/21/1 (data/clock/latch) edges → HD44780 on the second register (RS, E, DB4–7, backlight): 8-bit power-on state, 4-bit nibble pairing, DDRAM/CGRAM, entry mode/shift; a transfer starting within the busy time (37/41 µs, 1.52 ms for clear/home) is lost. Printed as a 16×2 box | +| RFID | MFRC522 register model (v4: I2C 0x28, v3: SPI with CS from the shift register) answering the real esp-hal-mfrc522 driver: REQA/anticollision/select/HLTA, MFAuthent against the card's sector trailer, MIFARE read/write. `card ` holds a 1K card in the field for 3 s; what is written to a card stays on it | +| Buttons | `btn <1-4> [ms]` (1 inspect, 2 delegate, 3 submit, 4 penalty). v4: GPIO0–3 pulses, timed in emulated time by the tap; v3: the matrix (shift register row × GPIO3) answered per scan | +| Battery | `bat <0-100>` (starts at 100). v4: BQ27441 on I2C 0x55; v3: ADC1 channel 2 through the divider | +| Stackmat | the real UART1 driver; `sim/stackmat.py` plays the timer | +| Wi-Fi setup | the host writes Wi-Fi + server url into the image's NVS partition (`bus.py nvs`, esp-nvs-partition-tool); the firmware also adds itself to the server (`auto_add`) | | BLE | starts (patched esp-radio), but esp-emu never answers its HCI commands: no BLE flows yet | | Sleep | off | +The firmware's `sim` feature (`SIM_CLIPPY=1 sim/run.sh` lints it) only adds what the host can't see: `state` (the `@state` line the +scripts read, v3 also with lcd_v3's change cache as `lcdtext`) and `wifi off|on` (radio torn down +/ restarted), read from the tap's console frames; it needs the patched esp-hal. Faults the host +injects: `lcd glitch|drop|reset|slow ` (below), `rfid error [n]` (parity errors on the next +transceives), `i2c nack [n]`. `tap stats` prints the tap's traffic and round-trip times. + `sim/run.sh` builds against patched copies of esp-hal (esp-emu never sets the interrupt matrix -status registers, which esp-hal relies on) and esp-radio (BLE bring-up behind the ESP-IDF names -esp-emu intercepts), without touching `Cargo.lock`. The patches live in -`$ESP_SIM_PATCHES` (default `~/tmp/esp-sim-patches`, see its README). The sim build also trades -12 KB of heap for main stack: wifimanager 0.6.1's Wi-Fi init overruns the ~15.6 KB the sim statics -leave. Control port `127.0.0.1:5557` takes `reset` (flash kept). +status registers, which esp-hal relies on; the bus tap) and esp-radio (BLE bring-up behind the +ESP-IDF names esp-emu intercepts), without touching `Cargo.lock`. The patches live in +`$ESP_SIM_PATCHES` (default `~/tmp/esp-sim-patches`, see its README). Control port +`127.0.0.1:5557` takes `reset` (flash kept). ## Run @@ -32,22 +39,23 @@ SIM_WS_URL=ws://192.168.4.1:8080 sim/run.sh # terminal 2: firmware; type c python3 sim/stackmat.py # terminal 3: start | stop | reset | off | on ``` +`sim/run.sh` takes the device commands above; `state` and `wifi off|on` go to the firmware. `192.168.4.1` is this machine inside the emulator. Without `SIM_WS_URL` the firmware uses mDNS, which doesn't get answers through esp-emu's relay on macOS. Cards in micro-connector's `tests.json`: `69420` (competitor), `3004425529` (judge). ## v3 LCD -`SIM_HW=v3 sim/run.sh` (or `--hw v3` for `web.py`/`monkey.py`). `@state` gets `lcd=`: how long -the emulated LCD has shown something else than what `lcd_v3` believes it wrote (0 = in sync); -`monkey.py` fails a run when that exceeds 4 s. Fault injection on stdin: +`SIM_HW=v3 sim/run.sh` (or `--hw v3` for `web.py`/`monkey.py`). The host adds `lcd=` to +`@state`: how long the emulated LCD has shown something else than lcd_v3's cache (0 = in sync); +`monkey.py` fails a run when that exceeds 4 s. Faults at the LCD model: | Command | Real-world fault | |---|---| | `lcd glitch` | one E edge lost (noise): every later byte pairs the wrong nibbles | | `lcd drop` | one write lost | | `lcd reset` | module brownout: back to 8-bit, display off, while the MCU keeps running | -| `lcd slow ` | controller execution times ×n. esp-emu bit-bangs the shift registers ~4× slower than a C3, so n = 3–4 puts the short gaps where real hardware has them | +| `lcd slow ` | controller execution times ×n: the emulated bit-banging (through the tap) is slower than a real C3's, so n puts the short gaps where real hardware has them | | `lcd stats` | writes lost to the busy time and the least slack seen since the last `stats` | The fuzzer injects the first three on v3; `sim/scenarios/lcd-heal.json` checks each heals. diff --git a/sim/bus.py b/sim/bus.py new file mode 100644 index 0000000..717cfca --- /dev/null +++ b/sim/bus.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Host side of the esp-sim-patches bus tap: esp-emu's UART0 carries the firmware's GPIO, I2C, +SPI and ADC traffic (frames `F5 `, see the patches' README); the +boards here wire the device models of sim/devices.py to it like the real PCBs do. + + python3 sim/bus.py run --hw v3 --bin target/sim-v3/sim.bin --elf ... # interactive device + python3 sim/bus.py nvs [ws_url] # seed Wi-Fi into NVS + +Interactive commands: btn <1-4> [ms] | card | bat <0-100> | screen | lcd|oled raw | +lcd glitch|drop|reset|slow |stats | rfid error [n] | i2c nack [n]; anything else +(state, wifi off|on) goes to the firmware's console. +""" +import argparse +import os +import shutil +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +from devices import BQ27441, MFRC522, SSD1309, Card, Chain595, HD44780 + +ROOT = Path(__file__).resolve().parent.parent +EMU = shutil.which("esp-emu") or str(Path.home() / ".local/bin/esp-emu") +MARK = 0xF5 +CARD_IN_FIELD_S = 3.0 +NVS_OFFSET, NVS_SIZE = 0x9000, 0x4000 # partitions.csv +NVS_TOOL = os.environ.get("SIM_NVS_TOOL", str(Path.home() / "projects/esp-nvs/target/debug/esp-nvs-partition-tool")) + + +# ── boards ────────────────────────────────────────────────────────────────────────────────── + +class Board: + """Device models + the wiring of one hardware revision. All calls hold `lock`.""" + + def __init__(self, hw: str, emit): + self.hw, self.emit, self.lock = hw, emit, threading.Lock() + self.tap = None + self.emu_us, self.emu_wall = 0, time.monotonic() # last known emulated time + self.raw, self.last_print, self.printed = False, 0.0, None + self.held = {} # button 1-4 → (start µs, end µs) + self.battery = 100 + self.rfid = MFRC522() + self.card_since = 0.0 + self.cards = {} # uid → Card: what was written to a card stays on it + self.nack = {} # i2c address → NACKs left + if hw == "v3": + self.chain, self.lcd, self.levels, self.cs_low = Chain595(2), HD44780(), {}, False + else: + self.oled, self.gauge = SSD1309(), BQ27441() + + def now_us(self): + return self.emu_us + int((time.monotonic() - self.emu_wall) * 1e6) + + def saw_time(self, us: int): + self.emu_us, self.emu_wall = us, time.monotonic() + + def held_mask(self): + now = self.now_us() + return sum(1 << (b - 1) for b, (s, e) in self.held.items() if s <= now < e) + + def power_on(self): + """Power cycle: the peripherals reset with the MCU.""" + self.rfid.soft_reset() + if self.hw == "v3": + self.lcd.reset() + self.chain = Chain595(2) + + # bus traffic + def gpio(self, t_us: int, pin: int, high: bool): + if self.hw != "v3": + return + if pin == 10: + self.chain.data = high + elif high and not self.levels.get(pin, False): + if pin == 21: + self.chain.clock() + elif pin == 1: + self.chain.latch() + self.latched(t_us) + self.levels[pin] = high + + def latched(self, t_us): + # LCD register: ShifterPin bit b = mask 0x80 >> b: CS, backlight, RS, E, DB4..DB7 + out = self.chain.reg(1) + db = (out & 1) << 3 | (out >> 1 & 1) << 2 | (out >> 2 & 1) << 1 | (out >> 3 & 1) + self.lcd.pins(bool(out & 0x20), bool(out & 0x10), db, t_us) + cs_low = not out & 0x80 + if cs_low and not self.cs_low: + self.rfid.spi_select() + self.cs_low = cs_low + + def inputs(self, now_us: int) -> int: + """Input levels for a `Q` query (v3: the button matrix behind GPIO3).""" + if self.hw != "v3": + return 0 + rows = self.chain.reg(0) + pressed = any(rows & (0x80 >> (b - 1)) for b, (s, e) in self.held.items() if s <= now_us < e) + return int(pressed) << 3 + + def i2c(self, addr: int, ops: list): + """ops: [("W", bytes) | ("R", n)] → (status, read bytes).""" + if self.nack.get(addr): + self.nack[addr] -= 1 + return 1, b"" + dev = {0x3C: "oled", 0x55: "gauge", 0x28: "rfid"}.get(addr) if self.hw == "v4" else None + if dev is None: + return 1, b"" + out, pending = bytearray(), bytearray() + for kind, x in ops + [("end", None)]: + if kind != "W" and pending: + {"oled": self.oled.write, "gauge": self.gauge.write, "rfid": self.rfid.i2c_write}[dev](bytes(pending)) + pending.clear() + if kind == "W": + pending += x + elif kind == "R": + out += self.gauge.read(x) if dev == "gauge" else self.rfid.i2c_read(x) if dev == "rfid" else bytes(x) + return 0, bytes(out) + + def spi(self, mosi: bytes) -> bytes: + if self.hw == "v3" and self.cs_low: + return self.rfid.spi(mosi) + return bytes(len(mosi)) + + def adc(self, channel: int) -> int: + """v3 battery divider on ADC1 channel 2, as the firmware's calibration reads it.""" + if self.hw != "v3" or channel != 2: + return 0 + curve = [(3350, 0), (3400, 13), (3450, 19), (3500, 25), (3550, 31), (3600, 38), + (3700, 50), (3800, 63), (3900, 75), (4000, 88), (4100, 100)] + mv = curve[-1][0] + for (v1, p1), (v2, p2) in zip(curve, curve[1:]): + if self.battery <= p2: + mv = v1 + (self.battery - p1) * (v2 - v1) / (p2 - p1) + break + return int((mv + 3 - 66.6103) / 1.69874) + 1 # inverse of utils::battery::adc_to_mv + + # host commands + def command(self, line: str) -> bool: + w = line.split() + if not w: + return False + num = lambda i, d=None: int(w[i], 0) if len(w) > i and w[i].lstrip("-").isdigit() else d + if w[0] == "btn" and num(1) in (1, 2, 3, 4): + b, ms = num(1), num(2, 150) + start = self.now_us() + self.held[b] = (start, start + ms * 1000) + if self.hw == "v4" and self.tap: # GPIO0..3, high while pressed + self.tap.send(b"g", struct.pack(" 1: + if w[1] == "glitch": + self.lcd.skip_edges = 1 # a lost E edge: later bytes pair the wrong nibbles + elif w[1] == "drop": + self.lcd.skip_transfers = 1 # one write lost + elif w[1] == "reset": + self.lcd.reset() # module brownout while the MCU runs on + elif w[1] == "slow" and num(2): + self.lcd.slow = num(2) + elif w[1] == "stats": + self.emit(f"lcd: {self.lcd.busy_drops} writes ignored while busy (last: {self.lcd.last_drop} " + f"= (RS, byte, us early)), least slack {self.lcd.min_slack} us (since the last stats)") + self.lcd.busy_drops, self.lcd.last_drop, self.lcd.min_slack = 0, None, None + else: + return False + elif w[0] == "tap" and w[1:2] == ["stats"] and self.tap: + r = sorted(self.tap.rtt) or [0] + self.emit(f"tap: {self.tap.stats['frames']} frames, {self.tap.stats['gpio']} pin changes " + f"({self.tap.gap_sum / max(1, self.tap.gaps):.2f} us apart in a batch); query round trip " + f"median {r[len(r) // 2]} us, p90 {r[len(r) * 9 // 10]} us, max {r[-1]} us ({len(r)} samples)") + elif w[0] == "rfid" and w[1:2] == ["error"]: + self.rfid.rf_errors = num(2, 1) + elif w[0] == "i2c" and w[1:2] == ["nack"] and len(w) > 2: + self.nack[int(w[2], 16)] = num(3, 1) + else: + return False + return True + + # display + def tick(self): + if self.rfid.card and time.monotonic() - self.card_since > CARD_IN_FIELD_S: + self.rfid.card = None + self.print_display() + + def frame(self): + if self.hw == "v3": + bl = bool(self.chain.reg(1) & 0x40) + return (tuple(bytes(r) for r in self.lcd.visible()), self.lcd.cursor_pos(), bl) + return bytes(self.oled.gram) + + def print_display(self, force=False): + now = time.monotonic() + if not force and now - self.last_print < (0.02 if self.raw else 0.2): + return + f = self.frame() + if f == self.printed and not force: + return + self.printed, self.last_print = f, now + if self.hw == "v4": + if self.raw: + return self.emit("@oled " + f.hex()) + self.emit("┌" + "─" * 128 + "┐") + for row in range(32): + self.emit("│" + "".join(" ▀▄█"[self.oled.pixel(x, 2 * row) | self.oled.pixel(x, 2 * row + 1) << 1] + for x in range(128)) + "│") + return self.emit("└" + "─" * 128 + "┘") + rows, cur, bl = f + if self.raw: + c = "-" if cur is None else f"{cur[0]},{cur[1]}" + return self.emit(f"@lcd {rows[0].hex()}{rows[1].hex()} bl={int(bl)} cur={c} cg={self.lcd.cgram.hex()}") + glyph = lambda c: chr(c) if 0x20 <= c < 0x7F else "▯" if c < 8 else "▒" + self.emit("┌" + "─" * 16 + "┐" + ("" if bl else " (backlight off)")) + for r in rows: + self.emit("│" + "".join(glyph(c) for c in r) + "│") + self.emit("└" + "─" * 16 + "┘") + + +# ── the tap connection ────────────────────────────────────────────────────────────────────── + +class Tap: + """Talks to the firmware over esp-emu's UART0 socket; non-frame bytes (ROM text) are + passed on as lines.""" + + def __init__(self, port: int, board: Board, on_text): + self.port, self.board, self.on_text = port, board, on_text + self.sock, self.alive, self.wlock = None, True, threading.Lock() + self.pending = [] # frames sent before the connection is up + self.stats = {"frames": 0, "gpio": 0} + # emulated µs: a Q query until the firmware's next pin write (= the reply's round trip + # plus a few instructions), and the spacing of pin writes inside a batch + self.rtt, self.last_q, self.gap_sum, self.gaps = [], None, 0, 0 + board.tap = self + threading.Thread(target=self.run, daemon=True).start() + + def send(self, ty: bytes, payload: bytes = b""): + data = bytes([MARK]) + ty + struct.pack(" 1 << 31: + t_hi += 1 << 32 + t_last = t32 + return t_hi + t32 + + with self.board.lock: + reply = self.frame(ty, payload, unwrap) + if reply is not None: + self.send(b"r", reply) + + def frame(self, ty, p, unwrap): + b = self.board + if ty == ord("G"): + t = unwrap(struct.unpack_from(" str: + """Adds what only the host knows: held buttons, and on v3 how long (ms) the LCD has + shown something else than the firmware wrote (`lcdtext`).""" + kv = dict(x.split("=", 1) for x in line[7:].split() if "=" in x) + with self.board.lock: + if "up" in kv: # emulated ms when the firmware printed it + self.board.saw_time(int(kv["up"]) * 1000) + line += f" held={self.board.held_mask()}" + if "lcdtext" in kv and self.board.hw == "v3": + shown = b"".join(bytes(r) for r in self.board.lcd.visible()).hex() + now = time.monotonic() + if shown == kv["lcdtext"]: + self.lcd_bad_since = None + elif self.lcd_bad_since is None or now - self.lcd_sampled > 1.0: + # only back-to-back samples tell it stayed wrong (scrolling rewrites briefly) + self.lcd_bad_since = now + self.lcd_sampled = now + line += f" lcd={0 if self.lcd_bad_since is None else int((now - self.lcd_bad_since) * 1000)}" + return line + + def read(self): + buf = b"" + while True: + chunk = self.proc.stdout.read(4096) + if not chunk: + break + buf += chunk + *lines, buf = buf.split(b"\n") + for raw in lines: + self.add(raw.decode(errors="replace").rstrip("\r")) + + def ticker(self): + while self.proc.poll() is None: + with self.board.lock: + self.board.tick() + time.sleep(0.02) + + def send(self, line: str): + with self.board.lock: + if self.board.command(line): + return + self.tap.send(b"c", (line + "\n").encode()) # firmware console (state, wifi) + + def since(self, t0: float) -> list: + with self.lock: + return [l for t, l in self.lines if t >= t0] + + def control_cmd(self, cmd: str) -> str: + with socket.create_connection(("127.0.0.1", self.control), timeout=5) as s: + s.sendall(cmd.encode() + b"\n") + return s.makefile().readline().strip() + + def power_cycle(self): + with self.board.lock: + self.board.power_on() + self.control_cmd("reset") + + def reseed(self, nvs_bin: Path): + """Writes the seeded NVS partition back (after `Reset settings`) and reboots.""" + self.control_cmd(f"write-region {NVS_OFFSET:#x} {nvs_bin}") + self.power_cycle() + + def stop(self): + self.tap.stop() + self.proc.send_signal(signal.SIGINT) + try: + self.proc.wait(5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +# ── Wi-Fi seeding ─────────────────────────────────────────────────────────────────────────── + +def nvs_partition(ws_url: str, out: Path) -> Path: + """The NVS partition with the emulator's Wi-Fi (myssid/mypassword) and the server url, as + the wifimanager stores it after provisioning (`SIM_PROVISION=1` builds skip it).""" + data = '{"mdns":false,"ws_url":"%s"}' % ws_url if ws_url else '{"mdns":true,"ws_url":null}' + with tempfile.TemporaryDirectory() as d: + Path(d, "wifi.txt").write_text('{"ssid":"myssid","psk":"mypassword","data":%s}' % data) + Path(d, "nvs.csv").write_text("key,type,encoding,value\nwifimanager,namespace,,\nWIFI_SETUP,file,string,wifi.txt\n") + subprocess.run([NVS_TOOL, "generate", str(Path(d, "nvs.csv")), str(out), "--size", hex(NVS_SIZE)], + check=True, stdout=subprocess.DEVNULL) + return out + + +def seed_image(image: Path, ws_url: str) -> Path: + """Splices the seeded NVS partition into a merged flash image; returns the partition file.""" + part = nvs_partition(ws_url, image.with_suffix(".nvs")) + with open(image, "r+b") as f: + f.seek(NVS_OFFSET) + f.write(part.read_bytes()) + return part + + +def main(): + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("run") + r.add_argument("--hw", choices=["v3", "v4"], default="v4") + r.add_argument("--bin", required=True) + r.add_argument("--elf", required=True) + r.add_argument("--http", type=int, default=18080) + r.add_argument("--uart1", type=int, default=5556) + r.add_argument("--control", type=int, default=5557) + r.add_argument("--tap", type=int, default=5558) + n = sub.add_parser("nvs") + n.add_argument("image") + n.add_argument("ws_url", nargs="?", default="") + a = p.parse_args() + if a.cmd == "nvs": + seed_image(Path(a.image), a.ws_url) + return + log = ROOT / "target" / f"sim-{a.hw}.log" + emu = Emu(Path(a.bin), Path(a.elf), dict(http=a.http, uart1=a.uart1, control=a.control, tap=a.tap), + log, hw=a.hw) + seen = 0 + + def echo(): + nonlocal seen + while emu.proc.poll() is None: + with emu.lock: + new = emu.lines[seen:] + seen += len(new) + for _, l in new: + print(l, flush=True) + time.sleep(0.05) + + threading.Thread(target=echo, daemon=True).start() + print(__doc__.split("Interactive commands:")[1].strip(), flush=True) + try: + for line in sys.stdin: + emu.send(line.strip()) + except KeyboardInterrupt: + pass + finally: + emu.stop() + + +if __name__ == "__main__": + main() diff --git a/sim/competition.py b/sim/competition.py index f610e01..00e7512 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -283,7 +283,8 @@ class Mismatch(Exception): class Station(M.Run): def __init__(self, n, run_dir, server, image, ports, args, restart_lock): self.n, self.dir, self.server, self.args, self.ports = n, run_dir, server, args, ports - self.expected_boots, self.settings_resets, self.heap_peak = 1, 0, 0 + self.expected_boots, self.settings_resets, self.heap_peak, self.reseed_after = 1, 0, 0, 0 + self.image = image self.executed, self.t0 = [], time.monotonic() self.restart_lock = restart_lock efuse = run_dir / f"dev{n}.efuse" @@ -292,7 +293,7 @@ def __init__(self, n, run_dir, server, image, ports, args, restart_lock): check=True, stdout=subprocess.DEVNULL) bin_ = run_dir / f"dev{n}.bin" shutil.copy(image[0], bin_) - self.emu = M.Emu(bin_, image[1], ports, run_dir / f"firmware{n}.log", efuse) + self.emu = M.Emu(bin_, image[1], ports, run_dir / f"firmware{n}.log", efuse, hw=args.hw) self.stackmat = M.Stackmat(ports["uart1"]) self.esp = None self.delegate_until = -10**9 # emulated ms until which a pending delegate blocks new calls @@ -640,7 +641,7 @@ def check_attempt(st, rec, new): class Day: def __init__(self, seed, day, job, args, image): self.seed, self.day, self.args = seed, day, args - self.dir = M.OUT / f"comp-d{day['devices']}" / str(seed) + self.dir = M.OUT / f"comp{'-v3' if args.hw == 'v3' else ''}-d{day['devices']}" / str(seed) if self.dir.exists(): shutil.rmtree(self.dir) self.dir.mkdir(parents=True) @@ -725,7 +726,7 @@ def execute(self): for i in range(n): base = 20000 + (self.args.job_base + self.job * n + i) * 10 stations.append(Station(i, self.dir, self.server, self.image, - dict(http=base, uart1=base + 1, control=base + 2), self.args, + dict(http=base, uart1=base + 1, control=base + 2, tap=base + 3), self.args, self.restart_lock)) for st in stations: st.boot() @@ -846,7 +847,7 @@ def worker(j): print(f" slips: {', '.join(f'{k} {v}' for k, v in sorted(s['slips'].items())) or '-'}") for m in s["mismatches"] + s["failures"]: print(f" ! {m}") - print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp-d{work[0][1]['devices']}//)") + print(f"\n{len(results) - bad}/{len(results)} days clean (logs: sim/out/comp{'-v3' if args.hw == 'v3' else ''}-d{work[0][1]['devices']}//)") return 1 if bad else 0 diff --git a/sim/devices.py b/sim/devices.py new file mode 100644 index 0000000..b2186bd --- /dev/null +++ b/sim/devices.py @@ -0,0 +1,439 @@ +"""Device models behind the esp-sim-patches bus tap (pure: no I/O, host-tested by the +self-check at the bottom). They see what the real chips see: pin edges, I2C/SPI bytes.""" + +# ── HD44780 character LCD (v3) ────────────────────────────────────────────────────────────── + +EXEC_US, EXEC_DATA_US, EXEC_CLEAR_US = 37, 41, 1520 # datasheet, fosc = 270 kHz +LINE = 40 + + +class HD44780: + """The controller as seen from RS/E/DB7..DB4: 8-bit power-on state, 4-bit nibble pairing, + DDRAM/CGRAM, entry mode/shift, and transfers that start while it is busy are lost (the + datasheet allows no writes while BF = 1). Faults: `skip_edges` (lost E edge → nibble + desync), `skip_transfers` (lost write), `reset()` (module brownout), `slow` (×exec times).""" + + def __init__(self): + self.slow, self.busy_drops, self.last_drop, self.min_slack = 1, 0, None, None + self.skip_edges = self.skip_transfers = 0 + self.reset() + + def reset(self): + self.ddram = [bytearray(b" " * LINE) for _ in range(2)] + self.cgram = bytearray(64) + self.ac, self.cgram_mode, self.eight_bit, self.two_lines = 0, False, True, False + self.display_on = self.cursor = self.blink = False + self.increment, self.shift_on_write, self.shift = True, False, 0 + self.high, self.en, self.busy_until = None, False, 0 + + def pins(self, rs: bool, en: bool, db: int, now_us: int): + """Pin levels at `now_us`; data latches on E's falling edge. db = DB7..DB4 as bits 3..0.""" + falling = self.en and not en + self.en = en + if not falling: + return + if self.skip_edges: + self.skip_edges -= 1 + return + busy = None + if self.high is None: + slack = now_us - self.busy_until + self.min_slack = slack if self.min_slack is None else min(self.min_slack, slack) + busy = -slack if slack < 0 else None + if self.eight_bit: + self.transfer(rs, db << 4 | 0x0F, busy, now_us) # DB3..DB0 float high (pull-ups) + elif self.high is not None: + high, busy = self.high + self.high = None + self.transfer(rs, high << 4 | db, busy, now_us) + else: + self.high = (db, busy) + + def transfer(self, rs, v, busy, now_us): + if self.skip_transfers: + self.skip_transfers -= 1 + return + if busy is not None: + self.busy_drops += 1 + self.last_drop = (rs, v, busy) + return + if rs: + self.write(v) + ex = EXEC_DATA_US + else: + ex = self.instruction(v) + self.busy_until = now_us + ex * self.slow + + def instruction(self, v): + if v >= 0x80: + self.cgram_mode, self.ac = False, v & 0x7F + elif v >= 0x40: + self.cgram_mode, self.ac = True, v & 0x3F + elif v >= 0x20: + self.eight_bit, self.two_lines, self.high = bool(v & 0x10), bool(v & 0x08), None + elif v >= 0x10: + right = bool(v & 0x04) + if v & 0x08: + self.shift = (self.shift + (LINE - 1 if right else 1)) % LINE + else: + self.step(right) + elif v >= 0x08: + self.display_on, self.cursor, self.blink = bool(v & 4), bool(v & 2), bool(v & 1) + elif v >= 0x04: + self.increment, self.shift_on_write = bool(v & 2), bool(v & 1) + elif v >= 0x02: + self.home() + return EXEC_CLEAR_US + elif v == 0x01: + self.ddram = [bytearray(b" " * LINE) for _ in range(2)] + self.increment = True + self.home() + return EXEC_CLEAR_US + return EXEC_US + + def home(self): + self.ac, self.cgram_mode, self.shift = 0, False, 0 + + def cell(self, ac): + line, col = (int(ac >= 0x40), ac & 0x3F) if self.two_lines else (0, ac) + return (line, col) if col < LINE else None + + def step(self, forward): + if self.cgram_mode: + self.ac = (self.ac + (1 if forward else -1)) & 0x3F + return + wrap = {(True, True, 0x27): 0x40, (True, True, 0x67): 0x00, (True, False, 0x40): 0x27, + (True, False, 0x00): 0x67, (False, True, 0x4F): 0x00, (False, False, 0x00): 0x4F} + nxt = wrap.get((self.two_lines, forward, self.ac)) + self.ac = nxt if nxt is not None else (self.ac + (1 if forward else -1)) & 0x7F + + def write(self, v): + if self.cgram_mode: + self.cgram[self.ac & 0x3F] = v & 0x1F + self.ac = (self.ac + 1) & 0x3F + return + c = self.cell(self.ac) + if c: + self.ddram[c[0]][c[1]] = v + self.step(self.increment) + if self.shift_on_write: + self.shift = (self.shift + (1 if self.increment else LINE - 1)) % LINE + + def visible(self) -> list: + """The 16×2 character codes shown (blank while the display is off).""" + out = [bytearray(b" " * 16) for _ in range(2)] + if self.display_on: + for row in range(2 if self.two_lines else 1): + for col in range(16): + out[row][col] = self.ddram[row][(col + self.shift) % LINE] + return out + + def cursor_pos(self): + if not (self.display_on and (self.cursor or self.blink)) or self.cgram_mode: + return None + c = self.cell(self.ac) + if not c: + return None + col = (c[1] + LINE - self.shift) % LINE + return (c[0], col) if col < 16 else None + + +class Chain595: + """74HC595 chain on data/clock/latch: the first bit clocked in ends up in the last + register; `latched` holds the outputs (bit 0..7 = first register's Q0..Q7, …).""" + + def __init__(self, n=2): + self.n, self.sr, self.latched, self.data = n, 0, 0, False + + def clock(self): + self.sr = (self.sr >> 1) | (int(self.data) << (8 * self.n - 1)) + + def latch(self): + self.latched = self.sr + + def reg(self, i): + """Register i in the order the firmware's AdvancedShiftRegister numbers them.""" + return self.latched >> (8 * (self.n - 1 - i)) & 0xFF + + +# ── SSD1309 OLED (v4) ─────────────────────────────────────────────────────────────────────── + +class SSD1309: + """Page addressing (what the firmware's driver uses) into a 1 KB GRAM.""" + + def __init__(self): + self.gram, self.page, self.col, self.dirty = bytearray(1024), 0, 0, True + + def write(self, b: bytes): + if len(b) == 4 and b[0] == 0x00 and 0xB0 <= b[1] <= 0xB7 and b[2] <= 0x0F and 0x10 <= b[3] <= 0x17: + self.page, self.col = b[1] & 7, b[2] | (b[3] & 0x0F) << 4 + elif b[:1] == b"\x40": + for x in b[1:]: + if self.col < 128: + self.gram[self.page * 128 + self.col] = x + self.col += 1 + self.dirty = True + + def pixel(self, x, y): + return self.gram[y // 8 * 128 + x] >> (y % 8) & 1 + + +# ── BQ27441 fuel gauge (v4) ───────────────────────────────────────────────────────────────── + +class BQ27441: + def __init__(self): + self.percent, self.ptr, self.control = 100, 0, 0 + + def mv(self): + return 3350 + self.percent * 7.5 + + def write(self, b: bytes): + self.ptr = b[0] + if b[0] == 0x00 and len(b) >= 3: # Control() subcommand + sub = b[1] | b[2] << 8 + self.control = {0x0001: 0x0421}.get(sub, 0) # DEVICE_TYPE + + def read(self, n: int) -> bytes: + words = {0x00: self.control, 0x02: 2982, 0x04: int(self.mv()), 0x06: 0, 0x10: 0, + 0x1C: self.percent, 0x1E: self.percent} + out = bytearray() + for i in range(n): + a = self.ptr + i + out.append(words.get(a & ~1, 0) >> (8 * (a & 1)) & 0xFF) + return bytes(out) + + +# ── MFRC522 reader + MIFARE Classic 1K cards ──────────────────────────────────────────────── + +def crc_a(data) -> bytes: + crc = 0x6363 + for b in data: + b ^= crc & 0xFF + b = (b ^ (b << 4)) & 0xFF + crc = (crc >> 8) ^ (b << 8) ^ (b << 3) ^ (b >> 4) + return bytes([crc & 0xFF, crc >> 8 & 0xFF]) + + +class Card: + def __init__(self, uid: int): + self.uid = uid.to_bytes(16, "little")[:4] # esp-hal-mfrc522's to_u128: little endian + self.blocks = [bytearray(16) for _ in range(64)] + self.blocks[0][:4] = self.uid + for t in range(3, 64, 4): + self.blocks[t][:] = b"\xff" * 6 + b"\xff\x07\x80\x69" + b"\xff" * 6 + self.state = "idle" # idle, ready, active, halt + self.pending_write = None + + def bcc(self): + return self.uid[0] ^ self.uid[1] ^ self.uid[2] ^ self.uid[3] + + +COMMAND, COMIRQ, DIVIRQ, ERROR, STATUS2, FIFODATA, FIFOLEVEL, CONTROL, BITFRAMING, COLL = \ + 0x01, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0C, 0x0D, 0x0E +CRC_H, CRC_L, VERSION, TXCONTROL = 0x21, 0x22, 0x37, 0x14 +IDLE, CALCCRC, TRANSCEIVE, MFAUTHENT, SOFTRESET = 0x0, 0x3, 0xC, 0xE, 0xF + + +class MFRC522: + """Register-level model answering the real esp-hal-mfrc522 driver (SPI on v3, I2C 0x28 on + v4). The chip does Crypto1 itself, so MFAuthent only checks the key against the card's + sector trailer. `card` is the card in the field (or None); `rf_errors` corrupts the next + transceives.""" + + def __init__(self): + self.card, self.rf_errors = None, 0 + self.soft_reset() + self.spi_first, self.spi_addr, self.ptr = True, 0, 0 + + def soft_reset(self): + self.regs = bytearray(64) + self.regs[TXCONTROL], self.regs[VERSION], self.regs[0x2A] = 0x80, 0x92, 0 + self.fifo = bytearray() + + def read(self, reg: int) -> int: + if reg == FIFODATA: + return self.fifo.pop(0) if self.fifo else 0 + if reg == FIFOLEVEL: + return len(self.fifo) + return self.regs[reg] + + def write(self, reg: int, v: int): + if reg == FIFODATA: + if len(self.fifo) < 64: + self.fifo.append(v) + elif reg == FIFOLEVEL: + if v & 0x80: + self.fifo.clear() + elif reg in (COMIRQ, DIVIRQ): + mask = v & 0x7F + self.regs[reg] = self.regs[reg] | mask if v & 0x80 else self.regs[reg] & ~mask + elif reg == COMMAND: + self.regs[COMMAND] = v & 0x3F + self.command(v & 0x0F) + elif reg == BITFRAMING: + self.regs[reg] = v & 0x7F + if v & 0x80 and self.regs[COMMAND] & 0x0F == TRANSCEIVE: + self.transceive(v & 0x07) + else: + self.regs[reg] = v + + def command(self, cmd): + if cmd == SOFTRESET: + self.soft_reset() + elif cmd == CALCCRC: + c = crc_a(self.fifo) + self.fifo.clear() + self.regs[CRC_L], self.regs[CRC_H] = c[0], c[1] + self.regs[DIVIRQ] |= 0x04 + elif cmd == MFAUTHENT: + f, card = bytes(self.fifo), self.card + self.fifo.clear() + ok = (card is not None and card.state == "active" and len(f) == 12 + and f[8:12] == card.uid and f[1] < 64) + if ok: + trailer = card.blocks[f[1] // 4 * 4 + 3] + key = trailer[:6] if f[0] == 0x60 else trailer[10:16] + ok = f[2:8] == key + if ok: + self.regs[STATUS2] |= 0x08 + self.regs[COMIRQ] |= 0x10 + else: + self.regs[COMIRQ] |= 0x01 # timer: the card never answers + + def transceive(self, last_bits): + frame = bytes(self.fifo) + self.fifo.clear() + self.regs[ERROR] = 0 + resp = self.picc(frame, last_bits) + if resp is None: + self.regs[COMIRQ] |= 0x01 + return + data, valid_bits = resp + if self.rf_errors: + self.rf_errors -= 1 + self.regs[ERROR] = 0x02 # parity error + self.fifo.extend(data) + self.regs[CONTROL] = self.regs[CONTROL] & ~0x07 | valid_bits + self.regs[COMIRQ] |= 0x30 + + def picc(self, f: bytes, last_bits): + """The card's answer (bytes, valid bits of the last byte) or None (no answer).""" + c = self.card + if c is None or not f: + return None + crypto = bool(self.regs[STATUS2] & 0x08) + if c.pending_write is not None and crypto: + block, c.pending_write = c.pending_write, None + if len(f) == 18 and crc_a(f[:16]) == f[16:]: + c.blocks[block][:] = f[:16] + return b"\x0a", 4 + return None + if last_bits == 7 and f[0] in (0x26, 0x52): # REQA / WUPA + if c.state == "idle" or (f[0] == 0x52 and c.state == "halt"): + c.state = "ready" + return b"\x04\x00", 0 + return None + if f[0] == 0x93 and len(f) == 2 and f[1] == 0x20 and c.state == "ready": + return c.uid + bytes([c.bcc()]), 0 + if f[0] == 0x93 and len(f) == 9 and f[1] == 0x70 and c.state == "ready": + if f[2:6] == c.uid and crc_a(f[:7]) == f[7:9]: + c.state = "active" + return bytes([0x08]) + crc_a(b"\x08"), 0 + return None + if f[0] == 0x50 and c.state == "active": # HLTA + c.state = "halt" + self.regs[STATUS2] &= ~0x08 + return None + if c.state != "active" or not crypto or len(f) != 4 or crc_a(f[:2]) != f[2:]: + return None + if f[0] == 0x30 and f[1] < 64: # MF_READ + data = bytes(c.blocks[f[1]]) + return data + crc_a(data), 0 + if f[0] == 0xA0 and f[1] < 64: # MF_WRITE, data follows + c.pending_write = f[1] + return b"\x0a", 4 + return None + + # bus framing + def i2c_write(self, b: bytes): + for v in b[1:]: + self.write(b[0] & 0x3F, v) + self.ptr = b[0] & 0x3F + + def i2c_read(self, n): + return bytes(self.read(self.ptr) for _ in range(n)) + + def spi_select(self): + self.spi_first = True + + def spi(self, mosi: bytes) -> bytes: + """Bytes while CS is low: address byte (bit 7 = read), then data / next addresses.""" + miso = bytearray() + for b in mosi: + if self.spi_first: + self.spi_first, self.spi_addr = False, b + miso.append(0) + elif self.spi_addr & 0x80: + miso.append(self.read(self.spi_addr >> 1 & 0x3F)) + self.spi_addr = b + else: + self.write(self.spi_addr >> 1 & 0x3F, b) + miso.append(0) + return bytes(miso) + + +if __name__ == "__main__": + # self-check: 4-bit init and text, a command while busy is lost, re-init resyncs + lcd, t = HD44780(), [0] + + def nib(rs, n, gap): + t[0] += gap + lcd.pins(rs, True, n, t[0]) + lcd.pins(rs, False, n, t[0]) + + def byte(rs, v, gap): + nib(rs, v >> 4, gap) + nib(rs, v & 15, 1) + + def init(): + for n in (3, 3, 3, 2): + nib(False, n, 5000) + for v in (0x28, 0x0C, 0x06, 0x01): + byte(False, v, 5000) + + def text(addr, s, gap=500): + byte(False, 0x80 | addr, 5000) + for ch in s.encode(): + byte(True, ch, gap) + + init() + text(0, "Hello") + text(0x45, "World") + assert lcd.visible() == [bytearray(b"Hello "), bytearray(b" World ")] + text(0, "0123456789abcdef") + byte(False, 0xC0, 20) # right after the last character: lost, the text goes off screen + for ch in b"NEW TEXT": + byte(True, ch, 500) + assert lcd.busy_drops == 1 and lcd.visible()[1] == bytearray(b" World ") + lcd.skip_edges = 1 + text(0, "abcd") + assert lcd.visible()[0][:4] != bytearray(b"abcd") + init() + text(0, "abcd") + assert lcd.visible()[0][:4] == bytearray(b"abcd") + + ch = Chain595(2) + for bit in [1, 0, 0, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 0, 0, 1]: # reg 1 then reg 0, LSB first + ch.data = bool(bit) + ch.clock() + ch.latch() + assert ch.reg(1) == 0x01 and ch.reg(0) == 0x80, (hex(ch.reg(0)), hex(ch.reg(1))) + + assert crc_a(b"\x50\x00") == bytes([0x57, 0xCD]) + rc = MFRC522() + rc.card = Card(69420) + rc.write(FIFODATA, 0x26) + rc.write(COMMAND, TRANSCEIVE) + rc.write(BITFRAMING, 0x87) + assert rc.regs[COMIRQ] & 0x30 and bytes(rc.fifo) == b"\x04\x00" + print("devices self-check ok") diff --git a/sim/monkey.py b/sim/monkey.py index 0c417cf..1b478bd 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -25,9 +25,10 @@ import time from pathlib import Path +from bus import Emu # esp-emu + the bus tap and device models + ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "sim" / "out" -EMU = shutil.which("esp-emu") or str(Path.home() / ".local/bin/esp-emu") COMPETITOR, JUDGE, MULTI = 69420, 3004425529, 1264825046 # micro-connector tests.json cards KNOWN_CARDS = {COMPETITOR, JUDGE, MULTI} @@ -254,59 +255,6 @@ def run(self): time.sleep(10 * 10 / 1200) -class Emu: - def __init__(self, bin_: Path, elf: Path, ports: dict, log: Path, efuse: Path = None): - self.log = open(log, "w", buffering=1) - self.lines, self.lock = [], threading.Lock() - # a station of several needs its own MAC (device id); esp-emu writes the file back - extra = ["--efuse", str(efuse)] if efuse else [] - self.proc = subprocess.Popen( - [EMU, "--chip", "esp32c3", "--firmware", str(bin_), "--elf", str(elf), - "--net", f"user,hostfwd=tcp:127.0.0.1:{ports['http']}-:80", - "--uart1-tcp", f"127.0.0.1:{ports['uart1']}", - "--control-tcp", f"127.0.0.1:{ports['control']}", *extra], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0) - self.control = ports["control"] - threading.Thread(target=self.read, daemon=True).start() - - def read(self): - buf = b"" - while True: - chunk = self.proc.stdout.read(4096) - if not chunk: - break - buf += chunk - *lines, buf = buf.split(b"\n") - now = time.monotonic() - for raw in lines: - line = raw.decode(errors="replace").rstrip("\r") - self.log.write(f"{now:.3f} {line}\n") - with self.lock: - self.lines.append((now, line)) - - def send(self, line: str): - try: - self.proc.stdin.write((line + "\n").encode()) - self.proc.stdin.flush() - except OSError: - pass - - def since(self, t0: float) -> list: - with self.lock: - return [l for t, l in self.lines if t >= t0] - - def power_cycle(self): - with socket.create_connection(("127.0.0.1", self.control), timeout=3) as s: - s.sendall(b"reset\n") - - def stop(self): - self.proc.send_signal(signal.SIGINT) - try: - self.proc.wait(5) - except subprocess.TimeoutExpired: - self.proc.kill() - - # ── one run ───────────────────────────────────────────────────────────────────────────────── class Failure(Exception): @@ -316,18 +264,19 @@ class Failure(Exception): class Run: def __init__(self, seed, actions, job, args, image): self.seed, self.actions, self.args = seed, actions, args - self.dir = OUT / str(seed) + self.dir = out_dir(seed, args.hw) if self.dir.exists(): shutil.rmtree(self.dir) self.dir.mkdir(parents=True) job += args.job_base base = 20000 + job * 10 - self.ports = dict(http=base, uart1=base + 1, control=base + 2) + self.ports = dict(http=base, uart1=base + 1, control=base + 2, tap=base + 3) self.server = Server(Path(args.mc).expanduser(), SRV_PORT + job, self.dir) self.image = image self.executed = [] self.expected_boots = 1 self.settings_resets = 0 + self.reseed_after = 0 self.heap_peak = 0 # helpers @@ -350,16 +299,21 @@ def check_alive(self): if self.emu.proc.poll() is not None: raise Failure(f"emulator exited ({self.emu.proc.returncode}): crash/panic stop") text = self.emu.since(0) - boots = sum(1 for l in text if l.startswith("ESP-ROM:")) + boots = sum(1 for l in text if "INFO - Hardware Rev:" in l) # "Reset settings" in the config menu legitimately wipes NVS and reboots; the device then - # registers again with a new sign key, so the server starts fresh too + # registers again with a new sign key, so the server starts fresh too. Its Wi-Fi comes + # back the way the host seeded it: written again after that reboot, then one more. resets = sum(1 for l in text if "Reset settings: rebooting" in l) if resets > self.settings_resets: self.settings_resets = resets - self.expected_boots += 1 + self.expected_boots += 2 self.server.stop() self.server = Server(self.server.mc, self.server.port, self.dir, fresh=True) self.server.start() + self.reseed_after = self.expected_boots - 1 + if self.reseed_after and boots >= self.reseed_after: + self.reseed_after = 0 + self.emu.reseed(self.image[0].with_suffix(".nvs")) if boots > self.expected_boots: raise Failure(f"unexpected reboot (#{boots})") for l in text[-400:]: @@ -537,7 +491,7 @@ def check_server(self): def execute(self, deadline): self.server.start() - self.emu = Emu(*self.image, self.ports, self.dir / "firmware.log") + self.emu = Emu(*self.image, self.ports, self.dir / "firmware.log", hw=self.args.hw) self.stackmat = Stackmat(self.ports["uart1"]) self.t0 = time.monotonic() try: @@ -568,6 +522,11 @@ def execute(self, deadline): # ── driver ────────────────────────────────────────────────────────────────────────────────── +def out_dir(seed, hw: str) -> Path: + """Logs of one run: sim/out/ (v4), sim/out/v3-.""" + return OUT / (f"v3-{seed}" if hw == "v3" else str(seed)) + + def target(hw: str) -> Path: return ROOT / ("target/sim-v3" if hw == "v3" else "target/sim") @@ -579,6 +538,7 @@ def build(port: int, hw: str = "v4") -> tuple: t = target(hw) bin_, elf = t / f"monkey-{port}.bin", t / f"monkey-{port}.elf" shutil.copy(t / "sim.bin", bin_) + shutil.copy(t / "sim.nvs", bin_.with_suffix(".nvs")) # the seeded Wi-Fi, for `Reset settings` shutil.copy(t / "riscv32imc-unknown-none-elf/release/fkm-firmware", elf) return bin_, elf @@ -633,7 +593,7 @@ def shrink(seed, actions, args, image): if n >= len(actions): break n = min(len(actions), n * 2) - json.dump({"seed": seed, "actions": actions}, open(OUT / str(seed) / "minimal.json", "w")) + json.dump({"seed": seed, "actions": actions}, open(out_dir(seed, args.hw) / "minimal.json", "w")) return actions @@ -718,10 +678,10 @@ def worker(j): failed = [(s, e, a) for s, e, a in results if e] print(f"\n{len(results) - len(failed)}/{len(results)} seeds clean") for seed, err, actions in failed: - print(f" seed {seed}: {err} (logs: sim/out/{seed}/)") + print(f" seed {seed}: {err} (logs: {out_dir(seed, args.hw)})") if args.shrink: minimal = shrink(seed, actions, args, images[0]) - print(f" seed {seed}: minimal repro {len(minimal)} actions -> sim/out/{seed}/minimal.json") + print(f" seed {seed}: minimal repro {len(minimal)} actions -> {out_dir(seed, args.hw)}/minimal.json") sys.exit(1 if failed else 0) diff --git a/sim/run.sh b/sim/run.sh index 92af4cf..690077d 100755 --- a/sim/run.sh +++ b/sim/run.sh @@ -1,13 +1,15 @@ #!/bin/sh -# Builds the `sim` firmware against the esp-emu patched crates and runs it in esp-emu. -# The patches live in $ESP_SIM_PATCHES (default ~/tmp/esp-sim-patches, see its README). +# Builds the `sim` firmware against the esp-emu patched crates (with the bus tap) and runs it in +# esp-emu with the host device models (sim/bus.py). The patches live in $ESP_SIM_PATCHES +# (default ~/tmp/esp-sim-patches, see its README). # sim/run.sh mdns finds the server # SIM_WS_URL=ws://192.168.4.1:8080 sim/run.sh fixed url (192.168.4.1 = this machine) # SIM_PROVISION=1 sim/run.sh no seeded Wi-Fi: AP provisioning, panel on http://127.0.0.1:18080 # SIM_WIFIMANAGER=~/projects/esp-hal-wifimanager sim/run.sh local wifimanager checkout # SIM_BLE=tcp:localhost:9544 sim/run.sh BLE through a Bumble controller (sim/ble_*.py) # SIM_HW=v3 sim/run.sh v3 hardware (HD44780 behind the shift registers) instead of v4 -# SIM_BUILD_ONLY=1, SIM_HTTP_PORT/SIM_UART1_PORT/SIM_CONTROL_PORT: used by sim/monkey.py +# SIM_CLIPPY=1 sim/run.sh clippy the sim build (it needs the patched esp-hal) +# SIM_BUILD_ONLY=1, SIM_HTTP_PORT/SIM_UART1_PORT/SIM_CONTROL_PORT/SIM_TAP_PORT: ports # Extra arguments go to esp-emu (e.g. --timeout 60s). set -eu cd "$(dirname "$0")/.." @@ -34,17 +36,23 @@ if [ -n "${SIM_WIFIMANAGER:-}" ]; then # a local version differing from the lockfile's would otherwise be ignored by the patch cargo update -q -p esp-hal-wifimanager $PATCHES fi +if [ -n "${SIM_CLIPPY:-}" ]; then + # shellcheck disable=SC2086 + ESP_SIM_TAP=1 CARGO_TARGET_DIR=$T cargo clippy --no-default-features --features $HW,sim $PATCHES -- -D warnings + exit +fi # shellcheck disable=SC2086 -CARGO_TARGET_DIR=$T cargo build -r --no-default-features --features $HW,sim $PATCHES +ESP_SIM_TAP=1 CARGO_TARGET_DIR=$T cargo build -r --no-default-features --features $HW,sim $PATCHES cp "$T/Cargo.lock.bak" Cargo.lock trap - EXIT ELF=$T/riscv32imc-unknown-none-elf/release/fkm-firmware espflash save-image --chip esp32c3 --merge --partition-table partitions.csv "$ELF" "$T/sim.bin" +# Wi-Fi + server url seeded into NVS, as provisioning would store them +[ -n "${SIM_PROVISION:-}" ] || python3 sim/bus.py nvs "$T/sim.bin" "${SIM_WS_URL:-}" # SIM_BUILD_ONLY=1: stop here (sim/monkey.py copies $T/sim.bin and the ELF per instance) [ -z "${SIM_BUILD_ONLY:-}" ] || exit 0 # firmware port 80 (its own AP while provisioning) on host :18080; `reset` on :5557 keeps flash -exec esp-emu --chip esp32c3 --firmware "$T/sim.bin" --elf "$ELF" \ - --net "user,hostfwd=tcp:127.0.0.1:${SIM_HTTP_PORT:-18080}-:80" \ - --uart1-tcp "127.0.0.1:${SIM_UART1_PORT:-5556}" \ - --control-tcp "127.0.0.1:${SIM_CONTROL_PORT:-5557}" ${SIM_BLE:+--ble-hci "$SIM_BLE"} "$@" +exec python3 sim/bus.py run --hw "$HW" --bin "$T/sim.bin" --elf "$ELF" \ + --http "${SIM_HTTP_PORT:-18080}" --uart1 "${SIM_UART1_PORT:-5556}" \ + --control "${SIM_CONTROL_PORT:-5557}" --tap "${SIM_TAP_PORT:-5558}" diff --git a/sim/web.py b/sim/web.py index 51e955b..8315d47 100644 --- a/sim/web.py +++ b/sim/web.py @@ -77,9 +77,9 @@ def main(): server = monkey.Server(Path(args.mc).expanduser(), srv_port, out) server.start() base = 20000 + JOB * 10 - ports = dict(http=base, uart1=base + 1, control=base + 2) + ports = dict(http=base, uart1=base + 1, control=base + 2, tap=base + 3) stackmat = monkey.Stackmat(ports["uart1"]) - emu = monkey.Emu(bin_, elf, ports, out / "firmware.log") + emu = monkey.Emu(bin_, elf, ports, out / "firmware.log", hw=args.hw) hub = Hub() threading.Thread(target=hub.watch, args=(emu,), daemon=True).start() diff --git a/src/battery_v3.rs b/src/battery_v3.rs index 322453f..99c4966 100644 --- a/src/battery_v3.rs +++ b/src/battery_v3.rs @@ -1,9 +1,7 @@ use crate::consts::BATTERY_SEND_INTERVAL_MS; use embassy_time::{Duration, Instant, Timer}; -#[cfg(not(feature = "sim"))] use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation}; -#[cfg(not(feature = "sim"))] type AdcCal = esp_hal::analog::adc::AdcCalCurve>; #[embassy_executor::task] @@ -12,15 +10,10 @@ pub async fn battery_read_task( adc: esp_hal::peripherals::ADC1<'static>, state: crate::state::GlobalState, ) { - #[cfg(not(feature = "sim"))] - let (mut adc, mut adc_pin) = { - let mut adc_config = AdcConfig::new(); - let pin = adc_config.enable_pin_with_cal::<_, AdcCal>(adc_pin, Attenuation::_11dB); - (Adc::new(adc, adc_config).into_async(), pin) - }; - // esp-emu has no ADC: the sim's `bat` command sets the reading - #[cfg(feature = "sim")] - let _ = (adc_pin, adc); + let mut adc_config = AdcConfig::new(); + + let mut adc_pin = adc_config.enable_pin_with_cal::<_, AdcCal>(adc_pin, Attenuation::_11dB); + let mut adc = Adc::new(adc, adc_config).into_async(); let mut battery_start = Instant::now().saturating_add(Duration::from_millis(300)); @@ -38,10 +31,7 @@ pub async fn battery_read_task( continue; } - #[cfg(not(feature = "sim"))] let read = adc.read_oneshot(&mut adc_pin).await; - #[cfg(feature = "sim")] - let read = crate::sim::battery_adc(); let read = smoother.tick(read as f32); #[cfg(feature = "bat_dev_lcd")] diff --git a/src/board.rs b/src/board.rs index 3109d06..0f17b99 100644 --- a/src/board.rs +++ b/src/board.rs @@ -131,10 +131,6 @@ impl Board { .with_frequency(esp_hal::time::Rate::from_khz(400)), ); - // esp-emu models no I2C devices: the sim build runs every I2C device as absent - #[cfg(feature = "sim")] - let i2c = i2c.ok().filter(|_| false).ok_or(()); - let i2c = match i2c { Ok(i2c) => { let i2c = i2c @@ -175,7 +171,6 @@ impl Board { impl Board { pub fn init(peripherals: Peripherals) -> Board { use embedded_hal::digital::OutputPin; - #[cfg(not(feature = "sim"))] use esp_hal::gpio::{Level, Output}; let timg0 = TimerGroup::new(peripherals.TIMG0); @@ -201,19 +196,9 @@ impl Board { InputConfig::default().with_pull(Pull::Down), ); - #[cfg(not(feature = "sim"))] - type ShiftOut = Output<'static>; - #[cfg(not(feature = "sim"))] - let (shifter_data_pin, shifter_clk_pin, shifter_latch_pin) = ( - Output::new(peripherals.GPIO10, Level::Low, Default::default()), - Output::new(peripherals.GPIO21, Level::Low, Default::default()), - Output::new(peripherals.GPIO1, Level::Low, Default::default()), - ); - // esp-emu can't observe GPIOs: the sim decodes the chain (and the LCD behind it) itself - #[cfg(feature = "sim")] - type ShiftOut = crate::sim::ShiftPin; - #[cfg(feature = "sim")] - let (shifter_data_pin, shifter_clk_pin, shifter_latch_pin) = crate::sim::shift_pins(); + let shifter_data_pin = Output::new(peripherals.GPIO10, Level::Low, Default::default()); + let shifter_latch_pin = Output::new(peripherals.GPIO1, Level::Low, Default::default()); + let shifter_clk_pin = Output::new(peripherals.GPIO21, Level::Low, Default::default()); let adv_shift_reg = adv_shift_registers::AdvancedShiftRegister::<2, _>::new( shifter_data_pin, @@ -222,7 +207,7 @@ impl Board { 0, ); static SHIFT_REG: static_cell::StaticCell< - adv_shift_registers::AdvancedShiftRegister<2, ShiftOut>, + adv_shift_registers::AdvancedShiftRegister<2, Output<'static>>, > = static_cell::StaticCell::new(); let adv_shift_reg = SHIFT_REG.init(adv_shift_reg); diff --git a/src/buttons.rs b/src/buttons.rs index d444bc9..e207c90 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -71,10 +71,6 @@ async fn button_loop(state: GlobalState, mut inputs: ButtonInputs) { #[allow(unused_mut)] let mut extra = 0u8; - #[cfg(feature = "sim")] - { - extra |= crate::sim::button_mask(); - } #[cfg(feature = "e2e")] { if let Some((btn_idx, press_ms)) = state.e2e.buttons_sig.try_take() { diff --git a/src/main.rs b/src/main.rs index 39bbad7..042c8cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,8 +91,6 @@ async fn main(spawner: Spawner) { set_brownout_detection(false); let board = Board::init(peripherals); - #[cfg(feature = "sim")] - let sim_rx = sim::init(); esp_rtos::start(board.timg1.timer0, board.sw_interrupt); FkmLogger::set_logger(); spawn_task( @@ -186,13 +184,11 @@ async fn main(spawner: Spawner) { battery_v3::battery_read_task(board.battery, board.adc1, global_state), ); #[cfg(feature = "sim")] - if let Some(rx) = sim_rx { - spawn_task( - &spawner, - "sim::command_task", - sim::command_task(rx, global_state), - ); - } + spawn_task( + &spawner, + "sim::command_task", + sim::command_task(global_state), + ); #[cfg(feature = "v4")] spawn_task( &spawner, @@ -286,9 +282,6 @@ async fn main(spawner: Spawner) { } } - #[cfg(feature = "sim")] - sim::seed_wifi(nvs).await; - Timer::after_millis(500).await; // boxed (boot only, freed after): kept in main's state this ~6 KB future would hold static RAM, // and with it main stack, for the whole runtime diff --git a/src/rfid.rs b/src/rfid.rs index da5cd6b..402e432 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -84,8 +84,8 @@ pub async fn rfid_task( esp_hal_mfrc522::Mfrc522::new(esp_hal_mfrc522::SpiInterface::new(spi)) }; - // e2e and sim cards are simulated: there is no reader to bring up - if !cfg!(any(feature = "e2e", feature = "sim")) { + // e2e cards are simulated: there is no reader to bring up + if !cfg!(feature = "e2e") { init_until_ready(&mut mfrc522).await; log::debug!("PCD ver: {:?}", mfrc522.version().await); } @@ -160,7 +160,7 @@ struct Card { selected: Option, } -/// The next scanned card: from the reader, or the simulated e2e/sim card source. +/// The next scanned card: from the reader, or the simulated e2e card source. async fn next_card( mfrc522: &mut Mfrc522, global_state: &GlobalState, @@ -176,17 +176,7 @@ async fn next_card( }) } - #[cfg(all(feature = "sim", not(feature = "e2e")))] - { - let _ = (mfrc522, global_state); - let uid = crate::sim::CARD.try_take()?; - Some(Card { - uid, - selected: None, - }) - } - - #[cfg(not(any(feature = "e2e", feature = "sim")))] + #[cfg(not(feature = "e2e"))] { let _ = global_state; if !matches!(mfrc522.is_new_card_present().await, Ok(true)) { diff --git a/src/sim.rs b/src/sim.rs index 4c162c9..b793980 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -1,449 +1,39 @@ //! esp-emu simulation build (feature `sim`, see sim/README.md). //! -//! The emulator has no I2C devices, no GPIO input and prints only UART0, so this module stands -//! in for them: logs go to UART0, the display is faked at its bus (v4: an I2C SSD1309, v3: the -//! shift register chain driving an HD44780) and printed as text, and stdin (forwarded to UART0 -//! RX) drives the buttons, cards and battery. +//! The firmware runs its real drivers: the esp-sim-patches bus tap (so `sim` builds need the +//! patched esp-hal, see sim/run.sh) hands GPIO, I2C, SPI and ADC traffic to device models on +//! the host (sim/bus.py). This module only adds what the host can't see: a machine-readable +//! state line and a Wi-Fi radio switch, read from the tap's console input. #[cfg(feature = "timer-func")] compile_error!("feature `sim` needs the UART stackmat (--no-default-features)"); -#[cfg(feature = "v3")] -mod hd44780; - use crate::state::GlobalState; -use core::{cell::RefCell, fmt::Write}; -use embassy_sync::blocking_mutex::{CriticalSectionMutex, raw::CriticalSectionRawMutex}; -use embassy_sync::signal::Signal; -use embassy_time::{Duration, Instant, Timer}; -#[cfg(feature = "v4")] -use embedded_hal::i2c::Operation; -#[cfg(feature = "v4")] -use esp_hal::i2c::master::{AcknowledgeCheckFailedReason, Error as I2cError}; -use esp_hal::{ - Async, Blocking, - uart::{Config, Uart, UartRx, UartTx}, -}; +use core::fmt::Write; +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, signal::Signal}; +use embassy_time::{Instant, Timer}; -/// Scanned by the fake RFID reader (`card `). -pub static CARD: Signal = Signal::new(); /// `wifi off|on`: `true` stops the radio (driver torn down), `false` restarts it pub static WIFI: Signal = Signal::new(); -/// `oled raw` / `lcd raw`: frames as `@oled ` / `@lcd ...` every 20 ms (sim/web.py) -/// instead of text art every 200 ms -static RAW: portable_atomic::AtomicBool = portable_atomic::AtomicBool::new(false); - -static TX: CriticalSectionMutex>>> = - CriticalSectionMutex::new(RefCell::new(None)); -/// Release instant (µs) of each held button. -static BUTTONS: CriticalSectionMutex> = - CriticalSectionMutex::new(RefCell::new([0; 4])); -#[cfg(feature = "v4")] -static OLED: CriticalSectionMutex> = CriticalSectionMutex::new(RefCell::new(Oled { - gram: [0; 1024], - page: 0, - col: 0, - dirty: false, -})); - -/// Takes UART0 for logs (TX) and commands (RX). Call before the first log line. -pub fn init() -> Option> { - // SAFETY: nothing else in the firmware uses UART0 (esp-println isn't used in sim builds) - let uart0 = unsafe { esp_hal::peripherals::UART0::steal() }; - let (rx, tx) = Uart::new(uart0, Config::default()).ok()?.split(); - TX.lock(|t| *t.borrow_mut() = Some(tx)); - Some(rx.into_async()) -} - -pub fn print(args: core::fmt::Arguments) { - struct W<'a>(&'a mut UartTx<'static, Blocking>); - impl Write for W<'_> { - fn write_str(&mut self, s: &str) -> core::fmt::Result { - let mut b = s.as_bytes(); - while !b.is_empty() { - let n = self.0.write(b).map_err(|_| core::fmt::Error)?; - b = &b[n..]; - } - Ok(()) - } - } - TX.lock(|t| { - if let Some(tx) = t.borrow_mut().as_mut() { - _ = W(tx).write_fmt(args); - _ = W(tx).write_str("\r\n"); - } - }); -} - -/// Mask of the buttons currently held by `btn` commands (bit 0 = first button). -pub fn button_mask() -> u8 { - let now = Instant::now().as_micros(); - BUTTONS.lock(|b| { - b.borrow() - .iter() - .enumerate() - .filter(|(_, until)| **until > now) - .fold(0, |m, (i, _)| m | 1 << i) - }) -} - -/// Seeds the emulator's Wi-Fi (`myssid`/`mypassword`) and server url into NVS, so the -/// wifimanager connects right away instead of starting AP/BLE provisioning -/// (`SIM_PROVISION=1` at build time skips this to test provisioning). -pub async fn seed_wifi(nvs: &esp_hal_wifimanager::Nvs) { - if option_env!("SIM_PROVISION").is_some() - || nvs - .get::(&esp_hal_wifimanager::WIFI_NVS_KEY) - .await - .is_ok() - { - return; - } - // SIM_WS_URL=ws://192.168.4.1:8080 at build time skips mdns (192.168.4.1 = host localhost) - let mut setup = heapless::String::<320>::new(); - _ = match option_env!("SIM_WS_URL") { - Some(url) => write!( - setup, - r#"{{"ssid":"myssid","psk":"mypassword","data":{{"mdns":false,"ws_url":"{url}"}}}}"# - ), - None => write!( - setup, - r#"{{"ssid":"myssid","psk":"mypassword","data":{{"mdns":true,"ws_url":null}}}}"# - ), - }; - if let Err(e) = nvs - .set(&esp_hal_wifimanager::WIFI_NVS_KEY, setup.as_str()) - .await - { - log::error!("[sim] wifi seed failed: {e:?}"); - } -} - -#[cfg(feature = "v4")] -/// The emulated I2C bus: only the SSD1309 (0x3C) answers. -pub fn i2c(address: u8, operations: &mut [Operation<'_>]) -> Result<(), I2cError> { - if address != 0x3C { - return Err(I2cError::AcknowledgeCheckFailed( - AcknowledgeCheckFailedReason::Address, - )); - } - OLED.lock(|o| { - let mut o = o.borrow_mut(); - for op in operations { - match op { - Operation::Write(bytes) => o.write(bytes), - Operation::Read(buf) => buf.fill(0), - } - } - }); - Ok(()) -} - -#[cfg(feature = "v4")] -/// Fake SSD1309: page addressing (what the driver uses) into a 1 KB GRAM. -struct Oled { - gram: [u8; 1024], - page: usize, - col: usize, - dirty: bool, -} - -#[cfg(feature = "v4")] -impl Oled { - fn write(&mut self, bytes: &[u8]) { - match bytes { - // set page + column (the driver sends exactly these before each page's data) - [0x00, page @ 0xB0..=0xB7, lo @ 0x00..=0x0F, hi @ 0x10..=0x17] => { - self.page = usize::from(page & 0x07); - self.col = usize::from(lo | (hi & 0x0F) << 4); - } - [0x40, data @ ..] => { - for &b in data { - if self.col < 128 { - self.gram[self.page * 128 + self.col] = b; - self.col += 1; - } - } - self.dirty = true; - } - _ => {} // other commands (init, contrast, …) don't change the picture - } - } - - fn pixel(&self, x: usize, y: usize) -> bool { - self.gram[y / 8 * 128 + x] & 1 << (y % 8) != 0 - } -} - -#[cfg(feature = "v4")] -/// Prints the OLED (1×2 pixels per character) if it changed since the last print. -fn print_oled() { - let Some(gram) = OLED.lock(|o| { - let mut o = o.borrow_mut(); - core::mem::take(&mut o.dirty).then(|| Oled { ..*o }) - }) else { - return; - }; - - if RAW.load(portable_atomic::Ordering::Relaxed) { - let mut line = heapless::String::<{ 6 + 2048 }>::new(); - _ = line.push_str("@oled "); - for b in gram.gram { - _ = write!(line, "{b:02x}"); - } - print(format_args!("{line}")); - return; - } - - print(format_args!("┌{:─<128}┐", "")); - for row in 0..32 { - let mut line = heapless::String::<{ 128 * 3 + 8 }>::new(); - _ = line.push('│'); - for x in 0..128 { - _ = line.push(match (gram.pixel(x, row * 2), gram.pixel(x, row * 2 + 1)) { - (false, false) => ' ', - (true, false) => '▀', - (false, true) => '▄', - (true, true) => '█', - }); - } - _ = line.push('│'); - print(format_args!("{line}")); - } - print(format_args!("└{:─<128}┘", "")); -} - -/// v3: the 74HC595 chain (data, clock, latch GPIOs) the real `adv_shift_registers` code -/// bit-bangs; the second register's latched outputs drive the emulated HD44780. -#[cfg(feature = "v3")] -pub struct ShiftPin(u8); +/// v3: what lcd_v3 last wrote (its change cache), so the host can compare it with its LCD. #[cfg(feature = "v3")] -const SR_DATA: u8 = 1; -#[cfg(feature = "v3")] -const SR_CLK: u8 = 2; -#[cfg(feature = "v3")] -const SR_LATCH: u8 = 4; - -/// Pin levels (`SR_*` bits) and the shift register contents: plain loads/stores, so a GPIO -/// write costs about what the real one does (all writers run on the one executor). -#[cfg(feature = "v3")] -static SR_PINS: portable_atomic::AtomicU8 = portable_atomic::AtomicU8::new(0); -#[cfg(feature = "v3")] -static SR_BITS: portable_atomic::AtomicU16 = portable_atomic::AtomicU16::new(0); - -#[cfg(feature = "v3")] -static LCD: CriticalSectionMutex> = CriticalSectionMutex::new(RefCell::new(Lcd { - hd: hd44780::Hd44780::new(), - backlight: false, - intended: [[b' '; hd44780::COLS]; 2], - printed: None, - bad_since: None, -})); - -#[cfg(feature = "v3")] -struct Lcd { - hd: hd44780::Hd44780, - backlight: bool, - /// what the firmware believes is shown (lcd_v3's cache after its last write) - intended: [[u8; hd44780::COLS]; 2], - /// last printed frame (visible, cursor, backlight) - printed: Option, - /// since when (ms) the visible text differs from `intended` - bad_since: Option, -} - -#[cfg(feature = "v3")] -type LcdFrame = ([[u8; hd44780::COLS]; 2], Option<(usize, usize)>, bool); +static LCD_TEXT: embassy_sync::blocking_mutex::CriticalSectionMutex< + core::cell::Cell<[[u8; crate::utils::lcd_text::COLS]; 2]>, +> = embassy_sync::blocking_mutex::CriticalSectionMutex::new(core::cell::Cell::new( + [[b' '; crate::utils::lcd_text::COLS]; 2], +)); -/// data, clock, latch #[cfg(feature = "v3")] -pub fn shift_pins() -> (ShiftPin, ShiftPin, ShiftPin) { - (ShiftPin(SR_DATA), ShiftPin(SR_CLK), ShiftPin(SR_LATCH)) +pub fn lcd_intended(rows: &[[u8; crate::utils::lcd_text::COLS]; 2]) { + LCD_TEXT.lock(|t| t.set(*rows)); } -#[cfg(feature = "v3")] -impl embedded_hal::digital::ErrorType for ShiftPin { - type Error = core::convert::Infallible; -} - -#[cfg(feature = "v3")] -impl embedded_hal::digital::OutputPin for ShiftPin { - fn set_low(&mut self) -> Result<(), Self::Error> { - let pins = SR_PINS.load(portable_atomic::Ordering::Relaxed); - SR_PINS.store(pins & !self.0, portable_atomic::Ordering::Relaxed); - Ok(()) - } - - fn set_high(&mut self) -> Result<(), Self::Error> { - use portable_atomic::Ordering::Relaxed; - let pins = SR_PINS.load(Relaxed); - SR_PINS.store(pins | self.0, Relaxed); - if pins & self.0 != 0 { - return Ok(()); - } - if self.0 == SR_CLK { - // 74HC595 chain: the first bit clocked in ends up in bit 0 after 16 clocks - let bits = SR_BITS.load(Relaxed) >> 1 | u16::from(pins & SR_DATA != 0) << 15; - SR_BITS.store(bits, Relaxed); - } else if self.0 == SR_LATCH { - latch(SR_BITS.load(Relaxed) as u8); - } - Ok(()) - } -} - -/// The LCD register's outputs (ShifterPin bit b = mask 0x80 >> b): CS, backlight, RS, E, -/// DB4..DB7. -#[cfg(feature = "v3")] -fn latch(out: u8) { - let now = esp_hal::time::Instant::now() - .duration_since_epoch() - .as_micros(); - LCD.lock(|l| { - let mut l = l.borrow_mut(); - l.backlight = out & 0x40 != 0; - // DB4..DB7 sit on bits 3..0: reversed into DB7..DB4 = bits 3..0 - let db = (out & 0x0F).reverse_bits() >> 4; - l.hd.pins(out & 0x20 != 0, out & 0x10 != 0, db, now); - }); -} - -/// lcd_v3 reports what it wrote; the sim compares that with what the controller shows. -#[cfg(feature = "v3")] -pub fn lcd_intended(rows: &[[u8; hd44780::COLS]; 2]) { - LCD.lock(|l| l.borrow_mut().intended = *rows); -} - -/// ms the visible LCD text has differed from what the firmware wrote (0 = matches). -#[cfg(feature = "v3")] -fn lcd_mismatch_ms() -> u64 { - let now = Instant::now().as_millis(); - LCD.lock(|l| { - let mut l = l.borrow_mut(); - if l.hd.visible() == l.intended { - l.bad_since = None; - 0 - } else { - now - *l.bad_since.get_or_insert(now) - } - }) -} - -/// Prints the LCD if it changed since the last print: `@lcd <32 char codes hex> bl= cur= cg=` -/// in raw mode, a 16×2 box otherwise. -#[cfg(feature = "v3")] -fn print_lcd(force: bool) { - lcd_mismatch_ms(); - let Some((frame, cgram)) = LCD.lock(|l| { - let mut l = l.borrow_mut(); - let frame = (l.hd.visible(), l.hd.cursor_pos(), l.backlight); - (force || l.printed != Some(frame)).then(|| { - l.printed = Some(frame); - (frame, l.hd.cgram) - }) - }) else { - return; - }; - let (rows, cursor, backlight) = frame; - - if RAW.load(portable_atomic::Ordering::Relaxed) { - let mut line = heapless::String::<{ 5 + 64 + 24 + 128 }>::new(); - _ = line.push_str("@lcd "); - for b in rows.as_flattened() { - _ = write!(line, "{b:02x}"); - } - _ = write!(line, " bl={}", u8::from(backlight)); - _ = match cursor { - Some((r, c)) => write!(line, " cur={r},{c}"), - None => write!(line, " cur=-"), - }; - _ = write!(line, " cg="); - for b in cgram { - _ = write!(line, "{b:02x}"); - } - print(format_args!("{line}")); - return; - } - - print(format_args!( - "┌{:─<16}┐{}", - "", - if backlight { "" } else { " (backlight off)" } - )); - for row in rows { - let mut line = heapless::String::<{ 16 * 3 + 8 }>::new(); - _ = line.push('│'); - for c in row { - _ = line.push(match c { - 0x20..=0x7E => char::from(c), - 0x00..=0x07 => '▯', // custom (CGRAM) character - _ => '▒', - }); - } - _ = line.push('│'); - print(format_args!("{line}")); - } - print(format_args!("└{:─<16}┘", "")); -} - -/// `lcd glitch|drop|reset|slow |stats`: faults a real module can suffer, and the bus timing. -#[cfg(feature = "v3")] -fn lcd_command(arg: Option<&str>, n: Option) -> bool { - LCD.lock(|l| { - let hd = &mut l.borrow_mut().hd; - match (arg, n) { - // a lost E edge: every later byte pairs the wrong nibbles - (Some("glitch"), _) => hd.skip_edges = 1, - // one write lost (noise on E) - (Some("drop"), _) => hd.skip_transfers = 1, - // brownout of the module while the MCU keeps running - (Some("reset"), _) => hd.reset(), - // execution times × n: esp-emu runs the bit-banging slower than a real C3 does - (Some("slow"), Some(n @ 1..=100)) => hd.slow = n, - (Some("stats"), _) => { - print(format_args!( - "lcd: {} writes ignored while busy (last: {:?} = (RS, byte, us early)), \ - least slack {} us (since the last stats)", - hd.busy_drops, hd.last_drop, hd.min_slack - )); - hd.busy_drops = 0; - hd.last_drop = None; - hd.min_slack = i64::MAX; - } - _ => return false, - } - true - }) -} - -const HELP: &str = "sim commands: btn <1-4> [ms] | card | bat <0-100> | screen | state | wifi off|on | help (btn 3 = submit)"; -#[cfg(feature = "v3")] -const HELP_LCD: &str = - " lcd glitch|drop|reset (faults) | lcd slow <1-100> (controller timing) | lcd stats | lcd raw"; - -/// Prints the display if it changed (`force`: anyway). -fn print_display(force: bool) { - #[cfg(feature = "v4")] - { - if force { - OLED.lock(|o| o.borrow_mut().dirty = true); - } - print_oled(); - } - #[cfg(feature = "v3")] - print_lcd(force); -} - -fn help() { - print(format_args!("{HELP}")); - #[cfg(feature = "v3")] - print(format_args!("{HELP_LCD}")); -} +const HELP: &str = "firmware sim commands: state | wifi off|on"; /// One machine-readable line for sim/monkey.py: `@state key=value ...` (`-` = none). fn print_state(state: GlobalState) { - let mut line = heapless::String::<512>::new(); + let mut line = heapless::String::<640>::new(); let opt = |v: Option| v.map_or(-1, |v| v as i64); state.solve.read(|s| { _ = write!( @@ -495,112 +85,47 @@ fn print_state(state: GlobalState) { } _ = write!( line, - " timer={:?} held={} up={} id={:X}", + " timer={:?} up={} id={:X}", state.timer.try_get(), - button_mask(), Instant::now().as_millis(), crate::utils::get_efuse_u32() ); #[cfg(feature = "v3")] { - _ = write!(line, " lcd={}", lcd_mismatch_ms()); + _ = write!(line, " lcdtext="); + for b in LCD_TEXT.lock(|t| t.get()).as_flattened() { + _ = write!(line, "{b:02x}"); + } } - print(format_args!("{line}")); + esp_println::println!("{line}"); } -/// Reads stdin commands and prints the OLED when it changes (every 200 ms, 20 ms in raw mode). +/// Reads console commands (polled from the tap). #[embassy_executor::task] -pub async fn command_task(mut rx: UartRx<'static, Async>, state: GlobalState) { - use embassy_futures::select::{Either, select}; - - set_battery(state, 100); - help(); - +pub async fn command_task(state: GlobalState) { let mut line = heapless::Vec::::new(); - let mut buf = [0u8; 32]; + let mut buf = [0u8; 64]; loop { - match select( - rx.read_async(&mut buf), - Timer::after(Duration::from_millis( - if RAW.load(portable_atomic::Ordering::Relaxed) { - 20 - } else { - 200 - }, - )), - ) - .await - { - Either::First(Ok(n)) => { - for &b in &buf[..n] { - if b == b'\n' || b == b'\r' { - if !line.is_empty() { - command(core::str::from_utf8(&line).unwrap_or(""), state); - } - line.clear(); - } else if line.push(b).is_err() { - line.clear(); // over-long garbage: drop the line - } + Timer::after_millis(10).await; + let n = esp_hal::sim_tap::console(&mut buf); + for &b in &buf[..n] { + if b == b'\n' || b == b'\r' { + if !line.is_empty() { + command(core::str::from_utf8(&line).unwrap_or(""), state); } + line.clear(); + } else if line.push(b).is_err() { + line.clear(); // over-long garbage: drop the line } - Either::First(Err(_)) => line.clear(), - Either::Second(()) => print_display(false), } } } fn command(line: &str, state: GlobalState) { - let mut words = line.split_whitespace(); - let (cmd, a, b) = (words.next(), words.next(), words.next()); - let num = |s: Option<&str>| s.and_then(|s| s.parse::().ok()); - - match (cmd, num(a)) { - (Some("btn"), Some(n @ 1..=4)) => { - let until = Instant::now().as_micros() + num(b).unwrap_or(150) * 1000; - BUTTONS.lock(|bt| bt.borrow_mut()[n as usize - 1] = until); - } - (Some("card"), Some(uid)) => CARD.signal(u128::from(uid)), - (Some("bat"), Some(pct @ 0..=100)) => set_battery(state, pct as u8), - (Some("screen"), _) => print_display(true), - (Some("state"), _) => print_state(state), - (Some("oled" | "lcd"), _) if a == Some("raw") => { - RAW.store(true, portable_atomic::Ordering::Relaxed); - print_display(true); - } - #[cfg(feature = "v3")] - (Some("lcd"), _) if lcd_command(a, num(b)) => {} - (Some("wifi"), _) if a == Some("off") => WIFI.signal(true), - (Some("wifi"), _) if a == Some("on") => WIFI.signal(false), - _ => { - print(format_args!("? {line}")); - help(); - } + match line.trim() { + "state" => print_state(state), + "wifi off" => WIFI.signal(true), + "wifi on" => WIFI.signal(false), + _ => esp_println::println!("? {line}\r\n{HELP}"), } } - -/// v4: the battery gauge doesn't exist in the emulator; the first value also unblocks the boot -/// screen. -#[cfg(feature = "v4")] -fn set_battery(state: GlobalState, percent: u8) { - state.battery.write(|b| b.percent = percent); - state.battery_ready.signal(()); -} - -/// v3: the raw ADC reading battery_v3 sees. -#[cfg(feature = "v3")] -static BATTERY_ADC: portable_atomic::AtomicU16 = portable_atomic::AtomicU16::new(0); - -#[cfg(feature = "v3")] -pub fn battery_adc() -> u16 { - BATTERY_ADC.load(portable_atomic::Ordering::Relaxed) -} - -/// v3: the ADC reading for `percent` (the lowest one, plus a margin for the smoother). -#[cfg(feature = "v3")] -fn set_battery(_: GlobalState, percent: u8) { - use crate::utils::battery::{adc_to_mv, percent as pct}; - let raw = (0..4096u16) - .find(|&raw| pct(adc_to_mv(f64::from(raw))) >= percent) - .unwrap_or(4095); - BATTERY_ADC.store(raw.saturating_add(2), portable_atomic::Ordering::Relaxed); -} diff --git a/src/sim/hd44780.rs b/src/sim/hd44780.rs deleted file mode 100644 index d9559a7..0000000 --- a/src/sim/hd44780.rs +++ /dev/null @@ -1,394 +0,0 @@ -//! HD44780 character LCD controller as seen from its pins (pure, host-tested): RS/E/DB7–DB4 -//! edges in, DDRAM/CGRAM and the visible 16×2 window out. Models what makes real modules show -//! wrong text: the 8-bit power-on state, 4-bit nibble pairing, and instructions that arrive -//! while the controller is still busy (ignored, as the datasheet allows no writes while BF = 1). - -/// Execution times (datasheet, fosc = 270 kHz). -const EXEC_US: u64 = 37; -const EXEC_DATA_US: u64 = 41; -const EXEC_CLEAR_US: u64 = 1520; - -pub const COLS: usize = 16; -const LINE: usize = 40; - -#[derive(Clone, Copy)] -pub struct Hd44780 { - pub ddram: [[u8; LINE]; 2], - pub cgram: [u8; 64], - /// address counter (DDRAM: 0x00–0x27 / 0x40–0x67, CGRAM: 0x00–0x3F) - pub ac: u8, - pub cgram_mode: bool, - pub eight_bit: bool, - pub two_lines: bool, - pub display_on: bool, - pub cursor: bool, - pub blink: bool, - increment: bool, - shift_on_write: bool, - /// display shift (characters, 0..40) - shift: usize, - /// 4-bit mode: high nibble of the byte in progress, and how early (µs) it came if busy - high: Option<(u8, Option)>, - en: bool, - busy_until: u64, - /// instructions/data ignored because they arrived while busy - pub busy_drops: u32, - /// the last one ignored: (RS, byte, µs too early) - pub last_drop: Option<(bool, u8, u64)>, - /// least time between the controller getting ready and the next transfer (µs, < 0 = early) - pub min_slack: i64, - /// fault injection: E falling edges to ignore (desyncs the nibble pairing) - pub skip_edges: u8, - /// fault injection: complete transfers to ignore - pub skip_transfers: u8, - /// execution times × this (a slower controller, or a faster MCU than the emulated one) - pub slow: u64, -} - -impl Hd44780 { - /// The state after the internal power-on reset: 8-bit, one line, display off, cleared. - pub const fn new() -> Self { - Self { - ddram: [[b' '; LINE]; 2], - cgram: [0; 64], - ac: 0, - cgram_mode: false, - eight_bit: true, - two_lines: false, - display_on: false, - cursor: false, - blink: false, - increment: true, - shift_on_write: false, - shift: 0, - high: None, - en: false, - busy_until: 0, - busy_drops: 0, - last_drop: None, - min_slack: i64::MAX, - skip_edges: 0, - skip_transfers: 0, - slow: 1, - } - } - - /// Power-on (brownout) reset; the statistics survive. - pub fn reset(&mut self) { - *self = Self { - busy_drops: self.busy_drops, - last_drop: self.last_drop, - min_slack: self.min_slack, - slow: self.slow, - ..Self::new() - }; - } - - /// Pin levels at `now_us`; data is latched on E's falling edge. `db` = DB7..DB4 as bits 3..0. - pub fn pins(&mut self, rs: bool, en: bool, db: u8, now_us: u64) { - let falling = self.en && !en; - self.en = en; - if !falling { - return; - } - if self.skip_edges > 0 { - self.skip_edges -= 1; - return; - } - let db = db & 0x0F; - // a transfer (its first E pulse) that starts while busy is ignored: no writes while BF = 1 - let busy = if self.high.is_none() { - self.min_slack = self.min_slack.min(now_us as i64 - self.busy_until as i64); - self.busy_until - .checked_sub(now_us) - .filter(|&early| early > 0) - } else { - None - }; - if self.eight_bit { - // DB3..DB0 of a 4-bit module float high (internal pull-ups) - self.transfer(rs, db << 4 | 0x0F, busy, now_us); - } else if let Some((high, busy)) = self.high.take() { - self.transfer(rs, high << 4 | db, busy, now_us); - } else { - self.high = Some((db, busy)); - } - } - - fn transfer(&mut self, rs: bool, v: u8, busy: Option, now_us: u64) { - if self.skip_transfers > 0 { - self.skip_transfers -= 1; - return; - } - if let Some(early) = busy { - self.busy_drops = self.busy_drops.saturating_add(1); - self.last_drop = Some((rs, v, early)); - return; - } - let exec = if rs { - self.write(v); - EXEC_DATA_US - } else { - self.instruction(v) - }; - self.busy_until = now_us + exec * self.slow; - } - - fn instruction(&mut self, v: u8) -> u64 { - match v { - 0x80.. => { - self.cgram_mode = false; - self.ac = v & 0x7F; - } - 0x40.. => { - self.cgram_mode = true; - self.ac = v & 0x3F; - } - 0x20.. => { - self.eight_bit = v & 0x10 != 0; - self.two_lines = v & 0x08 != 0; - self.high = None; - } - 0x10.. => { - let right = v & 0x04 != 0; - if v & 0x08 != 0 { - self.shift = (self.shift + if right { LINE - 1 } else { 1 }) % LINE; - } else { - self.step(right); - } - } - 0x08.. => { - self.display_on = v & 0x04 != 0; - self.cursor = v & 0x02 != 0; - self.blink = v & 0x01 != 0; - } - 0x04.. => { - self.increment = v & 0x02 != 0; - self.shift_on_write = v & 0x01 != 0; - } - 0x02.. => { - self.home(); - return EXEC_CLEAR_US; - } - 0x01 => { - self.ddram = [[b' '; LINE]; 2]; - self.increment = true; - self.home(); - return EXEC_CLEAR_US; - } - 0x00 => {} - } - EXEC_US - } - - fn home(&mut self) { - self.ac = 0; - self.cgram_mode = false; - self.shift = 0; - } - - fn write(&mut self, v: u8) { - if self.cgram_mode { - self.cgram[usize::from(self.ac & 0x3F)] = v & 0x1F; - self.ac = (self.ac + 1) & 0x3F; - return; - } - if let Some((line, col)) = self.cell(self.ac) { - self.ddram[line][col] = v; - } - self.step(self.increment); - if self.shift_on_write { - self.shift = (self.shift + if self.increment { 1 } else { LINE - 1 }) % LINE; - } - } - - /// DDRAM address → (line, column); unused addresses (0x28–0x3F, 0x68–0x7F) hold nothing. - fn cell(&self, ac: u8) -> Option<(usize, usize)> { - let (line, col) = if self.two_lines { - (usize::from(ac >= 0x40), usize::from(ac & 0x3F)) - } else { - (0, usize::from(ac)) - }; - (col < LINE).then_some((line, col)) - } - - /// Moves the address counter like the controller does (wrapping between the lines). - fn step(&mut self, forward: bool) { - if self.cgram_mode { - self.ac = if forward { - self.ac + 1 - } else { - self.ac.wrapping_sub(1) - } & 0x3F; - return; - } - self.ac = match (self.two_lines, forward, self.ac) { - (true, true, 0x27) => 0x40, - (true, true, 0x67) => 0x00, - (true, false, 0x40) => 0x27, - (true, false, 0x00) => 0x67, - (false, true, 0x4F) => 0x00, - (false, false, 0x00) => 0x4F, - (_, true, ac) => ac.wrapping_add(1) & 0x7F, - (_, false, ac) => ac.wrapping_sub(1) & 0x7F, - }; - } - - /// The visible characters (codes, not glyphs); blank while the display is off. - pub fn visible(&self) -> [[u8; COLS]; 2] { - let mut out = [[b' '; COLS]; 2]; - if !self.display_on { - return out; - } - for (row, line) in out.iter_mut().enumerate() { - if row == 1 && !self.two_lines { - break; - } - for (col, c) in line.iter_mut().enumerate() { - *c = self.ddram[row][(col + self.shift) % LINE]; - } - } - out - } - - /// Visible cursor position (row, col), if the cursor or blink is on. - pub fn cursor_pos(&self) -> Option<(usize, usize)> { - if !(self.display_on && (self.cursor || self.blink)) || self.cgram_mode { - return None; - } - let (line, col) = self.cell(self.ac)?; - let col = (col + LINE - self.shift) % LINE; - (col < COLS).then_some((line, col)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Drives the pins like ag-lcd-async's 4-bit `send`, one E pulse per nibble, `gap` µs apart. - struct Bus { - lcd: Hd44780, - t: u64, - } - - impl Bus { - fn nibble(&mut self, rs: bool, n: u8, gap: u64) { - self.t += gap; - self.lcd.pins(rs, true, n, self.t); - self.lcd.pins(rs, false, n, self.t); - } - fn byte(&mut self, rs: bool, v: u8, gap: u64) { - self.nibble(rs, v >> 4, gap); - self.nibble(rs, v & 0x0F, 1); - } - fn init(&mut self) { - for n in [3, 3, 3, 2] { - self.nibble(false, n, 5000); - } - for v in [0x28, 0x0C, 0x06, 0x01] { - self.byte(false, v, 5000); - } - } - fn print(&mut self, addr: u8, s: &str, gap: u64) { - self.byte(false, 0x80 | addr, 5000); - for b in s.bytes() { - self.byte(true, b, gap); - } - } - fn row(&self, r: usize) -> String { - String::from_utf8(self.lcd.visible()[r].to_vec()).unwrap() - } - } - - fn bus() -> Bus { - let mut b = Bus { - lcd: Hd44780::new(), - t: 0, - }; - b.init(); - b - } - - #[test] - fn four_bit_init_and_text() { - let mut b = bus(); - b.print(0x00, "Hello", 500); - b.print(0x45, "World", 500); - assert_eq!(b.row(0), "Hello "); - assert_eq!(b.row(1), " World "); - assert_eq!(b.lcd.busy_drops, 0); - } - - #[test] - fn power_on_state_is_blank_and_eight_bit() { - let mut b = Bus { - lcd: Hd44780::new(), - t: 0, - }; - b.print(0x00, "x", 500); - assert_eq!(b.row(0), " ".repeat(16)); - assert!(b.lcd.eight_bit); - } - - #[test] - fn command_while_busy_is_ignored() { - let mut b = bus(); - b.print(0x00, "0123456789abcdef", 500); - // the row-1 address right after the last character: the text lands off screen - b.byte(false, 0xC0, 20); - for c in b"NEW TEXT " { - b.byte(true, *c, 500); - } - assert_eq!(b.lcd.busy_drops, 1); - assert_eq!(b.row(1), " ".repeat(16)); - assert_eq!(b.lcd.ddram[0][16..24], *b"NEW TEXT"); - } - - #[test] - fn lost_nibble_desyncs_until_reinit() { - let mut b = bus(); - b.lcd.skip_edges = 1; - b.print(0x00, "abcd", 500); - assert_ne!(b.row(0), "abcd "); - b.init(); - b.print(0x00, "abcd", 500); - assert_eq!(b.row(0), "abcd "); - } - - #[test] - fn reinit_without_clear_recovers_from_any_nibble_phase() { - for skip in [0, 1] { - let mut b = bus(); - b.print(0x00, "keep", 500); - b.lcd.skip_edges = skip; - b.nibble(false, 0x0, 500); // leaves a half byte pending for skip = 0 - for n in [3, 3, 3, 2] { - b.nibble(false, n, 5000); - } - for v in [0x28, 0x0C, 0x06, 0x02] { - b.byte(false, v, 5000); - } - b.print(0x40, "ok", 500); - assert_eq!(b.row(0), "keep ", "skip {skip}"); - assert_eq!(b.row(1), "ok ", "skip {skip}"); - } - } - - #[test] - fn brownout_blanks_and_eight_bit_garbles() { - let mut b = bus(); - b.print(0x00, "before", 500); - b.lcd.reset(); - b.print(0x00, "after", 500); - assert!(!b.row(0).contains("after")); - } - - #[test] - fn address_wraps_between_lines() { - let mut b = bus(); - b.print(0x26, "xyz", 500); - assert_eq!(b.lcd.ddram[0][0x26..], *b"xy"); - assert_eq!(b.lcd.ddram[1][0], b'z'); - } -} diff --git a/src/utils/buttons.rs b/src/utils/buttons.rs index 23f99a7..ecb6f4f 100644 --- a/src/utils/buttons.rs +++ b/src/utils/buttons.rs @@ -56,11 +56,6 @@ impl ButtonInputs { } fn sample(&self) -> u8 { - // esp-emu reads every input pin high (no pulls): sim presses only come from stdin - if cfg!(feature = "sim") { - return 0; - } - let mut mask = 0; #[cfg(feature = "v4")] diff --git a/src/utils/logger.rs b/src/utils/logger.rs index c9c486f..a10f7e8 100644 --- a/src/utils/logger.rs +++ b/src/utils/logger.rs @@ -202,16 +202,7 @@ impl log::Log for FkmLogger { log::Level::Trace => CYAN, }; - #[cfg(not(feature = "sim"))] esp_println::println!("{}{} - {}{}", color, record.level(), record.args(), RESET); - #[cfg(feature = "sim")] - crate::sim::print(format_args!( - "{}{} - {}{}", - color, - record.level(), - record.args(), - RESET - )); // bat_dev_lcd/qa keep logs on the serial port only if !cfg!(any(feature = "bat_dev_lcd", feature = "qa")) diff --git a/src/utils/shared_i2c.rs b/src/utils/shared_i2c.rs index 610f599..532a6c0 100644 --- a/src/utils/shared_i2c.rs +++ b/src/utils/shared_i2c.rs @@ -19,9 +19,6 @@ impl embedded_hal_async::i2c::I2c for SharedI2C { operations: &mut [embedded_hal::i2c::Operation<'_>], ) -> Result<(), Self::Error> { let Some(i2c) = self.inner else { - #[cfg(feature = "sim")] - return crate::sim::i2c(address, operations); - #[cfg(not(feature = "sim"))] return Err(esp_hal::i2c::master::Error::Timeout); }; From eaf4334eab0f8116f800633ff335c403aa6ebe96 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:11:12 +0200 Subject: [PATCH 108/132] test: resend confirming a resolved incident is accepted --- sim/competition.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sim/competition.py b/sim/competition.py index 646d417..c6594ad 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -761,9 +761,14 @@ def on_server(): if s and s.get("err") != "1": break if on_server(): - # entered, but the answer got lost: a retap is refused ("already entered") + # entered, but the answer got lost: a retap is refused ("already entered"), + # unless it confirms a resolved incident (FKMTime takes the judge again) self.dismiss(self.st()) + self.connected() self.tap(C) + if row: + self.poll(self.idle, 16000, "resend of a resolved incident accepted") + break self.poll(lambda s: s.get("err") == "1", 16000, "resend refused (already entered)") self.dismiss(self.st()) self.to_idle() From 8f0fc34402ee8367a65d09fc32ca692ac87f7cec Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:29:50 +0200 Subject: [PATCH 109/132] feat: sim lcd cpu warp, tap sequence bytes, quicker esp-emu batches --- sim/README.md | 2 +- sim/bus.py | 56 ++++++++++++++++++++++++------------- sim/devices.py | 6 ++-- sim/scenarios/lcd-heal.json | 4 +-- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/sim/README.md b/sim/README.md index 89851ca..1e7054c 100644 --- a/sim/README.md +++ b/sim/README.md @@ -55,7 +55,7 @@ which doesn't get answers through esp-emu's relay on macOS. Cards in micro-conne | `lcd glitch` | one E edge lost (noise): every later byte pairs the wrong nibbles | | `lcd drop` | one write lost | | `lcd reset` | module brownout: back to 8-bit, display off, while the MCU keeps running | -| `lcd slow ` | controller execution times ×n: the emulated bit-banging (through the tap) is slower than a real C3's, so n puts the short gaps where real hardware has them | +| `lcd cpu ` | back-to-back pin writes (no wait between them, < 50 µs apart) count n× closer for the LCD's timing. Through the tap one pin change costs ~2.6 µs of emulated time, ~20× a real C3's bit-banging, so `lcd cpu 20` puts the gap between a character and the next command where real hardware has it (~20 µs, inside the controller's 37–41 µs busy time) | | `lcd stats` | writes lost to the busy time and the least slack seen since the last `stats` | The fuzzer injects the first three on v3; `sim/scenarios/lcd-heal.json` checks each heals. diff --git a/sim/bus.py b/sim/bus.py index 717cfca..0941c4a 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -7,7 +7,7 @@ python3 sim/bus.py nvs [ws_url] # seed Wi-Fi into NVS Interactive commands: btn <1-4> [ms] | card | bat <0-100> | screen | lcd|oled raw | -lcd glitch|drop|reset|slow |stats | rfid error [n] | i2c nack [n]; anything else +lcd glitch|drop|reset|cpu |stats | rfid error [n] | i2c nack [n]; anything else (state, wifi off|on) goes to the firmware's console. """ import argparse @@ -51,6 +51,9 @@ def __init__(self, hw: str, emit): self.nack = {} # i2c address → NACKs left if hw == "v3": self.chain, self.lcd, self.levels, self.cs_low = Chain595(2), HD44780(), {}, False + # the LCD's clock: emulated µs with back-to-back pin writes (< 50 µs apart: no wait + # in between) `cpu`× closer, as fast as a real C3 bit-bangs them + self.cpu, self.lcd_t, self.last_t = 1, 0.0, 0 else: self.oled, self.gauge = SSD1309(), BQ27441() @@ -72,18 +75,30 @@ def power_on(self): self.chain = Chain595(2) # bus traffic - def gpio(self, t_us: int, pin: int, high: bool): + def gpio_batch(self, t: int, p: bytes) -> int: + """A `G` frame's changes (`p` = its payload, `t` = the first change's µs); returns the + last change's µs. v3: GPIO10/21/1 = shift register data/clock/latch.""" if self.hw != "v3": - return - if pin == 10: - self.chain.data = high - elif high and not self.levels.get(pin, False): - if pin == 21: - self.chain.clock() - elif pin == 1: - self.chain.latch() - self.latched(t_us) - self.levels[pin] = high + return t + sum(p[5::2]) + chain, levels, cpu = self.chain, self.levels, self.cpu + dt = t - self.last_t + for i in range(4, len(p) - 1, 2): + if i > 4: + dt = p[i + 1] + t += dt + self.lcd_t += dt / cpu if dt < 50 else dt + pin, high = p[i] & 0x7F, p[i] >> 7 + if pin == 10: + chain.data = high + elif high and not levels.get(pin): + if pin == 21: + chain.sr = (chain.sr >> 1) | (chain.data << 15) # Chain595.clock, inlined + elif pin == 1: + chain.latch() + self.latched(self.lcd_t) + levels[pin] = high + self.last_t = t + return t def latched(self, t_us): # LCD register: ShifterPin bit b = mask 0x80 >> b: CS, backlight, RS, E, DB4..DB7 @@ -173,8 +188,8 @@ def command(self, line: str) -> bool: self.lcd.skip_transfers = 1 # one write lost elif w[1] == "reset": self.lcd.reset() # module brownout while the MCU runs on - elif w[1] == "slow" and num(2): - self.lcd.slow = num(2) + elif w[1] == "cpu" and num(2): + self.cpu = num(2) elif w[1] == "stats": self.emit(f"lcd: {self.lcd.busy_drops} writes ignored while busy (last: {self.lcd.last_drop} " f"= (RS, byte, us early)), least slack {self.lcd.min_slack} us (since the last stats)") @@ -312,10 +327,13 @@ def unwrap(t32): # µs counter (u32) → 64-bit t_last = t32 return t_hi + t32 + seq = b"" + if ty != ord("G") and payload: # requests start with their sequence byte + seq, payload = payload[:1], payload[1:] with self.board.lock: reply = self.frame(ty, payload, unwrap) if reply is not None: - self.send(b"r", reply) + self.send(b"r", seq + reply) def frame(self, ty, p, unwrap): b = self.board @@ -326,9 +344,7 @@ def frame(self, ty, p, unwrap): self.last_q = None self.gap_sum += sum(p[i + 1] for i in range(6, len(p) - 1, 2)) self.gaps += max(0, (len(p) - 6) // 2) - for i in range(4, len(p) - 1, 2): - t += p[i + 1] - b.gpio(t, p[i] & 0x7F, bool(p[i] & 0x80)) + t = b.gpio_batch(t, p) self.stats["gpio"] += (len(p) - 4) // 2 b.saw_time(t) elif ty == ord("Q"): @@ -376,6 +392,8 @@ def __init__(self, bin_: Path, elf: Path, ports: dict, log: Path, efuse: Path = self.lines, self.lock = [], threading.Lock() self.lcd_bad_since, self.lcd_sampled = None, 0.0 extra = ["--efuse", str(efuse)] if efuse else [] + # esp-emu serves sockets between instruction batches: smaller batches, quicker replies + extra += ["--batch-size", os.environ.get("SIM_BATCH", "5000")] if os.environ.get("SIM_BLE"): # e.g. tcp:localhost:9544, a Bumble controller extra += ["--ble-hci", os.environ["SIM_BLE"]] self.proc = subprocess.Popen( @@ -413,7 +431,7 @@ def annotate(self, line: str) -> str: now = time.monotonic() if shown == kv["lcdtext"]: self.lcd_bad_since = None - elif self.lcd_bad_since is None or now - self.lcd_sampled > 1.0: + elif self.lcd_bad_since is None or now - self.lcd_sampled > 3.0: # only back-to-back samples tell it stayed wrong (scrolling rewrites briefly) self.lcd_bad_since = now self.lcd_sampled = now diff --git a/sim/devices.py b/sim/devices.py index b2186bd..4007d79 100644 --- a/sim/devices.py +++ b/sim/devices.py @@ -11,10 +11,10 @@ class HD44780: """The controller as seen from RS/E/DB7..DB4: 8-bit power-on state, 4-bit nibble pairing, DDRAM/CGRAM, entry mode/shift, and transfers that start while it is busy are lost (the datasheet allows no writes while BF = 1). Faults: `skip_edges` (lost E edge → nibble - desync), `skip_transfers` (lost write), `reset()` (module brownout), `slow` (×exec times).""" + desync), `skip_transfers` (lost write), `reset()` (module brownout).""" def __init__(self): - self.slow, self.busy_drops, self.last_drop, self.min_slack = 1, 0, None, None + self.busy_drops, self.last_drop, self.min_slack = 0, None, None self.skip_edges = self.skip_transfers = 0 self.reset() @@ -62,7 +62,7 @@ def transfer(self, rs, v, busy, now_us): ex = EXEC_DATA_US else: ex = self.instruction(v) - self.busy_until = now_us + ex * self.slow + self.busy_until = now_us + ex def instruction(self, v): if v >= 0x80: diff --git a/sim/scenarios/lcd-heal.json b/sim/scenarios/lcd-heal.json index a3f2970..14117c6 100644 --- a/sim/scenarios/lcd-heal.json +++ b/sim/scenarios/lcd-heal.json @@ -1,7 +1,7 @@ -{"seed": 90004, "hw": "v3", "note": "regression (v3): the LCD must show what the firmware wrote again within LCD_STALE_MS after a lost E edge (nibble desync), a lost write, a module brownout (also during a running solve: the timer screen gets no idle ticks), and with a controller slower than the bus (commands sent while busy)", "actions": [ +{"seed": 90004, "hw": "v3", "note": "regression (v3): the LCD must show what the firmware wrote again within LCD_STALE_MS after a lost E edge (nibble desync), a lost write, a module brownout (also during a running solve: the timer screen gets no idle ticks), and at real C3 bus speed (`lcd cpu 20`: a command right after a character arrives while the controller is busy)", "actions": [ [1000, "emu", "lcd glitch"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd drop"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [6000, "expect", "lcd", 0], [10, "emu", "lcd reset"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], -[10, "emu", "lcd slow 4"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd stats"], [10, "emu", "lcd slow 1"], +[10, "emu", "lcd cpu 20"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"], [10, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [6000, "expect", "lcd", 0], [10, "emu", "lcd stats"], [10, "emu", "lcd cpu 1"], [10, "btn", 1, 150], [10, "expect", "scene", "Inspection"], [1500, "stk", "start"], [10, "expect", "scene", "Timer"], [10, "emu", "lcd glitch"], [7000, "expect", "lcd", 0], [10, "stk", "stop"], [10, "expect", "scene", "Finished"], [10, "stk", "reset"], [10, "btn", 3, 3400], [10, "expect", "scene", "WaitingForCompetitor"] ]} From 5cb88c788f90bf533cd2423682253f2cd55b3b40 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:29:50 +0200 Subject: [PATCH 110/132] fix: lcd heals on screens without events too --- src/lcd_v3.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index be09591..6e60379 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -19,13 +19,13 @@ use embassy_time::{Delay, Duration, Instant, Timer, with_timeout}; type Lcd = LcdDisplay; type Text = heapless::String<96>; -const EVENT_TIMEOUT: Duration = Duration::from_secs(30); /// HD44780 instructions and writes take 37–41 µs (~60 µs on a slow 190 kHz controller) and any /// transfer while busy is lost. ag-lcd-async waits only before characters, and the next command /// reaches the controller ~20 µs after a character through the shift registers. const BUSY_US: u64 = 100; -/// Re-sync and rewrite the LCD this often on idle screens: the write-only bus can't see a lost -/// write, a nibble desync or a module brownout, and the change-only cache would keep them. +/// Re-sync and rewrite the LCD this often: the write-only bus can't see a lost write, a nibble +/// desync or a module brownout, and the change-only cache would keep them. Also bounds the +/// wait for events (watchdog heartbeat, screens without live updates). const HEAL_INTERVAL: Duration = Duration::from_secs(2); async fn new_lcd(lcd_shifter: &ShifterValue) -> Lcd { @@ -197,9 +197,9 @@ pub async fn lcd_task( } crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); - // bounded so the heartbeat keeps beating while a live screen waits for its signal + // bounded: the heartbeat keeps beating and the LCD heals while a live screen waits let event = with_timeout( - EVENT_TIMEOUT, + HEAL_INTERVAL, next_event( global_state, &mut timer, From 0accfad7bc864415d2ae5065edeb7d9a8a9f911b Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:28:01 +0200 Subject: [PATCH 111/132] fix: keep a delegate call on its attempt until answered --- src/buttons.rs | 29 +++++++++++++++++++++++++---- src/consts.rs | 2 +- src/state.rs | 5 +++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/buttons.rs b/src/buttons.rs index e207c90..4d153c9 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -32,6 +32,12 @@ static DELEGATE_REQUEST: Signal bool { + DELEGATE_PENDING.load(Ordering::Relaxed) +} + /// The single button being held. Dropped when an action consumes the press, so it gets no /// further hold/up actions. struct Press { @@ -187,7 +193,9 @@ async fn on_up(state: &GlobalState, button: Button) { match button { Button::Third => submit(state).await, Button::Fourth => penalty(state), - Button::Second => state.solve.update(|s| s.delegate_hold = None), + // a sent call keeps its "waiting for delegate" screen until the answer + Button::Second if !delegate_pending() => state.solve.update(|s| s.delegate_hold = None), + Button::Second => {} Button::First => {} } } @@ -671,6 +679,10 @@ fn delegate_send(state: &GlobalState) { async fn delegate_loop(state: GlobalState) { loop { let packet = DELEGATE_REQUEST.wait().await; + let session_id = match &packet { + crate::structs::TimerPacketInner::Solve { session_id, .. } => Some(session_id.clone()), + _ => None, + }; let resp = crate::ws::delegate_request( packet, Duration::from_millis(crate::consts::DELEGATE_TIMEOUT_MS), @@ -680,18 +692,27 @@ async fn delegate_loop(state: GlobalState) { match resp { Ok(resp) => { - state.solve.update(|s| { + // only for the attempt that called (it can't change meanwhile, but a reboot + // restoring another one could) + let applied = state.solve.update(|s| { + s.delegate_hold = None; + if session_id.is_none() || s.session_id != session_id { + return false; + } s.solve_time = Some(resp.solve_time.unwrap_or(s.solve_time.unwrap_or(0))); s.penalty = Some(resp.penalty.unwrap_or(s.penalty.unwrap_or(0) as i64) as i8); s.scene = Scene::Finished; s.time_confirmed = true; s.delegate_used = true; + true }); - if !resp.should_scan_cards { + if !applied { + log::warn!("Delegate answer for another attempt ignored"); + } else if !resp.should_scan_cards { state.reset_solve(true).await; } } - // no answer: drop the "hold" indicator, the solve stays as it was + // no answer: drop the waiting screen, the solve stays as it was Err(_) => state.solve.update(|s| s.delegate_hold = None), } diff --git a/src/consts.rs b/src/consts.rs index 82be841..e39ef3b 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -20,7 +20,7 @@ pub const RFID_RETRY_INIT_MS: u64 = 1500; pub const WS_RETRY_MS: u64 = 1000; /// Total time for a request (send + tagged response); was 5 s send + 5 s response. pub const WS_REQUEST_TIMEOUT_MS: u64 = 10_000; -/// The delegate answers on the web panel, so wait long (the buttons task is blocked). +/// The delegate answers on the web panel, so wait long (the attempt takes no input meanwhile). #[cfg_attr(feature = "qa", allow(dead_code))] pub const DELEGATE_TIMEOUT_MS: u64 = 5 * 60_000; diff --git a/src/state.rs b/src/state.rs index edbc423..ef8ba11 100644 --- a/src/state.rs +++ b/src/state.rs @@ -512,9 +512,10 @@ impl GlobalStateInner { }) } - /// Buttons/cards are ignored while an error, a lost connection or a setup scene is shown. + /// Buttons/cards are ignored while an error, a lost connection, a setup scene or a pending + /// delegate call is shown. pub fn should_skip_other_actions(&self) -> bool { - if self.ui.read(|u| u.error_text.is_some()) { + if self.ui.read(|u| u.error_text.is_some()) || crate::buttons::delegate_pending() { return true; } From 63fa6ade9d8996085811308d8efdd1fd7cefcf1e Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:28:08 +0200 Subject: [PATCH 112/132] fix: confirming tap is no new competitor lookup --- src/rfid.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rfid.rs b/src/rfid.rs index a370f88..bd6d989 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -337,9 +337,9 @@ async fn handle_card( } } - let is_competitor = global_state - .solve - .read(|s| s.current_competitor.is_none_or(|c| c == card_uid as u64)); + // only a card starting an attempt: the server refuses it once the round is over, which must + // not hit the competitor's confirming tap (e.g. of a last attempt a delegate resolved) + let is_competitor = global_state.solve.read(|s| s.current_competitor.is_none()); let resp = crate::ws::request::( crate::structs::TimerPacketInner::CardInfoRequest { From 1fafe3fb621b5d984d5aa26459448e975cfae635 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:49:13 +0200 Subject: [PATCH 113/132] fix: sim button presses timed on the tap's clock only --- Cargo.lock | 4 ---- sim/bus.py | 2 -- 2 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd96f69..3fdcac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,8 +1402,6 @@ dependencies = [ [[package]] name = "esp-hal" version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e3dfd511fed638fe41fdb4595f8c7f0cbb488d0f0cf2bbc063d76d805057cf" dependencies = [ "bitfield", "bytemuck", @@ -1585,8 +1583,6 @@ dependencies = [ [[package]] name = "esp-radio" version = "1.0.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50247f4e91000fc87661e77254126c74acc4535f822ee247f7778807202dc938" dependencies = [ "allocator-api2", "bt-hci", diff --git a/sim/bus.py b/sim/bus.py index 0941c4a..cab040f 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -423,8 +423,6 @@ def annotate(self, line: str) -> str: shown something else than the firmware wrote (`lcdtext`).""" kv = dict(x.split("=", 1) for x in line[7:].split() if "=" in x) with self.board.lock: - if "up" in kv: # emulated ms when the firmware printed it - self.board.saw_time(int(kv["up"]) * 1000) line += f" held={self.board.held_mask()}" if "lcdtext" in kv and self.board.hw == "v3": shown = b"".join(bytes(r) for r in self.board.lcd.visible()).hex() From 76e1ea7b20e472cea40f805b8470e3c31b9ed832 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:51:12 +0200 Subject: [PATCH 114/132] feat: sim web.py --job picks the port block --- sim/web.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sim/web.py b/sim/web.py index 8315d47..14d0f59 100644 --- a/sim/web.py +++ b/sim/web.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """The sim device in a browser: display, buttons, cards, stackmat, battery, Wi-Fi/server/power. - python3 sim/web.py [--hw v3|v4] [--mc ~/projects/micro-connector] [--no-server] [--port 8000] + python3 sim/web.py [--hw v3|v4] [--job N] [--mc ~/projects/micro-connector] [--no-server] [--port 8000] Builds the sim image, starts esp-emu, the fake stackmat and (unless --no-server) a local micro-connector, then serves http://127.0.0.1:8000. Stdlib only: events go out as SSE, @@ -18,7 +18,7 @@ import monkey -JOB = 9 # monkey jobs use 0..; keep ports apart so a campaign can run next to this +JOB = 9 # default port block; monkey jobs use 0..: a campaign can run next to this ANSI = re.compile(r"\x1b\[[0-9;]*m") @@ -64,11 +64,12 @@ def main(): p.add_argument("--no-server", action="store_true") p.add_argument("--port", type=int, default=8000) p.add_argument("--hw", choices=["v3", "v4"], default="v4") + p.add_argument("--job", type=int, default=JOB, help="port block, as monkey.py's --job-base") args = p.parse_args() out = monkey.ROOT / "sim/out/web" out.mkdir(parents=True, exist_ok=True) - srv_port = monkey.SRV_PORT + JOB + srv_port = monkey.SRV_PORT + args.job print("building the sim image…") bin_, elf = monkey.build(srv_port, args.hw) @@ -76,7 +77,7 @@ def main(): if not args.no_server: server = monkey.Server(Path(args.mc).expanduser(), srv_port, out) server.start() - base = 20000 + JOB * 10 + base = 20000 + args.job * 10 ports = dict(http=base, uart1=base + 1, control=base + 2, tap=base + 3) stackmat = monkey.Stackmat(ports["uart1"]) emu = monkey.Emu(bin_, elf, ports, out / "firmware.log", hw=args.hw) From e59ef7b6ee0c0fa1d2c421e8cd236454475f9592 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 21:03:10 +0200 Subject: [PATCH 115/132] fix: sim button holds end on the firmware's clock (v4: the tap reports pulse ends) --- sim/bus.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/sim/bus.py b/sim/bus.py index cab040f..38cb026 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -41,7 +41,7 @@ class Board: def __init__(self, hw: str, emit): self.hw, self.emit, self.lock = hw, emit, threading.Lock() self.tap = None - self.emu_us, self.emu_wall = 0, time.monotonic() # last known emulated time + self.emu_us = 0 # the firmware's clock in its last timed frame (G, Q) self.raw, self.last_print, self.printed = False, 0.0, None self.held = {} # button 1-4 → (start µs, end µs) self.battery = 100 @@ -57,15 +57,18 @@ def __init__(self, hw: str, emit): else: self.oled, self.gauge = SSD1309(), BQ27441() - def now_us(self): - return self.emu_us + int((time.monotonic() - self.emu_wall) * 1e6) - def saw_time(self, us: int): - self.emu_us, self.emu_wall = us, time.monotonic() + self.emu_us = us def held_mask(self): - now = self.now_us() - return sum(1 << (b - 1) for b, (s, e) in self.held.items() if s <= now < e) + """Buttons still held as the firmware sees it (v3: by its clock in the last frame; + v4: pulses it hasn't reported over).""" + if self.hw == "v4": + return sum(1 << (b - 1) for b in self.held) + return sum(1 << (b - 1) for b, (s, e) in self.held.items() if s <= self.emu_us < e) + + def pulse_end(self, pin: int): + self.held.pop(pin + 1, None) def power_on(self): """Power cycle: the peripherals reset with the MCU.""" @@ -163,10 +166,15 @@ def command(self, line: str) -> bool: num = lambda i, d=None: int(w[i], 0) if len(w) > i and w[i].lstrip("-").isdigit() else d if w[0] == "btn" and num(1) in (1, 2, 3, 4): b, ms = num(1), num(2, 150) - start = self.now_us() - self.held[b] = (start, start + ms * 1000) - if self.hw == "v4" and self.tap: # GPIO0..3, high while pressed - self.tap.send(b"g", struct.pack(" Date: Sat, 17 Oct 2026 21:23:32 +0200 Subject: [PATCH 116/132] fix: sim tap resyncs after a cut frame, presses cleared on boot --- sim/bus.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sim/bus.py b/sim/bus.py index 38cb026..822d006 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -28,6 +28,7 @@ ROOT = Path(__file__).resolve().parent.parent EMU = shutil.which("esp-emu") or str(Path.home() / ".local/bin/esp-emu") MARK = 0xF5 +FRAME_TYPES, MAX_FRAME = b"GQISAP", 520 # what the tap sends; its largest is a full G batch CARD_IN_FIELD_S = 3.0 NVS_OFFSET, NVS_SIZE = 0x9000, 0x4000 # partitions.csv NVS_TOOL = os.environ.get("SIM_NVS_TOOL", str(Path.home() / "projects/esp-nvs/target/debug/esp-nvs-partition-tool")) @@ -89,7 +90,8 @@ def gpio_batch(self, t: int, p: bytes) -> int: if i > 4: dt = p[i + 1] t += dt - self.lcd_t += dt / cpu if dt < 50 else dt + if dt >= 0: # (the clock restarts when the firmware reboots) + self.lcd_t += dt / cpu if dt < 50 else dt pin, high = p[i] & 0x7F, p[i] >> 7 if pin == 10: chain.data = high @@ -322,6 +324,10 @@ def run(self): if len(buf) < 4: break n = struct.unpack_from(" MAX_FRAME: + # not a frame (a reset cut the last one short): resync on the next marker + text.append(buf.pop(0)) + continue if len(buf) < 4 + n: break ty, payload = buf[1], bytes(buf[4:4 + n]) @@ -424,6 +430,9 @@ def add(self, line: str): now = time.monotonic() if line.startswith("@state "): line = self.annotate(line) + elif "INFO - Hardware Rev:" in line: # booted: no press survives a reset + with self.board.lock: + self.board.held.clear() self.log.write(f"{now:.3f} {line}\n") with self.lock: self.lines.append((now, line)) From 7eb1817e45ee034e0e196eba6a709d3bd827f4a7 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 21:56:17 +0200 Subject: [PATCH 117/132] fix: sim cards whose number starts with the cascade tag get a 7-byte uid --- sim/bus.py | 2 +- sim/devices.py | 31 +++++++++++++++++++++---------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/sim/bus.py b/sim/bus.py index 822d006..5713d0f 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -178,7 +178,7 @@ def command(self, line: str) -> bool: else: # from the firmware's clock as of its last frame (one every ~1 ms on v3) self.held[b] = (self.emu_us, self.emu_us + ms * 1000) elif w[0] == "card" and num(1) is not None: - uid = num(1) & 0xFFFFFFFF + uid = num(1) & ((1 << 56) - 1) self.rfid.card = self.cards.setdefault(uid, Card(uid)) self.rfid.card.state = "idle" self.card_since = time.monotonic() diff --git a/sim/devices.py b/sim/devices.py index 4007d79..2bdc39c 100644 --- a/sim/devices.py +++ b/sim/devices.py @@ -216,7 +216,10 @@ def crc_a(data) -> bytes: class Card: def __init__(self, uid: int): - self.uid = uid.to_bytes(16, "little")[:4] # esp-hal-mfrc522's to_u128: little endian + # esp-hal-mfrc522's to_u128: little endian. A 4-byte UID can't start with the cascade + # tag 0x88, so such (and longer) numbers are 7-byte (double size) UIDs. + n = 4 if uid < 1 << 32 and uid & 0xFF != 0x88 else 7 + self.uid = uid.to_bytes(16, "little")[:n] self.blocks = [bytearray(16) for _ in range(64)] self.blocks[0][:4] = self.uid for t in range(3, 64, 4): @@ -224,8 +227,11 @@ def __init__(self, uid: int): self.state = "idle" # idle, ready, active, halt self.pending_write = None - def bcc(self): - return self.uid[0] ^ self.uid[1] ^ self.uid[2] ^ self.uid[3] + def level(self, sel: int): + """UID bytes answered at cascade level SEL (0x93, 0x95) and the SAK after selecting.""" + if len(self.uid) == 4: + return (self.uid, 0x08) if sel == 0x93 else (None, 0) + return {0x93: (b"\x88" + self.uid[:3], 0x04), 0x95: (self.uid[3:7], 0x08)}.get(sel, (None, 0)) COMMAND, COMIRQ, DIVIRQ, ERROR, STATUS2, FIFODATA, FIFOLEVEL, CONTROL, BITFRAMING, COLL = \ @@ -289,7 +295,7 @@ def command(self, cmd): f, card = bytes(self.fifo), self.card self.fifo.clear() ok = (card is not None and card.state == "active" and len(f) == 12 - and f[8:12] == card.uid and f[1] < 64) + and f[8:12] == card.uid[-4:] and f[1] < 64) if ok: trailer = card.blocks[f[1] // 4 * 4 + 3] key = trailer[:6] if f[0] == 0x60 else trailer[10:16] @@ -333,12 +339,14 @@ def picc(self, f: bytes, last_bits): c.state = "ready" return b"\x04\x00", 0 return None - if f[0] == 0x93 and len(f) == 2 and f[1] == 0x20 and c.state == "ready": - return c.uid + bytes([c.bcc()]), 0 - if f[0] == 0x93 and len(f) == 9 and f[1] == 0x70 and c.state == "ready": - if f[2:6] == c.uid and crc_a(f[:7]) == f[7:9]: - c.state = "active" - return bytes([0x08]) + crc_a(b"\x08"), 0 + part, sak = c.level(f[0]) + if part is not None and len(f) == 2 and f[1] == 0x20 and c.state == "ready": + return part + bytes([part[0] ^ part[1] ^ part[2] ^ part[3]]), 0 # + BCC + if part is not None and len(f) == 9 and f[1] == 0x70 and c.state == "ready": + if f[2:6] == part and crc_a(f[:7]) == f[7:9]: + if not sak & 0x04: # UID complete + c.state = "active" + return bytes([sak]) + crc_a(bytes([sak])), 0 return None if f[0] == 0x50 and c.state == "active": # HLTA c.state = "halt" @@ -436,4 +444,7 @@ def text(addr, s, gap=500): rc.write(COMMAND, TRANSCEIVE) rc.write(BITFRAMING, 0x87) assert rc.regs[COMIRQ] & 0x30 and bytes(rc.fifo) == b"\x04\x00" + # a 7-byte UID through both cascade levels (CL1 starts with the cascade tag) + c = Card(3319564424) + assert len(c.uid) == 7 and c.level(0x93)[0][0] == 0x88 and int.from_bytes(c.uid, "little") == 3319564424 print("devices self-check ok") From 767a8347966545e91a952b492d25bb638ef7148a Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 18:45:02 +0200 Subject: [PATCH 118/132] fix: lcd waits out the controller busy time and resyncs periodically --- src/lcd_v3.rs | 74 +++++++++++++++++++++++++++++++++++++++---- src/utils/lcd_text.rs | 17 ++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index ed6ed54..a1647c2 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -20,8 +20,15 @@ type Lcd = LcdDisplay; type Text = heapless::String<96>; const EVENT_TIMEOUT: Duration = Duration::from_secs(30); +/// HD44780 instructions and writes take 37–41 µs (~60 µs on a slow 190 kHz controller) and any +/// transfer while busy is lost. ag-lcd-async waits only before characters, and the next command +/// reaches the controller ~20 µs after a character through the shift registers. +const BUSY_US: u64 = 100; +/// Re-sync and rewrite the LCD this often on idle screens: the write-only bus can't see a lost +/// write, a nibble desync or a module brownout, and the change-only cache would keep them. +const HEAL_INTERVAL: Duration = Duration::from_secs(2); -async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { +async fn new_lcd(lcd_shifter: &ShifterValue) -> Lcd { let mut lcd = LcdDisplay::new( lcd_shifter.get_pin_mut(2, true), lcd_shifter.get_pin_mut(3, true), @@ -43,29 +50,77 @@ async fn new_lcd(lcd_shifter: ShifterValue) -> Lcd { .build() .await; + // build() sends its 4-bit function set right after the 8-bit one, while the controller is busy + resync(lcd_shifter).await; lcd.clear().await; lcd.backlight_on(); lcd } +/// Initialisation by instruction (HD44780 datasheet fig. 24) without the clear: brings the +/// controller back to 4-bit, 2 lines, display on from any state (nibble desync, 8-bit mode after +/// a brownout) with the shown text untouched, so it doesn't flicker. +async fn resync(lcd_shifter: &ShifterValue) { + // ShifterPin bit b is mask 0x80 >> b: RS = 2, E = 3, DB4..DB7 = 4..7 (CS and backlight kept) + const BUS: u8 = 0x3F; + const EN: u8 = 0x10; + let nibble = |n: u8| { + let db = (n & 0x0F).reverse_bits() >> 4; + for en in [0, EN, 0] { + let mut v = lcd_shifter.value(); // shifted out on drop + *v = *v & !BUS | db | en; + } + }; + + Timer::after_micros(BUSY_US).await; + for (n, wait_us) in [(0x3, 4100), (0x3, 100), (0x3, BUSY_US), (0x2, BUSY_US)] { + nibble(n); + Timer::after_micros(wait_us).await; + } + // function set 4-bit/2 lines/5x8, display on, entry left-to-right, home (resets any shift) + for (byte, wait_us) in [ + (0x28, BUSY_US), + (0x0C, BUSY_US), + (0x06, BUSY_US), + (0x02, 3500), + ] { + nibble(byte >> 4); + nibble(byte); + Timer::after_micros(wait_us).await; + } +} + +/// Moves the cursor once the previous character is done. +async fn goto(lcd: &mut Lcd, col: u8, row: u8) { + Timer::after_micros(BUSY_US).await; + lcd.set_position(col, row).await; +} + /// Writes the changed characters to the LCD. async fn show(text: &mut LcdText, lcd: &mut Lcd) { let mut runs: heapless::Vec<(u8, u8, heapless::String<16>), 2> = heapless::Vec::new(); text.changes(|row, col, s| _ = runs.push((row, col, s.try_into().unwrap_or_default()))); for (row, col, s) in runs { - lcd.set_position(col, row).await; + goto(lcd, col, row).await; lcd.print(&s).await; } } +/// Re-syncs the controller and rewrites every character. +async fn heal(lcd_shifter: &ShifterValue, text: &mut LcdText, lcd: &mut Lcd) { + resync(lcd_shifter).await; + text.invalidate(); + show(text, lcd).await; +} + pub async fn lcd_show_critical_code(lcd_shifter: ShifterValue, code: u8) { - let mut lcd = new_lcd(lcd_shifter).await; + let mut lcd = new_lcd(&lcd_shifter).await; let mut line = Text::new(); _ = write!(line, " E{code}"); - lcd.set_position(0, 0).await; + goto(&mut lcd, 0, 0).await; lcd.print(" CRITICAL ERROR ").await; - lcd.set_position(0, 1).await; + goto(&mut lcd, 0, 1).await; lcd.print(&line).await; } @@ -75,8 +130,9 @@ pub async fn lcd_task( global_state: GlobalState, wifi_setup_sig: &'static Signal, ) { - let mut lcd = new_lcd(lcd_shifter).await; + let mut lcd = new_lcd(&lcd_shifter).await; let mut text = LcdText::new(); + let mut last_heal = Instant::now(); let mut line = Text::new(); _ = write!(line, "{:X}", crate::utils::get_efuse_u32()); @@ -175,7 +231,11 @@ pub async fn lcd_task( .update(|s| s.scene = Scene::AutoSetupWait); } Event::Tick => { - if text.scroll_step() { + let scrolled = text.scroll_step(); + if last_heal.elapsed() >= HEAL_INTERVAL { + last_heal = Instant::now(); + heal(&lcd_shifter, &mut text, &mut lcd).await; + } else if scrolled { show(&mut text, &mut lcd).await; } sleep_check(&mut text, &mut lcd, global_state, last_update).await; diff --git a/src/utils/lcd_text.rs b/src/utils/lcd_text.rs index 292a39d..41ca816 100644 --- a/src/utils/lcd_text.rs +++ b/src/utils/lcd_text.rs @@ -101,6 +101,11 @@ impl LcdText { true } + /// Forget what the LCD shows: the next `changes` rewrites everything. + pub fn invalidate(&mut self) { + self.shown = [[0xFF; COLS]; ROWS]; + } + /// Calls `write(row, col, text)` for the changed run of each row and marks it shown. pub fn changes(&mut self, mut write: impl FnMut(u8, u8, &str)) { for row in 0..ROWS { @@ -185,4 +190,16 @@ mod tests { assert_eq!(&offsets[..10], [0, 1, 2, 3, 4, 5, 6, 7, 8, 7]); assert!(!flush(&mut t).is_empty()); } + + #[test] + fn invalidate_rewrites_everything() { + let mut t = LcdText::new(); + t.print(0, "abc", Align::Left, true); + flush(&mut t); + t.invalidate(); + assert_eq!( + flush(&mut t), + [(0, 0, "abc ".into()), (1, 0, " ".repeat(16))] + ); + } } From 5623b8b73403c61dbc66e0b8665ec8d4ad833dce Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 19:01:34 +0200 Subject: [PATCH 119/132] fix: lcd heals on live screens too (timer, inspection) --- src/lcd_v3.rs | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index a1647c2..0a83407 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -106,10 +106,19 @@ async fn show(text: &mut LcdText, lcd: &mut Lcd) { } } -/// Re-syncs the controller and rewrites every character. -async fn heal(lcd_shifter: &ShifterValue, text: &mut LcdText, lcd: &mut Lcd) { - resync(lcd_shifter).await; - text.invalidate(); +/// Writes the changes, or every `HEAL_INTERVAL` re-syncs the controller and rewrites every +/// character (also on live screens: a solve can outlast many intervals). +async fn update( + lcd_shifter: &ShifterValue, + text: &mut LcdText, + lcd: &mut Lcd, + healed: &mut Instant, +) { + if healed.elapsed() >= HEAL_INTERVAL { + *healed = Instant::now(); + resync(lcd_shifter).await; + text.invalidate(); + } show(text, lcd).await; } @@ -132,7 +141,7 @@ pub async fn lcd_task( ) { let mut lcd = new_lcd(&lcd_shifter).await; let mut text = LcdText::new(); - let mut last_heal = Instant::now(); + let mut healed = Instant::now(); let mut line = Text::new(); _ = write!(line, "{:X}", crate::utils::get_efuse_u32()); @@ -182,7 +191,7 @@ pub async fn lcd_task( if live != Live::Timer { local.timer_ms = None; } - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); @@ -199,6 +208,7 @@ pub async fn lcd_task( ), ); let Ok(event) = event.await else { + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; continue; }; match event { @@ -206,12 +216,12 @@ pub async fn lcd_task( Event::TimerValue(ms) => { local.timer_ms = Some(ms); text.print(0, &time_str(ms), Align::Center, true); - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } Event::InspectionFrame(start) => { let elapsed = start.elapsed().as_millis(); text.print(0, &time_str(elapsed), Align::Center, true); - show(&mut text, &mut lcd).await; + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; } Event::Progress(p) => { local.progress = Some(p); @@ -231,13 +241,8 @@ pub async fn lcd_task( .update(|s| s.scene = Scene::AutoSetupWait); } Event::Tick => { - let scrolled = text.scroll_step(); - if last_heal.elapsed() >= HEAL_INTERVAL { - last_heal = Instant::now(); - heal(&lcd_shifter, &mut text, &mut lcd).await; - } else if scrolled { - show(&mut text, &mut lcd).await; - } + text.scroll_step(); + update(&lcd_shifter, &mut text, &mut lcd, &mut healed).await; sleep_check(&mut text, &mut lcd, global_state, last_update).await; } } From 7f273a6f01e83a36b44edb1fabab8118bebd74a5 Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 20:29:50 +0200 Subject: [PATCH 120/132] fix: lcd heals on screens without events too --- src/lcd_v3.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 0a83407..593c330 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -19,13 +19,13 @@ use embassy_time::{Delay, Duration, Instant, Timer, with_timeout}; type Lcd = LcdDisplay; type Text = heapless::String<96>; -const EVENT_TIMEOUT: Duration = Duration::from_secs(30); /// HD44780 instructions and writes take 37–41 µs (~60 µs on a slow 190 kHz controller) and any /// transfer while busy is lost. ag-lcd-async waits only before characters, and the next command /// reaches the controller ~20 µs after a character through the shift registers. const BUSY_US: u64 = 100; -/// Re-sync and rewrite the LCD this often on idle screens: the write-only bus can't see a lost -/// write, a nibble desync or a module brownout, and the change-only cache would keep them. +/// Re-sync and rewrite the LCD this often: the write-only bus can't see a lost write, a nibble +/// desync or a module brownout, and the change-only cache would keep them. Also bounds the +/// wait for events (watchdog heartbeat, screens without live updates). const HEAL_INTERVAL: Duration = Duration::from_secs(2); async fn new_lcd(lcd_shifter: &ShifterValue) -> Lcd { @@ -195,9 +195,9 @@ pub async fn lcd_task( } crate::watchdog::heartbeat(crate::watchdog::TaskId::Lcd); - // bounded so the heartbeat keeps beating while a live screen waits for its signal + // bounded: the heartbeat keeps beating and the LCD heals while a live screen waits let event = with_timeout( - EVENT_TIMEOUT, + HEAL_INTERVAL, next_event( global_state, &mut timer, From 930b4b35c4e44f8b804b5e9f939cbadb8dfc745d Mon Sep 17 00:00:00 2001 From: filipton Date: Sat, 17 Oct 2026 23:50:11 +0200 Subject: [PATCH 121/132] chore: esp-emu patches in the repo, applied by sim/run.sh --- .gitignore | 1 + sim/README.md | 8 +- sim/ble_phone.py | 2 +- sim/bus.py | 2 +- sim/devices.py | 2 +- sim/patches/README.md | 53 +++ sim/patches/apply.sh | 25 + sim/patches/esp-hal-1.2.2.patch | 580 +++++++++++++++++++++++ sim/patches/esp-radio-1.0.0-beta.1.patch | 188 ++++++++ sim/run.sh | 6 +- src/sim.rs | 2 +- 11 files changed, 857 insertions(+), 12 deletions(-) create mode 100644 sim/patches/README.md create mode 100755 sim/patches/apply.sh create mode 100644 sim/patches/esp-hal-1.2.2.patch create mode 100644 sim/patches/esp-radio-1.0.0-beta.1.patch diff --git a/.gitignore b/.gitignore index e56b757..d2772ce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ target/ *.pdb dist/ sim/out/ +venv diff --git a/sim/README.md b/sim/README.md index 35e0f14..fc86cdb 100644 --- a/sim/README.md +++ b/sim/README.md @@ -2,7 +2,7 @@ [esp-emu](https://github.com/espressif/esp-emulator) (Espressif, beta, closed source) runs the real ESP32-C3 image with Wi-Fi. The firmware runs its real drivers: the bus tap in the patched esp-hal -(esp-sim-patches, `ESP_SIM_TAP=1`) hands every GPIO, I2C, SPI and ADC access to the host over +(`sim/patches/`, `ESP_SIM_TAP=1`) hands every GPIO, I2C, SPI and ADC access to the host over esp-emu's UART0 socket, where `sim/bus.py` wires the device models of `sim/devices.py` like the PCB does (`SIM_HW=v3|v4`, `--hw v3|v4`; v4 is the default): @@ -26,8 +26,8 @@ transceives), `i2c nack [n]`. `tap stats` prints the tap's traffic an `sim/run.sh` builds against patched copies of esp-hal (esp-emu never sets the interrupt matrix status registers, which esp-hal relies on; the bus tap) and esp-radio (BLE bring-up behind the -ESP-IDF names esp-emu intercepts), without touching `Cargo.lock`. The patches live in -`$ESP_SIM_PATCHES` (default `~/tmp/esp-sim-patches`, see its README). Control port +ESP-IDF names esp-emu intercepts), without touching `Cargo.lock`. The patches are in +`sim/patches/` (see its README); the patched crates go to `target/sim-patches/`. Control port `127.0.0.1:5557` takes `reset` (flash kept). ## Run @@ -87,7 +87,7 @@ network) are logged and the AP keeps running. ## BLE (not working yet) `SIM_BLE=tcp:localhost:9544 sim/run.sh` connects the firmware to a Bumble controller, e.g. -`~/tmp/esp-sim-patches/venv/bin/python sim/ble_phone.py` (a "phone" for BLE provisioning). esp-emu +`venv/bin/python sim/ble_phone.py` (Bumble, see `sim/patches/README.md`) (a "phone" for BLE provisioning). esp-emu intercepts the patched esp-radio and receives the first HCI command (`BLE HCI: Reset`), but never calls esp-radio's `notify_host_recv`/`notify_host_send_available`, so no response arrives and the host stack waits forever. AP provisioning keeps working next to it. diff --git a/sim/ble_phone.py b/sim/ble_phone.py index 192391f..ad1c8e1 100644 --- a/sim/ble_phone.py +++ b/sim/ble_phone.py @@ -2,7 +2,7 @@ """BLE "phone" that provisions Wi-Fi over wifimanager's GATT service (Bumble). Start this first, then the firmware in provisioning mode: - ~/tmp/esp-sim-patches/venv/bin/python sim/ble_phone.py + venv/bin/python sim/ble_phone.py # a venv with Bumble, see sim/patches/README.md SIM_PROVISION=1 SIM_BLE=tcp:localhost:9544 sim/run.sh """ diff --git a/sim/bus.py b/sim/bus.py index 5713d0f..3453e57 100644 --- a/sim/bus.py +++ b/sim/bus.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Host side of the esp-sim-patches bus tap: esp-emu's UART0 carries the firmware's GPIO, I2C, +"""Host side of the sim/patches bus tap: esp-emu's UART0 carries the firmware's GPIO, I2C, SPI and ADC traffic (frames `F5 `, see the patches' README); the boards here wire the device models of sim/devices.py to it like the real PCBs do. diff --git a/sim/devices.py b/sim/devices.py index 2bdc39c..b7285ff 100644 --- a/sim/devices.py +++ b/sim/devices.py @@ -1,4 +1,4 @@ -"""Device models behind the esp-sim-patches bus tap (pure: no I/O, host-tested by the +"""Device models behind the sim/patches bus tap (pure: no I/O, host-tested by the self-check at the bottom). They see what the real chips see: pin edges, I2C/SPI bytes.""" # ── HD44780 character LCD (v3) ────────────────────────────────────────────────────────────── diff --git a/sim/patches/README.md b/sim/patches/README.md new file mode 100644 index 0000000..299a797 --- /dev/null +++ b/sim/patches/README.md @@ -0,0 +1,53 @@ +# esp-emu patches + +Emulator-only patches that let esp-hal firmware run in [esp-emu](https://github.com/espressif/esp-emulator) +(Espressif, beta, closed source). Never use the patched crates for real hardware. + +| Patch | Why | +|---|---| +| `esp-hal-1.2.2.patch` | esp-emu never sets the interrupt matrix status registers: bounded wait in `SoftwareInterrupt::raise`, and `handle_interrupts` calls every mapped handler when the status reads empty |
Bus tap (`src/sim_tap.rs`, only with `ESP_SIM_TAP` set at build time): GPIO, I2C, SPI and ADC go to a host over UART0, see below | +| `esp-radio-1.0.0-beta.1.patch` | BLE: the controller bring-up and the VHCI host API go through the ESP-IDF names esp-emu intercepts (`esp_bt_controller_init/enable/deinit`, `esp_vhci_host_*`), `coex_init` runs outside the stubbed init, the callback struct lives in RAM, and a sent packet frees its buffer at once (esp-emu never calls `notify_host_send_available`) | + +`sim/run.sh` applies them itself: `apply.sh esp-hal-1.2.2` copies that crate from the cargo registry +into `target/sim-patches/`, applies its patch (only again when the patch changed) and prints the +path, which goes into `cargo build --config 'patch.crates-io.esp-hal.path="…"'`. A new esp-hal +version needs its own `-.patch` (`diff -ruN` of the registry copy and the patched one). + +The BLE scripts need Bumble (`python3.14 -m venv venv && venv/bin/pip install 'bumble>=0.0.220,<0.1'`). + +Known esp-emu gap: with the esp-radio patch the controller is intercepted and HCI commands reach +esp-emu, but it never calls the registered VHCI callbacks back, so no HCI events are delivered. + +## Bus tap (esp-hal) + +Built with `ESP_SIM_TAP=1` in the environment, the patched esp-hal hands bus traffic to a host +program that models the devices, so the firmware runs its real drivers. Without the variable the +patched crate behaves like the unpatched one. Hooks (one line each): `AnyPin::set_output_high` / +`is_input_high` (GPIO), the I2C master's `transaction_impl[_async]`, the SPI master's and SPI DMA +`read/write/transfer/transfer_in_place[_async]`, the RISC-V ADC `read_oneshot`. + +Transport: esp-emu's UART0 (`--uart-tcp HOST:PORT`, with `--console usb-jtag` so esp-println stays +on UART0), through the ROM's `uart_tx_one_char`/`uart_rx_one_char` (esp-emu intercepts those; the +FIFO registers don't reach the socket). Frames in both directions are `F5 +`; every other byte is text (ROM, bootloader and esp-println logs share the line). + +Every request (`Q`, `I`, `S`, `A`) starts with a sequence byte; the reply repeats it, and a +reply to an earlier request that timed out is dropped. + +| From the firmware | Payload | Reply | +|---|---|---| +| `G` GPIO outputs | `[t0 µs u32]` then `(pin \| level << 7, µs since previous)` per change; a batch is sent before any other frame, when full (255 changes) or when 255 µs passed | – | +| `Q` input query | `[now µs u32]`, sent on an input read when outputs changed since the last read (a matrix scan's answer depends on them) | `[0][levels u32]` | +| `I` I2C | `[addr]` then `(W, len u16, data)` / `(R, len u16)` per operation | only with reads: `[status][read bytes]` | +| `S` SPI | `[read? u8][MOSI bytes]` | only if read: `[status][MISO bytes]` | +| `A` ADC | `[channel]` | `[status][value u16]` | +| `P` pulse ended | `[pin]`: an input pulse from a `g` frame ran out | – | + +| From the host | Payload | +|---|---| +| `r` | `[seq][status][data]`: the reply to the request in flight; status 0 = ok, 1 = NACK address, 2 = NACK data, else error | +| `g` | `(pin, level, µs u32)`…: the input reads `level`, after `µs` (0 = never) the other level again, timed from when the firmware sees it | +| `c` | console text for the firmware, read with `esp_hal::sim_tap::console(&mut buf)` | + +Input reads without output changes in between don't wait for the host. A request waits up to +500 ms for its reply (then: I2C `Timeout`, SPI `Unknown`, ADC 0). No GPIO interrupts. diff --git a/sim/patches/apply.sh b/sim/patches/apply.sh new file mode 100755 index 0000000..6beb906 --- /dev/null +++ b/sim/patches/apply.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Copies the exact crate version from the cargo registry into target/sim-patches/-/ and +# applies -.patch from this folder. Idempotent: a crate is re-copied only when its patch +# changed. Prints the patched path. +# sim/patches/apply.sh esp-hal-1.2.2 +set -eu +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$HERE/../.." +OUT="$ROOT/target/sim-patches" +NAMEVER="$1" +PATCH="$HERE/$NAMEVER.patch" +DIR="$OUT/$NAMEVER" +[ -f "$PATCH" ] || { echo "no $PATCH (the lockfile's version has no sim patch)" >&2; exit 1; } +STAMP="$(shasum -a 256 "$PATCH" | cut -c1-16)" +if [ "$(cat "$DIR/.patched" 2>/dev/null)" != "$STAMP" ]; then + find_src() { ls -d "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/"$NAMEVER" 2>/dev/null | head -1; } + SRC="$(find_src)" + [ -n "$SRC" ] || { (cd "$ROOT" && cargo fetch -q) && SRC="$(find_src)"; } + [ -n "$SRC" ] || { echo "$NAMEVER not in the cargo registry" >&2; exit 1; } + rm -rf "$DIR" && mkdir -p "$DIR" + (cd "$SRC" && tar cf - .) | (cd "$DIR" && tar xf -) + patch -s -d "$DIR" -p1 <"$PATCH" + echo "$STAMP" >"$DIR/.patched" +fi +echo "$(cd "$DIR" && pwd)" diff --git a/sim/patches/esp-hal-1.2.2.patch b/sim/patches/esp-hal-1.2.2.patch new file mode 100644 index 0000000..d21762e --- /dev/null +++ b/sim/patches/esp-hal-1.2.2.patch @@ -0,0 +1,580 @@ +diff -ruN a/src/analog/adc/riscv.rs b/src/analog/adc/riscv.rs +--- a/src/analog/adc/riscv.rs ++++ b/src/analog/adc/riscv.rs +@@ -355,6 +355,9 @@ + PIN: super::AdcChannel, + CS: super::AdcCalScheme, + { ++ if crate::sim_tap::ACTIVE { ++ return Ok(crate::sim_tap::adc(pin.pin.adc_channel())); ++ } + if self.attenuations[pin.pin.adc_channel() as usize].is_none() { + panic!( + "Channel {} is not configured reading!", +@@ -499,6 +502,9 @@ + PIN: super::AdcChannel, + CS: super::AdcCalScheme, + { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::adc(pin.pin.adc_channel()); ++ } + let channel = pin.pin.adc_channel(); + if self.attenuations[channel as usize].is_none() { + panic!("Channel {} is not configured reading!", channel); +diff -ruN a/src/gpio/mod.rs b/src/gpio/mod.rs +--- a/src/gpio/mod.rs ++++ b/src/gpio/mod.rs +@@ -1991,12 +1991,18 @@ + /// The current state of the input. + #[inline] + pub(crate) fn is_input_high(&self) -> bool { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::gpio_in(self.number()); ++ } + self.bank().read_input() & self.mask() != 0 + } + + /// Sets the pin's level to high or low. + #[inline] + pub(crate) fn set_output_high(&self, high: bool) { ++ if crate::sim_tap::ACTIVE { ++ crate::sim_tap::gpio_out(self.number(), high); ++ } + self.bank().write_output(self.mask(), high); + } + +diff -ruN a/src/i2c/master/low_level/mod.rs b/src/i2c/master/low_level/mod.rs +--- a/src/i2c/master/low_level/mod.rs 2006-07-24 03:21:28 ++++ b/src/i2c/master/low_level/mod.rs 2026-10-17 19:42:39 +@@ -1348,6 +1348,9 @@ + address: I2cAddress, + operations: impl Iterator>, + ) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::i2c(address, operations); ++ } + address.validate()?; + self.ensure_idle_blocking(); + +@@ -1412,6 +1415,9 @@ + address: I2cAddress, + operations: impl Iterator>, + ) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::i2c(address, operations); ++ } + address.validate()?; + self.ensure_idle().await; + +diff -ruN a/src/interrupt/riscv.rs b/src/interrupt/riscv.rs +--- a/src/interrupt/riscv.rs 2006-07-24 03:21:28 ++++ b/src/interrupt/riscv.rs 2026-10-17 00:59:09 +@@ -597,7 +597,14 @@ + #[unsafe(no_mangle)] + #[crate::ram] + unsafe fn handle_interrupts(cpu_intr: CpuInterrupt) { +- let status = InterruptStatus::current(); ++ let mut status = InterruptStatus::current(); ++ // esp-emu never mirrors sources into INTR_STATUS: like xtensa edge interrupts, call every ++ // handler mapped to this interrupt (each checks its own peripheral) ++ if status.iterator().next().is_none() { ++ for nr in 0..crate::soc::pac::__EXTERNAL_INTERRUPTS.len() as u8 { ++ status.set(nr); ++ } ++ } + + // this has no effect on level interrupts, but the interrupt may be an edge one + // so we clear it anyway +diff -ruN a/src/interrupt/software.rs b/src/interrupt/software.rs +--- a/src/interrupt/software.rs 2006-07-24 03:21:28 ++++ b/src/interrupt/software.rs 2026-10-17 00:59:09 +@@ -105,7 +105,11 @@ + crate::interrupt::free(|| { + reg.write(|w| w.cpu_intr().set_bit()); + // Wait for the interrupt to actually take effect. +- while !self.is_pending() {} ++ // esp-emu never mirrors FROM_CPU_INTR into INTR_STATUS: bounded wait ++ let mut spins = 0u32; ++ while !self.is_pending() && spins < 1000 { ++ spins += 1; ++ } + }); + } + } +diff -ruN a/src/lib.rs b/src/lib.rs +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -310,6 +310,8 @@ + pub mod spi; + pub mod system; + pub mod time; ++#[doc(hidden)] ++pub mod sim_tap; + #[cfg(uart_driver_supported)] + pub mod uart; + +diff -ruN a/src/sim_tap.rs b/src/sim_tap.rs +--- a/src/sim_tap.rs 1970-01-01 01:00:00 ++++ b/src/sim_tap.rs 2026-10-17 21:01:54 +@@ -0,0 +1,335 @@ ++//! esp-sim-patches bus tap (emulator only). Built with `ESP_SIM_TAP` set, GPIO, I2C, SPI and ++//! ADC traffic goes over UART0 to a host that models the devices. Frames in both directions: ++//! `F5 `; anything else on the line (ROM text) is not a frame. ++//! ++//! To the host: `G` GPIO output changes `[t0 µs u32][(pin | level << 7, µs since previous)]..`, ++//! `Q` input query `[now µs u32]` (outputs changed since the last read: a matrix scan's answer ++//! depends on them), `I` I2C `[addr][(R|W, len u16, data if W)]..`, `S` SPI `[read? u8][MOSI]`, ++//! `A` ADC `[channel]`, each after a sequence byte. Writes don't wait; `Q`, reads and `A` wait ++//! for the host's `r` reply `[seq][status][data]` (status 0 = ok, 1 = NACK address, 2 = NACK ++//! data, other = error; `Q`: the input levels u32); a reply to an earlier, timed out request ++//! is dropped. The host may send `g` `[(pin, level, µs u32)]..` any time: the pin reads ++//! `level`, after `µs` (0 = never) the other level again, timed from when the firmware sees it ++//! (it reports the end with `P` `[pin]`); ++//! and `c` `[text]`: console input for the firmware ([`console`]). ++ ++use core::cell::RefCell; ++ ++use critical_section::Mutex; ++ ++/// Only builds with `ESP_SIM_TAP` set use the tap. ++pub const ACTIVE: bool = option_env!("ESP_SIM_TAP").is_some(); ++const MARK: u8 = 0xF5; ++const REPLY_TIMEOUT_US: u64 = 500_000; ++ ++struct Tap { ++ batch: [u8; 512], ++ len: usize, ++ last_us: u64, ++ out: u64, ++ inputs: u32, ++ /// end of each input pulse (µs, 0 = none) ++ pulse_end: [u64; 32], ++ outputs_changed: bool, ++ rx: [u8; 4 + 256], ++ rx_len: usize, ++ reply: Option<([u8; 256], usize)>, ++ seq: u8, ++ console: [u8; 128], ++ console_len: usize, ++} ++ ++static TAP: Mutex> = Mutex::new(RefCell::new(Tap { ++ batch: [0; 512], ++ len: 0, ++ last_us: 0, ++ out: 0, ++ inputs: 0, ++ pulse_end: [0; 32], ++ outputs_changed: false, ++ rx: [0; 4 + 256], ++ rx_len: 0, ++ reply: None, ++ seq: 0, ++ console: [0; 128], ++ console_len: 0, ++})); ++ ++fn now_us() -> u64 { ++ crate::time::Instant::now().duration_since_epoch().as_micros() ++} ++ ++// UART0 through the ROM functions esp-emu intercepts (it ignores the FIFO registers there) ++fn rx() -> Option { ++ // SAFETY: the ESP32-C3 ROM's `uart_rx_one_char` (0 = got a byte) ++ let rx_one_char: unsafe extern "C" fn(*mut u8) -> i32 = unsafe { core::mem::transmute(0x4000_0070usize) }; ++ let mut b = 0; ++ (unsafe { rx_one_char(&mut b) } == 0).then_some(b) ++} ++ ++fn tx(bytes: &[u8]) { ++ // SAFETY: the ESP32-C3 ROM's `uart_tx_one_char`, as esp-println calls it ++ let tx_one_char: unsafe extern "C" fn(u8) -> i32 = unsafe { core::mem::transmute(0x4000_0068usize) }; ++ for &b in bytes { ++ unsafe { tx_one_char(b) }; ++ } ++} ++ ++fn frame(ty: u8, parts: &[&[u8]]) { ++ let len = parts.iter().map(|p| p.len()).sum::() as u16; ++ let [lo, hi] = len.to_le_bytes(); ++ tx(&[MARK, ty, lo, hi]); ++ for p in parts { ++ tx(p); ++ } ++} ++ ++/// A frame starting with the sequence byte of the current request. ++fn frame_seq(t: &Tap, ty: u8, parts: &[&[u8]]) { ++ let len = 1 + parts.iter().map(|p| p.len()).sum::() as u16; ++ let [lo, hi] = len.to_le_bytes(); ++ tx(&[MARK, ty, lo, hi, t.seq]); ++ for p in parts { ++ tx(p); ++ } ++} ++ ++impl Tap { ++ fn expire_pulses(&mut self) { ++ let now = now_us(); ++ for pin in 0..32 { ++ if self.pulse_end[pin] != 0 && now >= self.pulse_end[pin] { ++ self.pulse_end[pin] = 0; ++ self.inputs ^= 1 << pin; ++ self.flush(); ++ frame(b'P', &[&[pin as u8]]); ++ } ++ } ++ } ++ ++ fn flush(&mut self) { ++ if self.len > 4 { ++ frame(b'G', &[&self.batch[..self.len]]); ++ } ++ self.len = 0; ++ } ++ ++ /// Feeds received bytes to the frame parser. ++ fn poll_rx(&mut self) { ++ while let Some(b) = rx() { ++ if self.rx_len == 0 && b != MARK { ++ continue; ++ } ++ self.rx[self.rx_len] = b; ++ self.rx_len += 1; ++ if self.rx_len < 4 { ++ continue; ++ } ++ let len = usize::from(u16::from_le_bytes([self.rx[2], self.rx[3]])); ++ if len > 256 { ++ self.rx_len = 0; // garbage: resync on the next marker ++ } else if self.rx_len == 4 + len { ++ let payload = &self.rx[4..4 + len]; ++ match self.rx[1] { ++ b'g' => { ++ let now = now_us(); ++ for e in payload.chunks_exact(6) { ++ let pin = usize::from(e[0] & 31); ++ let us = u32::from_le_bytes([e[2], e[3], e[4], e[5]]); ++ self.inputs = self.inputs & !(1 << pin) | u32::from(e[1] & 1) << pin; ++ self.pulse_end[pin] = if us == 0 { 0 } else { now + u64::from(us) }; ++ } ++ } ++ b'c' => { ++ let n = len.min(self.console.len() - self.console_len); ++ let at = self.console_len; ++ self.console[at..at + n].copy_from_slice(&payload[..n]); ++ self.console_len += n; ++ } ++ b'r' if len > 0 && payload[0] == self.seq => { ++ let mut buf = [0; 256]; ++ buf[..len - 1].copy_from_slice(&payload[1..]); ++ self.reply = Some((buf, len - 1)); ++ } ++ _ => {} ++ } ++ self.rx_len = 0; ++ } ++ } ++ } ++} ++ ++/// Sends a request and waits for the host's reply; `None` without a host. ++fn request(ty: u8, parts: &[&[u8]]) -> Option<([u8; 256], usize)> { ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.flush(); ++ t.poll_rx(); ++ t.reply = None; ++ t.seq = t.seq.wrapping_add(1); ++ frame_seq(&t, ty, parts); ++ }); ++ let start = now_us(); ++ loop { ++ let reply = critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.poll_rx(); ++ t.reply.take() ++ }); ++ if reply.is_some() || now_us() - start > REPLY_TIMEOUT_US { ++ return reply.filter(|(_, len)| *len > 0); ++ } ++ } ++} ++ ++/// An output pin changed its level. ++pub(crate) fn gpio_out(pin: u8, high: bool) { ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ let mask = 1u64 << pin; ++ if (t.out & mask != 0) == high { ++ return; ++ } ++ t.out = if high { t.out | mask } else { t.out & !mask }; ++ t.outputs_changed = true; ++ let now = now_us(); ++ let dt = now.saturating_sub(t.last_us); ++ if t.len + 2 > t.batch.len() || dt > 255 { ++ t.flush(); ++ } ++ if t.len == 0 { ++ t.batch[..4].copy_from_slice(&(now as u32).to_le_bytes()); ++ t.len = 4; ++ } ++ let len = t.len; ++ t.batch[len] = pin | u8::from(high) << 7; ++ t.batch[len + 1] = if len == 4 { 0 } else { dt as u8 }; ++ t.len += 2; ++ t.last_us = now; ++ }) ++} ++ ++/// An input pin's level: asks the host when outputs changed since the last read (a matrix ++/// scan's answer depends on them), else what it sent last. ++pub(crate) fn gpio_in(pin: u8) -> bool { ++ let ask = critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.poll_rx(); ++ core::mem::take(&mut t.outputs_changed) ++ }); ++ if ask { ++ if let Some((r, 5)) = request(b'Q', &[&(now_us() as u32).to_le_bytes()]) { ++ let levels = u32::from_le_bytes([r[1], r[2], r[3], r[4]]); ++ critical_section::with(|cs| TAP.borrow_ref_mut(cs).inputs = levels); ++ } ++ } ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.expire_pulses(); ++ t.inputs & 1 << pin != 0 ++ }) ++} ++ ++pub(crate) fn i2c<'a>( ++ address: crate::i2c::master::I2cAddress, ++ operations: impl Iterator>, ++) -> Result<(), crate::i2c::master::Error> { ++ use crate::i2c::master::{AcknowledgeCheckFailedReason as Nack, Error, I2cAddress, Operation}; ++ let I2cAddress::SevenBit(addr) = address; ++ let mut ops: [Option>; 8] = [const { None }; 8]; ++ let mut req = [0u8; 256]; ++ let (mut n, mut reads) = (1, 0); ++ req[0] = addr; ++ for (slot, op) in ops.iter_mut().zip(operations) { ++ let (kind, len, data): (u8, usize, &[u8]) = match &op { ++ Operation::Write(d) => (b'W', d.len(), d), ++ Operation::Read(d) => (b'R', d.len(), &[]), ++ }; ++ reads += usize::from(kind == b'R'); ++ let end = n + 3 + data.len(); ++ if end > req.len() { ++ return Err(Error::FifoExceeded); ++ } ++ req[n] = kind; ++ req[n + 1..n + 3].copy_from_slice(&(len as u16).to_le_bytes()); ++ req[n + 3..end].copy_from_slice(data); ++ n = end; ++ *slot = Some(op); ++ } ++ if reads == 0 { ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.flush(); ++ frame_seq(&t, b'I', &[&req[..n]]); ++ }); ++ return Ok(()); ++ } ++ let (r, len) = request(b'I', &[&req[..n]]).ok_or(Error::Timeout)?; ++ match r[0] { ++ 0 => {} ++ 1 => return Err(Error::AcknowledgeCheckFailed(Nack::Address)), ++ 2 => return Err(Error::AcknowledgeCheckFailed(Nack::Data)), ++ _ => return Err(Error::Timeout), ++ } ++ let mut data = &r[1..len]; ++ for op in ops.iter_mut().flatten() { ++ if let Operation::Read(buf) = op { ++ let k = buf.len().min(data.len()); ++ buf[..k].copy_from_slice(&data[..k]); ++ data = &data[k..]; ++ } ++ } ++ Ok(()) ++} ++ ++/// Full duplex transfer of `max(read, write)` bytes (MOSI padded with 0); without `read` the ++/// host isn't waited for. ++pub(crate) fn spi(read: &mut [u8], write: &[u8]) -> Result<(), ()> { ++ let n = read.len().max(write.len()).min(250); ++ let mut mosi = [0u8; 250]; ++ mosi[..write.len().min(n)].copy_from_slice(&write[..write.len().min(n)]); ++ if read.is_empty() { ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.flush(); ++ frame_seq(&t, b'S', &[&[0], &mosi[..n]]); ++ }); ++ return Ok(()); ++ } ++ let (r, len) = request(b'S', &[&[1], &mosi[..n]]).ok_or(())?; ++ if r[0] != 0 { ++ return Err(()); ++ } ++ let k = read.len().min(len - 1); ++ read[..k].copy_from_slice(&r[1..1 + k]); ++ Ok(()) ++} ++ ++pub(crate) fn spi_in_place(words: &mut [u8]) -> Result<(), ()> { ++ let mut write = [0u8; 250]; ++ let n = words.len().min(250); ++ write[..n].copy_from_slice(&words[..n]); ++ spi(words, &write[..n]) ++} ++ ++/// A oneshot ADC reading of `channel` (0 without a host). ++pub(crate) fn adc(channel: u8) -> u16 { ++ match request(b'A', &[&[channel]]) { ++ Some((r, 3)) if r[0] == 0 => u16::from_le_bytes([r[1], r[2]]), ++ _ => 0, ++ } ++} ++ ++/// Console input the host sent (`c` frames): copies up to `buf.len()` bytes, returns how many. ++pub fn console(buf: &mut [u8]) -> usize { ++ critical_section::with(|cs| { ++ let mut t = TAP.borrow_ref_mut(cs); ++ t.poll_rx(); ++ let n = buf.len().min(t.console_len); ++ buf[..n].copy_from_slice(&t.console[..n]); ++ let len = t.console_len; ++ t.console.copy_within(n..len, 0); ++ t.console_len -= n; ++ n ++ }) ++} +diff -ruN a/src/spi/master/dma.rs b/src/spi/master/dma.rs +--- a/src/spi/master/dma.rs 2006-07-24 03:21:28 ++++ b/src/spi/master/dma.rs 2026-10-17 19:21:21 +@@ -414,6 +414,9 @@ + /// Fills the given buffer with data from the bus. + #[instability::unstable] + pub async fn read_async(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(words, &[]).map_err(|_| Error::Unknown); ++ } + if words.is_empty() { + return Ok(()); + } +@@ -460,6 +463,9 @@ + /// Transmits the given buffer to the bus. + #[instability::unstable] + pub async fn write_async(&mut self, words: &[u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(&mut [], words).map_err(|_| Error::Unknown); ++ } + if words.is_empty() { + return Ok(()); + } +@@ -501,6 +507,9 @@ + /// the bus into another buffer. + #[instability::unstable] + pub async fn transfer_async(&mut self, read: &mut [u8], write: &[u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(read, write).map_err(|_| Error::Unknown); ++ } + if read.is_empty() && write.is_empty() { + return Ok(()); + } +@@ -582,6 +591,9 @@ + /// the bus into the same buffer. + #[instability::unstable] + pub async fn transfer_in_place_async(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi_in_place(words).map_err(|_| Error::Unknown); ++ } + if words.is_empty() { + return Ok(()); + } +@@ -1457,6 +1469,9 @@ + /// Reads data from the SPI bus using DMA. + #[instability::unstable] + pub fn read(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(words, &[]).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +@@ -1498,6 +1513,9 @@ + /// Writes data to the SPI bus using DMA. + #[instability::unstable] + pub fn write(&mut self, words: &[u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(&mut [], words).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +@@ -1533,6 +1551,9 @@ + /// Transfers data to and from the SPI bus simultaneously using DMA. + #[instability::unstable] + pub fn transfer(&mut self, read: &mut [u8], write: &[u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(read, write).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +@@ -1608,6 +1629,9 @@ + /// Transfers data in place on the SPI bus using DMA. + #[instability::unstable] + pub fn transfer_in_place(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi_in_place(words).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +diff -ruN a/src/spi/master/mod.rs b/src/spi/master/mod.rs +--- a/src/spi/master/mod.rs 2006-07-24 03:21:28 ++++ b/src/spi/master/mod.rs 2026-10-17 19:21:21 +@@ -858,6 +858,9 @@ + /// # {after_snippet} + /// ``` + pub async fn transfer_in_place_async(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi_in_place(words).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +@@ -1233,6 +1236,9 @@ + /// # {after_snippet} + /// ``` + pub fn write(&mut self, words: &[u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(&mut [], words).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + + self.driver().setup_full_duplex()?; +@@ -1263,6 +1269,9 @@ + /// # {after_snippet} + /// ``` + pub fn read(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi(words, &[]).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + self.driver().setup_full_duplex()?; + self.driver().read(words) +@@ -1292,6 +1301,9 @@ + /// # {after_snippet} + /// ``` + pub fn transfer(&mut self, words: &mut [u8]) -> Result<(), Error> { ++ if crate::sim_tap::ACTIVE { ++ return crate::sim_tap::spi_in_place(words).map_err(|_| Error::Unknown); ++ } + let _clock = SpiClockGuard::new(self.spi.info()); + self.driver().setup_full_duplex()?; + self.driver().transfer_in_place(words) diff --git a/sim/patches/esp-radio-1.0.0-beta.1.patch b/sim/patches/esp-radio-1.0.0-beta.1.patch new file mode 100644 index 0000000..1918511 --- /dev/null +++ b/sim/patches/esp-radio-1.0.0-beta.1.patch @@ -0,0 +1,188 @@ +diff --color -ru a/src/ble/btdm.rs b/src/ble/btdm.rs +--- a/src/ble/btdm.rs 2026-10-17 01:28:38 ++++ b/src/ble/btdm.rs 2026-10-17 01:28:38 +@@ -60,6 +60,8 @@ + fn coex_pti_v2(); + } + ++// in RAM like ESP-IDF's: esp-emu reads the callbacks from RAM when they get registered ++#[ram] + static VHCI_HOST_CALLBACK: VhciHostCallbacks = VhciHostCallbacks { + notify_host_send_available, + notify_host_recv, +@@ -315,20 +317,42 @@ + g_bt_plf_log_level = 10; + } + +- // esp32_bt_controller_init +- ble_os_adapter_chip_specific::btdm_controller_mem_init(); +- +- let mut cfg = ble_os_adapter_chip_specific::create_ble_config(config); +- +- let res = btdm_osi_funcs_register(addr_of!(G_OSI_FUNCS)); +- assert!(res == 0, "btdm_osi_funcs_register returned {}", res); +- ++ // software only (creates the coex locks): runs even where esp-emu stubs the controller + #[cfg(feature = "coex")] + { + let res = crate::wifi::coex_init(); + assert!(res == 0, "coex_init failed"); + } + ++ let mut cfg = ble_os_adapter_chip_specific::create_ble_config(config); ++ let res = esp_bt_controller_init(&mut cfg); ++ assert!(res == 0, "esp_bt_controller_init returned {}", res); ++ ++ phy_init_guard = esp_phy::enable_phy(); ++ ++ let res = esp_bt_controller_enable(esp_bt_mode_t_ESP_BT_MODE_BLE); ++ assert!(res == 0, "esp_bt_controller_enable returned {}", res); ++ ++ esp_vhci_host_register_callback(&VHCI_HOST_CALLBACK); ++ } ++ ++ // At some point the "High-speed ADC" entropy source became available. ++ unsafe { esp_hal::rng::TrngSource::increase_entropy_source_counter() }; ++ phy_init_guard ++} ++ ++// esp-emu has no BT radio: it intercepts these ESP-IDF names and swaps in its own controller, ++// so the blob bring-up lives behind them exactly as in ESP-IDF's esp_bt.c. ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_bt_controller_init(cfg: *mut esp_bt_controller_config_t) -> i32 { ++ unsafe { ++ // esp32_bt_controller_init ++ ble_os_adapter_chip_specific::btdm_controller_mem_init(); ++ ++ let res = btdm_osi_funcs_register(addr_of!(G_OSI_FUNCS)); ++ assert!(res == 0, "btdm_osi_funcs_register returned {}", res); ++ + let version = btdm_controller_get_compile_version(); + let version_str = str_from_c(version); + debug!("BT controller compile version {}", version_str); +@@ -338,23 +362,25 @@ + ble_os_adapter_chip_specific::disable_sleep_mode(); + + #[cfg(any(esp32c3, esp32s3))] +- let res = btdm_controller_init(&mut cfg as *mut esp_bt_controller_config_t); ++ let res = btdm_controller_init(cfg); + + #[cfg(esp32)] +- let res = btdm_controller_init( +- (1 << 3) | (1 << 4), +- &mut cfg as *mut esp_bt_controller_config_t, +- ); // see btdm_config_mask_load for mask ++ let res = btdm_controller_init((1 << 3) | (1 << 4), cfg); // see btdm_config_mask_load for mask + +- assert!(res == 0, "btdm_controller_init returned {}", res); ++ debug!("btdm_controller_init returned {}", res); + +- debug!("The btdm_controller_init was initialized"); +- + #[cfg(feature = "coex")] +- crate::sys::include::coex_enable(); ++ if res == 0 { ++ crate::sys::include::coex_enable(); ++ } ++ res ++ } ++} + +- phy_init_guard = esp_phy::enable_phy(); +- ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_bt_controller_enable(mode: esp_bt_mode_t) -> i32 { ++ unsafe { + cfg_select! { + esp32 => { + unsafe extern "C" { +@@ -372,27 +398,46 @@ + #[cfg(feature = "coex")] + coex_enable(); + +- btdm_controller_enable(esp_bt_mode_t_ESP_BT_MODE_BLE); +- +- API_vhci_host_register_callback(&VHCI_HOST_CALLBACK); ++ btdm_controller_enable(mode); + } ++ 0 ++} + +- // At some point the "High-speed ADC" entropy source became available. +- unsafe { esp_hal::rng::TrngSource::increase_entropy_source_counter() }; +- phy_init_guard ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_vhci_host_register_callback(callback: *const VhciHostCallbacks) -> i32 { ++ unsafe { API_vhci_host_register_callback(callback) } + } + +-pub(crate) fn ble_deinit() { +- esp_hal::rng::TrngSource::decrease_entropy_source_counter(unsafe { +- esp_hal::Internal::conjure() +- }); ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_vhci_host_check_send_available() -> bool { ++ unsafe { API_vhci_host_check_send_available() } ++} + ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_vhci_host_send_packet(data: *const u8, len: u16) { ++ unsafe { API_vhci_host_send_packet(data, len) } ++} ++ ++#[unsafe(no_mangle)] ++#[inline(never)] ++unsafe extern "C" fn esp_bt_controller_deinit() -> i32 { + unsafe extern "C" { + fn btdm_controller_deinit(); + } ++ unsafe { btdm_controller_deinit() }; ++ 0 ++} + ++pub(crate) fn ble_deinit() { ++ esp_hal::rng::TrngSource::decrease_entropy_source_counter(unsafe { ++ esp_hal::Internal::conjure() ++ }); ++ + unsafe { +- btdm_controller_deinit(); ++ esp_bt_controller_deinit(); + } + // Disabling the PHY happens automatically, when the BLEController gets dropped. + } +@@ -404,7 +449,7 @@ + pub(crate) fn send_hci(data: &[u8]) -> usize { + // make sure the packet buffer doesn't get touched until sent + while PACKET_IN_FLIGHT.load(Ordering::Acquire) {} +- while unsafe { !API_vhci_host_check_send_available() } { ++ while unsafe { !esp_vhci_host_check_send_available() } { + trace!("can_send is false"); + } + +@@ -416,7 +461,7 @@ + core::future::poll_fn(|cx| { + PACKET_SENT_WAKER.register(cx.waker()); + if PACKET_IN_FLIGHT.load(Ordering::Acquire) +- || unsafe { !API_vhci_host_check_send_available() } ++ || unsafe { !esp_vhci_host_check_send_available() } + { + core::task::Poll::Pending + } else { +@@ -437,7 +482,10 @@ + ble_os_adapter_chip_specific::BTDM_ASYNC_WAKEUP_REQ_HCI, + ); + +- API_vhci_host_send_packet(packet.as_ptr(), packet.len() as u16); ++ esp_vhci_host_send_packet(packet.as_ptr(), packet.len() as u16); ++ // esp-emu copies the packet synchronously and never calls notify_host_send_available ++ // (ESP-IDF hosts don't wait for it), so the buffer is free again right away ++ PACKET_IN_FLIGHT.store(false, Ordering::Release); + + #[cfg(all(esp32, feature = "coex"))] + ble_os_adapter_chip_specific::async_wakeup_request_end( diff --git a/sim/run.sh b/sim/run.sh index 690077d..4dac303 100755 --- a/sim/run.sh +++ b/sim/run.sh @@ -1,7 +1,6 @@ #!/bin/sh # Builds the `sim` firmware against the esp-emu patched crates (with the bus tap) and runs it in -# esp-emu with the host device models (sim/bus.py). The patches live in $ESP_SIM_PATCHES -# (default ~/tmp/esp-sim-patches, see its README). +# esp-emu with the host device models (sim/bus.py). The patches are in sim/patches/. # sim/run.sh mdns finds the server # SIM_WS_URL=ws://192.168.4.1:8080 sim/run.sh fixed url (192.168.4.1 = this machine) # SIM_PROVISION=1 sim/run.sh no seeded Wi-Fi: AP provisioning, panel on http://127.0.0.1:18080 @@ -18,10 +17,9 @@ HW="${SIM_HW:-v4}" case "$HW" in v3) T=target/sim-v3 ;; v4) T=target/sim ;; *) echo "SIM_HW must be v3 or v4" >&2; exit 2 ;; esac mkdir -p "$T" PATCHES="" -ESP_SIM_PATCHES="${ESP_SIM_PATCHES:-$HOME/tmp/esp-sim-patches}" locked() { grep -A1 "^name = \"$1\"\$" Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p'; } for CRATE in esp-hal esp-radio; do - DIR=$("$ESP_SIM_PATCHES/apply.sh" "$CRATE-$(locked "$CRATE")") + DIR=$(sim/patches/apply.sh "$CRATE-$(locked "$CRATE")") PATCHES="$PATCHES --config patch.crates-io.$CRATE.path=\"$DIR\"" done if [ -n "${SIM_WIFIMANAGER:-}" ]; then diff --git a/src/sim.rs b/src/sim.rs index b793980..f1d3ed8 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -1,6 +1,6 @@ //! esp-emu simulation build (feature `sim`, see sim/README.md). //! -//! The firmware runs its real drivers: the esp-sim-patches bus tap (so `sim` builds need the +//! The firmware runs its real drivers: the sim/patches bus tap (so `sim` builds need the //! patched esp-hal, see sim/run.sh) hands GPIO, I2C, SPI and ADC traffic to device models on //! the host (sim/bus.py). This module only adds what the host can't see: a machine-readable //! state line and a Wi-Fi radio switch, read from the tap's console input. From 38d75c0d7068c3447e89ef776ad68148b87b24a7 Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:34:24 +0200 Subject: [PATCH 122/132] fix: sim builds don't leak a patched lockfile into each other --- sim/run.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/sim/run.sh b/sim/run.sh index 4dac303..f1feb4f 100755 --- a/sim/run.sh +++ b/sim/run.sh @@ -26,9 +26,14 @@ if [ -n "${SIM_WIFIMANAGER:-}" ]; then PATCHES="$PATCHES --config patch.crates-io.esp-hal-wifimanager.path=\"$SIM_WIFIMANAGER\"" fi -# the patches must not leak into the committed lockfile +# the patches must not leak into the committed lockfile; one build at a time, or a second build +# would back up the first one's patched lockfile +LOCK=target/sim-build.lock +mkdir -p target +while ! mkdir "$LOCK" 2>/dev/null; do sleep 1; done cp Cargo.lock "$T/Cargo.lock.bak" -trap 'cp "$T/Cargo.lock.bak" Cargo.lock' EXIT +trap 'cp "$T/Cargo.lock.bak" Cargo.lock; rmdir "$LOCK"' EXIT +trap 'exit 1' INT TERM # shellcheck disable=SC2086 # PATCHES holds several --config arguments if [ -n "${SIM_WIFIMANAGER:-}" ]; then # a local version differing from the lockfile's would otherwise be ignored by the patch @@ -42,6 +47,7 @@ fi # shellcheck disable=SC2086 ESP_SIM_TAP=1 CARGO_TARGET_DIR=$T cargo build -r --no-default-features --features $HW,sim $PATCHES cp "$T/Cargo.lock.bak" Cargo.lock +rmdir "$LOCK" trap - EXIT ELF=$T/riscv32imc-unknown-none-elf/release/fkm-firmware From 4650fbc0abae97d2ff8985ec1acaf2ea2fe1659c Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:39:15 +0200 Subject: [PATCH 123/132] fix: show why a solve wasn't entered --- src/rfid.rs | 63 +++++++++++++++++++++++++--------------------------- src/state.rs | 9 ++++++++ 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/rfid.rs b/src/rfid.rs index 3c4e91a..2dfb139 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -355,18 +355,7 @@ async fn handle_card( match resp { Ok(resp) => process_card_info_response(resp, global_state).await, - Err(e) => { - log::error!( - "[RFID] Resp_error: ({}): {:?}", - e.should_reset_time, - e.error - ); - - global_state.ui.update(|u| u.error_text = Some(e.error)); - if e.should_reset_time { - global_state.reset_solve(false).await; - } - } + Err(e) => global_state.show_api_error(e).await, } if card.selected.is_some() { @@ -384,7 +373,7 @@ async fn handle_card( enum CardAction { Nothing, EmptyGroups, - SendSolve(crate::structs::TimerPacketInner, u64), + SendSolve(crate::structs::TimerPacketInner), SameCards, } @@ -459,21 +448,18 @@ async fn process_card_info_response(resp: CardInfoResponsePacket, global_state: .get_or_insert_with(crate::state::new_session_id) .clone(); - return CardAction::SendSolve( - crate::structs::TimerPacketInner::Solve { - solve_time, - penalty: s.penalty.unwrap_or(0) as i64, - competitor_id: resp.card_id, - judge_id: current_judge, - timestamp: current_epoch(), - session_id, - delegate: false, - inspection_time, - group_id, - sign_key: crate::state::SETTINGS.read(|s| s.sign_key), - }, - resp.card_id, - ); + return CardAction::SendSolve(crate::structs::TimerPacketInner::Solve { + solve_time, + penalty: s.penalty.unwrap_or(0) as i64, + competitor_id: resp.card_id, + judge_id: current_judge, + timestamp: current_epoch(), + session_id, + delegate: false, + inspection_time, + group_id, + sign_key: crate::state::SETTINGS.read(|s| s.sign_key), + }); } CardAction::Nothing } @@ -489,19 +475,30 @@ async fn process_card_info_response(resp: CardInfoResponsePacket, global_state: .ui .update(|u| u.error_text = Some(error.as_str().into())); } - CardAction::SendSolve(packet, competitor) => { + CardAction::SendSolve(packet) => { + let session_id = match &packet { + crate::structs::TimerPacketInner::Solve { session_id, .. } => { + Some(session_id.clone()) + } + _ => None, + }; let resp = crate::ws::request::( packet, Duration::from_millis(crate::consts::WS_REQUEST_TIMEOUT_MS), ) .await; - let Ok(resp) = resp else { - return; + // the solve may have been reset meanwhile (e.g. competitor reset hold) + let same_attempt = global_state + .solve + .read(|s| session_id.is_some() && s.session_id == session_id); + let resp = match resp { + Ok(resp) => resp, + Err(e) if same_attempt => return global_state.show_api_error(e).await, + Err(e) => return log::warn!("Solve error for a reset attempt: {:?}", e.error), }; log::warn!("solve_resp: {resp:?}"); - // the solve may have been reset meanwhile (e.g. competitor reset hold) - if global_state.solve.read(|s| s.current_competitor) == Some(competitor) { + if same_attempt { global_state.reset_solve(true).await; } diff --git a/src/state.rs b/src/state.rs index ef8ba11..bb3dcca 100644 --- a/src/state.rs +++ b/src/state.rs @@ -483,6 +483,15 @@ impl GlobalStateInner { crate::translations::restore_default_locale(); } + /// Shows a failed request's error; the server may also ask to drop the attempt. + pub async fn show_api_error(&self, e: crate::structs::ApiError) { + log::error!("Request error ({}): {:?}", e.should_reset_time, e.error); + self.ui.update(|u| u.error_text = Some(e.error)); + if e.should_reset_time { + self.reset_solve(false).await; + } + } + /// e2e: back to a clean attempt and no messages. #[cfg(feature = "e2e")] pub fn hard_reset(&self) { From 8d3406b068817b22fdfae481db5ad9f84cb79142 Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:42:54 +0200 Subject: [PATCH 124/132] chore: remove obsolete firmware e2e feature --- Cargo.toml | 1 - host-tests/Cargo.toml | 1 - host-tests/src/lib.rs | 19 -------- host-tests/src/old_structs.rs | 33 -------------- src/buttons.rs | 23 ---------- src/consts.rs | 4 +- src/lcd_v3.rs | 6 --- src/lcd_v4.rs | 3 -- src/main.rs | 3 -- src/rfid.rs | 54 ++++++----------------- src/stackmat.rs | 83 +---------------------------------- src/state.rs | 46 +------------------ src/structs.rs | 33 -------------- src/utils/buttons.rs | 2 +- src/utils/stackmat.rs | 8 ++-- src/ws.rs | 52 ++-------------------- 16 files changed, 27 insertions(+), 344 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 39a6f27..50555fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,6 @@ sim = ["auto_add"] default = ["sleep", "v4", "timer-func"] bat_dev_lcd = [] release_build = ["sleep"] -e2e = [] qa = [] timer-func = [] sleep = [] diff --git a/host-tests/Cargo.toml b/host-tests/Cargo.toml index 6004ad3..3443ca4 100644 --- a/host-tests/Cargo.toml +++ b/host-tests/Cargo.toml @@ -14,7 +14,6 @@ profont = "0.7.0" qrcodegen-no-heap = "1.8.1" [features] -e2e = [] v3 = [] v4 = [] timer-func = [] diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index be4fc63..d935433 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -172,23 +172,4 @@ mod outgoing_json { same(&TimerPacket { tag: Some(u64::MAX), data }); } } - - #[cfg(feature = "e2e")] - #[test] - fn test_ack_identical() { - same(&TimerPacket { - tag: None, - data: TimerPacketInner::TestAck(crate::structs::SnapshotData { - scene: 3, - inspection_time: Some(15000), - solve_time: None, - penalty: Some(-1), - time_confirmed: true, - possible_groups: 2, - group_selected_idx: 1, - current_competitor: Some(9), - current_judge: None, - }), - }); - } } diff --git a/host-tests/src/old_structs.rs b/host-tests/src/old_structs.rs index 3956033..a6ac75a 100644 --- a/host-tests/src/old_structs.rs +++ b/host-tests/src/old_structs.rs @@ -117,39 +117,6 @@ pub enum TimerPacketInner { volume: Option, }, DumpCrashLog, - - // packet for end to end testing - #[cfg(feature = "e2e")] - TestPacket(TestPacketData), - - #[cfg(feature = "e2e")] - TestAck(SnapshotData), -} - -#[cfg(feature = "e2e")] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum TestPacketData { - HardStateReset, - ResetState, - ScanCard(u64), - ButtonPress { pin: u8, press_time: u64 }, - StackmatTime(u64), - StackmatReset, -} - -#[cfg(feature = "e2e")] -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SnapshotData { - pub scene: usize, - pub inspection_time: Option, - pub solve_time: Option, - pub penalty: Option, - pub time_confirmed: bool, - pub possible_groups: usize, - pub group_selected_idx: usize, - pub current_competitor: Option, - pub current_judge: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/buttons.rs b/src/buttons.rs index 4d153c9..7a8da3d 100644 --- a/src/buttons.rs +++ b/src/buttons.rs @@ -64,11 +64,6 @@ pub async fn buttons_task( async fn button_loop(state: GlobalState, mut inputs: ButtonInputs) { let mut press: Option = None; - // simulated press: (start, length ms, button index) - #[cfg(feature = "e2e")] - let mut e2e_press = (Instant::now(), 0, 0); - #[cfg(feature = "e2e")] - let mut send_ack = false; #[cfg(feature = "qa")] let mut qa_down = None; @@ -77,17 +72,6 @@ async fn button_loop(state: GlobalState, mut inputs: ButtonInputs) { #[allow(unused_mut)] let mut extra = 0u8; - #[cfg(feature = "e2e")] - { - if let Some((btn_idx, press_ms)) = state.e2e.buttons_sig.try_take() { - e2e_press = (Instant::now(), press_ms, btn_idx); - send_ack = true; - log::debug!("[E2E] Button pressed: {btn_idx} for {press_ms}ms"); - } - if e2e_press.0.elapsed().as_millis() <= e2e_press.1 { - extra |= 1 << e2e_press.2; - } - } if let Some((prev, next)) = inputs.poll(extra) { #[cfg(feature = "qa")] @@ -104,12 +88,6 @@ async fn button_loop(state: GlobalState, mut inputs: ButtonInputs) { } else if let Some(p) = press.take() { on_up(&state, p.button).await; } - - #[cfg(feature = "e2e")] - if prev != 0 && send_ack { - crate::ws::send_test_ack(&state); - send_ack = false; - } } if let Some(p) = press.as_mut() { @@ -628,7 +606,6 @@ fn delegate_send(state: &GlobalState) { return; } - #[cfg(not(feature = "e2e"))] if !crate::state::SESSION.read(|s| s.trust_server) { log::error!("Skipping delegate hold. Server not trusted!"); return; diff --git a/src/consts.rs b/src/consts.rs index 02d049c..7164f6b 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,7 +1,7 @@ -/// The device never sleeps without the `sleep` feature or in e2e/qa/sim builds. +/// The device never sleeps without the `sleep` feature or in qa/sim builds. pub const SLEEP_ENABLED: bool = cfg!(all( feature = "sleep", - not(any(feature = "e2e", feature = "qa", feature = "sim")) + not(any(feature = "qa", feature = "sim")) )); pub const SLEEP_AFTER_MS: u64 = 60000 * 5; pub const DEEPER_SLEEP_AFTER_MS: u64 = 60000 * 15; diff --git a/src/lcd_v3.rs b/src/lcd_v3.rs index 6e60379..23fadc1 100644 --- a/src/lcd_v3.rs +++ b/src/lcd_v3.rs @@ -409,17 +409,11 @@ fn render(s: &View, text: &mut LcdText, local: &Local) -> Live { TranslationKey::SERVER_DISCONNECTED_FOOTER, ), Screen::DeviceNotAdded => { - #[cfg(not(feature = "e2e"))] tr( text, TranslationKey::DEVICE_NOT_ADDED_HEADER, TranslationKey::DEVICE_NOT_ADDED_FOOTER, ); - #[cfg(feature = "e2e")] - { - text.print(0, "Press submit", Align::Center, true); - text.print(1, "To start HIL", Align::Center, true); - } } Screen::StackmatLost => tr( text, diff --git a/src/lcd_v4.rs b/src/lcd_v4.rs index b9a708e..e29ac5c 100644 --- a/src/lcd_v4.rs +++ b/src/lcd_v4.rs @@ -322,13 +322,10 @@ fn render(s: &View, f: &mut Frame, local: &Local) -> Live { ), ), Screen::DeviceNotAdded => { - #[cfg(not(feature = "e2e"))] let lines = tr_lines( TranslationKey::DEVICE_NOT_ADDED_HEADER, TranslationKey::DEVICE_NOT_ADDED_FOOTER, ); - #[cfg(feature = "e2e")] - let lines = two_lines("Press submit", "To start HIL"); ui::text_main(f, &lines); } Screen::StackmatLost => ui::text_main( diff --git a/src/main.rs b/src/main.rs index 042c8cd..10d989d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -103,9 +103,6 @@ async fn main(spawner: Spawner) { log::info!("Hardware Rev: {}", version::HW_VER); log::info!("Firmware: {}", version::FIRMWARE); - #[cfg(feature = "e2e")] - log::info!("This firmware is in E2E mode!"); - #[cfg(feature = "qa")] log::info!("This firmware is in QA mode!"); diff --git a/src/rfid.rs b/src/rfid.rs index 2dfb139..44610c9 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -84,11 +84,8 @@ pub async fn rfid_task( esp_hal_mfrc522::Mfrc522::new(esp_hal_mfrc522::SpiInterface::new(spi)) }; - // e2e cards are simulated: there is no reader to bring up - if !cfg!(feature = "e2e") { - init_until_ready(&mut mfrc522).await; - log::debug!("PCD ver: {:?}", mfrc522.version().await); - } + init_until_ready(&mut mfrc522).await; + log::debug!("PCD ver: {:?}", mfrc522.version().await); let mut rfid_sleep = false; let mut last_card = (0, Instant::now()); @@ -118,7 +115,7 @@ pub async fn rfid_task( continue; } - let Some(card) = next_card(&mut mfrc522, &global_state).await else { + let Some(card) = next_card(&mut mfrc522).await else { continue; }; let card_uid = card.uid; @@ -153,41 +150,22 @@ pub async fn rfid_task( } } -/// A scanned card. `selected` is the reader's selection; simulated e2e cards have none, so they -/// skip everything that talks to a real card (trust, signing, secure check, halt). +/// A scanned card and the reader's selection. struct Card { uid: u128, selected: Option, } -/// The next scanned card: from the reader, or the simulated e2e card source. -async fn next_card( - mfrc522: &mut Mfrc522, - global_state: &GlobalState, -) -> Option { - #[cfg(feature = "e2e")] - { - let _ = mfrc522; - let uid = global_state.e2e.card_scan_sig.try_take()?; - log::debug!("[E2E] Card scan: {uid}"); - Some(Card { - uid, - selected: None, - }) - } - - #[cfg(not(feature = "e2e"))] - { - let _ = global_state; - if !matches!(mfrc522.is_new_card_present().await, Ok(true)) { - return None; - } - let selected = mfrc522.select().await.ok()?; - Some(Card { - uid: selected.to_u128(), - selected: Some(selected), - }) +/// The next scanned card from the reader. +async fn next_card(mfrc522: &mut Mfrc522) -> Option { + if !matches!(mfrc522.is_new_card_present().await, Ok(true)) { + return None; } + let selected = mfrc522.select().await.ok()?; + Some(Card { + uid: selected.to_u128(), + selected: Some(selected), + }) } /// Re-initializes the reader until it answers (logs the failure once). @@ -364,9 +342,6 @@ async fn handle_card( _ = mfrc522.stop_crypto1().await; } } - - #[cfg(feature = "e2e")] - crate::ws::send_test_ack(global_state); } /// What to do after the state was updated for a scanned card. @@ -433,8 +408,7 @@ async fn process_card_info_response(resp: CardInfoResponsePacket, global_state: return CardAction::Nothing; }; - // e2e runs against an untrusted test server - if !cfg!(feature = "e2e") && !crate::state::SESSION.read(|s| s.trust_server) { + if !crate::state::SESSION.read(|s| s.trust_server) { log::error!("Skipping solve send. Server not trusted!"); return CardAction::Nothing; } diff --git a/src/stackmat.rs b/src/stackmat.rs index 9e0619d..3cd08b4 100644 --- a/src/stackmat.rs +++ b/src/stackmat.rs @@ -6,11 +6,6 @@ use crate::{ consts::{INSPECTION_TIME_DNF, INSPECTION_TIME_PLUS2}, state::{GlobalState, Scene, Solve}, }; -// the e2e uart simulator replaces the timed read -#[cfg_attr( - all(feature = "e2e", not(feature = "timer-func")), - allow(unused_imports) -)] use embassy_time::{Duration, Instant, Timer, with_timeout}; /// Publishes the running time to the display, the BLE display and the logger. @@ -155,18 +150,10 @@ pub async fn stackmat_task( ); return; }; - #[cfg_attr(feature = "e2e", allow(unused_mut, unused_variables))] let mut uart = uart.with_rx(uart_pin).into_async(); - #[cfg(feature = "e2e")] - let mut sim = e2e::Sim::default(); let mut parser = Parser::default(); - let rx_delay = if cfg!(feature = "e2e") { - 0 - } else { - FRAME_RX_DELAY_US - }; - let mut tracker = Tracker::new(rx_delay); + let mut tracker = Tracker::new(FRAME_RX_DELAY_US); let mut connected: Option = None; let mut buf = [0u8; 16]; @@ -181,7 +168,6 @@ pub async fn stackmat_task( _ => 100, }; - #[cfg(not(feature = "e2e"))] let n = match with_timeout(Duration::from_millis(wait), uart.read_async(&mut buf)).await { Ok(Ok(n)) => n, Ok(Err(e)) => { @@ -191,12 +177,6 @@ pub async fn stackmat_task( } Err(_) => 0, }; - #[cfg(feature = "e2e")] - let n = { - let _ = wait; - Timer::after_millis(10).await; - sim.frame(&global_state, &mut buf) - }; if sleeping { continue; @@ -243,11 +223,6 @@ pub async fn stackmat_task( None => {} } - #[cfg(feature = "e2e")] - if sim.take_ack() { - crate::ws::send_test_ack(&global_state); - } - show_time(&global_state, frame.1); crate::bench!(FRAME_PUBLISH, Instant::now().as_micros().saturating_sub(at)); } @@ -311,62 +286,6 @@ fn reset_from_timer(s: &mut Solve) { } } -/// e2e: frames generated from the test server's commands instead of the UART. -#[cfg(all(feature = "e2e", not(feature = "timer-func")))] -mod e2e { - use crate::{state::GlobalState, utils::stackmat::StackmatTimerState}; - - pub struct Sim { - state: StackmatTimerState, - target_ms: u64, - since: embassy_time::Instant, - ack: bool, - } - - impl Default for Sim { - fn default() -> Self { - Self { - state: StackmatTimerState::Reset, - target_ms: 0, - since: embassy_time::Instant::now(), - ack: false, - } - } - } - - impl Sim { - /// Writes the current frame into `buf`, returns its length. - pub fn frame(&mut self, global_state: &GlobalState, buf: &mut [u8; 16]) -> usize { - if let Some((state, ms)) = global_state.e2e.stackmat_sig.try_take() { - (self.state, self.target_ms, self.since) = - (state, ms, embassy_time::Instant::now()); - } - - let ms = match self.state { - StackmatTimerState::Unknown | StackmatTimerState::Reset => 0, - StackmatTimerState::Running => { - let ms = self.since.elapsed().as_millis(); - if ms >= self.target_ms { - self.state = StackmatTimerState::Stopped; - self.ack = true; - } - ms.min(self.target_ms) - } - StackmatTimerState::Stopped => self.target_ms, - }; - - buf[..8].copy_from_slice(&crate::utils::stackmat::generate_stackmat_data( - self.state, ms, - )); - 8 - } - - pub fn take_ack(&mut self) -> bool { - core::mem::take(&mut self.ack) - } - } -} - async fn time_end(time: u64, dnf: bool, global_state: &GlobalState) { // `None` = this attempt already had a time; `Some(saved copy)` = new time recorded let recorded = global_state.solve.update(|s| { diff --git a/src/state.rs b/src/state.rs index bb3dcca..ec2f1c6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -277,21 +277,6 @@ impl Solve { self.scene = Scene::Finished; } } - - #[cfg(feature = "e2e")] - pub fn snapshot_data(&self) -> crate::structs::SnapshotData { - crate::structs::SnapshotData { - scene: self.scene as usize, - inspection_time: self.inspection_end.and(self.inspection_ms()), - penalty: self.penalty, - solve_time: self.solve_time, - current_judge: self.current_judge, - current_competitor: self.current_competitor, - group_selected_idx: self.group_selected_idx, - time_confirmed: self.time_confirmed, - possible_groups: self.possible_groups.len(), - } - } } /// Menus, cursors and messages shown on top of the scene (buttons write, display reads). @@ -417,14 +402,6 @@ pub fn current_epoch() -> u64 { .saturating_add(Instant::now().as_secs()) } -#[cfg(feature = "e2e")] -#[derive(Default)] -pub struct End2End { - pub buttons_sig: Signal, - pub card_scan_sig: Signal, - pub stackmat_sig: Signal, -} - pub type GlobalState = &'static GlobalStateInner; pub struct GlobalStateInner { pub solve: Shared, @@ -447,9 +424,6 @@ pub struct GlobalStateInner { pub nvs: &'static Nvs, pub aes: Mutex>, - - #[cfg(feature = "e2e")] - pub e2e: End2End, } impl GlobalStateInner { @@ -468,8 +442,6 @@ impl GlobalStateInner { buzzer_sound_test: Signal::new(), nvs, aes: Mutex::new(Aes::new(aes)), - #[cfg(feature = "e2e")] - e2e: End2End::default(), } } @@ -492,20 +464,6 @@ impl GlobalStateInner { } } - /// e2e: back to a clean attempt and no messages. - #[cfg(feature = "e2e")] - pub fn hard_reset(&self) { - self.solve.update(|s| { - s.reset(); - s.delegate_hold = None; - }); - self.timer.sender().clear(); - self.ui.update(|u| { - u.error_text = None; - u.custom_message = None; - }); - } - /// Read-only view of everything the display draws (borrowed, no copy of the lists). pub fn view(&self, f: impl FnOnce(&View) -> R) -> R { let (status, battery) = (self.status.read(|s| *s), self.battery.read(|b| *b)); @@ -559,8 +517,8 @@ pub struct SavedGlobalState { pub solve_group: PossibleGroup, } -/// e2e/qa runs never persist an unfinished solve. -const PERSIST_SOLVES: bool = !cfg!(any(feature = "e2e", feature = "qa")); +/// QA runs never persist an unfinished solve. +const PERSIST_SOLVES: bool = !cfg!(feature = "qa"); impl SavedGlobalState { pub async fn from_nvs(nvs: &Nvs) -> Option { diff --git a/src/structs.rs b/src/structs.rs index 1786ed6..294fb84 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -129,39 +129,6 @@ pub enum TimerPacketInner { volume: Option, }, DumpCrashLog, - - // packet for end to end testing - #[cfg(feature = "e2e")] - TestPacket(TestPacketData), - - #[cfg(feature = "e2e")] - TestAck(SnapshotData), -} - -#[cfg(feature = "e2e")] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data")] -pub enum TestPacketData { - HardStateReset, - ResetState, - ScanCard(u64), - ButtonPress { pin: u8, press_time: u64 }, - StackmatTime(u64), - StackmatReset, -} - -#[cfg(feature = "e2e")] -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SnapshotData { - pub scene: usize, - pub inspection_time: Option, - pub solve_time: Option, - pub penalty: Option, - pub time_confirmed: bool, - pub possible_groups: usize, - pub group_selected_idx: usize, - pub current_competitor: Option, - pub current_judge: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/utils/buttons.rs b/src/utils/buttons.rs index ecb6f4f..94da37a 100644 --- a/src/utils/buttons.rs +++ b/src/utils/buttons.rs @@ -76,7 +76,7 @@ impl ButtonInputs { mask } - /// Samples the buttons (OR-ed with `extra`, e.g. simulated e2e presses) and returns + /// Samples the buttons (OR-ed with `extra`, e.g. injected QA presses) and returns /// `(previous, new)` mask when it changed. pub fn poll(&mut self, extra: u8) -> Option<(u8, u8)> { let new = self.sample() | extra; diff --git a/src/utils/stackmat.rs b/src/utils/stackmat.rs index 04d47df..9ad71e4 100644 --- a/src/utils/stackmat.rs +++ b/src/utils/stackmat.rs @@ -15,7 +15,7 @@ pub const FRAME_RX_DELAY_US: u64 = 8 * BYTE_US; pub const DISCONNECT_US: u64 = 500_000; /// Largest time a stackmat frame can carry (9:59.999). -#[cfg(any(all(feature = "e2e", not(feature = "timer-func")), test))] +#[cfg(test)] const MAX_FRAME_MS: u64 = 9 * 60_000 + 59_999; #[derive(PartialEq, Debug, Clone, Copy)] @@ -36,7 +36,7 @@ impl StackmatTimerState { } } - #[cfg(any(all(feature = "e2e", not(feature = "timer-func")), test))] + #[cfg(test)] pub fn to_u8(self) -> u8 { match self { Self::Unknown => 0, @@ -197,8 +197,8 @@ impl Tracker { } } -/// Builds a gen4 frame (e2e simulation); times above 9:59.999 are clamped. -#[cfg(any(all(feature = "e2e", not(feature = "timer-func")), test))] +/// Builds a gen4 frame for tests; times above 9:59.999 are clamped. +#[cfg(test)] pub fn generate_stackmat_data(state: StackmatTimerState, time_ms: u64) -> [u8; 8] { let time_ms = time_ms.min(MAX_FRAME_MS); let digit = |v: u64| b'0' + (v % 10) as u8; diff --git a/src/ws.rs b/src/ws.rs index dc33434..a8b95b0 100644 --- a/src/ws.rs +++ b/src/ws.rs @@ -99,12 +99,6 @@ pub fn close(code: u16, reason: &'static str) { WAKE.signal(()); } -#[cfg(feature = "e2e")] -pub fn send_test_ack(global_state: &GlobalState) { - let data = TimerPacketInner::TestAck(global_state.solve.read(|s| s.snapshot_data())); - _ = send(TimerPacket { tag: None, data }); -} - /// Frees its request slot when dropped (also when the waiting future is cancelled). struct SlotGuard(&'static Slot); @@ -501,12 +495,9 @@ async fn handshake( s.fkm_token = fkm_token; }); } else { - #[cfg(not(feature = "e2e"))] - { - global_state - .ui - .update(|u| u.error_text = Some("Server Not Trusted!".into())); - } + global_state + .ui + .update(|u| u.error_text = Some("Server Not Trusted!".into())); } } @@ -660,7 +651,6 @@ async fn handle_packet( if firmware.as_str() != crate::version::FIRMWARE { return Ok(()); } - #[cfg(not(feature = "e2e"))] if !crate::state::SESSION.read(|s| s.trust_server) { return Ok(()); } @@ -692,10 +682,6 @@ async fn handle_packet( } } TimerPacketInner::DumpCrashLog => owed.crash_log = true, - #[cfg(feature = "e2e")] - TimerPacketInner::TestPacket(test_packet) => { - parse_test_packet(test_packet.clone(), &global_state).await; - } _ => {} } @@ -721,7 +707,6 @@ fn handle_ota_chunk( if !ota_active() { return Ok(()); } - #[cfg(not(feature = "e2e"))] if !crate::state::SESSION.read(|s| s.trust_server) { return Ok(()); } @@ -744,37 +729,6 @@ fn handle_ota_chunk( Ok(()) } -#[cfg(feature = "e2e")] -async fn parse_test_packet( - test_packet: crate::structs::TestPacketData, - global_state: &GlobalState, -) { - use crate::structs::TestPacketData; - use crate::utils::stackmat::StackmatTimerState; - - log::warn!("TEST PACKET: {test_packet:?}"); - let e2e = &global_state.e2e; - match test_packet { - TestPacketData::ResetState => { - global_state.reset_solve(false).await; - e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)); - send_test_ack(global_state); - } - TestPacketData::HardStateReset => { - e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)); - global_state.hard_reset(); - } - TestPacketData::ScanCard(uid) => e2e.card_scan_sig.signal(uid as u128), - TestPacketData::ButtonPress { pin, press_time } => { - e2e.buttons_sig.signal((pin as usize, press_time)) - } - TestPacketData::StackmatTime(ms) => { - e2e.stackmat_sig.signal((StackmatTimerState::Running, ms)) - } - TestPacketData::StackmatReset => e2e.stackmat_sig.signal((StackmatTimerState::Reset, 0)), - } -} - // boxing the TLS connection would need the heap; only one Socket exists at a time #[allow(clippy::large_enum_variant)] enum Socket<'a, 'b> { From 4debe4638880fbdb87c2abcbcc33e055b3a2da6d Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:42:54 +0200 Subject: [PATCH 125/132] chore: use fake FKMTime throughout sim tooling --- sim/README.md | 10 ++- sim/competition.py | 4 +- sim/monkey.py | 65 ++++--------------- sim/scenarios/cutoff-not-passed.json | 2 +- sim/scenarios/delegate-accept.json | 2 +- .../delegate-answer-other-attempt.json | 2 +- sim/scenarios/delegate-change.json | 2 +- sim/scenarios/delegate-delete.json | 2 +- sim/scenarios/delegate-extra.json | 2 +- sim/scenarios/delegate-last-attempt.json | 2 +- sim/scenarios/delegate-late.json | 2 +- sim/scenarios/delegate-no-cards.json | 2 +- sim/scenarios/lost-reply-heartbeat.json | 2 +- sim/scenarios/lost-reply.json | 2 +- sim/server.sh | 6 +- 15 files changed, 32 insertions(+), 75 deletions(-) diff --git a/sim/README.md b/sim/README.md index fc86cdb..caced41 100644 --- a/sim/README.md +++ b/sim/README.md @@ -115,7 +115,6 @@ showed, one payload per session id). python3 sim/monkey.py --seeds 1-40 --jobs 4 --minutes 5 # campaign (builds one image per job) python3 sim/monkey.py --replay 7 # same actions again python3 sim/monkey.py --replay-file sim/out/7/actions.json --shrink # minimize a failure -python3 sim/monkey.py --seed 7 --emit-hil tests.json # export for micro-connector's HIL for f in sim/scenarios/*.json; do python3 sim/monkey.py --replay-file $f; done # regressions ``` @@ -134,7 +133,7 @@ applied and corrected, DNF hold, long/double submit, wrong button, scrolling pas delegate call, Wi-Fi blip, abandoned attempt, solve over the limit, lost answer to a solve) plus server restarts mid-group and a draining battery. Each step's effect is checked on the device. -micro-connector's e2e plays FKMTime here (`E2E_SIM=1`, only on micro-connector's `sim-e2e` +micro-connector's `e2e` binary plays FKMTime here (fake server included on `master` branch: `--mc `): it keeps attempts by session id like FKMTime, refuses resends ("already entered"), ends a round at its cutoff ("No attempts left" + reset), DNFs at the time limit, and turns delegate calls into incidents. A seeded delegate answers each call after a few @@ -151,7 +150,7 @@ at once. ```sh python3 sim/monkey.py --mode competition --seeds 1-6 --jobs 3 --job-base 20 --max-attempts 36 \ - --mc ~/projects/micro-connector # on its sim-e2e branch + --mc ~/projects/micro-connector python3 sim/monkey.py --mode competition --seeds 1-4 --delegate-rate 8 # every decision a few times python3 sim/monkey.py --mode competition --hw v3 --seeds 1-6 --jobs 3 --job-base 20 \ --mc ~/projects/micro-connector @@ -168,12 +167,11 @@ stations (the firmware never asks for attendance), BLE. e2e's control socket (`Server.ctl`, JSON lines): `attempts`, `resolve` (`sessionId`, `status` resolved / standard / extra_given, optional `value` cs, `penalty`, `noCards`), `delete`, `drop_reply` (`espId`: the next attempt is entered, its answer lost). Scenarios use it with -`"sim_e2e": true`, optional `"server"` (e.g. `rounds` with attempts/cutoff/limit in cs) and the +optional `"server"` (e.g. `rounds` with attempts/cutoff/limit in cs) and the steps `resolve [args]` / `delete` (the open incident), `drop_reply`, `expect_db [rows]`; `expect [s]` takes an optional timeout. Failures land in `sim/out//` (actions, firmware log with timestamps, server log). Needs a -built micro-connector (`cargo build --bins` in it; `--mc` path; `--sim-e2e` lets e2e play FKMTime -here too). The `state` sim command prints the +built micro-connector (`cargo build --bins` in it; `--mc` path). The `state` sim command prints the machine-readable line the monkey reads. Not covered: pads (`timer-func`), BLE, Wi-Fi drops (esp-emu can't drop the link), real display/RFID timing. diff --git a/sim/competition.py b/sim/competition.py index 4cfe72e..f3a0876 100644 --- a/sim/competition.py +++ b/sim/competition.py @@ -4,7 +4,7 @@ Every human action is a planned step with a defined outcome. The oracle models the firmware rules (src/buttons.rs, src/rfid.rs, src/stackmat.rs) independently and checks the device state after each -step. micro-connector's e2e plays FKMTime (`sim-e2e` branch, Server(sim=True)): a delegate answers +step. micro-connector's e2e plays FKMTime: a delegate answers each call with a seeded decision, cutoffs end rounds early, answers get lost. The oracle keeps the attempts FKMTime should hold (`Ledger`) and compares them with e2e's after each attempt and at the end: station, competitor, judge, group, attempt number, extra, status, value, penalty, replacement @@ -917,7 +917,7 @@ def __init__(self, seed, day, job, args, image): shutil.rmtree(self.dir) self.dir.mkdir(parents=True) self.server = M.Server(Path(args.mc).expanduser(), M.SRV_PORT + args.job_base + job, self.dir, - sim=True, extra=dict(groups=day["groups"], cards=day["cards"], + extra=dict(groups=day["groups"], cards=day["cards"], rounds=day.get("rounds", {}))) self.ledger, self.refused = Ledger(day), set() self.job, self.image = job, image diff --git a/sim/monkey.py b/sim/monkey.py index cb86899..1f20ad6 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -5,7 +5,6 @@ python3 sim/monkey.py --seeds 1-20 --jobs 4 --minutes 4 # fuzz python3 sim/monkey.py --replay 7 # rerun one seed (same actions) python3 sim/monkey.py --replay-file sim/out/7/actions.json --shrink - python3 sim/monkey.py --seed 7 --emit-hil /tmp/tests.json # scenario for micro-connector's HIL python3 sim/monkey.py --hw v3 --seeds 1-6 # v3 hardware (character LCD) Needs: esp-emu on PATH (or ~/.local/bin), a built micro-connector (--mc, default @@ -34,7 +33,6 @@ KNOWN_CARDS = {COMPETITOR, JUDGE, MULTI} # real thresholds (src/buttons.rs) and their neighbours HOLDS = [5, 20, 29, 31, 80, 150, 400, 990, 1010, 1500, 2990, 3010, 4500, 9990, 10010, 12500] -BUTTON_NAMES = {1: "inspect", 2: "delegate", 3: "submit", 4: "penalty"} SLOW = 3 # emulated time runs up to ~3x slower than real time with several instances SRV_PORT = 18100 # per-job server port base, away from the default 8080 other sims use HEAP_LIMIT = 0.92 # of the heap size printed by the firmware @@ -147,14 +145,12 @@ def check_invariants(s: dict): # ── processes ─────────────────────────────────────────────────────────────────────────────── class Server: - """micro-connector (dev, plain ws) + its e2e backend. HIL mode: e2e answers card lookups and - accepts every solve. Sim mode (`sim=True`, needs micro-connector's `sim-e2e` branch): e2e - plays FKMTime (attempts by session, incidents, extra attempts, cutoffs, limits), keeps running - across server restarts like the real FKMTime would, takes delegate decisions on its control - socket (`ctl`) and logs every request with its answer to srv/e2e.jsonl (`log`).""" - - def __init__(self, mc: Path, port: int, workdir: Path, fresh=False, sim=False, extra=None): - self.mc, self.port, self.dir, self.sim = mc, port, workdir, sim + """micro-connector (dev, plain ws) + fake FKMTime. Attempts and incidents survive + connector restarts; delegate decisions use `ctl`, requests and answers go to srv/e2e.jsonl. + """ + + def __init__(self, mc: Path, port: int, workdir: Path, fresh=False, extra=None): + self.mc, self.port, self.dir = mc, port, workdir self.e2e, self.backend = None, None # unix socket paths are limited to 104 bytes on macOS (SUN_LEN): keep them short self.sock, self.ctl_path = f"/tmp/fkm-monkey-{port}.sock", f"/tmp/fkm-monkey-{port}.ctl" @@ -163,11 +159,10 @@ def __init__(self, mc: Path, port: int, workdir: Path, fresh=False, sim=False, e if fresh: self.log_file.write("\n=== fresh server after reset settings ===\n") tests = json.load(open(mc / "tests.json")) - tests["tests"] = [{"name": "idle (monkey)", "steps": [{"type": "Sleep", "data": 86_400_000}]}] tests.update(extra or {}) (workdir / "srv").mkdir(exist_ok=True) json.dump(tests, open(workdir / "srv" / "tests.json", "w")) - if fresh and sim: # a new FKMTime: its attempts start over + if fresh: # a new FKMTime: its attempts start over (workdir / "srv" / "e2e.jsonl").unlink(missing_ok=True) def start(self): @@ -177,8 +172,7 @@ def start(self): env = dict(os.environ, SOCKET_PATH=self.sock) if self.e2e is None: os.unlink(self.sock) if os.path.exists(self.sock) else None - if self.sim: - env.update(E2E_SIM="1", E2E_CONTROL=self.ctl_path, E2E_LOG=str(srv / "e2e.jsonl")) + env.update(E2E_CONTROL=self.ctl_path, E2E_LOG=str(srv / "e2e.jsonl")) self.e2e = subprocess.Popen([self.mc / "target/debug/e2e"], cwd=srv, env=env, stdout=self.log_file, stderr=self.log_file) time.sleep(0.8) @@ -205,11 +199,8 @@ def stop(self): self.backend = self.e2e = None def restart(self, down_s: float): - """The server goes away for `down_s` (in sim mode FKMTime stays up).""" + """The connector goes away for `down_s`; fake FKMTime stays up.""" self._end(self.backend) - if not self.sim: - self._end(self.e2e) - self.e2e = None time.sleep(down_s) self.start() @@ -317,7 +308,7 @@ def __init__(self, seed, actions, job, args, image): base = 20000 + job * 10 self.ports = dict(http=base, uart1=base + 1, control=base + 2, tap=base + 3) self.server_extra = getattr(args, "server_extra", None) - self.server = Server(Path(args.mc).expanduser(), SRV_PORT + job, self.dir, sim=args.sim_e2e, + self.server = Server(Path(args.mc).expanduser(), SRV_PORT + job, self.dir, extra=self.server_extra) self.image = image self.executed = [] @@ -356,7 +347,7 @@ def check_alive(self): self.expected_boots += 2 self.server.stop() self.server = Server(self.server.mc, self.server.port, self.dir, fresh=True, - sim=self.server.sim, extra=self.server_extra) + extra=self.server_extra) self.server.start() self.reseed_after = self.expected_boots - 1 if self.reseed_after and boots >= self.reseed_after: @@ -613,30 +604,6 @@ def build(port: int, hw: str = "v4") -> tuple: return bin_, elf -def emit_hil(actions, path): - """Exports what micro-connector's HIL format can express (single buttons, cards, sleeps, - one random solve time); misuse it can't express is dropped and listed.""" - steps, dropped = [], set() - for delay, kind, *a in actions: - if delay: - steps.append({"type": "Sleep", "data": delay}) - if kind == "btn": - steps.append({"type": "Button", "data": {"name": BUTTON_NAMES[a[0]], "time": a[1]}}) - elif kind == "card": - steps.append({"type": "ScanCard", "data": a[0]}) - elif kind == "stk" and a[0] == "stop": - steps.append({"type": "SolveTime"}) - elif kind == "wait": - pass - elif kind == "expect": # scripted scenarios only: state field reaches a value - self.wait_for(lambda s: s.get(a[0]) == str(a[1]), 15, f"{a[0]}={a[1]}") - else: - dropped.add(f"{kind} {a[0] if a else ''}".strip()) - json.dump({"name": "monkey", "sleepBetween": 0, "steps": steps}, open(path, "w"), indent=1) - if dropped: - print("not expressible in tests.json (dropped):", ", ".join(sorted(dropped))) - - def run_one(seed, actions, job, args, image): deadline = time.monotonic() + args.minutes * 60 err = Run(seed, actions, job, args, image).execute(deadline) @@ -680,10 +647,9 @@ def main(): p.add_argument("--hw", choices=["v3", "v4"], default=os.environ.get("SIM_HW", "v4")) p.add_argument("--cfg-exit", type=int, help="index of Exit in the config menu (v3: 5, v4: 6)") p.add_argument("--mc", default="~/projects/micro-connector", - help="micro-connector checkout (competition mode needs its sim-e2e branch)") + help="micro-connector checkout with its fake FKMTime binary built") p.add_argument("--sim-e2e", action="store_true", - help="fuzz: e2e plays FKMTime (always on in competition mode)") - p.add_argument("--emit-hil") + help="compatibility option: e2e always plays FKMTime") p.add_argument("--shrink", action="store_true") p.add_argument("--shrink-runs", type=int, default=12) p.add_argument("--no-build", action="store_true") @@ -709,7 +675,6 @@ def main(): if args.replay_file: d = json.load(open(args.replay_file)) work = [(d["seed"], d["actions"])] - args.sim_e2e = args.sim_e2e or d.get("sim_e2e", False) args.server_extra = d.get("server") if any(str(a[1]).startswith("expect") for a in d["actions"]): args.chunk = 10**9 # scripted: no recovery probe between its steps @@ -723,10 +688,6 @@ def main(): seeds += range(int(lo), int(hi or lo) + 1) work = [(s, generate(s, args.actions, args.hw)) for s in seeds] - if args.emit_hil: - emit_hil(work[0][1], args.emit_hil) - return - jobs = max(1, min(args.jobs, len(work))) images = {} for j in range(jobs): diff --git a/sim/scenarios/cutoff-not-passed.json b/sim/scenarios/cutoff-not-passed.json index 46da1ee..a872976 100644 --- a/sim/scenarios/cutoff-not-passed.json +++ b/sim/scenarios/cutoff-not-passed.json @@ -1,4 +1,4 @@ -{"seed": 90109, "note": "cutoff missed: the next scan answers No attempts left and resets the device", "sim_e2e": true, "server": {"rounds": {"333-bf": {"attempts": 3, "cutoff": {"attempts": 1, "value": 100}}}}, "actions": [ +{"seed": 90109, "note": "cutoff missed: the next scan answers No attempts left and resets the device", "server": {"rounds": {"333-bf": {"attempts": 3, "cutoff": {"attempts": 1, "value": 100}}}}, "actions": [ [500, "card", 3004425529], [10, "expect", "scene", "CompetitorInfo"], [1500, "stk", "start"], diff --git a/sim/scenarios/delegate-accept.json b/sim/scenarios/delegate-accept.json index 6897f6a..ad0ee7b 100644 --- a/sim/scenarios/delegate-accept.json +++ b/sim/scenarios/delegate-accept.json @@ -1,4 +1,4 @@ -{"seed": 90100, "note": "delegate accepts the time: device confirms it, cards add the judge on the server", "sim_e2e": true, "actions": [ +{"seed": 90100, "note": "delegate accepts the time: device confirms it, cards add the judge on the server", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-answer-other-attempt.json b/sim/scenarios/delegate-answer-other-attempt.json index af9b537..93edb99 100644 --- a/sim/scenarios/delegate-answer-other-attempt.json +++ b/sim/scenarios/delegate-answer-other-attempt.json @@ -1,4 +1,4 @@ -{"seed": 90106, "note": "regression: the judge moved on while a delegate call was pending and the answer was applied to the next competitor's attempt", "sim_e2e": true, "actions": [ +{"seed": 90106, "note": "regression: the judge moved on while a delegate call was pending and the answer was applied to the next competitor's attempt", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-change.json b/sim/scenarios/delegate-change.json index a16744f..e63cf93 100644 --- a/sim/scenarios/delegate-change.json +++ b/sim/scenarios/delegate-change.json @@ -1,4 +1,4 @@ -{"seed": 90101, "note": "delegate changes time and penalty: the device shows and confirms the decision", "sim_e2e": true, "actions": [ +{"seed": 90101, "note": "delegate changes time and penalty: the device shows and confirms the decision", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-delete.json b/sim/scenarios/delegate-delete.json index 286cbe3..ee30d77 100644 --- a/sim/scenarios/delegate-delete.json +++ b/sim/scenarios/delegate-delete.json @@ -1,4 +1,4 @@ -{"seed": 90104, "note": "delegate deletes the incident: idle, the redo takes the same attempt number", "sim_e2e": true, "actions": [ +{"seed": 90104, "note": "delegate deletes the incident: idle, the redo takes the same attempt number", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-extra.json b/sim/scenarios/delegate-extra.json index 60ab46c..17fb036 100644 --- a/sim/scenarios/delegate-extra.json +++ b/sim/scenarios/delegate-extra.json @@ -1,4 +1,4 @@ -{"seed": 90103, "note": "delegate gives an extra attempt: idle, the redo is extra 1 replacing the attempt", "sim_e2e": true, "actions": [ +{"seed": 90103, "note": "delegate gives an extra attempt: idle, the redo is extra 1 replacing the attempt", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-last-attempt.json b/sim/scenarios/delegate-last-attempt.json index 85167db..c4660ee 100644 --- a/sim/scenarios/delegate-last-attempt.json +++ b/sim/scenarios/delegate-last-attempt.json @@ -1,4 +1,4 @@ -{"seed": 90107, "note": "regression: confirming a resolved last attempt was refused (No attempts left) and reset the device", "sim_e2e": true, "server": {"rounds": {"333-bf": {"attempts": 1}}}, "actions": [ +{"seed": 90107, "note": "regression: confirming a resolved last attempt was refused (No attempts left) and reset the device", "server": {"rounds": {"333-bf": {"attempts": 1}}}, "actions": [ [500, "card", 3004425529], [10, "expect", "scene", "CompetitorInfo"], [1500, "stk", "start"], diff --git a/sim/scenarios/delegate-late.json b/sim/scenarios/delegate-late.json index 939be94..1183ccc 100644 --- a/sim/scenarios/delegate-late.json +++ b/sim/scenarios/delegate-late.json @@ -1,4 +1,4 @@ -{"seed": 90105, "note": "no answer within the device's 5 min window: the solve stays, its resend is refused, the late answer is ignored", "sim_e2e": true, "actions": [ +{"seed": 90105, "note": "no answer within the device's 5 min window: the solve stays, its resend is refused, the late answer is ignored", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/delegate-no-cards.json b/sim/scenarios/delegate-no-cards.json index 699c09d..757f8de 100644 --- a/sim/scenarios/delegate-no-cards.json +++ b/sim/scenarios/delegate-no-cards.json @@ -1,4 +1,4 @@ -{"seed": 90102, "note": "delegate resolves without cards: the device goes back to idle", "sim_e2e": true, "actions": [ +{"seed": 90102, "note": "delegate resolves without cards: the device goes back to idle", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/lost-reply-heartbeat.json b/sim/scenarios/lost-reply-heartbeat.json index 8d31f4b..6672aed 100644 --- a/sim/scenarios/lost-reply-heartbeat.json +++ b/sim/scenarios/lost-reply-heartbeat.json @@ -1,4 +1,4 @@ -{"seed": 90110, "note": "regression: an answer slower than the 5 s heartbeat (here: lost, 7.5 s timeout) made micro-connector close the device's connection (missed heartbeat ticks fired in a burst), so its error never arrived", "sim_e2e": true, "actions": [ +{"seed": 90110, "note": "regression: an answer slower than the 5 s heartbeat (here: lost, 7.5 s timeout) made micro-connector close the device's connection (missed heartbeat ticks fired in a burst), so its error never arrived", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/scenarios/lost-reply.json b/sim/scenarios/lost-reply.json index fb81ffd..817fa92 100644 --- a/sim/scenarios/lost-reply.json +++ b/sim/scenarios/lost-reply.json @@ -1,4 +1,4 @@ -{"seed": 90108, "note": "the answer to a solve is lost: error, the resend is refused (already entered), the judge resets", "sim_e2e": true, "actions": [ +{"seed": 90108, "note": "the answer to a solve is lost: error, the resend is refused (already entered), the judge resets", "actions": [ [500, "card", 69420], [10, "expect", "scene", "CompetitorInfo"], [300, "btn", 1, 150], diff --git a/sim/server.sh b/sim/server.sh index e0d67b7..834a1cc 100755 --- a/sim/server.sh +++ b/sim/server.sh @@ -1,14 +1,12 @@ #!/bin/sh # Local server for the sim: micro-connector (dev mode, plain ws on :8080, no BT, no auto-update) -# plus its e2e backend answering card lookups from tests.json (its test cases replaced by one -# idle test, so it doesn't drive the device). +# plus its fake FKMTime backend using the cards/groups/rounds in tests.json. # sim/server.sh ~/projects/micro-connector set -eu MC=$(cd "${1:?usage: sim/server.sh }" && pwd) D=${TMPDIR:-/tmp}/fkm-sim-server mkdir -p "$D/fw" "$D/logs" -python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d["tests"]=[{"name":"idle (sim)","steps":[{"type":"Sleep","data":86400000}]}]; json.dump(d,open(sys.argv[2],"w"))' \ - "$MC/tests.json" "$D/tests.json" +cp "$MC/tests.json" "$D/tests.json" (cd "$MC" && cargo build -q --bins) export SOCKET_PATH="$D/socket.sock" From b2704027ffe42bc00bfecd95db0aa169b92ff06e Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:49:48 +0200 Subject: [PATCH 126/132] feat: add sim heap statistics command --- sim/README.md | 3 ++- src/sim.rs | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/sim/README.md b/sim/README.md index caced41..d4e135a 100644 --- a/sim/README.md +++ b/sim/README.md @@ -19,7 +19,8 @@ PCB does (`SIM_HW=v3|v4`, `--hw v3|v4`; v4 is the default): | Sleep | off | The firmware's `sim` feature (`SIM_CLIPPY=1 sim/run.sh` lints it) only adds what the host can't see: `state` (the `@state` line the -scripts read, v3 also with lcd_v3's change cache as `lcdtext`) and `wifi off|on` (radio torn down +scripts read, v3 also with lcd_v3's change cache as `lcdtext`), `heap` (current/peak/capacity, +also available during provisioning), and `wifi off|on` (radio torn down / restarted), read from the tap's console frames; it needs the patched esp-hal. Faults the host injects: `lcd glitch|drop|reset|slow ` (below), `rfid error [n]` (parity errors on the next transceives), `i2c nack [n]`. `tap stats` prints the tap's traffic and round-trip times. diff --git a/src/sim.rs b/src/sim.rs index f1d3ed8..d08d24f 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -3,7 +3,7 @@ //! The firmware runs its real drivers: the sim/patches bus tap (so `sim` builds need the //! patched esp-hal, see sim/run.sh) hands GPIO, I2C, SPI and ADC traffic to device models on //! the host (sim/bus.py). This module only adds what the host can't see: a machine-readable -//! state line and a Wi-Fi radio switch, read from the tap's console input. +//! state line, heap statistics and a Wi-Fi radio switch, read from the tap's console input. #[cfg(feature = "timer-func")] compile_error!("feature `sim` needs the UART stackmat (--no-default-features)"); @@ -29,7 +29,7 @@ pub fn lcd_intended(rows: &[[u8; crate::utils::lcd_text::COLS]; 2]) { LCD_TEXT.lock(|t| t.set(*rows)); } -const HELP: &str = "firmware sim commands: state | wifi off|on"; +const HELP: &str = "firmware sim commands: state | heap | wifi off|on"; /// One machine-readable line for sim/monkey.py: `@state key=value ...` (`-` = none). fn print_state(state: GlobalState) { @@ -124,6 +124,15 @@ pub async fn command_task(state: GlobalState) { fn command(line: &str, state: GlobalState) { match line.trim() { "state" => print_state(state), + "heap" => { + let stats = esp_alloc::HEAP.stats(); + esp_println::println!( + "Heap: used {} max {} of {} bytes", + stats.current_usage, + stats.max_usage, + stats.size + ); + } "wifi off" => WIFI.signal(true), "wifi on" => WIFI.signal(false), _ => esp_println::println!("? {line}\r\n{HELP}"), From 5dafb0cbb77a97d7734893e9798a11d5d476a6f6 Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 00:51:35 +0200 Subject: [PATCH 127/132] fix: finish sim replays and support older connectors --- sim/README.md | 3 ++- sim/monkey.py | 13 ++++++++----- sim/server.sh | 2 +- sim/test_monkey.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 sim/test_monkey.py diff --git a/sim/README.md b/sim/README.md index d4e135a..cdad36e 100644 --- a/sim/README.md +++ b/sim/README.md @@ -104,7 +104,8 @@ does use the patched copy. it with a seeded, weighted random action list: button mashing, odd hold lengths around the real thresholds, several buttons at once, card spam in every phase (competitor, judge, multi-group, unknown), stackmat chaos (start/stop at wrong times, unplug, garbage bytes), battery changes, -menu walks, server restarts and power cycles. Same seed ⇒ same actions. +menu walks, server restarts and power cycles. Same seed ⇒ same actions. Replay files always run every step, including assertions after a long +delegate timeout; `--minutes` limits generated fuzz runs only. Every few actions it checks liveness (answers `state`, no unexpected reboot/panic, heap bound) and state invariants; every `--chunk` actions it runs a recovery probe: get back to idle the way a user diff --git a/sim/monkey.py b/sim/monkey.py index 1f20ad6..e05609b 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -172,7 +172,8 @@ def start(self): env = dict(os.environ, SOCKET_PATH=self.sock) if self.e2e is None: os.unlink(self.sock) if os.path.exists(self.sock) else None - env.update(E2E_CONTROL=self.ctl_path, E2E_LOG=str(srv / "e2e.jsonl")) + # Older connector builds select fake FKMTime with this flag; newer ones always use it. + env.update(E2E_SIM="1", E2E_CONTROL=self.ctl_path, E2E_LOG=str(srv / "e2e.jsonl")) self.e2e = subprocess.Popen([self.mc / "target/debug/e2e"], cwd=srv, env=env, stdout=self.log_file, stderr=self.log_file) time.sleep(0.8) @@ -559,7 +560,7 @@ def execute(self, deadline): self.boot() self.probe() for i, act in enumerate(self.actions): - if time.monotonic() > deadline: + if not self.args.replay_file and time.monotonic() > deadline: break self.do(act) if i % 10 == 0: @@ -577,8 +578,9 @@ def execute(self, deadline): self.stackmat.alive = False self.emu.stop() self.server.stop() - json.dump({"seed": self.seed, "actions": self.actions, "executed": self.executed}, - open(self.dir / "actions.json", "w"), indent=0) + with (self.dir / "actions.json").open("w") as out: + json.dump({"seed": self.seed, "actions": self.actions, "executed": self.executed}, + out, indent=0) # ── driver ────────────────────────────────────────────────────────────────────────────────── @@ -641,7 +643,8 @@ def main(): p.add_argument("--replay", type=int) p.add_argument("--replay-file") p.add_argument("--jobs", type=int, default=1) - p.add_argument("--minutes", type=float, default=4) + p.add_argument("--minutes", type=float, default=4, + help="fuzz duration; replay files always run every step") p.add_argument("--actions", type=int, default=400) p.add_argument("--chunk", type=int, default=40, help="recovery probe every N actions") p.add_argument("--hw", choices=["v3", "v4"], default=os.environ.get("SIM_HW", "v4")) diff --git a/sim/server.sh b/sim/server.sh index 834a1cc..d487921 100755 --- a/sim/server.sh +++ b/sim/server.sh @@ -11,7 +11,7 @@ cp "$MC/tests.json" "$D/tests.json" (cd "$MC" && cargo build -q --bins) export SOCKET_PATH="$D/socket.sock" rm -f "$SOCKET_PATH" -(cd "$D" && exec "$MC/target/debug/e2e") >"$D/backend.log" 2>&1 & +(cd "$D" && exec env E2E_SIM=1 "$MC/target/debug/e2e") >"$D/backend.log" 2>&1 & BACKEND=$! trap 'kill $BACKEND' EXIT sleep 1 diff --git a/sim/test_monkey.py b/sim/test_monkey.py new file mode 100644 index 0000000..cdf8610 --- /dev/null +++ b/sim/test_monkey.py @@ -0,0 +1,42 @@ +"""Checks replay execution without starting an emulator or connector.""" +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import monkey + + +class ReplayExecution(unittest.TestCase): + def run_expired(self, replay_file): + with tempfile.TemporaryDirectory() as directory: + run = monkey.Run.__new__(monkey.Run) + run.args = SimpleNamespace(replay_file=replay_file, hw="v4", chunk=100) + run.dir = Path(directory) + run.image = (Path("image.bin"), Path("firmware.elf")) + run.ports = {"uart1": 1234} + run.seed = 1 + run.actions = [[0, "wait", 0], [0, "expect", "err", 0]] + run.executed = [] + run.server = Mock() + for method in ("boot", "probe", "check_alive", "state", "check_server"): + setattr(run, method, Mock()) + run.do = run.executed.append + with patch.object(monkey, "Emu"), patch.object(monkey, "Stackmat"): + self.assertIsNone(run.execute(deadline=-1)) + record = json.loads((run.dir / "actions.json").read_text()) + run.server.stop.assert_called_once() + return record + + def test_replay_finishes_assertions_after_fuzz_deadline(self): + record = self.run_expired("delegate-late.json") + self.assertEqual(record["executed"], record["actions"]) + + def test_fuzz_stops_at_deadline(self): + self.assertEqual(self.run_expired(None)["executed"], []) + + +if __name__ == "__main__": + unittest.main() From aa88cb02493dfa2b6166a6928965f03f15eb0f2f Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 01:00:16 +0200 Subject: [PATCH 128/132] fix: wait for connections before fuzz recovery probes --- sim/monkey.py | 8 +++++--- sim/test_monkey.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/sim/monkey.py b/sim/monkey.py index e05609b..196b2e2 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -38,7 +38,8 @@ HEAP_LIMIT = 0.92 # of the heap size printed by the firmware # v3: the emulated LCD may differ from what the firmware wrote only this long (emulated ms) LCD_STALE_MS = 4000 -IDLE = dict(scene="WaitingForCompetitor", comp="-1", time="-1", menu="None", cfg="-1", err="0") +IDLE = dict(scene="WaitingForCompetitor", comp="-1", time="-1", menu="None", cfg="-1", err="0", + srv="Some(true)", stk="Some(true)", added="Some(true)") # ── action generation (pure: same seed ⇒ same list) ───────────────────────────────────────── @@ -462,8 +463,9 @@ def recover(self): if all(s.get(k) == v for k, v in IDLE.items()) and s.get("msg") == "0": # a card lookup sent during the abuse phase may still answer: idle must hold time.sleep(3) - if all(self.state().get(k) == v for k, v in IDLE.items()): - return s + stable = self.state() + if all(stable.get(k) == v for k, v in IDLE.items()) and stable.get("msg") == "0": + return stable continue # reconnecting / timer replugged above: the device must come back by itself # (while it shows the disconnected screen it ignores input by design) diff --git a/sim/test_monkey.py b/sim/test_monkey.py index cdf8610..ac13b29 100644 --- a/sim/test_monkey.py +++ b/sim/test_monkey.py @@ -37,6 +37,19 @@ def test_replay_finishes_assertions_after_fuzz_deadline(self): def test_fuzz_stops_at_deadline(self): self.assertEqual(self.run_expired(None)["executed"], []) + def test_recovery_waits_for_connection_before_tapping_a_card(self): + run = monkey.Run.__new__(monkey.Run) + run.stackmat = Mock() + run.wait_for = Mock() + run.check_alive = Mock() + run.press = Mock() + ready = dict(monkey.IDLE, msg="0") + run.state = Mock(side_effect=[dict(ready, srv="Some(false)"), ready, ready]) + with patch.object(monkey.time, "sleep"): + self.assertEqual(run.recover(), ready) + self.assertEqual(run.state.call_count, 3) + run.press.assert_not_called() + if __name__ == "__main__": unittest.main() From c68ff486b5dbedd4edbc774b2fa104da5e2033bc Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 01:10:11 +0200 Subject: [PATCH 129/132] fix: ignore stale solve success messages --- sim/README.md | 7 ++++++- sim/monkey.py | 10 ++++++++-- sim/scenarios/late-solve-reply.json | 28 ++++++++++++++++++++++++++++ src/rfid.rs | 5 +++-- 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 sim/scenarios/late-solve-reply.json diff --git a/sim/README.md b/sim/README.md index cdad36e..de4eb9f 100644 --- a/sim/README.md +++ b/sim/README.md @@ -168,11 +168,16 @@ stations (the firmware never asks for attendance), BLE. e2e's control socket (`Server.ctl`, JSON lines): `attempts`, `resolve` (`sessionId`, `status` resolved / standard / extra_given, optional `value` cs, `penalty`, `noCards`), `delete`, -`drop_reply` (`espId`: the next attempt is entered, its answer lost). Scenarios use it with +`drop_reply` (`espId`: the next attempt is entered, its answer lost), `hold_reply` / `release_reply` +(`espId`: enter the next attempt but hold its answer until released). Scenarios use it with optional `"server"` (e.g. `rounds` with attempts/cutoff/limit in cs) and the steps `resolve [args]` / `delete` (the open incident), `drop_reply`, `expect_db [rows]`; `expect [s]` takes an optional timeout. +`assert ` checks immediately, so a transient wrong message cannot pass by timing +out and disappearing. `late-solve-reply.json` holds a success through a reset and starts another +timer before releasing it; the old success must not cover the new attempt with a message. + Failures land in `sim/out//` (actions, firmware log with timestamps, server log). Needs a built micro-connector (`cargo build --bins` in it; `--mc` path). The `state` sim command prints the machine-readable line the monkey reads. Not covered: pads (`timer-func`), BLE, Wi-Fi drops (esp-emu diff --git a/sim/monkey.py b/sim/monkey.py index 196b2e2..b4d5bc7 100644 --- a/sim/monkey.py +++ b/sim/monkey.py @@ -407,6 +407,10 @@ def do(self, act): pass elif kind == "expect": # scripted scenarios only: state field reaches a value (in a[2] s) self.wait_for(lambda s: s.get(a[0]) == str(a[1]), a[2] if len(a) > 2 else 15, f"{a[0]}={a[1]}") + elif kind == "assert": # immediate: a transient wrong overlay must not pass after timing out + s = self.state() + if s.get(a[0]) != str(a[1]): + raise Failure(f"expected {a[0]}={a[1]} now; state {s}") elif kind in ("resolve", "delete"): # sim e2e: the delegate decides the open incident open_ = [x for x in self.server.attempts() if x["status"] == "unresolved"] if not open_: @@ -415,8 +419,10 @@ def do(self, act): r = self.server.ctl(cmd=kind, sessionId=open_[-1]["sessionId"], **extra) if not r.get("ok"): raise Failure(f"{kind}: {r}") - elif kind == "drop_reply": # sim e2e: the next attempt is entered, its answer lost - self.server.ctl(cmd="drop_reply", espId=int(self.state()["id"], 16)) + elif kind in ("drop_reply", "hold_reply", "release_reply"): + r = self.server.ctl(cmd=kind, espId=int(self.state()["id"], 16)) + if not r.get("ok") or (kind == "release_reply" and not r.get("delivered")): + raise Failure(f"{kind}: {r}") elif kind == "expect_db": # sim e2e: the attempts of a[0] (competitor) match a[1] def db_matches(): rows = [x for x in self.server.attempts() if x["competitorId"] == a[0]] diff --git a/sim/scenarios/late-solve-reply.json b/sim/scenarios/late-solve-reply.json new file mode 100644 index 0000000..987e8b2 --- /dev/null +++ b/sim/scenarios/late-solve-reply.json @@ -0,0 +1,28 @@ +{"seed": 90111, "note": "a success reply held while the judge resets must not show Attempt entered over the next timer", "actions": [ +[500, "card", 69420], +[10, "expect", "scene", "CompetitorInfo"], +[300, "btn", 1, 150], +[10, "expect", "scene", "Inspection"], +[1500, "stk", "start"], +[10, "expect", "scene", "Timer"], +[1500, "stk", "stop"], +[10, "expect", "scene", "Finished"], +[300, "btn", 3, 150], +[10, "expect", "conf", 1], +[10, "hold_reply"], +[500, "card", 3004425529], +[10, "expect", "judge", 3004425529], +[500, "card", 69420], +[10, "expect_db", 69420, [{"status": "standard"}, {"status": "standard"}]], +[10, "btn", 3, 3400], +[10, "expect", "comp", -1], +[10, "expect", "time", -1], +[10, "expect", "held", 0], +[10, "stk", "reset"], +[300, "stk", "start"], +[10, "expect", "scene", "Timer"], +[10, "release_reply"], +[1500, "assert", "msg", 0], +[10, "assert", "scene", "Timer"], +[10, "assert", "comp", -1] +]} diff --git a/src/rfid.rs b/src/rfid.rs index 44610c9..03a59e3 100644 --- a/src/rfid.rs +++ b/src/rfid.rs @@ -472,9 +472,10 @@ async fn process_card_info_response(resp: CardInfoResponsePacket, global_state: }; log::warn!("solve_resp: {resp:?}"); - if same_attempt { - global_state.reset_solve(true).await; + if !same_attempt { + return; } + global_state.reset_solve(true).await; let (first, second) = split_after_two_words(&resp.message).unwrap_or(("Solve", "sent")); show_message(global_state, (first.into(), second.into()), 3000).await; From 5da253d8abb73c6465288736b4af41f26b712f34 Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 16:25:27 +0200 Subject: [PATCH 130/132] fix: preserve flash capacity across storage instances --- Cargo.lock | 2 - Cargo.toml | 3 + src/main.rs | 4 +- vendor/README.md | 20 + vendor/esp-storage/.cargo_vcs_info.json | 6 + vendor/esp-storage/.clippy.toml | 1 + vendor/esp-storage/CHANGELOG.md | 121 ++++ vendor/esp-storage/Cargo.toml | 265 +++++++++ vendor/esp-storage/LICENSE-APACHE | 201 +++++++ vendor/esp-storage/LICENSE-MIT | 25 + vendor/esp-storage/README.md | 30 + vendor/esp-storage/build.rs | 18 + vendor/esp-storage/src/buffer.rs | 39 ++ vendor/esp-storage/src/common.rs | 350 ++++++++++++ vendor/esp-storage/src/encrypted.rs | 110 ++++ vendor/esp-storage/src/hardware.rs | 140 +++++ vendor/esp-storage/src/lib.rs | 87 +++ vendor/esp-storage/src/ll.rs | 90 +++ vendor/esp-storage/src/mmu.rs | 696 ++++++++++++++++++++++++ vendor/esp-storage/src/nor_flash.rs | 573 +++++++++++++++++++ vendor/esp-storage/src/storage.rs | 123 +++++ vendor/esp-storage/src/stub.rs | 141 +++++ 22 files changed, 3042 insertions(+), 3 deletions(-) create mode 100644 vendor/README.md create mode 100644 vendor/esp-storage/.cargo_vcs_info.json create mode 100644 vendor/esp-storage/.clippy.toml create mode 100644 vendor/esp-storage/CHANGELOG.md create mode 100644 vendor/esp-storage/Cargo.toml create mode 100644 vendor/esp-storage/LICENSE-APACHE create mode 100644 vendor/esp-storage/LICENSE-MIT create mode 100644 vendor/esp-storage/README.md create mode 100644 vendor/esp-storage/build.rs create mode 100644 vendor/esp-storage/src/buffer.rs create mode 100644 vendor/esp-storage/src/common.rs create mode 100644 vendor/esp-storage/src/encrypted.rs create mode 100644 vendor/esp-storage/src/hardware.rs create mode 100644 vendor/esp-storage/src/lib.rs create mode 100644 vendor/esp-storage/src/ll.rs create mode 100644 vendor/esp-storage/src/mmu.rs create mode 100644 vendor/esp-storage/src/nor_flash.rs create mode 100644 vendor/esp-storage/src/storage.rs create mode 100644 vendor/esp-storage/src/stub.rs diff --git a/Cargo.lock b/Cargo.lock index fd96f69..7dccb1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,8 +1681,6 @@ dependencies = [ [[package]] name = "esp-storage" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef0e785f54e316f64a1e14a7a2f061a2ea0b038b284902ca71e1e1f5574cf47" dependencies = [ "document-features", "embedded-storage", diff --git a/Cargo.toml b/Cargo.toml index 50555fb..e2d2e4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,3 +105,6 @@ serde_json = "1.0.154" # slimmer esp-nvs (sorted vec map instead of btreemap, same on-flash format) [patch.crates-io] esp-nvs = { git = "https://github.com/filipton/esp-nvs.git", rev = "af9a88793ada2496efb7420f26b96823e3b42842" } + +# Cache the detected flash size before ROM reads change SPI1 state (see vendor/README.md). +esp-storage = { path = "vendor/esp-storage" } diff --git a/src/main.rs b/src/main.rs index 10d989d..acc57c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,7 +106,9 @@ async fn main(spawner: Spawner) { #[cfg(feature = "qa")] log::info!("This firmware is in QA mode!"); - let Ok(nvs) = Nvs::new(unsafe { board.flash.clone_unchecked() }) else { + let Ok(nvs) = Nvs::new(unsafe { board.flash.clone_unchecked() }).inspect_err(|error| { + log::error!("NVS initialization failed: {error:?}"); + }) else { utils::error_log::add_error(utils::error_log::codes::WRONG_PARTITION_TABLE); // boxed (error path only): kept in main's state it would hold ~5 KB of static RAM forever #[cfg(feature = "v3")] diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..2e47b46 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,20 @@ +# esp-storage 0.10.0 + +This is the published esp-storage 0.10.0 source from esp-rs/esp-hal commit +`63e97be553f435f29171f7ae5f37a41ad737a75a`, with its original dual MIT/Apache-2.0 +license. Only `src/hardware.rs` differs: cache the first nonzero flash capacity. + +On the v4 ESP32-C3, a fresh FlashStorage detects 4 MB, but another instance made +after a ROM flash read detects 0 bytes. Wi-Fi manager reads the partition table +with one instance and opens NVS with another, so the second instance rejects a +valid NVS partition as `InvalidPartitionSize`. Other flash users, including OTA, +also create fresh instances. The physical capacity is immutable; caching it +avoids repeating the hardware RDID command after ROM operations change SPI1 +state. A failed detection is not cached. + +Reproduced on real v4 hardware: refresh c68ff48 fails to open NVS; with this +patch, the same NVS data opens and Wi-Fi/TLS connect. No NVS erase is needed. +The simulator did not reproduce the repeated-detection failure. + +Remove this override after validating an upstream release that fixes repeated +flash-size detection on the device. No upstream report or pull request was made. diff --git a/vendor/esp-storage/.cargo_vcs_info.json b/vendor/esp-storage/.cargo_vcs_info.json new file mode 100644 index 0000000..7be8bbf --- /dev/null +++ b/vendor/esp-storage/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "63e97be553f435f29171f7ae5f37a41ad737a75a" + }, + "path_in_vcs": "esp-storage" +} \ No newline at end of file diff --git a/vendor/esp-storage/.clippy.toml b/vendor/esp-storage/.clippy.toml new file mode 100644 index 0000000..cda8d17 --- /dev/null +++ b/vendor/esp-storage/.clippy.toml @@ -0,0 +1 @@ +avoid-breaking-exported-api = false diff --git a/vendor/esp-storage/CHANGELOG.md b/vendor/esp-storage/CHANGELOG.md new file mode 100644 index 0000000..00d065a --- /dev/null +++ b/vendor/esp-storage/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + + +### Changed + + +### Fixed + + +### Removed + + +## [v0.10.0] - 2026-08-25 + +### Added + +- Add ESP32-S31 support (#5922) +- `FlashStorage::read_encrypted` and `FlashStorage::write_encrypted` for transparent flash encryption read/write (#5810) +- `flash_encryption()` helper to query whether flash encryption is enabled in eFuses (#5810) +- Support for ESP32-P4 (#5751) +- Inherent flash I/O methods on `FlashStorage`: `read`, `write`, `capacity`, `read_nor`, `write_nor`, and `erase` (#5739) + +### Changed + +- `FlashStorage::read_encrypted` now follows the multi-core strategy, so it can return `OtherCoreRunning`. (#6120) +- esp-storage can now be built with any opt-level (#5911) +- updated defmt to 1.1 (#5752) +- `embedded-storage` trait implementations are now an opt-in feature (non-default) (#5739) + +### Fixed + +- On ESP32, reading and writing encrypted flash no longer hangs. The flash MMU and the cache are now changed with the cache turned off, as the hardware requires. (#6120) +- `FlashStorage::erase` rejects reversed ranges (`to < from`) (#6045) + +## [v0.9.0] - 2026-04-16 + +### Added + +- Support for ESP32-C5 (#4998) +- Support for ESP32-C61 (#5240) + +### Changed + +- Speed up erase by erasing entire blocks when possible (#5079) + +## [v0.8.1] - 2025-10-30 + +## [v0.8.0] - 2025-10-13 + +### Added + +- `defmt` feature and `FlashStorage`, `FlashStorageError` now implements `Defmt` (#4127) +- `Drop` impl for `FlashStorage` (#4132) +- `FlashStorage::new()` now takes a `Flash` argument (#4173) + +### Removed + +- `Default` impl for `FlashStorage` (#4132) + +## v0.8.0 - 2025-09-10 + +### Added + +- Added strategies for dealing with multi-core systems (#4082) + +## [v0.7.0] - 2025-07-16 + +### Fixed + +- Fix incorrect usage of MaybeUninit (#3677) +- Use `libesp_rom.a` from ESP-IDF on ESP32 as a replacement for our incomplete and less accurate port (#3688) + +## [v0.6.0] - 2025-06-03 + +### Changed + +- Bump Rust edition to 2024, bump MSRV to 1.86. (#3391, #3560) +- The `low-level` feature has been removed, the gated API is always available (#3425) + +### Removed + +- The `storage` and `nor-flash` features have been removed, the related functionality is now always available. (#3431) + +## [0.5.0] - 2025-02-24 + +### Changed + +- Bump MSRV to 1.84 (#2951) +- Add support for 32MB flash (#3163) + +## 0.4.0 - 2024-11-20 + +### Added + +- Added trait MultiwriteNorFlash to FlashStorage (#2478) + +## 0.3.1 - 2024-10-10 + +## 0.3.0 - 2023-08-16 + +## 0.2.0 - 2023-07-05 + +## 0.1.0 - 2022-09-26 + +[0.5.0]: https://github.com/esp-rs/esp-hal/releases/tag/esp-storage-v0.5.0 +[v0.6.0]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.5.0...esp-storage-v0.6.0 +[v0.7.0]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.6.0...esp-storage-v0.7.0 +[v0.8.0]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.7.0...esp-storage-v0.8.0 +[v0.8.1]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.8.0...esp-storage-v0.8.1 +[v0.9.0]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.8.1...esp-storage-v0.9.0 +[v0.10.0]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.9.0...esp-storage-v0.10.0 +[Unreleased]: https://github.com/esp-rs/esp-hal/compare/esp-storage-v0.10.0...HEAD diff --git a/vendor/esp-storage/Cargo.toml b/vendor/esp-storage/Cargo.toml new file mode 100644 index 0000000..44d3a02 --- /dev/null +++ b/vendor/esp-storage/Cargo.toml @@ -0,0 +1,265 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.95.0" +name = "esp-storage" +version = "0.10.0" +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Access ESP32 flash, including encrypted read/write. Optionally implements esp-storage traits for unencrypted flash." +documentation = "https://docs.espressif.com/projects/rust/esp-storage/latest/" +readme = "README.md" +keywords = [ + "embedded-storage", + "esp32", + "espressif", + "no-std", +] +categories = [ + "embedded", + "hardware-support", + "no-std", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/esp-rs/esp-hal" + +[package.metadata.espressif] +chip-features-matter = true + +[[package.metadata.espressif.check-configs]] +features = [] + +[[package.metadata.espressif.check-configs]] +features = ["embedded-storage"] + +[[package.metadata.espressif.check-configs]] +features = ["critical-section"] + +[[package.metadata.espressif.check-configs]] +features = ["bytewise-read"] + +[[package.metadata.espressif.check-configs]] +features = [ + "embedded-storage", + "critical-section", +] + +[[package.metadata.espressif.check-configs]] +features = [ + "embedded-storage", + "critical-section", + "bytewise-read", +] + +[[package.metadata.espressif.clippy-configs]] +features = [ + "embedded-storage", + "critical-section", + "bytewise-read", +] + +[package.metadata.docs.rs] +default-target = "riscv32imac-unknown-none-elf" +features = [ + "esp32c6", + "embedded-storage", +] + +[features] +bytewise-read = [] +critical-section = [] +default = ["critical-section"] +defmt = ["dep:defmt"] +embedded-storage = ["dep:embedded-storage"] +emulation = [] +esp32 = [ + "esp-hal/esp32", + "esp-hal/unstable", + "esp-rom-sys/esp32", + "esp-sync/esp32", + "dep:esp32", +] +esp32c2 = [ + "esp-hal/esp32c2", + "esp-hal/unstable", + "esp-rom-sys/esp32c2", + "esp-sync/esp32c2", + "dep:esp32c2", +] +esp32c3 = [ + "esp-hal/esp32c3", + "esp-hal/unstable", + "esp-rom-sys/esp32c3", + "esp-sync/esp32c3", + "dep:esp32c3", +] +esp32c5 = [ + "esp-hal/esp32c5", + "esp-hal/unstable", + "esp-rom-sys/esp32c5", + "esp-sync/esp32c5", + "dep:esp32c5", +] +esp32c6 = [ + "esp-hal/esp32c6", + "esp-hal/unstable", + "esp-rom-sys/esp32c6", + "esp-sync/esp32c6", + "dep:esp32c6", +] +esp32c61 = [ + "esp-hal/esp32c61", + "esp-hal/unstable", + "esp-rom-sys/esp32c61", + "esp-sync/esp32c61", + "dep:esp32c61", +] +esp32h2 = [ + "esp-hal/esp32h2", + "esp-hal/unstable", + "esp-rom-sys/esp32h2", + "esp-sync/esp32h2", + "dep:esp32h2", +] +esp32p4 = [ + "esp-hal/esp32p4", + "esp-hal/unstable", + "esp-rom-sys/esp32p4", + "esp-sync/esp32p4", + "dep:esp32p4", +] +esp32s2 = [ + "esp-hal/esp32s2", + "esp-hal/unstable", + "esp-rom-sys/esp32s2", + "esp-sync/esp32s2", + "dep:esp32s2", +] +esp32s3 = [ + "esp-hal/esp32s3", + "esp-hal/unstable", + "esp-rom-sys/esp32s3", + "esp-sync/esp32s3", + "dep:esp32s3", +] +esp32s31 = [ + "esp-hal/esp32s31", + "esp-hal/unstable", + "esp-rom-sys/esp32s31", + "esp-sync/esp32s31", + "dep:esp32s31", +] + +[lib] +name = "esp_storage" +path = "src/lib.rs" +bench = false + +[dependencies.defmt] +version = "1.1" +optional = true + +[dependencies.document-features] +version = "0.2" + +[dependencies.embedded-storage] +version = "0.3.1" +optional = true + +[dependencies.esp-hal] +version = "~1.2.0-rc.0" +optional = true +default-features = false + +[dependencies.esp-metadata-generated] +version = "0.5.0" + +[dependencies.esp-rom-sys] +version = "~0.1" +optional = true + +[dependencies.esp-sync] +version = "0.3.0" +optional = true + +[dependencies.esp32] +version = "0.41" +features = ["critical-section"] +optional = true + +[dependencies.esp32c2] +version = "0.30" +features = ["critical-section"] +optional = true + +[dependencies.esp32c3] +version = "0.33" +features = ["critical-section"] +optional = true + +[dependencies.esp32c5] +version = "0.3" +features = ["critical-section"] +optional = true + +[dependencies.esp32c6] +version = "0.24" +features = ["critical-section"] +optional = true + +[dependencies.esp32c61] +version = "0.4" +features = ["critical-section"] +optional = true + +[dependencies.esp32h2] +version = "0.20" +features = ["critical-section"] +optional = true + +[dependencies.esp32p4] +version = "0.3" +features = ["critical-section"] +optional = true + +[dependencies.esp32s2] +version = "0.32" +features = ["critical-section"] +optional = true + +[dependencies.esp32s3] +version = "0.36" +features = ["critical-section"] +optional = true + +[dependencies.esp32s31] +version = "0.2" +features = ["critical-section"] +optional = true + +[dependencies.procmacros] +version = "0.23.0" +package = "esp-hal-procmacros" + +[build-dependencies.esp-metadata-generated] +version = "0.5.0" +features = ["build-script"] + +[lints.rust.unexpected_cfgs] +level = "warn" +priority = 0 +check-cfg = ["cfg(test)"] diff --git a/vendor/esp-storage/LICENSE-APACHE b/vendor/esp-storage/LICENSE-APACHE new file mode 100644 index 0000000..15c1d15 --- /dev/null +++ b/vendor/esp-storage/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2021 esp-rs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/esp-storage/LICENSE-MIT b/vendor/esp-storage/LICENSE-MIT new file mode 100644 index 0000000..b815dcb --- /dev/null +++ b/vendor/esp-storage/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2021 esp-rs + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/esp-storage/README.md b/vendor/esp-storage/README.md new file mode 100644 index 0000000..9a30dca --- /dev/null +++ b/vendor/esp-storage/README.md @@ -0,0 +1,30 @@ +# esp-storage + +[![Crates.io](https://img.shields.io/crates/v/esp-storage?labelColor=1C2C2E&color=C96329&logo=Rust&style=flat-square)](https://crates.io/crates/esp-storage) +[![docs.rs](https://img.shields.io/docsrs/esp-storage?labelColor=1C2C2E&color=C96329&logo=rust&style=flat-square)](https://docs.espressif.com/projects/rust/esp-storage/latest/) +![MSRV](https://img.shields.io/badge/MSRV-1.95.0-blue?labelColor=1C2C2E&style=flat-square) +![Crates.io](https://img.shields.io/crates/l/esp-storage?labelColor=1C2C2E&style=flat-square) +[![Matrix](https://img.shields.io/matrix/esp-rs:matrix.org?label=join%20matrix&labelColor=1C2C2E&color=BEC5C9&logo=matrix&style=flat-square)](https://matrix.to/#/#esp-rs:matrix.org) + +This crate provides functionality to access ESP32 flash. Enable the +`embedded-storage` feature for [`embedded-storage`](https://github.com/rust-embedded-community/embedded-storage) trait implementations. + +## Minimum Supported Rust Version (MSRV) + +This crate is guaranteed to compile when using the latest stable Rust version at the time of the crate's release. It _might_ compile with older versions, but that may change in any new release, including patches. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](../LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](../LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the +work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any +additional terms or conditions. diff --git a/vendor/esp-storage/build.rs b/vendor/esp-storage/build.rs new file mode 100644 index 0000000..2867f57 --- /dev/null +++ b/vendor/esp-storage/build.rs @@ -0,0 +1,18 @@ +use esp_metadata_generated::{Chip, emit_check_cfg_directives}; + +fn main() -> Result<(), String> { + println!("cargo::rustc-check-cfg=cfg(__test_esp_storage)"); + + if !cfg!(feature = "emulation") { + // Load the configuration file for the configured device: + let chip = Chip::from_cargo_feature()?; + + // Define all necessary configuration symbols for the configured device: + chip.define_cfgs(); + } else { + // Even though we don't have a chip, make sure we're not warned about the config symbols. + emit_check_cfg_directives(); + } + + Ok(()) +} diff --git a/vendor/esp-storage/src/buffer.rs b/vendor/esp-storage/src/buffer.rs new file mode 100644 index 0000000..53f7f76 --- /dev/null +++ b/vendor/esp-storage/src/buffer.rs @@ -0,0 +1,39 @@ +use core::{mem::MaybeUninit, slice}; + +pub fn uninit_slice(bytes: &[u8]) -> &[MaybeUninit] { + unsafe { core::slice::from_raw_parts(bytes.as_ptr() as *const _, bytes.len()) } +} + +pub fn uninit_slice_mut(bytes: &mut [u8]) -> &mut [MaybeUninit] { + unsafe { core::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut _, bytes.len()) } +} + +#[cfg_attr(not(feature = "bytewise-read"), expect(unused))] +pub type FlashWordBuffer = FlashBuffer<4>; + +pub type FlashSectorBuffer = FlashBuffer<4096>; + +#[repr(C, align(4))] +pub union FlashBuffer { + bytes: [MaybeUninit; N], +} + +impl FlashBuffer { + pub const fn uninit() -> Self { + Self { + bytes: [MaybeUninit::uninit(); N], + } + } + + pub fn as_bytes_mut(&mut self) -> &mut [MaybeUninit] { + unsafe { self.bytes.as_mut() } + } + + pub unsafe fn assume_init_bytes(&self) -> &[u8] { + unsafe { slice::from_raw_parts(self.bytes.as_ptr() as *const _, self.bytes.len()) } + } + + pub unsafe fn assume_init_bytes_mut(&mut self) -> &mut [u8] { + unsafe { slice::from_raw_parts_mut(self.bytes.as_mut_ptr() as *mut _, self.bytes.len()) } + } +} diff --git a/vendor/esp-storage/src/common.rs b/vendor/esp-storage/src/common.rs new file mode 100644 index 0000000..e884e28 --- /dev/null +++ b/vendor/esp-storage/src/common.rs @@ -0,0 +1,350 @@ +use core::mem::MaybeUninit; + +#[cfg(not(feature = "emulation"))] +pub use esp_hal::peripherals::FLASH as Flash; + +#[cfg(multi_core)] +esp_hal::if_unstable_hal! { + use esp_hal::peripherals::CPU_CTRL; + use esp_hal::system::Cpu; + use esp_hal::system::CpuControl; + use esp_hal::system::is_running; +} + +use crate::chip_specific; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +/// Flash storage error. +pub enum FlashStorageError { + /// I/O error. + IoError, + /// I/O operation timed out. + IoTimeout, + /// Flash could not be unlocked for writing. + CantUnlock, + /// Address or length not aligned to required boundary. + NotAligned, + /// Address or length out of bounds. + OutOfBounds, + /// Operation not supported (e.g. no free MMU entry). + NotSupported, + /// Cannot write to flash as more than one core is running. + /// Either manually suspend the other core, or use one of the available strategies: + /// * [`FlashStorage::multicore_auto_park`] + /// * [`FlashStorage::multicore_ignore`] + #[cfg(multi_core)] + OtherCoreRunning, + /// Other error with the given error code. + Other(i32), +} + +#[inline(always)] +/// Check return code from flash operations. +pub fn check_rc(rc: i32) -> Result<(), FlashStorageError> { + match rc { + 0 => Ok(()), + 1 => Err(FlashStorageError::IoError), + 2 => Err(FlashStorageError::IoTimeout), + _ => Err(FlashStorageError::Other(rc)), + } +} + +#[cfg(feature = "emulation")] +#[derive(Debug)] +pub struct Flash<'d> { + _phantom: core::marker::PhantomData<&'d ()>, +} + +#[cfg(feature = "emulation")] +impl<'d> Flash<'d> { + pub fn new() -> Self { + Flash { + _phantom: core::marker::PhantomData, + } + } +} + +#[derive(Debug)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +/// Flash storage abstraction. +/// +/// This type can read and write any location on the SPI flash chip. For +/// application data, it is recommended to reserve a dedicated +#[cfg_attr( + not(feature = "emulation"), + doc = concat!( + "[partition](https://docs.espressif.com/projects/esp-idf/en/latest/", + esp_metadata_generated::chip!(), + "/api-guides/partition-tables.html)" + ) +)] +#[cfg_attr( + feature = "emulation", + doc = "[partition](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/partition-tables.html)" +)] +/// instead of writing to arbitrary addresses. For partition-table helpers, see +/// `esp-bootloader-esp-idf`. +/// +/// Use [`FlashStorage::write_nor`] and [`FlashStorage::erase`] for low-level +/// NOR flash semantics, or [`FlashStorage::write`] for read-modify-write with +/// automatic sector erasure. +/// +/// See the [crate-level documentation](crate#buffer-alignment-and-stack-usage) +/// for stack usage. +pub struct FlashStorage<'d> { + pub(crate) capacity: usize, + unlocked: bool, + pub(crate) multi_core_strategy: MultiCoreStrategy, + _flash: Flash<'d>, +} + +impl<'d> FlashStorage<'d> { + /// Flash word size in bytes. + pub const WORD_SIZE: u32 = 4; + /// Flash sector size in bytes. + pub const SECTOR_SIZE: u32 = 4096; + /// Flash block size in bytes. + pub const BLOCK_SIZE: u32 = 65536; + + /// Create a new flash storage instance. + /// + /// # Panics + /// + /// Panics if called more than once. + pub fn new(flash: Flash<'d>) -> Self { + Self { + capacity: chip_specific::get_flash_size() as usize, + unlocked: false, + multi_core_strategy: cfg_select! { + multi_core => MultiCoreStrategy::Error, + _ => MultiCoreStrategy::Ignore, + }, + _flash: flash, + } + } + + #[inline(always)] + pub(crate) fn check_alignment( + &self, + offset: u32, + length: usize, + ) -> Result<(), FlashStorageError> { + let offset = offset as usize; + if !offset.is_multiple_of(ALIGN as usize) || !length.is_multiple_of(ALIGN as usize) { + return Err(FlashStorageError::NotAligned); + } + Ok(()) + } + + #[inline(always)] + pub(crate) fn check_bounds(&self, offset: u32, length: usize) -> Result<(), FlashStorageError> { + let offset = offset as usize; + if length > self.capacity || offset > self.capacity - length { + return Err(FlashStorageError::OutOfBounds); + } + Ok(()) + } + + pub(crate) fn internal_read( + &mut self, + offset: u32, + bytes: &mut [MaybeUninit], + ) -> Result<(), FlashStorageError> { + check_rc(chip_specific::spiflash_read( + offset, + bytes.as_mut_ptr() as *mut u32, + bytes.len() as u32, + )) + } + + #[inline(always)] + fn unlock_once(&mut self) -> Result<(), FlashStorageError> { + if !self.unlocked { + if chip_specific::spiflash_unlock() != 0 { + return Err(FlashStorageError::CantUnlock); + } + self.unlocked = true; + } + Ok(()) + } + + pub(crate) fn internal_erase_sector(&mut self, sector: u32) -> Result<(), FlashStorageError> { + self.multi_core_strategy.with(|| { + self.unlock_once()?; + check_rc(chip_specific::spiflash_erase_sector(sector)) + }) + } + + pub(crate) fn internal_erase_block(&mut self, block: u32) -> Result<(), FlashStorageError> { + self.multi_core_strategy.with(|| { + self.unlock_once()?; + check_rc(chip_specific::spiflash_erase_block(block)) + }) + } + + pub(crate) fn internal_write( + &mut self, + offset: u32, + bytes: &[u8], + ) -> Result<(), FlashStorageError> { + self.multi_core_strategy.with(|| { + self.unlock_once()?; + check_rc(chip_specific::spiflash_write( + offset, + bytes.as_ptr() as *const u32, + bytes.len() as u32, + )) + }) + } + + pub(crate) fn internal_read_encrypted( + &mut self, + offset: u32, + bytes: &mut [MaybeUninit], + ) -> Result<(), FlashStorageError> { + // SAFETY: `read_flash_encrypted` fully initializes every byte in `bytes`. + let initialized = unsafe { + core::slice::from_raw_parts_mut(bytes.as_mut_ptr().cast::(), bytes.len()) + }; + // Reading encrypted flash changes the flash MMU and the cache, which the other core must + // not use while this happens. + self.multi_core_strategy + .with(|| chip_specific::read_flash_encrypted(offset, initialized)) + } + + pub(crate) fn internal_write_encrypted( + &mut self, + offset: u32, + bytes: &[u8], + ) -> Result<(), FlashStorageError> { + self.multi_core_strategy.with(|| { + check_rc(chip_specific::spiflash_write_encrypted( + offset, + bytes.as_ptr() as *mut u32, + bytes.len() as u32, + )) + }) + } +} + +/// Strategy to use on a multi core system where writing to the flash needs exclusive access from +/// one core. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub(crate) enum MultiCoreStrategy { + /// Flash writes simply fail if the second core is active while attempting a write (default + /// behavior). + #[cfg(multi_core)] + Error, + + /// Auto park the other core before writing. Un-park it when writing is complete. + #[cfg(multi_core)] + AutoPark, + + /// Ignore that the other core is active. + /// This is useful if the second core is known to not fetch instructions from the flash for the + /// duration of the write. This is unsafe to use. + Ignore, +} + +#[cfg(multi_core)] +impl<'d> FlashStorage<'d> { + /// Enable auto parking of the second core before writing to flash. + /// The other core will be automatically un-parked when the write is complete. + pub fn multicore_auto_park(mut self) -> FlashStorage<'d> { + self.multi_core_strategy = MultiCoreStrategy::AutoPark; + self + } + + /// Do not check if the second core is active before writing to flash. + /// + /// # Safety + /// Only enable this if you are sure that the second core is not fetching instructions from the + /// flash during the write. + pub unsafe fn multicore_ignore(mut self) -> FlashStorage<'d> { + self.multi_core_strategy = MultiCoreStrategy::Ignore; + self + } +} + +impl MultiCoreStrategy { + /// Perform checks/Prepare for a flash write according to the current strategy. + /// + /// # Returns + /// * `True` if the other core needs to be un-parked by post_write + /// * `False` otherwise + pub(crate) fn pre_write(&self) -> Result { + match self { + #[cfg(multi_core)] + MultiCoreStrategy::Error => { + esp_hal::if_unstable_hal! { + for other_cpu in Cpu::other() { + if is_running(other_cpu) { + return Err(FlashStorageError::OtherCoreRunning); + } + } + } + Ok(false) + } + + #[cfg(multi_core)] + MultiCoreStrategy::AutoPark => { + esp_hal::if_unstable_hal! { + let mut cpu_ctrl = CpuControl::new(unsafe { CPU_CTRL::steal() }); + for other_cpu in Cpu::other() { + if is_running(other_cpu) { + unsafe { cpu_ctrl.park_core(other_cpu) }; + return Ok(true); + } + } + } + Ok(false) + } + + MultiCoreStrategy::Ignore => Ok(false), + } + } + + /// Perform post-write actions. + /// + /// # Returns + /// * `True` if the other core needs to be un-parked by post_write + /// * `False` otherwise + pub(crate) fn post_write(&self, unpark: bool) { + cfg_select! { + multi_core => { + if let MultiCoreStrategy::AutoPark = self + && unpark + { + esp_hal::if_unstable_hal! { + let mut cpu_ctrl = CpuControl::new(unsafe { CPU_CTRL::steal() }); + for other_cpu in Cpu::other() { + cpu_ctrl.unpark_core(other_cpu); + } + } + } + } + _ => { + let _ = unpark; + } + } + } + + /// Run a flash write operation, handling multi-core synchronization. + /// + /// # Returns + /// * `Ok` with the result of the operation + /// * `Err` if the operation fails + pub(crate) fn with( + self, + f: impl FnOnce() -> Result, + ) -> Result { + let unpark = self.pre_write()?; + + let result = f(); + + self.post_write(unpark); + result + } +} diff --git a/vendor/esp-storage/src/encrypted.rs b/vendor/esp-storage/src/encrypted.rs new file mode 100644 index 0000000..b92e370 --- /dev/null +++ b/vendor/esp-storage/src/encrypted.rs @@ -0,0 +1,110 @@ +use crate::{FlashStorage, FlashStorageError, buffer::FlashSectorBuffer}; + +impl FlashStorage<'_> { + /// Read bytes from encrypted flash. + /// + /// Uses the MMU to map flash pages and reads decrypted data through the cache. + /// Unaligned offsets and lengths are supported. + /// + /// If flash encryption is not enabled this will just read plaintext. + /// + /// # Note + /// + /// This function always allocates a [`Self::SECTOR_SIZE`]-byte buffer on + /// the stack. See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the read would extend past + /// the end of the flash. + #[cfg_attr( + multi_core, + doc = "Returns [`FlashStorageError::OtherCoreRunning`] if the other core is active, because this function changes the flash MMU and the cache." + )] + pub fn read_encrypted( + &mut self, + offset: u32, + mut bytes: &mut [u8], + ) -> Result<(), FlashStorageError> { + self.check_bounds(offset, bytes.len())?; + + let mut data_offset = offset % Self::SECTOR_SIZE; + let mut aligned_offset = offset - data_offset; + + let mut sector_data = FlashSectorBuffer::uninit(); + + while !bytes.is_empty() { + let len = bytes.len().min((Self::SECTOR_SIZE - data_offset) as _); + + self.internal_read_encrypted(aligned_offset, sector_data.as_bytes_mut())?; + let sector_data = unsafe { sector_data.assume_init_bytes_mut() }; + bytes[..len].copy_from_slice(§or_data[data_offset as usize..][..len]); + + aligned_offset += Self::SECTOR_SIZE; + data_offset = 0; + bytes = &mut bytes[len..]; + } + + Ok(()) + } + + /// Write bytes to encrypted flash. + /// + /// Performs read-modify-write on affected sectors: reads the current encrypted + /// content, merges the new bytes, erases the sector, then writes it back encrypted. + /// + /// # Note + /// + /// This function always allocates a [`Self::SECTOR_SIZE`]-byte buffer on + /// the stack. See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::NotSupported`] if flash encryption is not + /// enabled. + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the write would extend past + /// the end of the flash. + #[cfg_attr( + multi_core, + doc = "Returns [`FlashStorageError::OtherCoreRunning`] if the other core is active, because this function changes the flash MMU and the cache." + )] + pub fn write_encrypted( + &mut self, + offset: u32, + mut bytes: &[u8], + ) -> Result<(), FlashStorageError> { + // we have a HIL test which exploits the fact that the ROM function + // will actually do encryption even if the flash encryption isn't enabled via efuse + #[cfg(not(any(__test_esp_storage, feature = "emulation")))] + if !crate::flash_encryption() { + return Err(FlashStorageError::NotSupported); + } + + self.check_bounds(offset, bytes.len())?; + + let mut data_offset = offset % Self::SECTOR_SIZE; + let mut aligned_offset = offset - data_offset; + + let mut sector_data = FlashSectorBuffer::uninit(); + + while !bytes.is_empty() { + let len = bytes.len().min((Self::SECTOR_SIZE - data_offset) as _); + + self.internal_read_encrypted(aligned_offset, sector_data.as_bytes_mut())?; + let sector_data = unsafe { sector_data.assume_init_bytes_mut() }; + + sector_data[data_offset as usize..][..len].copy_from_slice(&bytes[..len]); + self.internal_erase_sector(aligned_offset / Self::SECTOR_SIZE)?; + self.internal_write_encrypted(aligned_offset, sector_data)?; + + aligned_offset += Self::SECTOR_SIZE; + data_offset = 0; + bytes = &bytes[len..]; + } + + Ok(()) + } +} diff --git a/vendor/esp-storage/src/hardware.rs b/vendor/esp-storage/src/hardware.rs new file mode 100644 index 0000000..d1e75f6 --- /dev/null +++ b/vendor/esp-storage/src/hardware.rs @@ -0,0 +1,140 @@ +use esp_rom_sys as _; +use esp_rom_sys::rom::spiflash::*; +use procmacros::ram; + +use crate::maybe_with_critical_section; + +#[ram] +pub(crate) fn spiflash_read(src_addr: u32, data: *const u32, len: u32) -> i32 { + maybe_with_critical_section(|| unsafe { esp_rom_spiflash_read(src_addr, data, len) }) +} + +#[ram] +pub(crate) fn spiflash_unlock() -> i32 { + maybe_with_critical_section(|| unsafe { esp_rom_spiflash_unlock() }) +} + +#[ram] +pub(crate) fn spiflash_erase_sector(sector_number: u32) -> i32 { + maybe_with_critical_section(|| unsafe { esp_rom_spiflash_erase_sector(sector_number) }) +} + +#[ram] +pub(crate) fn spiflash_erase_block(block_number: u32) -> i32 { + maybe_with_critical_section(|| unsafe { esp_rom_spiflash_erase_block(block_number) }) +} + +#[ram] +pub(crate) fn spiflash_write(dest_addr: u32, data: *const u32, len: u32) -> i32 { + maybe_with_critical_section(|| unsafe { esp_rom_spiflash_write(dest_addr, data, len) }) +} + +#[ram] +pub(crate) fn spiflash_write_encrypted(dest_addr: u32, data: *mut u32, len: u32) -> i32 { + maybe_with_critical_section(|| { + let rc = unsafe { esp_rom_spiflash_write_encrypted(dest_addr, data, len) }; + if rc == 0 { + // Some chips turn the cache off to invalidate it, which no interrupt handler in + // flash must interrupt. + crate::mmu::invalidate_flash_cache(dest_addr, len); + } + rc + }) +} + +pub(crate) fn read_flash_encrypted( + offset: u32, + bytes: &mut [u8], +) -> Result<(), crate::FlashStorageError> { + crate::mmu::read_flash_encrypted(offset, bytes) +} + +/// Detect the physical flash size once, before ROM reads change SPI1's command state. +pub(crate) fn get_flash_size() -> u32 { + use core::sync::atomic::{AtomicU32, Ordering}; + + static CAPACITY: AtomicU32 = AtomicU32::new(0); + let capacity = CAPACITY.load(Ordering::Relaxed); + if capacity != 0 { + return capacity; + } + + let capacity = detect_flash_size(); + if capacity != 0 { + CAPACITY.store(capacity, Ordering::Relaxed); + } + capacity +} + +fn detect_flash_size() -> u32 { + // On ESP32 the hardware RDID mechanism does not work reliably, so we + // read the flash size from the ROM global `g_rom_flashchip` instead. + #[cfg(esp32)] + { + #[repr(C)] + struct RomSpiflashChip { + device_id: u32, + chip_size: u32, + } + unsafe extern "C" { + static g_rom_flashchip: RomSpiflashChip; + } + unsafe { g_rom_flashchip.chip_size } + } + + #[cfg(not(esp32))] + { + let id = maybe_with_critical_section(|| { + let spi1 = esp_hal::peripherals::SPI1::regs(); + spi1.cmd().write(|w| w.flash_rdid().set_bit()); + while spi1.cmd().read().flash_rdid().bit_is_set() {} + spi1.w(0).read().buf().bits() & 0x00FF_FFFF + }); + + const KB: u32 = 1024; + const MB: u32 = 1024 * KB; + + // https://github.com/espressif/esptool/blob/8363cae8eca42ec70e26edfe4d1727549d6ce578/esptool/cmds.py#L55-L98 + let [manufacturer, _, _, _] = id.to_le_bytes(); + if manufacturer == 0x1F { + let [_, capacity, _, _] = id.to_le_bytes(); + match capacity & 0x1F { + 0x04 => 512 * KB, + 0x05 => MB, + 0x06 => 2 * MB, + 0x07 => 4 * MB, + 0x08 => 8 * MB, + 0x09 => 16 * MB, + _ => 0, + } + } else { + let [_, _, capacity, _] = id.to_le_bytes(); + match capacity { + 0x12 => 256 * KB, + 0x13 => 512 * KB, + 0x14 => MB, + 0x15 => 2 * MB, + 0x16 => 4 * MB, + 0x17 => 8 * MB, + 0x18 => 16 * MB, + 0x19 => 32 * MB, + 0x1A => 64 * MB, + 0x1B => 128 * MB, + 0x1C => 256 * MB, + 0x20 => 64 * MB, + 0x21 => 128 * MB, + 0x22 => 256 * MB, + 0x32 => 256 * KB, + 0x33 => 512 * KB, + 0x34 => MB, + 0x35 => 2 * MB, + 0x36 => 4 * MB, + 0x37 => 8 * MB, + 0x38 => 16 * MB, + 0x39 => 32 * MB, + 0x3A => 64 * MB, + _ => 0, + } + } + } +} diff --git a/vendor/esp-storage/src/lib.rs b/vendor/esp-storage/src/lib.rs new file mode 100644 index 0000000..069f3c8 --- /dev/null +++ b/vendor/esp-storage/src/lib.rs @@ -0,0 +1,87 @@ +//! `esp-storage` contains API functions related to reading, writing and erasing memory for data in +//! the external flash. +//! +//! For higher-level functionality which works with partitions defined in the partition table, see +//! `esp-bootloader-esp-idf`. +//! +//! Encrypted flash read/write is available on chips with hardware flash encryption support when +//! flash encryption is enabled in eFuses. See [`FlashStorage::read_encrypted`] and +//! [`FlashStorage::write_encrypted`]. +//! +//! The `embedded-storage` feature flag only implements traits from [`embedded_storage`] +//! and always assumes access to non-encrypted flash. +//! +//! If you need to use this struct where traits from [`embedded_storage_async`] are needed, you can +//! use [`embassy_embedded_hal::adapter::BlockingAsync`] or +//! [`embassy_embedded_hal::adapter::YieldingAsync`] wrappers. +//! +//! [`embedded_storage`]: https://docs.rs/embedded-storage/latest/embedded_storage/ +//! [`embedded_storage_async`]: https://docs.rs/embedded-storage-async/latest/embedded_storage_async/ +//! [`embassy_embedded_hal::adapter::BlockingAsync`]: https://docs.rs/embassy-embedded-hal/latest/embassy_embedded_hal/adapter/struct.BlockingAsync.html +//! [`embassy_embedded_hal::adapter::YieldingAsync`]: https://docs.rs/embassy-embedded-hal/latest/embassy_embedded_hal/adapter/struct.YieldingAsync.html +//! +//! ## Buffer alignment and stack usage +//! +//! The ESP flash ROM read/write path requires word-aligned (4-byte) buffers. +//! Several methods accept any `&[u8]` / `&mut [u8]` and, when needed, copy +//! through a temporary sector-sized buffer on the stack. That cost is not +//! visible from the slice type and can surprise users in stack-constrained +//! contexts (for example Embassy tasks with small stacks). +//! +//! [`FlashStorage::read_nor`] and [`FlashStorage::write_nor`] only allocate +//! this fallback when the caller's slice pointer is not word-aligned. Each +//! fallback is [`FlashStorage::SECTOR_SIZE`] bytes. +//! +//! [`FlashStorage::read`], [`FlashStorage::write`], and the encrypted variants +//! always allocate a sector buffer on the stack for each call. +//! +//! ## Feature Flags +#![doc = document_features::document_features!(feature_label = r#"{feature}"#)] +#![doc(html_logo_url = "https://docs.espressif.com/projects/rust/esp-rs-grey-bg.svg")] +#![cfg_attr(not(all(test, feature = "emulation")), no_std)] + +#[macro_use] +extern crate esp_metadata_generated; + +#[cfg_attr(not(feature = "emulation"), path = "hardware.rs")] +#[cfg_attr(feature = "emulation", path = "stub.rs")] +mod chip_specific; + +mod buffer; +mod common; + +pub use common::{Flash, FlashStorage, FlashStorageError}; + +pub mod ll; +mod nor_flash; +mod storage; + +#[cfg(not(feature = "emulation"))] +mod mmu; + +mod encrypted; + +#[cfg(not(feature = "emulation"))] +#[inline(always)] +fn maybe_with_critical_section(f: impl FnOnce() -> R) -> R { + #[cfg(feature = "critical-section")] + { + static LOCK: esp_sync::RawMutex = esp_sync::RawMutex::new(); + + LOCK.lock(f) + } + + #[cfg(not(feature = "critical-section"))] + f() +} + +#[cfg(feature = "emulation")] +fn maybe_with_critical_section(f: impl FnOnce() -> R) -> R { + f() +} + +/// Whether flash encryption is enabled or not. +#[cfg(not(feature = "emulation"))] +pub fn flash_encryption() -> bool { + esp_hal::efuse::flash_encryption() +} diff --git a/vendor/esp-storage/src/ll.rs b/vendor/esp-storage/src/ll.rs new file mode 100644 index 0000000..ad587d8 --- /dev/null +++ b/vendor/esp-storage/src/ll.rs @@ -0,0 +1,90 @@ +//! # Low-level API +//! +//! ⚠️ This is a low-level API and should be used with caution. ⚠️ +//! +//! This gives you access to the underlying low level functionality. +//! These operate on raw pointers and all functions here are unsafe. +//! No pre-conditions are checked by any of these functions. + +use crate::chip_specific; + +/// Low-level SPI NOR Flash read +/// +/// # Safety +/// +/// The `src_addr` + `len` should not exceeds the size of flash. +/// The `data` expected to points to word-aligned pre-allocated buffer with size +/// greater or equals to `len`. +pub unsafe fn spiflash_read(src_addr: u32, data: *mut u32, len: u32) -> Result<(), i32> { + match chip_specific::spiflash_read(src_addr, data, len) { + 0 => Ok(()), + value => Err(value), + } +} + +/// Low-level SPI NOR Flash unlock +/// +/// # Safety +pub unsafe fn spiflash_unlock() -> Result<(), i32> { + match chip_specific::spiflash_unlock() { + 0 => Ok(()), + value => Err(value), + } +} + +/// Low-level SPI NOR Flash sector erase +/// +/// # Safety +/// +/// The `sector_number` * sector_size should not exceeds the size of flash. +pub unsafe fn spiflash_erase_sector(sector_number: u32) -> Result<(), i32> { + match chip_specific::spiflash_erase_sector(sector_number) { + 0 => Ok(()), + value => Err(value), + } +} + +/// Low-level SPI NOR Flash block erase +/// +/// # Safety +/// +/// The `block_number` * block_size should not exceeds the size of flash. +pub unsafe fn spiflash_erase_block(block_number: u32) -> Result<(), i32> { + match chip_specific::spiflash_erase_block(block_number) { + 0 => Ok(()), + value => Err(value), + } +} + +/// Low-level SPI NOR Flash write +/// +/// # Safety +/// +/// The `dest_addr` + `len` should not exceeds the size of flash. +/// The `data` expected to points to word-aligned buffer with size greater or +/// equals to `len`. +pub unsafe fn spiflash_write(dest_addr: u32, data: *const u32, len: u32) -> Result<(), i32> { + match chip_specific::spiflash_write(dest_addr, data, len) { + 0 => Ok(()), + value => Err(value), + } +} + +/// Low-level SPI NOR Flash encrypted write +/// +/// # Safety +/// +/// The `dest_addr` + `len` should not exceed the size of flash. +/// Both `dest_addr` and `len` must be 32-byte aligned. +/// The target flash region must already be erased. +/// The `data` pointer must be word-aligned. +pub unsafe fn spiflash_write_encrypted( + dest_addr: u32, + data: *mut u32, + len: u32, +) -> Result<(), i32> { + match chip_specific::spiflash_write_encrypted(dest_addr, data, len) { + 0 => Ok(()), + value => Err(value), + } +} diff --git a/vendor/esp-storage/src/mmu.rs b/vendor/esp-storage/src/mmu.rs new file mode 100644 index 0000000..df0a11d --- /dev/null +++ b/vendor/esp-storage/src/mmu.rs @@ -0,0 +1,696 @@ +//! Temporary MMU mapping for reading encrypted flash through the cache. +//! +//! Maps a flash physical page into an unused MMU entry, reads decrypted data via +//! the cache, then unmaps and invalidates the cache line. + +use core::ptr; + +#[cfg(esp32)] +use esp32 as pac; +#[cfg(esp32c2)] +use esp32c2 as pac; +#[cfg(esp32c3)] +use esp32c3 as pac; +#[cfg(esp32c5)] +use esp32c5 as pac; +#[cfg(esp32c6)] +use esp32c6 as pac; +#[cfg(esp32c61)] +use esp32c61 as pac; +#[cfg(esp32h2)] +use esp32h2 as pac; +#[cfg(esp32p4)] +use esp32p4 as pac; +#[cfg(esp32s2)] +use esp32s2 as pac; +#[cfg(esp32s3)] +use esp32s3 as pac; +#[cfg(esp32s31)] +use esp32s31 as pac; +#[cfg(not(soc_has_mmu_table))] +use indexed::*; +#[cfg(soc_has_mmu_table)] +use table::*; + +use crate::FlashStorageError; + +/// Guard for a temporarily mapped flash MMU page. +pub(crate) struct FlashMmapGuard { + entry_id: u32, + vaddr: *const u8, + #[cfg_attr( + esp32, + expect(dead_code, reason = "the ESP32 can only flush the whole cache") + )] + page_size: u32, + /// When `false` the entry was already mapped and must not be invalidated + owned: bool, +} + +impl FlashMmapGuard { + pub(crate) fn vaddr(&self) -> *const u8 { + self.vaddr + } + + fn new(entry_id: u32, page_size: u32, owned: bool) -> Result { + let vaddr = entry_id_to_vaddr(entry_id); + if vaddr.is_null() { + if owned { + set_entry_invalid(entry_id); + } + return Err(FlashStorageError::NotSupported); + } + Ok(Self { + entry_id, + vaddr, + page_size, + owned, + }) + } +} + +/// Map a flash physical address to a virtual address for encrypted reading. +/// +/// The returned mapping is ready to be read through: a temporary slot can retain cache lines from +/// its previous mapping, so the complete mapping is invalidated before this function returns. +pub(crate) fn map_flash_page(paddr: u32) -> Result { + let page_size = mmu_page_size(); + let page_paddr = paddr & !(page_size - 1); + + if let Some(entry_id) = find_existing_entry(page_paddr) { + let guard = FlashMmapGuard::new(entry_id, page_size, false)?; + invalidate_cache(guard.vaddr as u32, page_size); + return Ok(guard); + } + + cfg_select! { + esp32s2 => { + let entry_id = s2::alloc_entry(); + s2::map_entry(entry_id, page_paddr)?; + FlashMmapGuard::new(entry_id, page_size, true) + } + esp32 => { + let entry_id = find_free_entry().ok_or(FlashStorageError::NotSupported)?; + let guard = FlashMmapGuard::new(entry_id, page_size, true)?; + // Writing the entry and making it visible to the cache must happen in one + // cache-off window. + esp32_cache::map_entry(entry(entry_id), page_paddr); + Ok(guard) + } + _ => { + let entry_id = find_free_entry().ok_or(FlashStorageError::NotSupported)?; + write_flash_entry(entry_id, page_paddr); + let guard = FlashMmapGuard::new(entry_id, page_size, true)?; + invalidate_cache(guard.vaddr as u32, page_size); + Ok(guard) + } + } +} + +/// Unmap a temporarily mapped flash page and invalidate the cache. +pub(crate) fn unmap_flash_page(guard: FlashMmapGuard) { + #[cfg(esp32s2)] + if guard.owned { + s2::unmap_entry(guard.entry_id, guard.vaddr as u32, guard.page_size); + return; + } + + cfg_select! { + esp32 => { + esp32_cache::unmap_entry(guard.owned.then(|| entry(guard.entry_id))); + } + _ => { + invalidate_cache(guard.vaddr as u32, guard.page_size); + if guard.owned { + set_entry_invalid(guard.entry_id); + } + } + } +} + +/// Read decrypted flash bytes via temporary MMU mappings. +pub(crate) fn read_flash_encrypted(offset: u32, bytes: &mut [u8]) -> Result<(), FlashStorageError> { + if bytes.is_empty() { + return Ok(()); + } + + let page_size = mmu_page_size(); + let mut remaining = bytes; + let mut current_offset = offset; + + while !remaining.is_empty() { + let page_base = current_offset & !(page_size - 1); + let in_page = (current_offset - page_base) as usize; + let chunk = remaining.len().min(page_size as usize - in_page); + + crate::maybe_with_critical_section(|| { + let guard = map_flash_page(page_base)?; + let src = unsafe { guard.vaddr().add(in_page) }; + unsafe { + ptr::copy_nonoverlapping(src, remaining.as_mut_ptr(), chunk); + } + unmap_flash_page(guard); + Ok(()) + })?; + + current_offset += chunk as u32; + remaining = &mut remaining[chunk..]; + } + + Ok(()) +} + +/// Invalidate cache lines covering a flash physical address range. +pub(crate) fn invalidate_flash_cache(start: u32, len: u32) { + if len == 0 { + return; + } + + let page_size = mmu_page_size(); + let page_start = start & !(page_size - 1); + let end = start.saturating_add(len); + let mut addr = page_start; + + while addr < end { + if let Some(entry_id) = find_existing_entry(addr) { + let vaddr = entry_id_to_vaddr(entry_id); + if !vaddr.is_null() { + invalidate_cache(vaddr as u32, page_size); + } + } + addr = addr.saturating_add(page_size); + } +} + +#[cfg(not(any(esp32, esp32p4, esp32s31)))] +fn invalidate_cache(vaddr: u32, size: u32) { + unsafe extern "C" { + fn Cache_Invalidate_Addr(addr: u32, size: u32); + } + unsafe { + Cache_Invalidate_Addr(vaddr, size); + } +} + +#[cfg(esp32s31)] +fn invalidate_cache(vaddr: u32, size: u32) { + const CACHE_MAP_L1_DCACHE: u32 = 1 << 4; + + unsafe extern "C" { + fn Cache_Invalidate_Addr(map: u32, addr: u32, size: u32); + } + unsafe { + Cache_Invalidate_Addr(CACHE_MAP_L1_DCACHE, vaddr, size); + } +} + +#[cfg(esp32p4)] +fn invalidate_cache(vaddr: u32, size: u32) { + const CACHE_MAP_L1_DCACHE: u32 = 1 << 4; + const CACHE_MAP_L2_CACHE: u32 = 1 << 5; + + unsafe extern "C" { + fn Cache_Invalidate_Addr(map: u32, addr: u32, size: u32); + } + unsafe { + Cache_Invalidate_Addr(CACHE_MAP_L1_DCACHE | CACHE_MAP_L2_CACHE, vaddr, size); + } +} + +#[cfg(esp32)] +fn invalidate_cache(_vaddr: u32, _size: u32) { + // The ESP32 cache cannot invalidate a single address range, so the whole cache is flushed. + esp32_cache::flush_caches(); +} + +#[cfg(not(esp32s2))] +const FLASH_VADDR_BASE: u32 = memory_range!("DROM").start as u32; + +fn entry_id_to_vaddr(entry_id: u32) -> *const u8 { + cfg_select! { + esp32s2 => s2::entry_id_to_vaddr(entry_id), + _ => (FLASH_VADDR_BASE + entry_id * mmu_page_size()) as *const u8, + } +} + +fn find_existing_entry(page_paddr: u32) -> Option { + let page = flash_page_number(page_paddr); + let (start, end) = mmu_entry_scan_range(); + + (start..end).find(|&entry_id| { + entry_is_valid(entry_id) + && entry_is_flash_mapping(entry_id) + && entry_flash_page(entry_id) == page + }) +} + +fn find_free_entry() -> Option { + let (start, end) = mmu_entry_scan_range(); + // Skip the last entry — reserved for bootloader internal flash access. + (start..end.saturating_sub(1)) + .rev() + .find(|&entry_id| !entry_is_valid(entry_id)) +} + +fn mmu_entry_scan_range() -> (u32, u32) { + cfg_select! { + not(soc_has_mmu_table) => (0, indexed::entry_count()), + esp32s2 => (s2::DATA_ENTRY_START, s2::DATA_ENTRY_END), + _ => (0, table::entry_count()), + } +} + +#[cfg(not(soc_has_mmu_table))] +mod indexed { + use super::*; + + #[inline(always)] + fn spi0() -> &'static pac::spi0::RegisterBlock { + unsafe { &*pac::SPI0::ptr() } + } + + pub(super) fn mmu_page_size() -> u32 { + let code = mmu_page_size_code(); + cfg_select! { + // The S31 flash MMU's maximum page size is 256 KiB. + esp32s31 => 0x40000 >> code, + // Other indexed flash MMUs have a 64 KiB maximum page size. + _ => 0x10000 >> code, + } + } + + fn mmu_page_size_code() -> u32 { + let ctrl = spi0().mmu_power_ctrl().read(); + cfg_select! { + any(esp32c5, esp32c61, esp32s31) => ctrl.mmu_page_size().bits() as u32, + _ => ctrl.spi_mmu_page_size().bits() as u32, + } + } + + fn page_size_shift(page_size: u32) -> u32 { + match page_size { + 0x40000 => 18, + 0x20000 => 17, + 0x10000 => 16, + 0x8000 => 15, + 0x4000 => 14, + 0x2000 => 13, + _ => 16, + } + } + + pub(super) fn flash_page_number(page_paddr: u32) -> u32 { + page_paddr >> page_size_shift(mmu_page_size()) + } + + pub(super) fn entry_is_flash_mapping(entry_id: u32) -> bool { + with_entry(entry_id, flash_mapping_from_entry) + } + + fn flash_mapping_from_entry(entry: pac::spi0::mmu_item_content::R) -> bool { + #[cfg(any(esp32c5, esp32c61))] + { + !entry.access_spiram().bit() + } + #[cfg(not(any(esp32c5, esp32c61)))] + { + let _ = entry; + true + } + } + + fn with_entry(entry_id: u32, f: impl FnOnce(pac::spi0::mmu_item_content::R) -> R) -> R { + select_entry(entry_id); + f(spi0().mmu_item_content().read()) + } + + fn select_entry(entry_id: u32) { + spi0() + .mmu_item_index() + .write(|w| unsafe { w.mmu_item_index().bits(entry_id) }); + } + + pub(super) fn write_flash_entry(entry_id: u32, page_paddr: u32) { + let page = flash_page_number(page_paddr) as u16; + let encrypted = esp_hal::efuse::flash_encryption(); + select_entry(entry_id); + spi0().mmu_item_content().write(|w| { + unsafe { w.paddr().bits(page) }; + #[cfg(any(esp32c5, esp32c61))] + w.access_spiram().clear_bit(); + w.valid().set_bit(); + if encrypted { + w.sensitive().set_bit() + } else { + w + } + }); + } + + pub(super) fn set_entry_invalid(entry_id: u32) { + select_entry(entry_id); + // Match `mmu_ll_set_entry_invalid`: the PAC reset value is not zero on every chip. + spi0().mmu_item_content().write(|w| unsafe { w.bits(0) }); + } + + pub(super) fn entry_count() -> u32 { + property!("mmu.entry_num") + } + + pub(super) fn entry_is_valid(entry_id: u32) -> bool { + with_entry(entry_id, |entry| entry.valid().bit()) + } + + pub(super) fn entry_flash_page(entry_id: u32) -> u32 { + with_entry(entry_id, |entry| entry.paddr().bits() as u32) + } +} + +#[cfg(soc_has_mmu_table)] +mod table { + use super::*; + + #[inline(always)] + fn mmu_table() -> &'static pac::mmu_table::RegisterBlock { + unsafe { &*pac::MMU_TABLE::ptr() } + } + + #[inline(always)] + pub(super) fn mmu_page_size() -> u32 { + property!("mmu.page_size") + } + + #[inline(always)] + pub(super) fn flash_page_number(page_paddr: u32) -> u32 { + page_paddr >> 16 + } + + pub(super) fn entry_is_flash_mapping(entry_id: u32) -> bool { + with_entry(entry_id, flash_mapping_from_entry) + } + + fn flash_mapping_from_entry(entry: pac::mmu_table::entry::R) -> bool { + #[cfg(esp32s2)] + { + entry.access_flash().bit() + } + #[cfg(esp32s3)] + { + !entry.access_spiram().bit() + } + #[cfg(not(any(esp32s2, esp32s3)))] + { + let _ = entry; + true + } + } + + fn with_entry(entry_id: u32, f: impl FnOnce(pac::mmu_table::entry::R) -> R) -> R { + f(entry(entry_id).read()) + } + + /// Look up one MMU entry. + /// + /// The ESP32 looks the entry up before it turns the cache off: the bounds check of the + /// lookup can call into flash, which is not reachable with the cache off. + #[inline(always)] + pub(super) fn entry(entry_id: u32) -> &'static pac::mmu_table::ENTRY { + mmu_table().entry(entry_id as usize) + } + + // The ESP32 calls the two functions below with the cache off, where a call into flash would + // hang. Inlining them into the caller in RAM keeps them reachable. + #[cfg(not(esp32s2))] + #[inline(always)] + pub(super) fn write_entry(entry: &pac::mmu_table::ENTRY, page_paddr: u32) { + let page = flash_page_number(page_paddr) as u16; + entry.write(|w| { + cfg_select! { + any(esp32, esp32c2, esp32c3) => unsafe { w.paddr().bits(page as u8) }, + _ => unsafe { w.paddr().bits(page) }, + }; + w.invalid().clear_bit(); + #[cfg(all(soc_has_psram, not(any(esp32, esp32s2))))] + w.access_spiram().clear_bit(); + w + }); + } + + #[inline(always)] + pub(super) fn invalidate_entry(entry: &pac::mmu_table::ENTRY) { + entry.write(|w| w.invalid().set_bit()); + } + + #[inline(always)] + pub(super) fn set_entry_invalid(entry_id: u32) { + invalidate_entry(entry(entry_id)); + } + + pub(super) fn entry_is_valid(entry_id: u32) -> bool { + with_entry(entry_id, |entry| !entry.invalid().bit()) + } + + pub(super) fn entry_flash_page(entry_id: u32) -> u32 { + with_entry(entry_id, |entry| entry.paddr().bits() as u32) + } + + // The ESP32 writes the entry with the cache off, and so uses `write_entry` directly. + #[cfg(not(any(esp32, esp32s2)))] + #[inline(always)] + pub(super) fn write_flash_entry(entry_id: u32, page_paddr: u32) { + write_entry(entry(entry_id), page_paddr); + } + + #[cfg(not(esp32s2))] + pub(super) fn entry_count() -> u32 { + esp_hal::peripherals::MMU_TABLE::regs().entry_iter().count() as u32 + } +} + +/// Flash MMU and cache maintenance for the ESP32. +/// +/// The ESP32 cache must be off while the flash MMU is written or the cache is flushed. Both +/// operations disturb a cache fill that runs at the same time, and the core that waits for the +/// fill then hangs. ESP-IDF turns the cache off around both operations, see `s_do_mapping()` in +/// `components/esp_mm/esp_mmu_map.c`. +/// +/// Code that runs with the cache off must not access flash, which is why the functions below are +/// placed in RAM and only call ROM code. +#[cfg(esp32)] +mod esp32_cache { + use procmacros::ram; + + use super::*; + + unsafe extern "C" { + fn Cache_Flush_rom(cpu: u32); + } + + /// The caches that were on before this crate turned them off. + #[derive(Clone, Copy)] + struct CachesOn { + pro: bool, + app: bool, + } + + #[inline(always)] + fn dport() -> &'static pac::dport::RegisterBlock { + unsafe { &*pac::DPORT::ptr() } + } + + /// Turn off the cache of both cores. + /// + /// The flash MMU is shared, so the other core must not read through it either. The caller + /// makes sure that the other core does not execute from flash in the meantime. + #[inline(always)] + fn cache_off() -> CachesOn { + let on = CachesOn { + pro: dport().pro_cache_ctrl().read().pro_cache_enable().bit(), + app: dport().app_cache_ctrl().read().app_cache_enable().bit(), + }; + + // A cache must be idle before it is turned off, see `cache_ll_l1_disable_cache()`. + if on.pro { + while dport().pro_dcache_dbug0().read().pro_cache_state().bits() != 1 {} + dport() + .pro_cache_ctrl() + .modify(|_, w| w.pro_cache_enable().clear_bit()); + } + if on.app { + while dport().app_dcache_dbug0().read().app_cache_state().bits() != 1 {} + dport() + .app_cache_ctrl() + .modify(|_, w| w.app_cache_enable().clear_bit()); + } + + // Complete the writes above before the flash MMU is written. + let _ = dport().pro_cache_ctrl().read(); + + on + } + + /// Drop every cached line, so that both cores see the current mapping. + #[inline(always)] + fn flush(on: CachesOn) { + // A cache that is off holds no lines that its core can hit. + unsafe { + if on.pro { + Cache_Flush_rom(0); + } + if on.app { + Cache_Flush_rom(1); + } + } + } + + #[inline(always)] + fn cache_on(on: CachesOn) { + if on.pro { + dport() + .pro_cache_ctrl() + .modify(|_, w| w.pro_cache_enable().set_bit()); + } + if on.app { + dport() + .app_cache_ctrl() + .modify(|_, w| w.app_cache_enable().set_bit()); + } + + // The caller returns to flash right away, so make sure that the cache is on again before + // the next instruction is fetched: reading the register back completes the write. + let _ = dport().pro_cache_ctrl().read(); + } + + /// Map `page_paddr` into `entry` and make the new mapping visible. + #[ram] + pub(super) fn map_entry(entry: &pac::mmu_table::ENTRY, page_paddr: u32) { + let on = cache_off(); + write_entry(entry, page_paddr); + flush(on); + cache_on(on); + } + + /// Drop the lines of a temporary mapping and invalidate its entry, if we own it. + #[ram] + pub(super) fn unmap_entry(owned_entry: Option<&pac::mmu_table::ENTRY>) { + let on = cache_off(); + if let Some(entry) = owned_entry { + invalidate_entry(entry); + } + flush(on); + cache_on(on); + } + + /// Drop every cached line. + #[ram] + pub(super) fn flush_caches() { + let on = cache_off(); + flush(on); + cache_on(on); + } +} + +#[cfg(esp32s2)] +mod s2 { + use procmacros::ram; + + use super::*; + + pub(super) const DATA_ENTRY_START: u32 = 0x200 / 4; + pub(super) const DATA_ENTRY_END: u32 = 0x500 / 4; + const IBUS_ENTRY_END: u32 = 0x300 / 4; + /// Dedicated scratch slot on the DROM / IBUS2 bus (entry 191 reserved for the bootloader). + const TEMP_ENTRY_ID: u32 = IBUS_ENTRY_END - 2; + + /// Virtual address for an S2 MMU entry (DROM/IBUS2 or DBUS0/DBUS1). + /// + /// On ESP32-S2, DROM (entries 128..192) is backed by the I-cache bus + /// (`drom0_in_icache = 1` in ESP-IDF). DBUS2/DPORT must not be used here. + pub(super) fn entry_id_to_vaddr(entry_id: u32) -> *const u8 { + let page_size = mmu_page_size(); + // Matches `mmu_ll_entry_id_to_vaddr_base()` for data-bus entries. + // Do not use IBUS0/1/2 (entries 0..128): those are not reachable with 8-bit + // loads. Do not use DBUS2/DPORT (entries 320..384): 32-bit access only. + let relative = match entry_id { + 0x80..0xC0 => entry_id - 0x80, + 0xC0..0x100 => entry_id - 0xC0, + 0x100..0x140 => entry_id - 0x100, + _ => return ptr::null(), + }; + (0x3F00_0000u32 + relative * page_size) as *const u8 + } + + /// Pick a free MMU slot, preferring IBUS2/DROM entries first. + pub(super) fn alloc_entry() -> u32 { + for entry_id in (DATA_ENTRY_START..IBUS_ENTRY_END.saturating_sub(1)).rev() { + if !entry_is_valid(entry_id) { + return entry_id; + } + } + find_free_entry().unwrap_or(TEMP_ENTRY_ID) + } + + /// Returns `true` for DROM MMU slots (128..192) which live on the I-cache bus on ESP32-S2. + fn entry_uses_ibus(entry_id: u32) -> bool { + (DATA_ENTRY_START..IBUS_ENTRY_END).contains(&entry_id) + } + + /// Program one MMU slot through the correct ROM helper (I-bus vs D-bus). + #[ram] + fn mmu_rom_set(entry_id: u32, access: u32, vaddr: u32, paddr: u32) -> i32 { + unsafe extern "C" { + fn Cache_Ibus_MMU_Set( + ext_ram: u32, + vaddr: u32, + paddr: u32, + psize: u32, + num: u32, + fixed: u32, + ) -> i32; + fn Cache_Dbus_MMU_Set( + ext_ram: u32, + vaddr: u32, + paddr: u32, + psize: u32, + num: u32, + fixed: u32, + ) -> i32; + } + + unsafe { + if entry_uses_ibus(entry_id) { + Cache_Ibus_MMU_Set(access, vaddr, paddr, 64, 1, 0) + } else { + Cache_Dbus_MMU_Set(access, vaddr, paddr, 64, 1, 0) + } + } + } + + /// Map an MMU entry to a flash page via ROM (must run from IRAM on ESP32-S2). + #[ram] + pub(super) fn map_entry( + entry_id: u32, + page_paddr: u32, + ) -> Result<*const u8, FlashStorageError> { + let vaddr = entry_id_to_vaddr(entry_id); + if vaddr.is_null() { + return Err(FlashStorageError::NotSupported); + } + let page_size = mmu_page_size(); + let vaddr_u32 = vaddr as u32; + invalidate_cache(vaddr_u32, page_size); + let rc = mmu_rom_set(entry_id, 1 << 15, vaddr_u32, page_paddr); + if rc != 0 { + return Err(FlashStorageError::NotSupported); + } + invalidate_cache(vaddr_u32, page_size); + Ok(vaddr) + } + + /// Drop cached lines for a temporary mapping. The MMU slot is left mapped so the next + /// remap can overwrite it without touching unrelated I/D-cache state. + #[ram] + pub(super) fn unmap_entry(_entry_id: u32, vaddr: u32, page_size: u32) { + invalidate_cache(vaddr, page_size); + } +} diff --git a/vendor/esp-storage/src/nor_flash.rs b/vendor/esp-storage/src/nor_flash.rs new file mode 100644 index 0000000..d4ad257 --- /dev/null +++ b/vendor/esp-storage/src/nor_flash.rs @@ -0,0 +1,573 @@ +#[cfg(feature = "bytewise-read")] +use crate::buffer::FlashWordBuffer; +use crate::{ + FlashStorage, + FlashStorageError, + buffer::{FlashSectorBuffer, uninit_slice, uninit_slice_mut}, +}; + +impl FlashStorage<'_> { + #[cfg(not(feature = "bytewise-read"))] + /// Minimum read size in bytes for [`FlashStorage::read_nor`]. + pub const READ_SIZE: usize = Self::WORD_SIZE as _; + + #[cfg(feature = "bytewise-read")] + /// Minimum read size in bytes for [`FlashStorage::read_nor`]. + pub const READ_SIZE: usize = 1; + + /// Minimum write size in bytes for [`FlashStorage::write_nor`]. + pub const WRITE_SIZE: usize = Self::WORD_SIZE as _; + + /// Minimum erase size in bytes for [`FlashStorage::erase`]. + pub const ERASE_SIZE: usize = Self::SECTOR_SIZE as _; + + #[inline(always)] + fn is_word_aligned(bytes: &[u8]) -> bool { + // TODO: Use is_aligned_to when stabilized (see `pointer_is_aligned`) + (bytes.as_ptr() as usize).is_multiple_of(Self::WORD_SIZE as usize) + } + + /// Read bytes from flash using NOR flash semantics. + /// + /// `offset` and `bytes.len()` must be aligned to [`Self::READ_SIZE`]. + /// + /// # Note + /// + /// If `bytes` is not word-aligned (4-byte), this function allocates a + /// [`Self::SECTOR_SIZE`]-byte buffer on the stack and copies through it. + /// See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::NotAligned`] if `offset` or `bytes.len()` is + /// not aligned to [`Self::READ_SIZE`]. + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the read would extend past + /// the end of the flash. + pub fn read_nor(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), FlashStorageError> { + const RS: u32 = FlashStorage::READ_SIZE as u32; + self.check_alignment::<{ RS }>(offset, bytes.len())?; + self.check_bounds(offset, bytes.len())?; + + #[cfg(feature = "bytewise-read")] + let (offset, bytes) = { + let byte_offset = (offset % Self::WORD_SIZE) as usize; + if byte_offset > 0 { + let mut word_buffer = FlashWordBuffer::uninit(); + + let offset = offset - byte_offset as u32; + let length = bytes.len().min(Self::WORD_SIZE as usize - byte_offset); + + self.internal_read(offset, word_buffer.as_bytes_mut())?; + let word_buffer = unsafe { word_buffer.assume_init_bytes_mut() }; + bytes[..length].copy_from_slice(&word_buffer[byte_offset..][..length]); + + (offset + Self::WORD_SIZE, &mut bytes[length..]) + } else { + (offset, bytes) + } + }; + + if Self::is_word_aligned(bytes) { + // Bytes buffer is word-aligned so we can read directly to it + for (offset, chunk) in (offset..) + .step_by(Self::SECTOR_SIZE as _) + .zip(bytes.chunks_mut(Self::SECTOR_SIZE as _)) + { + // Chunk already is word aligned so we can read directly to it + #[cfg(not(feature = "bytewise-read"))] + self.internal_read(offset, uninit_slice_mut(chunk))?; + + #[cfg(feature = "bytewise-read")] + { + let length = chunk.len(); + let byte_length = length % Self::WORD_SIZE as usize; + let length = length - byte_length; + + self.internal_read(offset, &mut uninit_slice_mut(chunk)[..length])?; + + // Read not aligned rest of data + if byte_length > 0 { + let mut word_buffer = FlashWordBuffer::uninit(); + + self.internal_read(offset + length as u32, word_buffer.as_bytes_mut())?; + let word_buffer = unsafe { word_buffer.assume_init_bytes_mut() }; + chunk[length..].copy_from_slice(&word_buffer[..byte_length]); + } + } + } + } else { + // Bytes buffer isn't word-aligned so we might read only via aligned buffer + let mut buffer = FlashSectorBuffer::uninit(); + + for (offset, chunk) in (offset..) + .step_by(Self::SECTOR_SIZE as _) + .zip(bytes.chunks_mut(Self::SECTOR_SIZE as _)) + { + // Read to temporary buffer first (chunk length is aligned) + #[cfg(not(feature = "bytewise-read"))] + self.internal_read(offset, &mut buffer.as_bytes_mut()[..chunk.len()])?; + + // Read to temporary buffer first (chunk length is not aligned) + #[cfg(feature = "bytewise-read")] + { + let length = chunk.len(); + let byte_length = length % Self::WORD_SIZE as usize; + let length = if byte_length > 0 { + length - byte_length + Self::WORD_SIZE as usize + } else { + length + }; + + self.internal_read(offset, &mut buffer.as_bytes_mut()[..length])?; + } + let buffer = unsafe { buffer.assume_init_bytes() }; + + // Copy to bytes buffer + chunk.copy_from_slice(&buffer[..chunk.len()]); + } + } + + Ok(()) + } + + /// Write bytes to flash using NOR flash semantics. + /// + /// NOR flash can only change bits from 1 to 0. Setting a bit from 0 to 1 + /// requires erasing the containing sector first (see [`Self::erase`]). Each + /// byte is updated by ANDing it with the data being written; if the target + /// still contains 0 bits where you need 1s, the stored value will not match + /// what you passed in even though the operation succeeds. + /// + /// `offset` and `bytes.len()` must be aligned to [`Self::WRITE_SIZE`]. + /// + /// # Note + /// + /// If `bytes` is not word-aligned (4-byte), this function allocates a + /// [`Self::SECTOR_SIZE`]-byte buffer on the stack and copies through it. + /// See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::NotAligned`] if `offset` or `bytes.len()` is + /// not aligned to [`Self::WRITE_SIZE`]. + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the write would extend past + /// the end of the flash. + pub fn write_nor(&mut self, offset: u32, bytes: &[u8]) -> Result<(), FlashStorageError> { + const WS: u32 = FlashStorage::WORD_SIZE; + self.check_alignment::<{ WS }>(offset, bytes.len())?; + self.check_bounds(offset, bytes.len())?; + + if Self::is_word_aligned(bytes) { + // Bytes buffer is word-aligned so we can write directly from it + for (offset, chunk) in (offset..) + .step_by(Self::SECTOR_SIZE as _) + .zip(bytes.chunks(Self::SECTOR_SIZE as _)) + { + // Chunk already is word aligned so we can write directly from it + self.internal_write(offset, chunk)?; + } + } else { + // Bytes buffer isn't word-aligned so we might write only via aligned buffer + let mut buffer = FlashSectorBuffer::uninit(); + + for (offset, chunk) in (offset..) + .step_by(Self::SECTOR_SIZE as _) + .zip(bytes.chunks(Self::SECTOR_SIZE as _)) + { + // Copy to temporary buffer first + buffer.as_bytes_mut()[..chunk.len()].copy_from_slice(uninit_slice(chunk)); + // Write from temporary buffer + self.internal_write(offset, unsafe { + &buffer.assume_init_bytes()[..chunk.len()] + })?; + } + } + + Ok(()) + } + + /// Erase flash from `from` up to but not including `to`. + /// + /// Erased bytes are set to `0xFF`. Both addresses must be aligned to + /// [`Self::ERASE_SIZE`], and `to - from` must be a multiple of + /// [`Self::ERASE_SIZE`]. + /// + /// # Errors + /// + /// Returns [`FlashStorageError::NotAligned`] if `from` or `to - from` is + /// not aligned to [`Self::ERASE_SIZE`]. + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the range would extend past + /// the end of the flash, or if `to` is less than `from`. + pub fn erase(&mut self, from: u32, to: u32) -> Result<(), FlashStorageError> { + if to < from { + return Err(FlashStorageError::OutOfBounds); + } + + let len = (to - from) as _; + const SZ: u32 = FlashStorage::SECTOR_SIZE; + self.check_alignment::<{ SZ }>(from, len)?; + self.check_bounds(from, len)?; + + // First erase by sector up to the block boundary. + let mut address = from; + while address < to && !address.is_multiple_of(Self::BLOCK_SIZE) { + self.internal_erase_sector(address / Self::SECTOR_SIZE)?; + address += Self::SECTOR_SIZE; + } + + // Then erase as much as possible by blocks. + while (to - address) >= Self::BLOCK_SIZE { + self.internal_erase_block(address / Self::BLOCK_SIZE)?; + address += Self::BLOCK_SIZE; + } + + // Finally, erase any remaining sectors. + while address < to { + self.internal_erase_sector(address / Self::SECTOR_SIZE)?; + address += Self::SECTOR_SIZE; + } + + Ok(()) + } +} + +#[cfg(feature = "embedded-storage")] +mod embedded_storage_traits { + use ::embedded_storage::nor_flash::{ + ErrorType, + MultiwriteNorFlash, + NorFlash, + NorFlashError, + NorFlashErrorKind, + ReadNorFlash, + }; + + use super::*; + + impl NorFlashError for FlashStorageError { + fn kind(&self) -> NorFlashErrorKind { + match self { + FlashStorageError::NotAligned => NorFlashErrorKind::NotAligned, + FlashStorageError::OutOfBounds => NorFlashErrorKind::OutOfBounds, + _ => NorFlashErrorKind::Other, + } + } + } + + impl ErrorType for FlashStorage<'_> { + type Error = FlashStorageError; + } + + impl ReadNorFlash for FlashStorage<'_> { + const READ_SIZE: usize = FlashStorage::READ_SIZE; + + fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { + FlashStorage::read_nor(self, offset, bytes) + } + + fn capacity(&self) -> usize { + FlashStorage::capacity(self) + } + } + + impl NorFlash for FlashStorage<'_> { + const WRITE_SIZE: usize = FlashStorage::WRITE_SIZE; + const ERASE_SIZE: usize = FlashStorage::ERASE_SIZE; + + fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { + FlashStorage::write_nor(self, offset, bytes) + } + + fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> { + FlashStorage::erase(self, from, to) + } + } + + impl MultiwriteNorFlash for FlashStorage<'_> {} +} + +// Run the tests with `--test-threads=1` - the emulation is not multithread safe +#[cfg(test)] +mod tests { + use super::*; + use crate::common::Flash; + + const WORD_SIZE: u32 = 4; + const SECTOR_SIZE: u32 = 4 << 10; + const BLOCK_SIZE: u32 = 4 << 14; + const NUM_BLOCKS: u32 = 3; + const FLASH_SIZE: u32 = BLOCK_SIZE * NUM_BLOCKS; + const MAX_OFFSET: u32 = SECTOR_SIZE * 1; + const MAX_LENGTH: u32 = SECTOR_SIZE * 2; + + #[repr(C, align(4))] + struct TestBuffer { + data: [u8; FLASH_SIZE as _], + } + + impl TestBuffer { + const fn seq() -> Self { + let mut data = [0u8; FLASH_SIZE as _]; + let mut index = 0; + while index < FLASH_SIZE { + data[index as usize] = (index & 0xff) as u8; + index += 1; + } + Self { data } + } + } + + impl Default for TestBuffer { + fn default() -> Self { + Self { + data: [0u8; FLASH_SIZE as usize], + } + } + } + + #[cfg(not(miri))] + fn range_gen( + aligned: Option, + ) -> impl Iterator { + (0..=MAX_OFF).flat_map(move |off| { + (0..=MAX_LEN - off) + .filter(move |len| { + aligned + .map(|aligned| aligned == (off % ALIGN == 0 && len % ALIGN == 0)) + .unwrap_or(true) + }) + .map(move |len| (off, len)) + }) + } + + #[cfg(miri)] + fn range_gen( + aligned: Option, + ) -> impl Iterator { + // MIRI is very slow - just use a couple of combinations + match aligned { + Some(true) => vec![(0, 4), (0, 8), (0, 16), (0, 32), (0, 1024)], + Some(false) => vec![(3, 7), (11, 11)], + None => vec![ + (0, 4), + (0, 8), + (0, 16), + (0, 32), + (0, 1024), + (3, 7), + (11, 11), + (0, 4098), + ], + } + .into_iter() + } + + #[test] + #[cfg(not(feature = "bytewise-read"))] + fn aligned_read() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let src = TestBuffer::seq(); + let mut data = TestBuffer::default(); + + flash.erase(0, FLASH_SIZE).unwrap(); + flash.write_nor(0, &src.data).unwrap(); + + for (off, len) in range_gen::(Some(true)) { + flash.read_nor(off, &mut data.data[..len as usize]).unwrap(); + assert_eq!( + data.data[..len as usize], + src.data[off as usize..][..len as usize] + ); + } + } + + #[test] + #[cfg(not(feature = "bytewise-read"))] + fn not_aligned_read_aligned_buffer() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let mut data = TestBuffer::default(); + + for (off, len) in range_gen::(Some(false)) { + flash + .read_nor(off, &mut data.data[..len as usize]) + .unwrap_err(); + } + } + + #[test] + #[cfg(not(feature = "bytewise-read"))] + fn aligned_read_not_aligned_buffer() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let src = TestBuffer::seq(); + let mut data = TestBuffer::default(); + + flash.erase(0, FLASH_SIZE).unwrap(); + flash.write_nor(0, &src.data).unwrap(); + + for (off, len) in range_gen::(Some(true)) { + flash + .read_nor(off, &mut data.data[1..][..len as usize]) + .unwrap(); + assert_eq!( + data.data[1..][..len as usize], + src.data[off as usize..][..len as usize] + ); + } + } + + #[test] + #[cfg(feature = "bytewise-read")] + fn bytewise_read_aligned_buffer() { + let mut flash = FlashStorage::new(Flash::new()); + + flash.capacity = FLASH_SIZE as usize; + let src = TestBuffer::seq(); + let mut data = TestBuffer::default(); + + flash.erase(0, FLASH_SIZE).unwrap(); + flash.write_nor(0, &src.data).unwrap(); + + for (off, len) in range_gen::(None) { + flash.read_nor(off, &mut data.data[..len as usize]).unwrap(); + assert_eq!( + data.data[..len as usize], + src.data[off as usize..][..len as usize] + ); + } + } + + #[test] + #[cfg(feature = "bytewise-read")] + fn bytewise_read_not_aligned_buffer() { + let mut flash = FlashStorage::new(Flash::new()); + + flash.capacity = FLASH_SIZE as usize; + let src = TestBuffer::seq(); + let mut data = TestBuffer::default(); + + flash.erase(0, FLASH_SIZE).unwrap(); + flash.write_nor(0, &src.data).unwrap(); + + for (off, len) in range_gen::(None) { + flash + .read_nor(off, &mut data.data[1..][..len as usize]) + .unwrap(); + assert_eq!( + data.data[1..][..len as usize], + src.data[off as usize..][..len as usize] + ); + } + } + + #[test] + fn write_not_aligned_buffer() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let mut read_data = TestBuffer::default(); + let write_data = TestBuffer::seq(); + + flash.erase(0, FLASH_SIZE).unwrap(); + flash.write_nor(0, &write_data.data[1..129]).unwrap(); + + flash.read_nor(0, &mut read_data.data[..128]).unwrap(); + + assert_eq!(&read_data.data[..128], &write_data.data[1..129]); + } + + #[test] + fn erase_up_to_end_of_flash() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let mut read_data = TestBuffer::default(); + let write_data = [0u8; SECTOR_SIZE as usize]; + + flash.erase(FLASH_SIZE - BLOCK_SIZE, FLASH_SIZE).unwrap(); + flash + .write_nor(FLASH_SIZE - 2 * SECTOR_SIZE, &write_data) + .unwrap(); + flash + .write_nor(FLASH_SIZE - SECTOR_SIZE, &write_data) + .unwrap(); + + // Erasing the last sector of the flash must be allowed + flash.erase(FLASH_SIZE - SECTOR_SIZE, FLASH_SIZE).unwrap(); + + flash + .read( + FLASH_SIZE - 2 * SECTOR_SIZE, + &mut read_data.data[..(2 * SECTOR_SIZE) as usize], + ) + .unwrap(); + assert!( + read_data.data[..SECTOR_SIZE as usize] + .iter() + .all(|v| *v == 0x0) + ); + assert!( + read_data.data[SECTOR_SIZE as usize..(2 * SECTOR_SIZE) as usize] + .iter() + .all(|v| *v == 0xFF) + ); + + // Erasing the last block of the flash must be allowed + flash.erase(FLASH_SIZE - BLOCK_SIZE, FLASH_SIZE).unwrap(); + + flash + .read( + FLASH_SIZE - 2 * SECTOR_SIZE, + &mut read_data.data[..(2 * SECTOR_SIZE) as usize], + ) + .unwrap(); + assert!( + read_data.data[..(2 * SECTOR_SIZE) as usize] + .iter() + .all(|v| *v == 0xFF) + ); + } + + #[test] + fn erase_reversed_range_is_rejected() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + + assert_eq!( + flash.erase(SECTOR_SIZE, 0), + Err(FlashStorageError::OutOfBounds) + ); + } + + #[test] + fn erase_across_blocks() { + let mut flash = FlashStorage::new(Flash::new()); + flash.capacity = FLASH_SIZE as usize; + let mut read_data = TestBuffer::default(); + let write_data = [0u8; SECTOR_SIZE as usize]; + + // An entire block and some sectors before and after + let from = BLOCK_SIZE - (2 * SECTOR_SIZE); + let to = (2 * BLOCK_SIZE) + (1 * SECTOR_SIZE); + + // Clear area before and after erase + flash.erase(from - SECTOR_SIZE, from).unwrap(); + flash.write_nor(from - SECTOR_SIZE, &write_data).unwrap(); + flash.erase(to, to + SECTOR_SIZE).unwrap(); + flash.write_nor(to, &write_data).unwrap(); + + // Erase and verify that only the desired parts were touched + flash.erase(from, to).unwrap(); + flash.read(0, &mut read_data.data).unwrap(); + for i in from..to { + assert_eq!(read_data.data[i as usize], 0xFF); + } + assert_eq!(read_data.data[(from - 1) as usize], 0x0); + assert_eq!(read_data.data[to as usize], 0x0); + } +} diff --git a/vendor/esp-storage/src/storage.rs b/vendor/esp-storage/src/storage.rs new file mode 100644 index 0000000..2e7cc9f --- /dev/null +++ b/vendor/esp-storage/src/storage.rs @@ -0,0 +1,123 @@ +use crate::{FlashStorage, FlashStorageError, buffer::FlashSectorBuffer}; + +impl FlashStorage<'_> { + /// Read bytes from flash. + /// + /// Unaligned offsets and lengths are supported. + /// + /// # Note + /// + /// This function always allocates a [`Self::SECTOR_SIZE`]-byte buffer on + /// the stack. See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the read would extend past + /// the end of the flash. + pub fn read(&mut self, offset: u32, mut bytes: &mut [u8]) -> Result<(), FlashStorageError> { + self.check_bounds(offset, bytes.len())?; + + let mut data_offset = offset % Self::WORD_SIZE; + let mut aligned_offset = offset - data_offset; + + // Bypass clearing sector buffer for performance reasons + let mut sector_data = FlashSectorBuffer::uninit(); + + while !bytes.is_empty() { + let len = bytes.len().min((Self::SECTOR_SIZE - data_offset) as _); + + let aligned_end = (data_offset as usize + len + (Self::WORD_SIZE - 1) as usize) + & !(Self::WORD_SIZE - 1) as usize; + + // Read only needed data words + let sector_data = &mut sector_data.as_bytes_mut()[..aligned_end]; + self.internal_read(aligned_offset, sector_data)?; + let sector_data = unsafe { + core::slice::from_raw_parts_mut(sector_data.as_ptr() as *mut u8, sector_data.len()) + }; + bytes[..len].copy_from_slice(§or_data[data_offset as usize..][..len]); + + aligned_offset += Self::SECTOR_SIZE; + data_offset = 0; + bytes = &mut bytes[len..]; + } + + Ok(()) + } + + /// The SPI flash size is configured by writing a field in the software + /// bootloader image header. This is done during flashing in espflash / + /// esptool. + pub fn capacity(&self) -> usize { + self.capacity + } + + /// Write bytes to flash. + /// + /// Performs read-modify-write on affected sectors and erases each sector + /// before writing. Unlike [`FlashStorage::write_nor`], alignment is not + /// required and erasure is handled automatically. + /// + /// # Note + /// + /// This function always allocates a [`Self::SECTOR_SIZE`]-byte buffer on + /// the stack. See the + /// [crate-level documentation](crate#buffer-alignment-and-stack-usage). + /// + /// # Errors + /// + /// Returns [`FlashStorageError::OutOfBounds`] if the write would extend past + /// the end of the flash. + pub fn write(&mut self, offset: u32, mut bytes: &[u8]) -> Result<(), FlashStorageError> { + self.check_bounds(offset, bytes.len())?; + + let mut data_offset = offset % Self::SECTOR_SIZE; + let mut aligned_offset = offset - data_offset; + + // Bypass clearing sector buffer for performance reasons + let mut sector_data = FlashSectorBuffer::uninit(); + + while !bytes.is_empty() { + self.internal_read(aligned_offset, sector_data.as_bytes_mut())?; + let sector_data = unsafe { sector_data.assume_init_bytes_mut() }; + + let len = bytes.len().min((Self::SECTOR_SIZE - data_offset) as _); + + sector_data[data_offset as usize..][..len].copy_from_slice(&bytes[..len]); + self.internal_erase_sector(aligned_offset / Self::SECTOR_SIZE)?; + self.internal_write(aligned_offset, sector_data)?; + + aligned_offset += Self::SECTOR_SIZE; + data_offset = 0; + bytes = &bytes[len..]; + } + + Ok(()) + } +} + +#[cfg(feature = "embedded-storage")] +mod embedded_storage_traits { + use ::embedded_storage::{ReadStorage, Storage}; + + use super::*; + + impl ReadStorage for FlashStorage<'_> { + type Error = FlashStorageError; + + fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> { + FlashStorage::read(self, offset, bytes) + } + + fn capacity(&self) -> usize { + FlashStorage::capacity(self) + } + } + + impl Storage for FlashStorage<'_> { + fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> { + FlashStorage::write(self, offset, bytes) + } + } +} diff --git a/vendor/esp-storage/src/stub.rs b/vendor/esp-storage/src/stub.rs new file mode 100644 index 0000000..eb1ded5 --- /dev/null +++ b/vendor/esp-storage/src/stub.rs @@ -0,0 +1,141 @@ +use core::{ptr, slice}; + +use crate::{FlashStorageError, maybe_with_critical_section}; + +const SUCCESS_CODE: i32 = 0; +const ERROR_CODE: i32 = 1; +const ERASE_BYTE: u8 = 0xff; +const WORD_SIZE: u32 = 4; +const SECTOR_SIZE: u32 = 4 << 10; +const BLOCK_SIZE: u32 = 4 << 14; +const NUM_BLOCKS: u32 = 4; +const FLASH_SIZE: u32 = BLOCK_SIZE * NUM_BLOCKS; +const NUM_SECTORS: u32 = FLASH_SIZE / SECTOR_SIZE; + +static mut FLASH_LOCK: bool = true; +static mut FLASH_DATA: [u8; FLASH_SIZE as usize] = [0u8; FLASH_SIZE as usize]; + +macro_rules! print_error { + ($($tt:tt)*) => { + #[cfg(all(test, feature = "emulation"))] + eprintln!($($tt)*) + }; +} + +fn check( + offset: u32, + length: u32, + data: *const u32, + is_write: bool, +) -> bool { + if offset % ALIGN > 0 { + print_error!("Not aligned offset: {offset}"); + return false; + } + if length % ALIGN > 0 { + print_error!("Not aligned length: {length}"); + return false; + } + if offset > SIZE { + print_error!("Offset out of range: {offset} > {SIZE}"); + return false; + } + if offset + length > SIZE { + print_error!("Length out of range: {offset} + {length} > {SIZE}"); + return false; + } + if length > MAX_LEN { + print_error!("Length out of range: {length} > {MAX_LEN}"); + return false; + } + let addr = data as u32; + if addr % ALIGN > 0 { + print_error!("Not aligned data: {addr:#0x}"); + return false; + } + if is_write && unsafe { FLASH_LOCK } { + print_error!("Flash locked"); + return false; + } + true +} + +pub(crate) fn spiflash_read(src_addr: u32, data: *mut u32, len: u32) -> i32 { + if check::(src_addr, len, data, false) { + maybe_with_critical_section(|| { + let src = unsafe { slice::from_raw_parts_mut(data as *mut u8, len as _) }; + unsafe { src.copy_from_slice(&FLASH_DATA[src_addr as usize..][..len as usize]) }; + }); + SUCCESS_CODE + } else { + ERROR_CODE + } +} + +pub(crate) fn spiflash_unlock() -> i32 { + maybe_with_critical_section(|| { + unsafe { FLASH_LOCK = false }; + }); + SUCCESS_CODE +} + +pub(crate) fn spiflash_erase_sector(sector_number: u32) -> i32 { + if check::<1, NUM_SECTORS, 1>(sector_number, 1, ptr::null(), true) { + maybe_with_critical_section(|| { + let dst_addr = sector_number * SECTOR_SIZE; + let len = SECTOR_SIZE; + unsafe { FLASH_DATA[dst_addr as usize..][..len as usize].fill(ERASE_BYTE) }; + }); + SUCCESS_CODE + } else { + ERROR_CODE + } +} + +pub(crate) fn spiflash_erase_block(block_number: u32) -> i32 { + if check::<1, NUM_BLOCKS, 1>(block_number, 1, ptr::null(), true) { + maybe_with_critical_section(|| { + let dst_addr = block_number * BLOCK_SIZE; + let len = BLOCK_SIZE; + unsafe { FLASH_DATA[dst_addr as usize..][..len as usize].fill(ERASE_BYTE) }; + }); + SUCCESS_CODE + } else { + ERROR_CODE + } +} + +pub(crate) fn spiflash_write(dest_addr: u32, data: *const u32, len: u32) -> i32 { + if check::(dest_addr, len, data, true) { + maybe_with_critical_section(|| { + let dst = unsafe { slice::from_raw_parts(data as *const u8, len as _) }; + for (d, s) in unsafe { &mut FLASH_DATA[dest_addr as usize..][..len as usize] } + .iter_mut() + .zip(dst) + { + *d &= *s; + } + }); + SUCCESS_CODE + } else { + ERROR_CODE + } +} + +pub(crate) fn spiflash_write_encrypted(dest_addr: u32, data: *mut u32, len: u32) -> i32 { + spiflash_write(dest_addr, data as *const u32, len) +} + +pub(crate) fn read_flash_encrypted(offset: u32, bytes: &mut [u8]) -> Result<(), FlashStorageError> { + if offset as usize + bytes.len() > FLASH_SIZE as usize { + return Err(FlashStorageError::OutOfBounds); + } + maybe_with_critical_section(|| unsafe { + bytes.copy_from_slice(&FLASH_DATA[offset as usize..][..bytes.len()]); + }); + Ok(()) +} + +pub(crate) fn get_flash_size() -> u32 { + FLASH_SIZE +} From e1ec289a5eab108ed540bdd3bceecea7ec0a1c0e Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 17:24:11 +0200 Subject: [PATCH 131/132] fix: preserve BLE display address types --- host-tests/src/lib.rs | 13 +++++++++++-- src/bluetooth/codec.rs | 12 +++++++----- src/bluetooth/mod.rs | 21 +++++++++++++++++---- src/structs.rs | 1 + 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index d935433..ef587b1 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -34,17 +34,26 @@ mod ble_tests { #[test] fn bond_round_trip() { - let buf = encode_bond([9, 8, 7, 6, 5, 4], [3; 16], 2); - assert_eq!(decode_bond(&buf), Some(([9, 8, 7, 6, 5, 4], [3; 16], 2))); + for random in [false, true] { + let buf = encode_bond([9, 8, 7, 6, 5, 4], [3; 16], 2, random); + assert_eq!( + decode_bond(&buf), + Some(([9, 8, 7, 6, 5, 4], [3; 16], 2, random)), + ); + } } #[test] fn rejects_bad_bonds() { assert_eq!(decode_bond(&[]), None); assert_eq!(decode_bond(&[0; 32]), None); // old format: re-pair + assert_eq!(decode_bond(&[0; 23]), None); // missing address type let mut buf = [0u8; BOND_LEN]; buf[22] = 3; // unknown security level assert_eq!(decode_bond(&buf), None); + buf[22] = 1; + buf[23] = 2; // unknown address type + assert_eq!(decode_bond(&buf), None); } #[test] diff --git a/src/bluetooth/codec.rs b/src/bluetooth/codec.rs index 0bcb85b..331e6b3 100644 --- a/src/bluetooth/codec.rs +++ b/src/bluetooth/codec.rs @@ -1,23 +1,25 @@ //! Pure helpers of the BLE display link (no hardware types, host-tested). /// Bond record stored in NVS: `[0..6]` display address, `[6..22]` LTK (little endian), -/// `[22]` security level (0 none, 1 encrypted, 2 authenticated). -pub const BOND_LEN: usize = 23; +/// `[22]` security level (0 none, 1 encrypted, 2 authenticated), +/// `[23]` address type (0 public, 1 random). +pub const BOND_LEN: usize = 24; -pub fn encode_bond(addr: [u8; 6], ltk: [u8; 16], level: u8) -> [u8; BOND_LEN] { +pub fn encode_bond(addr: [u8; 6], ltk: [u8; 16], level: u8, random: bool) -> [u8; BOND_LEN] { let mut buf = [0; BOND_LEN]; buf[..6].copy_from_slice(&addr); buf[6..22].copy_from_slice(<k); buf[22] = level; + buf[23] = u8::from(random); buf } /// `None` for anything that isn't a current record (old formats just mean "pair again"). -pub fn decode_bond(buf: &[u8]) -> Option<([u8; 6], [u8; 16], u8)> { +pub fn decode_bond(buf: &[u8]) -> Option<([u8; 6], [u8; 16], u8, bool)> { let buf: &[u8; BOND_LEN] = buf.try_into().ok()?; let addr = buf[..6].try_into().ok()?; let ltk = buf[6..22].try_into().ok()?; - (buf[22] <= 2).then_some((addr, ltk, buf[22])) + (buf[22] <= 2 && buf[23] <= 1).then_some((addr, ltk, buf[22], buf[23] == 1)) } /// Local name (complete or shortened) from BLE advertising data. diff --git a/src/bluetooth/mod.rs b/src/bluetooth/mod.rs index af9edf3..87e45f6 100644 --- a/src/bluetooth/mod.rs +++ b/src/bluetooth/mod.rs @@ -188,7 +188,10 @@ async fn link( }; central = scanner.into_inner(); match picked { - BleAction::Connect(device) => Address::random(device.addr), + BleAction::Connect(device) => match device.random_address { + true => Address::random(device.addr), + false => Address::new(AddrKind::PUBLIC, BdAddr::new(device.addr)), + }, _ => return Ok(()), } } @@ -374,6 +377,11 @@ impl EventHandler for Discovery { } while let Some(Ok(report)) = reports.next() { let addr = report.addr.into_inner(); + let random_address = match report.addr_kind { + AddrKind::PUBLIC => false, + AddrKind::RANDOM => true, + _ => continue, + }; let Some(name) = codec::parse_device_name(report.data).filter(|n| codec::is_fkm_display(n)) else { @@ -382,7 +390,7 @@ impl EventHandler for Discovery { if self.state.ui.read(|u| { u.discovered_bluetooth_devices .iter() - .any(|d| d.addr == addr) + .any(|d| d.addr == addr && d.random_address == random_address) }) { continue; } @@ -395,6 +403,7 @@ impl EventHandler for Discovery { let device = BleDisplayDevice { name: name.into(), addr, + random_address, }; if u.discovered_bluetooth_devices.push(device) && after_list { u.selected_bluetooth_item += 1; @@ -414,6 +423,7 @@ async fn store_bond(state: GlobalState, bond: &BondInformation) { bond.identity.addr.addr.into_inner(), bond.ltk.to_le_bytes(), level, + bond.identity.addr.kind == AddrKind::RANDOM, ); if let Err(e) = state.nvs.set(&NVS_BONDING_KEY, record.as_slice()).await { log::error!("[ble] storing bond failed ({e:?})"); @@ -423,10 +433,13 @@ async fn store_bond(state: GlobalState, bond: &BondInformation) { async fn load_bond(state: GlobalState) -> Option { let buf = state.nvs.get::>(&NVS_BONDING_KEY).await.ok()?; - let (addr, ltk, level) = codec::decode_bond(&buf)?; + let (addr, ltk, level, random) = codec::decode_bond(&buf)?; Some(BondInformation { identity: Identity { - addr: Address::random(addr), + addr: match random { + true => Address::random(addr), + false => Address::new(AddrKind::PUBLIC, BdAddr::new(addr)), + }, irk: None, }, security_level: match level { diff --git a/src/structs.rs b/src/structs.rs index 294fb84..add2535 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -38,6 +38,7 @@ pub const CONFIG_MENU_ITEMS: [&str; 7] = [ pub struct BleDisplayDevice { pub name: ShortText, pub addr: [u8; 6], + pub random_address: bool, } #[derive(Deserialize, Debug)] From 9d3a5ddcc5a80ceba711a4d158154f85a4a02298 Mon Sep 17 00:00:00 2001 From: filipton Date: Sun, 18 Oct 2026 17:31:58 +0200 Subject: [PATCH 132/132] fix: restore bonded BLE display connections --- host-tests/src/lib.rs | 13 +++++++++++++ src/bluetooth/codec.rs | 7 +++++++ src/bluetooth/mod.rs | 7 ++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/host-tests/src/lib.rs b/host-tests/src/lib.rs index ef587b1..e50d933 100644 --- a/host-tests/src/lib.rs +++ b/host-tests/src/lib.rs @@ -43,6 +43,19 @@ mod ble_tests { } } + #[test] + fn static_identity_uses_ble_byte_order_and_static_bits() { + assert_eq!( + static_address_from_mac([0xb0, 0x81, 0x84, 0xfe, 0x7e, 0x9c]), + [0x9c, 0x7e, 0xfe, 0x84, 0x81, 0xf0], + ); + for first in [0x00, 0x40, 0x80, 0xc0] { + let addr = static_address_from_mac([first, 1, 2, 3, 4, 5]); + assert_eq!(addr[5] & 0xc0, 0xc0); + assert_eq!(&addr[..5], &[5, 4, 3, 2, 1]); + } + } + #[test] fn rejects_bad_bonds() { assert_eq!(decode_bond(&[]), None); diff --git a/src/bluetooth/codec.rs b/src/bluetooth/codec.rs index 331e6b3..d176a94 100644 --- a/src/bluetooth/codec.rs +++ b/src/bluetooth/codec.rs @@ -5,6 +5,13 @@ /// `[23]` address type (0 public, 1 random). pub const BOND_LEN: usize = 24; +/// Stable random identity from the device MAC, in BLE little-endian byte order. +pub fn static_address_from_mac(mut mac: [u8; 6]) -> [u8; 6] { + mac.reverse(); + mac[5] |= 0xc0; + mac +} + pub fn encode_bond(addr: [u8; 6], ltk: [u8; 16], level: u8, random: bool) -> [u8; BOND_LEN] { let mut buf = [0; BOND_LEN]; buf[..6].copy_from_slice(&addr); diff --git a/src/bluetooth/mod.rs b/src/bluetooth/mod.rs index 87e45f6..3522880 100644 --- a/src/bluetooth/mod.rs +++ b/src/bluetooth/mod.rs @@ -128,7 +128,7 @@ async fn session( })?; let stack = trouble_host::new(ExternalController::new(connector), resources) - .set_random_address(Address::random(mac)) + .set_random_address(Address::random(codec::static_address_from_mac(mac))) .build(); let mut runner = stack.runner(); let discovery = Discovery { @@ -271,6 +271,11 @@ async fn connect_once( } => { break Ok((security_level.encrypted(), bond)); } + // Resuming a stored bond encrypts the link without pairing again. + // Fresh pairings must still wait for PairingComplete to save their keys. + ConnectionEvent::Encrypted { security_level, .. } if bond.is_some() => { + break Ok((security_level.encrypted(), None)); + } ConnectionEvent::PairingFailed(e) => { log::error!("[ble] pairing failed ({e:?})"); crate::log_once!(codes::BLE_PAIRING_FAILED);