commit 0341cf93797a7a4048f9e729f78129d8c731ac89 Author: Stephen Waits Date: Thu Apr 16 13:02:51 2026 -0600 initial commit: hsmc v0.2 with during: activities Hierarchical statechart codegen for Rust with: - during: activities for state-scoped concurrent I/O - field-list borrowing (split-borrow inside the run loop) - embassy-first async with tokio support - STATE_CHART const for tooling/inspection - examples: microwave, microwave_tui, embassy_full, during_radio diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..857ec4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Rust build artifacts +/target +**/target/ + +# Local Claude Code config (per-host overrides) +.claude/settings.local.json + +# OS / editor +.DS_Store +*.swp diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6b72cf3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1066 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless 0.9.2", +] + +[[package]] +name = "embassy-time" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hsmc" +version = "0.1.0" +dependencies = [ + "crossterm", + "embassy-futures", + "embassy-sync", + "embassy-time", + "futures", + "heapless 0.8.0", + "hsmc-macros", + "ratatui", + "tokio", + "trybuild", +] + +[[package]] +name = "hsmc-macros" +version = "0.1.0" +dependencies = [ + "hsmc", + "proc-macro2", + "quote", + "syn", + "trybuild", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..28f5e39 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["hsmc", "hsmc-macros"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..40a2ed9 --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +# hsmc — Hierarchical State Machines for Rust + +`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets +embedded Rust (embassy) as the primary runtime and supports `tokio` via +`LocalSet`. Machines are compiled from a `statechart!` block into +monomorphized code with no dynamic dispatch, no interior mutability, and no +heap allocations by default. + +```rust +statechart! { + Radio { + context: Ctx; + events: Ev; + default(Idle); + + state Idle { + on(StartRx) => Receiving; + } + state Receiving { + during: next_packet(lora, rx_buf); + on(PacketRx { rssi: i16, snr: i16 }) => record_packet; + on(StopRx) => Idle; + } + } +} +``` + +## What it does + +- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:` + actions, LCA-aware transitions, default-child descent, `terminate`. +- **Event-driven transitions and actions.** Typed payload bindings: + `on(PacketRx { rssi: i16 }) => handler;`. +- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and + `on(every Duration::from_millis(100)) => tick;`. +- **`during:` activities.** Async functions that run while a state is + active and produce events. Multiple durings can borrow disjoint fields + of the context concurrently via Rust's native split borrow — no + `RefCell`, no `Mutex`, no `UnsafeCell` in user code. +- **`m.run().await` is the whole task body.** Races active durings, the + external event channel, and timer deadlines. The MCU sleeps between + events (embassy's cooperative executor parks all futures). +- **Backward-compatible.** Every v0.1 statechart compiles under v0.2 + unchanged. + +## Quickstart + +Add to `Cargo.toml`: + +```toml +[dependencies] +hsmc = { version = "0.2", features = ["embassy"] } # or ["tokio"] +``` + +Minimal timer-only machine: + +```rust +use hsmc::{statechart, Duration}; + +pub struct Ctx { pub ticks: u32 } + +statechart! { + Blinker { + context: Ctx; + default(On); + + state On { + entry: light_on; + on(after Duration::from_millis(500)) => Off; + } + state Off { + on(after Duration::from_millis(500)) => On; + } + } +} + +impl BlinkerActions for BlinkerActionContext<'_> { + async fn light_on(&mut self) { self.ticks += 1; } +} +``` + +Drive it under tokio: + +```rust +let mut m = Blinker::new(Ctx { ticks: 0 }); +tokio::task::LocalSet::new().run_until(m.run()).await.unwrap(); +``` + +Or under embassy: + +```rust +static CHANNEL: Channel = Channel::new(); +// (timer-only machines don't need a channel; omit `events:` and use `Blinker::new(ctx)`.) + +#[embassy_executor::task] +async fn blink_task() { + let mut m = Blinker::new(Ctx { ticks: 0 }); + let _ = m.run().await; +} +``` + +## `during:` — async activities scoped to a state + +A `during:` runs while its state is on the active path. It's a plain async +function that takes `&mut` references to specific fields of the root context +and returns one event: + +```rust +state Receiving { + during: next_packet(lora, rx_buf); + on(PacketRx { rssi: i16 }) => count_packet; + on(StopRx) => Idle; +} + +async fn next_packet( + lora: &mut LoRaDriver, + rx_buf: &mut [u8; 256], +) -> RadioInput { + lora.rx(rx_buf).await.into() +} +``` + +The macro emits `next_packet(&mut ctx.lora, &mut ctx.rx_buf)` and Rust's +native split-borrow verifies the fields don't overlap with any other +concurrent during on the active path. Overlapping fields produce a +compile-time error. + +### Multiple concurrent durings + +A state (or any ancestor on its path) may declare more than one `during:`. +The run loop races all of them against the external channel and the next +timer deadline via `tokio::select!` / `embassy_futures::select`. Each +during borrows disjoint fields, so the split borrow is free. + +```rust +state Configured { + during: heartbeat(hb_counter); // active in every Configured substate + state Receiving { + during: next_packet(lora, rx_buf); + } +} +``` + +### Cancel-safety contract + +> A `during:` future **will** be dropped at any `.await` point when a +> handler fires, a timer expires, an external event arrives, or the state +> transitions. Write durings as cancel-safe state machines: every +> `.await` must be a clean resume point where dropping the future leaves +> the borrowed fields in a valid, re-enterable state. + +**Anti-pattern:** + +```rust +async fn bad(acc: &mut u32) -> Ev { + *acc += 1; // commit happens here + tokio::time::sleep(Duration::from_ms(100)).await; // ← may be dropped + *acc += 1; // may never run + Ev::Tick +} +``` + +**Preferred:** + +```rust +async fn good(acc: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_ms(100)).await; + *acc = acc.wrapping_add(2); // all mutations after the .await + Ev::Tick +} +``` + +### Starvation in select-drop semantics + +When several durings race, the shortest-to-complete wins every iteration +and the others are dropped without making progress. This is expected: +firmware I/O activities are naturally cancel-safe (a dropped `lora.rx()` +just stops receiving) and the starved ones would typically be +heartbeat-like tasks that deliver their events eventually. + +If two durings compete for the same "always ready" resource, combine them +into a single during that uses `select` internally. + +## Generated API per machine + +For `statechart! Foo { ... }` the macro emits: + +| Item | What it is | +|---------------------------------|----------------------------------------------------------------------| +| `FooState` | Enum of every user-declared state. | +| `FooActions` | Trait with one async fn per unique action name. User implements. | +| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. | +| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. | +| `Foo` | The machine itself. | +| `FooSender` | Cloneable handle for cross-task / ISR event injection. | +| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).| + +### Machine methods + +| Method | Notes | +|-------------------------------------|------------------------------------------------------------------| +| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. | +| `with_queue_capacity::(ctx,...)` | Custom event-queue capacity. | +| `async fn run()` | Drive the machine to completion (async features). | +| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. | +| `fn step(elapsed) -> Option` | One unit of work (test/polling driver). | +| `fn send(ev)` | Raw push, non-draining. For tests. | +| `sender() -> Sender` | Clone handle for other tasks / ISRs. | +| `current_state()` | The active leaf state. | +| `is_terminated()` | True after `terminate` event. | +| `has_pending_events()` | Queue non-empty. | +| `context()` / `context_mut()` | Borrow the root context. | +| `into_context(self)` | Consume, return ctx. | +| `STATE_CHART` | Const ASCII diagram. | + +## Feature flags + +| Feature | Effect | +|-----------|-------------------------------------------------------------------------| +| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. | +| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. | +| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.| + +`embassy` and `tokio` are mutually exclusive — enabling both is a compile +error. + +## Migration from v0.1 + +v0.1 statecharts continue to compile. New features are purely additive: + +### 1. `events:` is now optional + +Previously required; now omit for pure timer-only machines: + +```rust +statechart! { + Blinker { + context: Ctx; + default(On); + state On { on(after Duration::from_millis(500)) => Off; } + state Off { on(after Duration::from_millis(500)) => On; } + } +} +``` + +### 2. `dispatch(ev).await` replaces the push+drain helper + +Before (v0.1, in the dogfood firmware): + +```rust +async fn push(m: &mut M, ev: E) { + m.send(ev).expect("queue full"); + while m.has_pending_events() { + m.step(Duration::ZERO).await; + } +} +push(&mut machine, ev).await; +``` + +After (v0.2): + +```rust +machine.dispatch(ev).await.unwrap(); +``` + +### 3. `during:` replaces the imperative I/O outer loop + +Before — a radio task with per-state match and select: + +```rust +loop { + match machine.current_state() { + RadioState::Receiving => { + match select(rx_once(&mut lora, &cfg, &mut rx_buf), + commands.receive()).await { + Either::First(r) => handle_rx(r, &mut machine, ...).await, + Either::Second(c) => handle_cmd(c, &mut machine, ...).await, + } + } + _ => { ... } + } +} +``` + +After — the statechart owns the loop: + +```rust +state Receiving { + during: next_packet(lora, rx_buf); + on(PacketRx { rssi: i16, snr: i16 }) => record_packet; + on(StopRx) => Idle; +} + +#[embassy_executor::task] +async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) { + let mut m = Radio::new(RadioCtx { lora, ... }, ch); + m.run().await.unwrap(); +} +``` + +## Examples + +| Example | Purpose | +|--------------------|---------------------------------------------------------------------| +| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.| +| `microwave_tui` | TUI variant of microwave. | +| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. | +| `embassy_full` | Embassy feature demonstration. | + +Run `cargo run --example during_radio --features tokio` to see the +`during:` pattern end-to-end. + +## Status + +- v0.1 spec: fully implemented. +- v0.2 additions: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`, + drain-on-send. +- Out of scope: state-scoped context types (hierarchy), guard conditions, + orthogonal/parallel regions, history states, runtime statechart + modification, multi-threaded tokio. + +## License + +Dual-licensed MIT OR Apache-2.0. diff --git a/docs/000. original-hsmc-spec.md b/docs/000. original-hsmc-spec.md new file mode 100644 index 0000000..5035758 --- /dev/null +++ b/docs/000. original-hsmc-spec.md @@ -0,0 +1,1177 @@ +# `hsmc` — Hierarchical State Machine Crate for Rust + +## Specification v0.1 + +--- + +## 1. Overview + +`hsmc` is a Rust crate for defining and running Harel-style hierarchical state machines (statecharts). It provides a `statechart!` proc macro for declarative machine definition, separating structure from behavior. The crate targets `no_std` embedded systems through `std` async runtimes, with near-zero runtime cost by doing as much work as possible at compile time. + +### 1.1 Design Principles + +1. **Separation of structure and behavior.** The statechart macro defines states, transitions, and wiring. Action implementations live in a separate `impl` block on a generated trait. A developer can read the statechart and reason about flow without seeing any implementation code. + +2. **No framework leakage.** The user's context struct is their data. No framework fields, no marker types, no special derives. The crate wraps the context internally and provides `emit()` without polluting the user's type. + +3. **Compile-time maximalism.** State enums, transition tables, and structural validation are resolved at compile time. Invalid statecharts (duplicate transitions on the same event, missing default states, terminate outside root) are compile errors, not runtime panics. + +4. **Runtime portability.** The same statechart definition and action implementations work across bare metal (`no_std`), Embassy, and Tokio. The runtime is selected via a Cargo feature flag. No code changes required. + +5. **Minimal surface area.** The crate provides exactly the primitives from Harel's statecharts that are needed: states, substates, entry/exit actions, event-driven transitions, timer-driven transitions, event-driven actions, timer-driven actions, default initial states, and termination. No guards, no orthogonal regions, no history states, no deferred events. + +--- + +## 2. Concepts and Semantics + +### 2.1 Machine + +A machine is the root-level container. It is itself a state (the root state) and may contain all the same elements as any other state, plus `terminate` and `default` declarations. A machine owns its context, its event queue, its timer state, and its current state configuration. + +### 2.2 States + +A state is a named node in the state hierarchy. States may be nested arbitrarily deep. A state may contain: + +- `entry:` — one or more action function references, called in declaration order when the state is entered. +- `exit:` — one or more action function references, called in declaration order when the state is exited. +- `default(ChildState)` — required if and only if the state has child states. Specifies which child state to enter when this state becomes active. +- `state ChildName { ... }` — nested child states. +- `transition(trigger) => TargetState;` — at most one per trigger per state. +- `action(trigger) => function_name;` — zero or more per trigger per state. Multiple actions on the same trigger are called in declaration order. + +### 2.3 State Configuration + +At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to a leaf (or to a state with no children). If state `A` contains state `B` which contains state `C`, and `C` is the active leaf, then the machine is simultaneously "in" states `A`, `B`, and `C`. + +When entering a state that has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default. + +### 2.4 Events + +Events are variants of a user-defined Rust enum. They are the primary external input to the machine. Events are delivered to the machine via a channel (async runtimes) or polled queue (bare metal). Action functions may also emit events back into the machine via `self.emit()`. + +### 2.5 Triggers + +A trigger is either: + +- **An event:** a variant of the event enum (e.g., `Failure`, `ButtonPressed`). +- **A duration:** a `core::time::Duration` value (e.g., `Duration::from_secs(5)`). This starts an internal timer when the containing state is entered. The timer fires once. If the state is exited before the timer fires, the timer is cancelled. Descending into a child state is NOT an exit and does NOT cancel the parent's timer. + +### 2.6 Transitions + +A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`. + +The transition algorithm: + +1. Find the least common ancestor (LCA) of the source state and the target state. +2. Exit states from the current active leaf up to (but not including) the LCA, calling exit actions in inner-to-outer order. +3. Enter states from the LCA down to the target state, calling entry actions in outer-to-inner order. +4. If the target state has children, descend into defaults recursively, calling entry actions along the way. + +Transitions may target any state in the entire machine, not just siblings. + +Only one transition per trigger per state is allowed. This is enforced at compile time. + +### 2.7 Actions + +An action specifies: when trigger `T` occurs in state `S`, call function `F`. No state change occurs. Multiple actions may be registered on the same trigger within the same state. They are called in declaration order. + +If both actions and a transition are registered on the same trigger in the same state, the actions are called first (in declaration order), then the transition executes (which triggers exit/entry actions as described above). + +### 2.8 Event Dispatch + +When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the current active leaf state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded. + +Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy. + +### 2.9 Entry and Exit Actions + +- **Entry actions** are called when a state is entered, in declaration order. Multiple entry actions are allowed per state. +- **Exit actions** are called when a state is exited, in declaration order. Multiple exit actions are allowed per state. +- Entry and exit actions are called during transitions (as described in §2.6), during initial machine startup (entering the default state path), and during termination (exiting all active states). + +### 2.10 Default State + +`default(ChildState)` specifies the initial child state. It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time. + +### 2.11 Terminate + +`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means: + +1. Exit the current active leaf state, calling its exit actions. +2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order. +3. The machine is now terminated. `run()` returns. `is_terminated()` returns true. + +Terminate is only valid at the root level. Placing `terminate` inside a child state is a compile error. + +### 2.12 Emitting Events from Actions + +Inside any action function, the user may call `self.emit(EventVariant)` to push an event into the machine's internal queue. Emitted events are processed after the current event's handling is complete (i.e., after all actions and the transition for the current trigger have fully resolved, including all entry/exit actions). This prevents re-entrant state changes. + +### 2.13 Timer Semantics + +- A timer is started when its containing state is entered. +- A timer is cancelled when its containing state is exited. +- Entering a child state does NOT cancel the parent's timer. +- Exiting a child state (to a sibling) does NOT cancel the parent's timer. +- Exiting the parent state cancels the timer. +- A timer fires at most once. After firing, it is not restarted unless the state is re-entered. +- If multiple timers expire at the same instant, they are processed in innermost-state-first order. + +--- + +## 3. Macro Syntax + +### 3.1 Grammar + +``` +statechart! { + context: ; + events: ; + + [entry: [, ]* ;]* + [exit: [, ]* ;]* + [default();] + [terminate();] + + [transition() => ;]* + [action() => ;]* + + [state { + [entry: [, ]* ;]* + [exit: [, ]* ;]* + [default();] + + [transition() => ;]* + [action() => ;]* + + [state { ... }]* + }]* +} +``` + +Where `` is one of: +- An event variant name (e.g., `Failure`, `ButtonPressed`) +- A `Duration` expression (e.g., `Duration::from_secs(5)`, `Duration::from_millis(200)`) + +### 3.2 Entry and Exit Shorthand + +Both of these are valid and equivalent: + +```rust +// Comma-separated on one line +entry: setup, configure, log_start; + +// Repeated keyword on separate lines +entry: setup; +entry: configure; +entry: log_start; + +// Mixed +entry: setup, configure; +entry: log_start; +``` + +All forms result in the functions being called in the order they appear, top to bottom, left to right. + +### 3.3 Declaration Order + +Within a state, declarations may appear in any order. The following orderings are all valid: + +```rust +state Foo { + exit: cleanup; + entry: setup; + transition(SomeEvent) => Bar; +} +``` + +However, the relative order of multiple `entry:`, `exit:`, or `action:` declarations IS significant. They are called in declaration order. + +--- + +## 4. Generated Code + +The `statechart!` macro generates the following items, where `` is the machine name: + +### 4.1 `State` Enum + +An enum with one variant per state declared in the machine (including nested states). Derives `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`. The exact representation of nested states is an implementation detail but must support comparison and debugging. + +### 4.2 `Actions` Trait + +A trait with one method per unique action function name referenced anywhere in the statechart (entry, exit, action, across all states). Every method has the signature: + +```rust +fn (&mut self); +``` + +The trait is implemented by the user on `ActionContext<'_>`. + +### 4.3 `ActionContext<'a>` Struct + +A struct that wraps `&'a mut ` and provides: + +- `Deref` and `DerefMut` so that `self.field` accesses the user's context directly. +- `fn emit(&mut self, event: )` to push an event into the machine's internal queue. + +The user implements `Actions` on this type. + +### 4.4 `` Struct (The Machine) + +The machine struct, with the following public API: + +```rust +impl { + /// Create a new machine instance. Does NOT enter the initial state. + /// Call `run()` or `step()` to start. + fn new(context: ContextType) -> Self; + + /// Returns a cloneable sender handle for pushing events + /// into the machine from external code (other tasks, ISRs, threads). + fn sender(&self) -> Sender; + + /// Run the machine to completion (async runtimes). + /// Enters the default state on first call. + /// Returns when the machine terminates. + /// Returns Err if the machine is already terminated. + async fn run(&mut self) -> Result<(), HsmcError>; + + /// Process one unit of work: one pending event or one expired timer. + /// For bare metal / synchronous use. + /// `elapsed` is the time since the last call to `step()`. + /// Enters the default state on first call. + /// Returns the duration until the next timer fires, or None. + fn step(&mut self, elapsed: Duration) -> Option; + + /// Returns true if the machine has terminated. + fn is_terminated(&self) -> bool; + + /// Returns the current active state configuration (leaf state). + fn current_state(&self) -> State; + + /// Consumes the machine, returns the user's context. + fn into_context(self) -> ContextType; +} +``` + +### 4.5 `Sender` Struct + +A cloneable, send-safe handle for pushing events into the machine. + +```rust +implSender { + /// Push an event. Non-blocking. + /// Returns Err if the queue is full. + fn send(&self, event: EventType) -> Result<(), HsmcError>; +} +``` + +--- + +## 5. Runtime Configuration + +### 5.1 Feature Flags + +| Feature | Channel Backend | Timer Backend | `run()` available | `step()` available | +|------------|------------------------------|-------------------------|--------------------|--------------------| +| (default) | `heapless::spsc::Queue` | manual via `step(elapsed)` | No | Yes | +| `embassy` | `embassy_sync::channel::Channel` | `embassy_time::Timer` | Yes (async) | Yes | +| `tokio` | `tokio::sync::mpsc` | `tokio::time::sleep` | Yes (async) | Yes | + +Only one async feature may be enabled at a time. Enabling both `embassy` and `tokio` is a compile error. + +`step()` is always available regardless of feature flags, for testing and for mixed-use scenarios. + +### 5.2 Queue Capacity + +The default internal event queue capacity is 8. This may be overridden at instantiation: + +```rust +let mut machine = Stoplight::with_queue_capacity::<16>(context); +``` + +--- + +## 6. Error Handling + +### 6.1 `HsmcError` Enum + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +enum HsmcError { + /// The event queue is full. Event was not enqueued. + QueueFull, + + /// The machine has already terminated. No further operations are valid. + AlreadyTerminated, +} +``` + +### 6.2 Compile-Time Errors + +The macro produces compile errors for: + +| Condition | Error Message | +|-----------|---------------| +| Duplicate transition on same trigger in same state | "duplicate transition on trigger `X` in state `Y`" | +| `terminate` inside a non-root state | "`terminate` is only valid at the root level" | +| `default` missing in a state that has children | "state `X` has children but no `default` declaration" | +| `default` referencing a non-direct-child | "`default(Y)` in state `X`: `Y` is not a direct child of `X`" | +| `default` in a state that has no children | "`default` in state `X` which has no child states" | +| Action function name used but not in trait impl | Standard Rust trait error: "method `X` not found" | +| State referenced in transition not found | "unknown state `X` in transition" | +| Event variant in trigger not found in event enum | "unknown event variant `X`" | +| Empty state (no entries, exits, transitions, actions, or children) | "empty state `X`" | +| Multiple `default` declarations in one state | "multiple `default` declarations in state `X`" | +| Multiple `terminate` declarations at root | "multiple `terminate` declarations" | + +--- + +## 7. Complete Example: Traffic Light + +```rust +use hsmc::{statechart, Duration}; + +// --- Events --- +#[derive(Event, Debug, Clone)] +enum StoplightEvent { + Failure, +} + +// --- User Context --- +struct StoplightContext { + led_controller: LedController, +} + +// --- Statechart Definition --- +statechart! Stoplight { + context: StoplightContext; + events: StoplightEvent; + + entry: do_post; + default(Red); + terminate(Failure); + exit: enable_red_flash_mode; + + state Red { + entry: red_on; + exit: red_off; + transition(Duration::from_secs(5)) => Green; + } + + state Green { + entry: green_on; + exit: green_off; + transition(Duration::from_secs(5)) => Yellow; + } + + state Yellow { + entry: yellow_on; + exit: yellow_off; + transition(Duration::from_millis(1000)) => Red; + } +} + +// --- Action Implementations --- +impl StoplightActions for StoplightActionContext<'_> { + fn do_post(&mut self) { + assert!(self.led_controller.self_test()); + } + + fn enable_red_flash_mode(&mut self) { + self.led_controller.flash(Led::Red); + } + + fn red_on(&mut self) { self.led_controller.set(Led::Red, true); } + fn red_off(&mut self) { self.led_controller.set(Led::Red, false); } + fn green_on(&mut self) { self.led_controller.set(Led::Green, true); } + fn green_off(&mut self) { self.led_controller.set(Led::Green, false); } + fn yellow_on(&mut self) { self.led_controller.set(Led::Yellow, true); } + fn yellow_off(&mut self){ self.led_controller.set(Led::Yellow, false); } +} + +// --- Embassy Usage --- +#[embassy_executor::task] +async fn traffic_light_task() { + let ctx = StoplightContext { + led_controller: LedController::new(), + }; + let mut light = Stoplight::new(ctx); + let sender = light.sender(); + + // Wire up fault detection + spawn(async move { + if fault_detected().await { + sender.send(StoplightEvent::Failure).unwrap(); + } + }); + + light.run().await.unwrap(); +} + +// --- Bare Metal Usage --- +fn main() -> ! { + let ctx = StoplightContext { + led_controller: LedController::new(), + }; + let mut light = Stoplight::new(ctx); + + loop { + let elapsed = hw_timer.lap(); + light.step(elapsed); + + if light.is_terminated() { + enter_safe_mode(); + } + } +} +``` + +--- + +## 8. Complete Example: Nested States (Parse Test) + +This exercises the full grammar. + +```rust +use hsmc::{statechart, Duration}; + +#[derive(Event, Debug, Clone)] +enum MyEvent { + Event1, + Event2, + Halt, +} + +struct MyContext { + log: Vec, +} + +statechart! Name { + context: MyContext; + events: MyEvent; + + default(FirstState); + entry: mainentry, mainentry2, mainentry3; + exit: mainexit; + terminate(Halt); + + // Root-level transitions and actions + transition(Duration::from_secs(1)) => FirstState; + action(Duration::from_secs_f64(0.99)) => asldkfj; + action(Duration::from_secs_f64(0.99)) => asldkfj1; + action(Duration::from_secs_f64(0.99)) => asldkfj2; + + state FirstState { + entry: blah_entry; + exit: blah_exit; + + transition(Event2) => SecondState; + action(Event1) => x_action; + + state SubState { + default(SubSubState); + + state SubSubState { + default(SubSubSubState); + + state SubSubSubState { + } + } + } + } + + state SecondState { + transition(Event1) => FirstState; + } + + state XSecondState { + entry: x_entry; + } + + state SomeEndState { + entry: end_entry; + exit: end_exit; + action(Duration::from_secs_f64(0.23)) => something; + } +} + +impl NameActions for NameActionContext<'_> { + fn mainentry(&mut self) { self.log.push("mainentry".into()); } + fn mainentry2(&mut self) { self.log.push("mainentry2".into()); } + fn mainentry3(&mut self) { self.log.push("mainentry3".into()); } + fn mainexit(&mut self) { self.log.push("mainexit".into()); } + fn blah_entry(&mut self) { self.log.push("blah_entry".into()); } + fn blah_exit(&mut self) { self.log.push("blah_exit".into()); } + fn x_action(&mut self) { self.log.push("x_action".into()); } + fn asldkfj(&mut self) { self.log.push("asldkfj".into()); } + fn asldkfj1(&mut self) { self.log.push("asldkfj1".into()); } + fn asldkfj2(&mut self) { self.log.push("asldkfj2".into()); } + fn x_entry(&mut self) { self.log.push("x_entry".into()); } + fn end_entry(&mut self) { self.log.push("end_entry".into()); } + fn end_exit(&mut self) { self.log.push("end_exit".into()); } + fn something(&mut self) { self.log.push("something".into()); } +} +``` + +--- + +## 9. Test Cases + +All test cases use `step()` with manual time advancement for deterministic testing. + +### 9.1 Initialization + +#### T1.1: Default state entry on first step + +``` +Given: A machine with default(Red), root entry: [do_post], Red entry: [red_on] +When: First call to step(Duration::ZERO) +Then: Actions called in order: [do_post, red_on] + current_state() == Red + is_terminated() == false +``` + +#### T1.2: Nested default descent on initialization + +``` +Given: A machine with: + default(A) + state A { default(B); state B { default(C); state C {} } } + Root entry: [r_entry], A entry: [a_entry], B entry: [b_entry], C entry: [c_entry] +When: First call to step(Duration::ZERO) +Then: Actions called in order: [r_entry, a_entry, b_entry, c_entry] + current_state() == C +``` + +#### T1.3: Machine not started until first step + +``` +Given: A new machine created with Stoplight::new(ctx) +When: No calls to step() or run() +Then: is_terminated() == false + No action functions have been called +``` + +### 9.2 Transitions + +#### T2.1: Event-driven transition between siblings + +``` +Given: Machine in state Red, with transition(GoGreen) => Green + Red exit: [red_off], Green entry: [green_on] +When: Event GoGreen is delivered +Then: Actions called in order: [red_off, green_on] + current_state() == Green +``` + +#### T2.2: Timer-driven transition + +``` +Given: Machine in state Red, with transition(Duration::from_secs(5)) => Green +When: step() called with cumulative elapsed time reaching 5 seconds +Then: Transition fires. current_state() == Green +``` + +#### T2.3: Timer cancelled on state exit + +``` +Given: Machine in state Red, with: + transition(Duration::from_secs(5)) => Green + transition(EarlyExit) => Yellow +When: At t=3s, event EarlyExit is delivered + At t=5s, step() is called +Then: Machine is in Yellow (not Green). The 5s timer was cancelled when Red was exited. +``` + +#### T2.4: Cross-hierarchy transition + +``` +Given: Machine with: + state A { state B { state C { transition(Jump) => D } } } + state D {} + Machine is in state C. +When: Event Jump is delivered +Then: Exit order: [C_exit, B_exit, A_exit] + Entry order: [D_entry] + current_state() == D +``` + +#### T2.5: Transition to a state with children descends to default + +``` +Given: Machine with: + state A { transition(Go) => B } + state B { default(C); state C { default(D); state D {} } } + Machine is in state A. +When: Event Go is delivered +Then: Entry order: [B_entry, C_entry, D_entry] + current_state() == D +``` + +#### T2.6: Self-transition (transition to own state) + +``` +Given: Machine in state Red, with transition(Reset) => Red + Red exit: [red_off], Red entry: [red_on] +When: Event Reset is delivered +Then: Actions called: [red_off, red_on] + current_state() == Red + Any timers in Red are restarted +``` + +#### T2.7: Transition from child to sibling of parent + +``` +Given: Machine with: + state A { state B { transition(Go) => C } } + state C {} + Machine is in B. +When: Event Go is delivered +Then: Exit order: [B_exit, A_exit] + Entry order: [C_entry] + current_state() == C +``` + +#### T2.8: Transition to an ancestor state + +``` +Given: Machine with: + state A { default(B); state B { transition(Up) => A } } + Machine is in B. +When: Event Up is delivered +Then: Exit order: [B_exit, A_exit] + Entry order: [A_entry, B_entry] (re-enters A, descends to default B) + current_state() == B +``` + +### 9.3 Actions + +#### T3.1: Action does not change state + +``` +Given: Machine in state Red, with action(Beep) => honk +When: Event Beep is delivered +Then: honk() is called + current_state() == Red (unchanged) +``` + +#### T3.2: Multiple actions on same trigger, declaration order + +``` +Given: Machine in state Red, with: + action(Beep) => honk1; + action(Beep) => honk2; + action(Beep) => honk3; +When: Event Beep is delivered +Then: Actions called in order: [honk1, honk2, honk3] +``` + +#### T3.3: Actions fire before transition on same trigger + +``` +Given: Machine in state Red, with: + action(Go) => log_go; + action(Go) => prepare; + transition(Go) => Green; + Red exit: [red_off], Green entry: [green_on] +When: Event Go is delivered +Then: Actions called in order: [log_go, prepare, red_off, green_on] + current_state() == Green +``` + +#### T3.4: Timer-driven action + +``` +Given: Machine in state Red, with: + action(Duration::from_millis(500)) => heartbeat; +When: step() called with cumulative elapsed time reaching 500ms +Then: heartbeat() is called + current_state() == Red (unchanged) + Timer does NOT restart automatically +``` + +### 9.4 Event Dispatch and Bubbling + +#### T4.1: Event handled by current leaf state + +``` +Given: Machine in state C (inside B inside A) + C has transition(Foo) => D +When: Event Foo is delivered +Then: Transition fires from C. C handled the event. +``` + +#### T4.2: Event bubbles to parent + +``` +Given: Machine in state C (inside B inside A) + C has no handler for Foo + B has no handler for Foo + A has transition(Foo) => D +When: Event Foo is delivered +Then: A handles the event. Exit order: [C_exit, B_exit, A_exit]. Entry: [D_entry]. +``` + +#### T4.3: Event bubbles to root + +``` +Given: Machine in state C (inside B inside A) + Root has action(Foo) => root_handler + No other state handles Foo +When: Event Foo is delivered +Then: root_handler() is called +``` + +#### T4.4: Leaf state shadows parent handler + +``` +Given: Machine in state C (inside A) + C has transition(Foo) => D + A has transition(Foo) => E +When: Event Foo is delivered +Then: C's handler wins. Machine transitions to D, not E. +``` + +#### T4.5: Unhandled event is silently discarded + +``` +Given: Machine in state Red. No state handles event Unknown. +When: Event Unknown is delivered +Then: No actions called. No state change. No error. +``` + +#### T4.6: Timer triggers do NOT bubble + +``` +Given: Machine in state C (inside B inside A) + A has transition(Duration::from_secs(10)) => D + C has no timer +When: A's timer fires while machine is in C +Then: A's timer handler fires (machine is "in" A). This is NOT bubbling; + A's timer belongs to A and fires because A is active. +``` + +### 9.5 Entry and Exit Ordering + +#### T5.1: Multiple entry actions in declaration order + +``` +Given: State A with entry: alpha, beta, gamma; +When: State A is entered +Then: Called in order: [alpha, beta, gamma] +``` + +#### T5.2: Multiple exit actions in declaration order + +``` +Given: State A with: + exit: cleanup1; + exit: cleanup2; +When: State A is exited +Then: Called in order: [cleanup1, cleanup2] +``` + +#### T5.3: Entry across hierarchy (outer to inner) + +``` +Given: Transition into state C (inside B inside A), all having entry actions +When: Transition fires +Then: Entry order: [A_entry, B_entry, C_entry] +``` + +#### T5.4: Exit across hierarchy (inner to outer) + +``` +Given: Machine in state C (inside B inside A), all having exit actions +When: Transition out of the entire subtree fires +Then: Exit order: [C_exit, B_exit, A_exit] +``` + +#### T5.5: Transition between states with a common ancestor + +``` +Given: Machine with: + state Parent { + default(A); + state A { transition(Go) => B } + state B {} + } + Machine is in A. +When: Event Go is delivered +Then: Exit: [A_exit] (Parent is NOT exited — it's the LCA) + Entry: [B_entry] (Parent is NOT re-entered) + Parent's timers remain active +``` + +### 9.6 Terminate + +#### T6.1: Terminate event exits all states + +``` +Given: Machine in state C (inside B inside A, inside root) + Root has terminate(Halt) + All states have exit actions +When: Event Halt is delivered +Then: Exit order: [C_exit, B_exit, A_exit, root_exit] + is_terminated() == true +``` + +#### T6.2: Terminated machine rejects further events + +``` +Given: Machine is terminated +When: sender.send(SomeEvent) is called +Then: Returns Err(HsmcError::QueueFull) or similar + (or the event is silently dropped — TBD based on runtime) +``` + +#### T6.3: run() returns after termination + +``` +Given: Machine running via run().await +When: Terminate event is received and processed +Then: run() returns Ok(()) +``` + +#### T6.4: step() after termination + +``` +Given: Machine is terminated +When: step(elapsed) is called +Then: Returns None. No actions called. No state changes. +``` + +### 9.7 Emit (Events from Actions) + +#### T7.1: Emitted event is processed after current event completes + +``` +Given: Machine in state A. + A has action(Foo) => emit_bar, where emit_bar calls self.emit(Bar) + A has transition(Bar) => B +When: Event Foo is delivered +Then: emit_bar() runs first. + After Foo is fully processed, Bar is dequeued. + Transition to B fires. + current_state() == B +``` + +#### T7.2: Emitted events during transition entry/exit + +``` +Given: Machine in state A, transition(Go) => B + A exit action calls self.emit(Cleanup) + Root has action(Cleanup) => do_cleanup +When: Event Go is delivered +Then: A_exit runs (emits Cleanup). + B_entry runs. + Transition completes. + Then Cleanup event is processed: do_cleanup() runs. +``` + +#### T7.3: Queue overflow from emit + +``` +Given: Machine with queue capacity 8 + An action calls self.emit() 9 times +When: The 9th emit() is called +Then: Returns Err(HsmcError::QueueFull) or panics (TBD — define behavior) +``` + +### 9.8 Timer Semantics + +#### T8.1: Timer starts on state entry + +``` +Given: State Red with transition(Duration::from_secs(5)) => Green +When: Red is entered at t=0 + step() called at t=4.9s +Then: No transition. current_state() == Red +When: step() called at t=5.0s +Then: Transition fires. current_state() == Green +``` + +#### T8.2: Timer cancelled on state exit + +``` +Given: State Red with: + transition(Duration::from_secs(5)) => Green + transition(Interrupt) => Yellow +When: Red entered at t=0 + Event Interrupt at t=2s -> transitions to Yellow + step() at t=5s +Then: current_state() == Yellow (timer was cancelled, no transition to Green) +``` + +#### T8.3: Parent timer survives child state transitions + +``` +Given: State Parent with: + transition(Duration::from_secs(10)) => Done + default(ChildA) + state ChildA { transition(Duration::from_secs(3)) => ChildB } + state ChildB {} +When: Parent entered at t=0 (descends to ChildA) + t=3s: ChildA transitions to ChildB + t=10s: Parent timer fires +Then: At t=3s: current_state() == ChildB, Parent timer still running + At t=10s: Parent timer fires, machine transitions to Done + Exit order: [ChildB_exit, Parent_exit, Done_entry] +``` + +#### T8.4: Timer restarts on re-entry + +``` +Given: State Red with transition(Duration::from_secs(5)) => Green + State Green with transition(Duration::from_secs(5)) => Red +When: Red entered at t=0 + t=5s: transitions to Green + t=10s: transitions back to Red (timer restarts) + t=14s: step() +Then: current_state() == Red (timer hasn't fired yet, only 4s since re-entry) +When: t=15s: step() +Then: current_state() == Green (timer fires at 5s after re-entry) +``` + +#### T8.5: Multiple timers in different hierarchy levels + +``` +Given: State A with: + action(Duration::from_secs(2)) => ping + default(B) + state B { transition(Duration::from_secs(5)) => C } + state C {} +When: A entered at t=0 (descends to B) + t=2s: A's action timer fires +Then: ping() called. current_state() == B (unchanged) +When: t=5s: B's transition timer fires +Then: current_state() == C + A's timer has already fired and does not repeat +``` + +### 9.9 Compile-Time Validation + +#### T9.1: Duplicate transition on same trigger + +```rust +state Red { + transition(Go) => Green; + transition(Go) => Yellow; // COMPILE ERROR +} +``` + +#### T9.2: Missing default with children + +```rust +state Parent { + state ChildA {} + state ChildB {} + // No default — COMPILE ERROR +} +``` + +#### T9.3: Terminate in child state + +```rust +state SomeChild { + terminate(Halt); // COMPILE ERROR +} +``` + +#### T9.4: Default references non-child + +```rust +state Parent { + default(Cousin); // COMPILE ERROR — Cousin is not a direct child + state Child {} +} +``` + +#### T9.5: Default in childless state + +```rust +state Leaf { + default(Ghost); // COMPILE ERROR — Leaf has no children +} +``` + +#### T9.6: Empty state + +```rust +state Empty {} // COMPILE ERROR +``` + +#### T9.7: Duplicate default + +```rust +state Parent { + default(A); + default(B); // COMPILE ERROR + state A {} + state B {} +} +``` + +#### T9.8: Unknown state in transition target + +```rust +state A { + transition(Go) => NonExistent; // COMPILE ERROR +} +``` + +#### T9.9: Multiple terminate declarations + +```rust +statechart! Foo { + terminate(Halt); + terminate(Abort); // COMPILE ERROR +} +``` + +### 9.10 Edge Cases + +#### T10.1: Same action name used in multiple states + +``` +Given: States A and B both reference entry: shared_setup +When: A is entered, then later B is entered +Then: shared_setup() is called both times. Single trait method, reused. +``` + +#### T10.2: Machine with no events (timer-only) + +```rust +#[derive(Event)] +enum NoEvents {} // empty enum + +statechart! TimerOnly { + context: Ctx; + events: NoEvents; + default(A); + state A { + transition(Duration::from_secs(1)) => B; + } + state B { + transition(Duration::from_secs(1)) => A; + } +} +// Must compile and run. Machine oscillates between A and B on timers alone. +``` + +#### T10.3: Deeply nested states (5+ levels) + +``` +Given: States nested 5 levels deep: A > B > C > D > E + Default chain descends all the way to E +When: Machine starts +Then: Entry order: [root_entry, A_entry, B_entry, C_entry, D_entry, E_entry] + current_state() == E +``` + +#### T10.4: Rapid event emission chain + +``` +Given: State A action on Foo emits Bar + State A action on Bar emits Baz + State A transition on Baz => B +When: Event Foo delivered +Then: Foo processed (Bar emitted), then Bar processed (Baz emitted), + then Baz processed (transition to B). + No stack overflow. Events are queued, not recursed. +``` + +#### T10.5: Event emitted during termination exit cascade + +``` +Given: Root has terminate(Halt) + State A exit calls self.emit(Cleanup) +When: Halt event received while in A +Then: A_exit runs (emits Cleanup). Root_exit runs. + Machine is terminated. + Cleanup event is NOT processed (machine is terminated). +``` + +#### T10.6: Transition from root level + +``` +Given: Root has transition(Duration::from_secs(60)) => FirstState + Machine is in some deep nested state +When: Root timer fires +Then: All nested states exit (inner to outer), then FirstState is entered + via default descent. Root is NOT exited (it's the LCA of itself). +``` + +#### T10.7: into_context() after termination + +``` +Given: Machine has terminated +When: into_context() is called +Then: Returns the user's context struct with any mutations made by actions +``` + +--- + +## 10. Acceptance Criteria + +The crate is considered complete when: + +1. **All test cases in §9 pass.** Every test case must have a corresponding automated test. + +2. **Compiles with `#![no_std]`** with default features (no `embassy`, no `tokio`). + +3. **Zero dynamic allocation** with default features. No `alloc` crate dependency. The `tokio` feature may use heap allocation for channels. + +4. **All compile-time validations in §9.9 produce clear, actionable error messages** pointing to the offending line in the macro invocation. + +5. **The generated `StoplightActions` trait exactly matches** the set of unique action function names in the statechart, with no extra methods and no missing methods. + +6. **`Deref`/`DerefMut` on `ActionContext`** allows transparent access to all fields of the user's context struct without qualification. + +7. **`emit()` on `ActionContext`** successfully enqueues events that are processed after the current event completes. + +8. **`sender()` returns a handle** that is `Send + Clone` (when the event type is `Send`), usable from other threads, tasks, or ISRs. + +9. **Feature flag mutual exclusion.** Enabling both `embassy` and `tokio` produces a compile error. + +10. **`step()` is deterministic.** Given the same sequence of events and elapsed durations, the same sequence of actions is always called in the same order. + +11. **Timer precision.** Timers fire on the first `step()` call where cumulative elapsed time meets or exceeds the duration. They do not fire early. + +12. **No unsafe code** in the public API or generated code (implementation may use unsafe internally if justified and documented). + +13. **Documentation.** Every public type, method, and trait has rustdoc with examples. + +--- + +## 11. Out of Scope + +The following are explicitly NOT part of this design: + +- Guard conditions on transitions +- Orthogonal / parallel regions +- History states (shallow or deep) +- Deferred events +- Internal transitions (transitions that don't trigger exit/re-entry) +- State-local context / per-state data +- Visual statechart rendering or diagram export +- Runtime statechart modification +- Event priority or ordering guarantees beyond FIFO +- Inter-machine communication primitives + +These may be considered for future versions but are not part of v0.1. + +--- + +## 12. Open Questions + +1. **`emit()` overflow behavior.** Should `emit()` return `Result<(), HsmcError>` (forcing the action to handle it), or panic on overflow (simpler action code, fails loud)? Recommendation: return `Result` for `no_std` safety, provide a `emit_or_panic()` convenience method. + +2. **Empty event enum.** Should a timer-only machine require an event enum at all, or should `events:` be optional in the macro? Recommendation: make it optional; if omitted, no `sender()` is generated. + +3. **`step()` return semantics.** Should `step()` return how many events/timers it processed, or just the next deadline? Recommendation: return `Option` (time until next timer), plus a separate `has_pending_events()` method. + +4. **Action context lifetime.** The `Deref` approach means users write `impl StoplightActions for StoplightActionContext<'_>`. Is the lifetime annotation too noisy? Alternative: use a type alias. Recommendation: provide `type StoplightCtx<'a> = StoplightActionContext<'a>;` as a convenience alias. + +5. **Const Duration expressions in macro.** `Duration::from_secs(5)` works in const context, but `Duration::from_secs_f64(0.23)` does not (as of Rust 1.82). Should the macro accept float literals and convert them internally? Recommendation: yes, accept bare float literals like `5.0` and integer literals like `5` as seconds, converting to `Duration` internally. Reserve `Duration::` syntax for when the user needs explicit units. diff --git a/docs/001. hsmc-design-change-request.md b/docs/001. hsmc-design-change-request.md new file mode 100644 index 0000000..de989a6 --- /dev/null +++ b/docs/001. hsmc-design-change-request.md @@ -0,0 +1,1004 @@ + +**hsmc** + +Change Request: Hierarchical State-Scoped Context + +with the during: Activity Model + +| Status | DRAFT — Checkpoint for review | +| :---- | :---- | +| **Scope** | Proc-macro codegen, runtime run-loop, DevUX surface | +| **Affects** | statechart\! macro, Machine::run(), handler signatures | +| **Audience** | hsmc core contributors, firmware team | + +| | First Principle The DevUX needs to be EXTREMELY SIMPLE and CLEAR. Users get perfect, near "zero cost abstraction" level performance, while ALSO getting a beautiful DevUX that produces the "read a statechart like plain English" interface. Every design decision in this document is evaluated against this principle first. | +| :---- | :---- | + +# **1\. Executive Summary** + +This change request proposes two additions to hsmc: + +* State-scoped context: any state may declare a context: struct that is constructed on state entry and dropped on state exit. Each level of the hierarchy owns its own data independently. + +* The during: keyword: a state-scoped asynchronous activity that repeatedly produces events while the state is active. During functions receive exclusive access to their state’s local context. Handlers receive exclusive access to the root context and read-only access to all other active contexts on the state path. + +Together these features enable the "one-line task body" goal for all three dogfood consumers: + +let m \= Radio::new(ctx); + +m.run().await; // handles state-scoped I/O, timers, everything + +The design uses no interior mutability visible to the user, no runtime locks, no RefCell, and no panic paths. Under the hood, the macro uses UnsafeCell with a soundness proof that is trivial on a single-threaded cooperative executor: only one thing runs at a time, and the macro controls every access point. + +# **2\. DevUX Analysis: Why This Matters More Than the Mechanics** + +Before diving into implementation, it is worth dwelling on why this particular shape was chosen. The mechanics exist to serve the developer experience, not the other way around. + +## **2.1 What the User Writes Today (Before)** + +A firmware engineer writing the LoRa radio task currently produces something like this: + +\#\[embassy\_executor::task\] + +async fn radio\_task(lora: LoRaDriver, commands: Receiver\) { + + let ctx \= RadioCtx { lora, config: None, rx\_count: 0, .. }; + + let mut m \= Radio::new\_local(ctx); + + loop { + + match m.current\_state() { + + State::Idle \=\> { + + let cmd \= commands.receive().await; + + push(\&mut m, cmd).await; // hand-rolled drain helper + + } + + State::Receiving \=\> { + + match select(lora.rx(), commands.receive()).await { + + Either::First(pkt) \=\> { + + push(\&mut m, RadioInput::PacketReceived { .. }).await; + + } + + Either::Second(cmd) \=\> { + + push(\&mut m, cmd).await; + + } + + } + + } + + // ... more arms ... + + } + + } + +} + +This is 30+ lines of imperative control flow that duplicates what the statechart already knows. Every state that involves I/O requires a match arm. The user must remember to drain events. The declarative statechart becomes a lie — the real behavior lives in the imperative loop. + +## **2.2 What the User Writes After** + +The same task, with this change request applied: + +\#\[embassy\_executor::task\] + +async fn radio\_task(lora: LoRaDriver) { + + let ctx \= RadioCtx { rx\_count: 0, config: None, .. }; + + let m \= Radio::new(ctx); + + m.run().await; + +} + +Five lines. The statechart declaration is the single source of truth for behavior. The imperative outer loop, the drain helper, and the match-on-state are all gone. + +## **2.3 Where Did the I/O Logic Go?** + +Into the statechart declaration and two small functions: + +state Receiving { + + context: ReceivingCtx; + + during: next\_packet; + + on(PacketReceived { rssi, data }) \=\> handle\_packet; + + on(StopRx) \=\> Idle; + +} + +async fn next\_packet(local: \&mut ReceivingCtx) \-\> RadioInput { + + match local.lora.rx(\&mut local.buf).await { + + Ok(pkt) \=\> RadioInput::PacketReceived { rssi: pkt.rssi, .. }, + + Err(e) \=\> RadioInput::RxFailed(e), + + } + +} + +async fn handle\_packet(ctx: \&mut RadioCtx, rssi: i16, data: Vec\) { + + ctx.rx\_count \+= 1; + + ctx.last\_rssi \= rssi; + +} + +The developer never thinks about borrow conflicts, drain loops, or select-driven dispatch. They think about: "while in Receiving, produce packets" and "when a packet arrives, count it." The statechart reads as English. The functions are plain Rust. + +## **2.4 DevUX Principles Scorecard** + +| Principle | Met? | Notes | +| :---- | :---- | :---- | +| One context: slot per state | **Yes** | Root has context:, states optionally add their own | +| User writes a plain struct | **Yes** | No Cell, RefCell, Mutex, or attributes required | +| Idiomatic \&mut T / \&T access | **Yes** | during: gets \&mut Local; handlers get \&mut Root, & ancestors | +| Async-first (embassy / tokio) | **Yes** | All handlers and during: functions are async fn | +| One m.run().await call | **Yes** | Works for Display, Radio, and Host | +| Declarative block reads as English | **Yes** | "during: next\_packet" in state Receiving reads naturally | +| No user-visible footguns | **Yes** | No runtime panics; soundness proven at compile time by macro | +| Zero runtime cost | **\~Yes** | UnsafeCell is zero-cost; minor overhead from select in run loop | + +## **2.5 Concepts the User Must Learn** + +The cost of any feature is what you must teach. This change introduces exactly two new concepts: + +* context: on a state — "this state has its own data, constructed when entering, dropped when exiting." Existing users already understand entry:/exit: lifecycle; this is a natural extension. + +* during: — "while in this state, repeatedly call this function to produce events." The mental model is Iterator::next() but async. Call it, get an event, process the event, call it again. If the state exits, the in-flight call is dropped. + +Concepts the user does NOT need to learn: UnsafeCell, partial borrows, interior mutability, cancel-safety (beyond the standard embassy contract), or any new Rust language features. + +# **3\. Before: Current hsmc Architecture** + +## **3.1 Statechart Surface** + +The declarative surface is unchanged by this proposal. For reference: + +statechart\! { + + Radio { + + context: RadioCtx; + + events: RadioInput; + + default(Unconfigured); + + state Unconfigured { + + on(ConfigSet(cfg)) \=\> apply\_config, Configured; + + } + + state Configured { + + default(Idle); + + state Idle { on(StartRx) \=\> Receiving; } + + state Receiving { + + entry: announce\_rx; + + on(PacketReceived { rssi, snr }) \=\> handle\_packet; + + on(StopRx) \=\> Idle; + + } + + } + + } + +} + +## **3.2 Procedural Surface (The Problem)** + +Two issues exist in the current procedural API: + +### **3.2.1 The Drain Papercut** + +Machine::send(event) enqueues but does not process. Every user independently writes the same drain helper: + +async fn push\(m: \&mut M, ev: E) { + + m.send(ev).expect("queue overflow"); + + while m.has\_pending\_events() { + + m.step(Duration::ZERO).await; + + } + +} + +Three copies of this exist in the dogfood firmware. This is a bug, not a design choice. Fix: make send() drain internally. This portion of the change is uncontroversial. + +### **3.2.2 No State-Scoped I/O** + +Machine::run().await works for pure-event machines (Display) but not for machines whose I/O depends on current state (Radio, Host). These consumers must write imperative outer loops using match machine.current\_state(), duplicating the statechart’s own state knowledge in user code. + +# **4\. After: Proposed Design** + +## **4.1 Feature 1 — State-Scoped Context** + +Any state may declare a context: type. This type is constructed on state entry and dropped on state exit. Construction receives a read-only reference to the parent state’s context (or the root context if the state is a top-level state). + +### **4.1.1 Declaration** + +state Receiving { + + context: ReceivingCtx; + + entry: announce\_rx; + + // ... + +} + +### **4.1.2 Context Struct and Construction** + +struct ReceivingCtx { + + lora: LoRaDriver, + + buf: \[u8; 256\], + +} + +impl ReceivingCtx { + + fn new(parent: \&ConfiguredCtx) \-\> Self { + + Self { + + lora: take\_lora(), + + buf: \[0; 256\], + + } + + } + +} + +If no context: is declared, the state has no local data. This is the common case for simple states. + +### **4.1.3 Lifecycle** + +* State entered: LocalCtx::new(\&parent\_ctx) is called. Then entry: actions run. + +* State active: during: function (if any) has \&mut LocalCtx. Handlers can read \&LocalCtx. + +* State exited: exit: actions run. Then LocalCtx is dropped (Drop trait runs if implemented). + +* Nested entry order: parent context constructed first, then child. Exit order is reversed (child dropped first, then parent), like stack unwinding. + +### **4.1.4 Hierarchy Example** + +struct RadioCtx { rx\_count: u32, config: Option\ } + +struct ConfiguredCtx { validated\_config: RadioConfig } + +struct ReceivingCtx { lora: LoRaDriver, buf: \[u8; 256\] } + +struct ScanningCtx { rssi\_samples: \[i16; 8\], sample\_idx: usize } + +statechart\! { + + Radio { + + context: RadioCtx; + + state Configured { + + context: ConfiguredCtx; + + state Receiving { + + context: ReceivingCtx; + + state Scanning { + + context: ScanningCtx; + + } + + } + + } + + } + +} + +When machine is in Configured \> Receiving \> Scanning, four context structs are alive simultaneously, each independently owned by its state. + +## **4.2 Feature 2 — during: Activities** + +### **4.2.1 Declaration** + +state Receiving { + + context: ReceivingCtx; + + during: next\_packet; + + on(PacketReceived { rssi, data }) \=\> handle\_packet; + + on(StopRx) \=\> Idle; + +} + +Reads as: "During Receiving, run next\_packet. When it produces a PacketReceived event, call handle\_packet." + +### **4.2.2 During Function Signature** + +// Takes \&mut of its own state's context. + +// Returns a single event. + +// Called repeatedly while the state is active. + +async fn next\_packet(local: \&mut ReceivingCtx) \-\> RadioInput { + + match local.lora.rx(\&mut local.buf).await { + + Ok(pkt) \=\> RadioInput::PacketReceived { + + rssi: pkt.rssi, + + snr: pkt.snr, + + data: pkt.data, + + }, + + Err(e) \=\> RadioInput::RxFailed(e), + + } + +} + +### **4.2.3 Handler Signature** + +// Handlers get \&mut of the root context. + +// They also get & (read-only) of any ancestor context on the active path. + +// They run AFTER the during: future is suspended or dropped. + +async fn handle\_packet( + + ctx: \&mut RadioCtx, // root, always \&mut + + receiving: \&ReceivingCtx, // ancestor on active path, read-only + + rssi: i16, + + data: Vec\, + +) { + + ctx.rx\_count \+= 1; + + ctx.last\_rssi \= rssi; + + defmt::info\!("ch={}", receiving.lora.current\_channel()); + +} + +### **4.2.4 Execution Model** + +The generated run() loop operates as follows: + +* 1\. Collect all active during: functions on the current state path (one per state level that declares one). + +* 2\. Race all of them against the external event channel and the next timer deadline using select. + +* 3\. When any branch wins, suspend all others (they remain parked at their .await points). + +* 4\. If a during: function returned an event, or an external event arrived, or a timer fired: dispatch the event through the normal handler chain. + +* 5\. Handlers run with \&mut root context and & of all ancestor contexts. This is safe because all during: futures are either dropped (the one that produced the event) or parked (all others). On a single-threaded cooperative executor, parked means unreachable. + +* 6\. After handler dispatch completes, respawn the during: that was dropped (if the state hasn’t changed), and loop back to step 2\. + +### **4.2.5 Multiple during: Functions on the Same Active Path** + +This is the hierarchical case and the primary reason state-scoped context exists: + +state Configured { + + context: ConfiguredCtx; + + during: monitor\_battery; // \&mut ConfiguredCtx + + state Receiving { + + context: ReceivingCtx; + + during: next\_packet; // \&mut ReceivingCtx + + } + +} + +Both during: functions run concurrently (via select in the run loop). No borrow conflict because they hold \&mut of different structs. Each state’s context is an independent island of ownership. + +| | Key Insight State-scoped context is not just an organizational convenience. It is the mechanism that makes concurrent during: functions possible without interior mutability. By giving each state its own data, the borrow checker sees independent borrows on independent values — no conflict, no locks, no runtime checks. | +| :---- | :---- | + +### **4.2.6 Multiple I/O Sources Within a Single State** + +A single state cannot declare multiple during: entries. If a state needs to multiplex I/O sources, the developer uses select inside one during: function: + +state Connected { + + context: HostLocal; + + during: next\_host\_event; + +} + +async fn next\_host\_event(local: \&mut HostLocal) \-\> HostInput { + + use embassy\_futures::select::{select3, Either3}; + + match select3( + + local.usb.read\_packet(\&mut local.buf), + + local.responses.receive(), + + Timer::after\_millis(250), + + ).await { + + Either3::First(Ok(n)) \=\> HostInput::UsbPacket(..), + + Either3::Second(resp) \=\> HostInput::ResponseReady(resp), + + Either3::Third(\_) \=\> HostInput::Tick, + + } + +} + +This is the standard embassy pattern. Developers in the target audience already know it. The during: keyword simply gives it a declarative home. + +# **5\. Access Rules** + +The access model can be stated in three sentences: + +| | Access Rules 1\. A during: function gets \&mut of its own state’s context. Nothing else.2. A handler (entry:, exit:, on() action) gets \&mut of the root context and & of every other context on the active state path.3. A context’s new() constructor gets & of its parent’s context. | +| :---- | :---- | + +## **5.1 Why These Rules Are Sound** + +On a single-threaded cooperative executor (embassy, or tokio’s LocalSet): + +* Only one piece of code runs at a time. There is no true parallelism. + +* A during: function holds \&mut LocalCtx across .await points, but while it is parked at .await, no code is executing through that reference. It is frozen inside a suspended future. + +* When an event is dispatched, the during: function is either dropped (it returned the event) or parked (a different branch of select won). Either way, no code is actively using its \&mut borrow. + +* The macro controls every call site. No user code can obtain a raw reference to the UnsafeCell-wrapped storage. The only way to access a state’s context is through a macro-generated during: call or handler call, both of which enforce the rules above. + +## **5.2 Walk-Through: Three Levels Deep** + +Machine is in Configured \> Receiving \> Scanning. Three during: functions are in the select race: + +monitor\_battery holds \&mut ConfiguredCtx \[parked at .await\] + +next\_packet holds \&mut ReceivingCtx \[parked at .await\] + +scan\_channels holds \&mut ScanningCtx \[parked at .await\] + +scan\_channels wakes up and returns ScanComplete: + +* scan\_channels future is dropped. Its \&mut ScanningCtx borrow is released. + +* monitor\_battery and next\_packet remain parked. Their \&mut borrows exist inside frozen futures but are unreachable. + +* Handler runs with: \&mut RadioCtx (root), \&ConfiguredCtx (parked during: holds \&mut, but parked \= unreachable), \&ReceivingCtx (same reasoning), \&ScanningCtx (during: was dropped). + +* Handler returns. Machine respawns scan\_channels with fresh \&mut ScanningCtx. Loop continues. + +## **5.3 What About ISRs?** + +Interrupt service routines never touch statechart context. In embassy’s model, ISRs set waker flags and return. The during: function’s .await point resumes in task context on the single thread. If a developer needs to share data between a raw ISR and a statechart, they use atomics or critical sections outside hsmc, exactly as they would without hsmc. + +Embassy’s InterruptExecutor (multiple priority levels) introduces real preemption, but only between executors. A single statechart runs on a single executor. The access model holds. + +# **6\. Complete Before/After: Radio Task** + +## **6.1 Before — Current Firmware** + +struct RadioCtx { + + lora: LoRaDriver, // peripheral lives in shared context + + config: Option\, + + rx\_count: u32, + + tx\_count: u32, + + last\_rssi: i16, + + buf: \[u8; 256\], + +} + +statechart\! { + + Radio { + + context: RadioCtx; + + events: RadioInput; + + default(Unconfigured); + + state Unconfigured { + + on(ConfigSet(cfg)) \=\> apply\_config, Configured; + + } + + state Configured { + + default(Idle); + + state Idle { + + entry: radio\_idle; + + on(StartRx) \=\> Receiving; + + } + + state Receiving { + + entry: announce\_rx; + + on(PacketReceived { rssi, snr }) \=\> handle\_packet; + + on(StopRx) \=\> Idle; + + } + + } + + } + +} + +// Imperative outer loop (30+ lines of boilerplate) + +\#\[embassy\_executor::task\] + +async fn radio\_task(lora: LoRaDriver, cmds: Receiver\) { + + let ctx \= RadioCtx { lora, config: None, rx\_count: 0, + + tx\_count: 0, last\_rssi: 0, buf: \[0; 256\] }; + + let mut m \= Radio::new\_local(ctx); + + loop { + + match m.current\_state() { + + State::Idle \=\> { + + let cmd \= cmds.receive().await; + + m.send(cmd).expect("overflow"); + + while m.has\_pending\_events() { + + m.step(Duration::ZERO).await; + + } + + } + + State::Receiving \=\> { + + let ctx \= m.context\_mut(); + + match select( + + ctx.lora.rx(\&mut ctx.buf), + + cmds.receive() + + ).await { + + Either::First(Ok(pkt)) \=\> { + + m.send(RadioInput::PacketReceived { + + rssi: pkt.rssi, snr: pkt.snr + + }).expect("overflow"); + + while m.has\_pending\_events() { + + m.step(Duration::ZERO).await; + + } + + } + + Either::Second(cmd) \=\> { /\* same drain \*/ } + + Either::First(Err(\_)) \=\> { /\* same drain \*/ } + + } + + } + + \_ \=\> { /\* drain helper again \*/ } + + } + + } + +} + +## **6.2 After — Proposed** + +// Root context: bookkeeping only + +struct RadioCtx { + + config: Option\, + + rx\_count: u32, + + tx\_count: u32, + + last\_rssi: i16, + +} + +// State-scoped: peripherals and buffers live here + +struct ReceivingCtx { + + lora: LoRaDriver, + + buf: \[u8; 256\], + +} + +impl ReceivingCtx { + + fn new(parent: \&RadioCtx) \-\> Self { + + Self { lora: take\_lora(), buf: \[0; 256\] } + + } + +} + +struct TransmittingCtx { + + lora: LoRaDriver, + +} + +impl TransmittingCtx { + + fn new(parent: \&RadioCtx) \-\> Self { + + Self { lora: take\_lora() } + + } + +} + +statechart\! { + + Radio { + + context: RadioCtx; + + events: RadioInput; + + default(Unconfigured); + + state Unconfigured { + + on(ConfigSet(cfg)) \=\> apply\_config, Configured; + + } + + state Configured { + + on(every Duration::from\_secs(30)) \=\> heartbeat; + + default(Idle); + + state Idle { + + entry: radio\_idle; + + on(StartRx) \=\> Receiving; + + on(Transmit(pkt)) \=\> begin\_tx, Transmitting; + + } + + state Receiving { + + context: ReceivingCtx; + + during: next\_packet; + + on(PacketReceived { rssi, snr, data }) \=\> handle\_packet; + + on(RxFailed(err)) \=\> handle\_rx\_error, Idle; + + on(StopRx) \=\> Idle; + + } + + state Transmitting { + + context: TransmittingCtx; + + during: await\_tx\_done; + + on(TxDone) \=\> handle\_tx\_done, Idle; + + on(TxFailed(err)) \=\> handle\_tx\_error, Idle; + + } + + } + + } + +} + +// during: functions — each gets \&mut of its own state's context + +async fn next\_packet(local: \&mut ReceivingCtx) \-\> RadioInput { + + match local.lora.rx(\&mut local.buf).await { + + Ok(pkt) \=\> RadioInput::PacketReceived { + + rssi: pkt.rssi, snr: pkt.snr, data: pkt.data, + + }, + + Err(e) \=\> RadioInput::RxFailed(e), + + } + +} + +async fn await\_tx\_done(local: \&mut TransmittingCtx) \-\> RadioInput { + + match local.lora.wait\_tx\_complete().await { + + Ok(()) \=\> RadioInput::TxDone, + + Err(e) \=\> RadioInput::TxFailed(e), + + } + +} + +// Handlers — \&mut root, & ancestors + +async fn apply\_config(ctx: \&mut RadioCtx, cfg: RadioConfig) { + + ctx.config \= Some(cfg); + +} + +async fn handle\_packet( + + ctx: \&mut RadioCtx, + + receiving: \&ReceivingCtx, + + rssi: i16, snr: i16, data: Vec\, + +) { + + ctx.rx\_count \+= 1; + + ctx.last\_rssi \= rssi; + +} + +async fn heartbeat(ctx: \&mut RadioCtx) { + + defmt::trace\!("alive, {} pkts", ctx.rx\_count); + +} + +// Task body — the entire procedural surface + +\#\[embassy\_executor::task\] + +async fn radio\_task(lora: LoRaDriver) { + + let ctx \= RadioCtx { + + config: None, + + rx\_count: 0, + + tx\_count: 0, + + last\_rssi: 0, + + }; + + let m \= Radio::new(ctx); + + m.run().await; + +} + +# **7\. Implementation Notes** + +## **7.1 Macro Codegen** + +The statechart\! macro generates a StateStorage struct containing the root context and Option-wrapped UnsafeCell for each state that declares a context:. The macro also generates: + +* Entry sequences that call LocalCtx::new(\&parent\_ctx), store the result, then call entry: actions. + +* Exit sequences that call exit: actions, then drop the local context (set Option to None). + +* A run() method containing the select-based event loop described in Section 4.2.4. + +* Handler wrappers that extract the correct references (\&mut root, & ancestors) from StateStorage using UnsafeCell::get() and pass them to user functions. + +## **7.2 Soundness Argument** + +The macro emits unsafe code to obtain references from UnsafeCell. The soundness argument has four premises: + +* P1. Single-threaded executor: the machine runs on one thread. No data races are possible. + +* P2. Cooperative scheduling: futures only yield at .await points. Between .await points, execution is sequential and uninterruptible. + +* P3. Macro-controlled access: the only code that obtains references to context storage is generated by the macro. User functions receive references as parameters; they cannot fabricate new references to other contexts. + +* P4. Select semantics: when one branch of select completes, all other branches are either dropped or remain parked. A parked future’s captured \&mut is inside a frozen state machine and is unreachable by any running code. + +From P1–P4: at any instant, at most one \&mut reference to each context is reachable by running code. The UnsafeCell usage satisfies Rust’s aliasing requirements. + +## **7.3 Cancel-Safety** + +During functions may be dropped at any .await point when an external event arrives or when the state exits. This is the standard embassy contract. The documentation must emphasize: + +* During functions must not leave hardware in an inconsistent state across .await points. If the peripheral requires cleanup, implement Drop on the state’s context struct. + +* Synchronous mutations before the first .await are committed even if the future is later dropped. This is normal Rust behavior but worth calling out. + +## **7.4 Multi-Threaded Executors (tokio)** + +Under the tokio feature, the machine must be constrained to a single-threaded context (tokio::task::LocalSet) or the macro must use real synchronization primitives (tokio::sync::RwLock) instead of UnsafeCell. The recommended approach is LocalSet for v1, with real-lock support as a v2 option. + +## **7.5 Peripheral Ownership** + +The new() constructor for state-scoped contexts must obtain peripheral handles. Several patterns are possible: + +* Static singleton via a take function (take\_lora()), common in embedded HALs. + +* Option::take() from the root context (the root holds Option\, Receiving takes it on entry, returns it on exit via Drop). + +* Passed via a peripheral pool or registry external to the statechart. + +This proposal does not mandate a specific pattern. The new(\&parent) signature is sufficient to support all of them. + +# **8\. Open Questions and Acknowledged Rough Edges** + +This design is a checkpoint, not a final answer. The following items need further exploration: + +### **8.1 Peripheral Shuttle Between States** + +When LoRaDriver moves from ReceivingCtx to TransmittingCtx (via Idle), where does it live during the transition? Option::take() from root context works but forces the root to hold Option\, which is mild clutter. Is there a cleaner peripheral lifecycle pattern? + +### **8.2 Handler Access to Local Context** + +Handlers get & (read-only) of state contexts on the active path. Is there a legitimate case where a handler needs \&mut of a non-root context? If so, the during: at that level would need to be fully dropped and respawned for that event, which adds latency and complexity. + +### **8.3 The new() Convention** + +Requiring a new(\&ParentCtx) \-\> Self constructor is simple but opaque. Should the macro instead accept an init: action that returns the context (re-introducing the keyword that was removed earlier)? Or should context construction happen inside entry: actions? The current proposal chooses new() for minimal syntax, but this may feel like hidden magic. + +### **8.4 Testing** + +State-scoped contexts that own peripherals complicate unit testing. The existing step(Duration) test interface works for pure-event machines. Machines with during: functions need a way to inject mock peripherals through the context construction path. This likely means making context types generic over a peripheral trait, which adds complexity to struct definitions. + +### **8.5 Does the Declarative Block Still Read as English?** + +The extended declaration for a state with context and during reads: + +state Receiving { + + context: ReceivingCtx; + + during: next\_packet; + + on(PacketReceived { rssi, data }) \=\> handle\_packet; + + on(StopRx) \=\> Idle; + +} + +This is two lines longer than a state without I/O. The keywords read naturally: "state Receiving has context ReceivingCtx; during Receiving, run next\_packet." But does the additional structure start to obscure the statechart’s topology for a reader who just wants to understand the state graph? Feedback requested. + +### **8.6 Is This Just Path B With Better Names?** + +An honest concern. The original RFC rejected Path B ("split context \+ resources") as "too many concepts." This proposal introduces per-state context structs, which is arguably more concepts, not fewer. The counter-argument is that per-state context is semantically motivated by the state hierarchy (each state owns its data for its lifetime), whereas Path B’s flat "context vs resources" split was semantically arbitrary. Whether this distinction is meaningful to users, or is a rationalization, is an open question. + +# **9\. Migration Path** + +## **9.1 Phase 1: Drain-on-Send (Ship Immediately)** + +* send() drains to quiescence internally. + +* Firmware migration: delete three copies of the drain helper, replace with machine.send(ev).await. + +* No breaking changes to existing statechart declarations. + +## **9.2 Phase 2: State-Scoped Context \+ during: (This Proposal)** + +* Existing statecharts without context: or during: are unaffected. Zero migration required. + +* display.rs: unchanged. Can optionally switch from new\_local() to new() \+ run().await for consistency. + +* radio.rs: move LoRaDriver and buf into ReceivingCtx. Move bookkeeping (rx\_count, etc.) into root RadioCtx. Add context: and during: to Receiving and Transmitting states. Delete the imperative outer loop entirely. + +* host/usb.rs: add HostLocal context holding USB handle, response channel, and buffer. Add during: next\_host\_event with select3 inside. Delete the imperative outer loop. + +## **9.3 Backward Compatibility** + +new\_local() and the step/send/current\_state procedural API remain available for users who prefer or require imperative control. The during: feature is purely additive. + +# **10\. Recommendation** + +This document represents a checkpoint in an ongoing design iteration. The authors are not yet fully confident in this approach. Specific areas of discomfort include the new() construction convention, the peripheral ownership lifecycle, and whether per-state contexts introduce too much structural overhead for simple statecharts. + +However, the core insight — that hierarchical state-scoped data ownership eliminates borrow conflicts between during: functions without interior mutability visible to the user — appears sound and is worth validating with a prototype implementation. + +Recommended next steps: + +* Ship Phase 1 (drain-on-send) immediately. This is uncontroversial and immediately valuable. + +* Prototype the during: \+ state context model against the radio.rs dogfood consumer. If the prototype surfaces new DevUX problems (especially around peripheral lifecycle), iterate on the design before committing. + +* Collect feedback from at least two firmware engineers outside the core team on whether the extended statechart declaration (with context: and during:) remains readable. + +* Defer multi-threaded executor support (real RwLock under tokio) to a later phase. Single-threaded embassy is the primary target. diff --git a/hsmc-macros/Cargo.toml b/hsmc-macros/Cargo.toml new file mode 100644 index 0000000..0a58664 --- /dev/null +++ b/hsmc-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hsmc-macros" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Procedural macro implementation for the hsmc crate." + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full", "extra-traits"] } +quote = "1" +proc-macro2 = "1" + +[dev-dependencies] +trybuild = "1" +hsmc = { path = "../hsmc" } diff --git a/hsmc-macros/src/codegen.rs b/hsmc-macros/src/codegen.rs new file mode 100644 index 0000000..1638e3c --- /dev/null +++ b/hsmc-macros/src/codegen.rs @@ -0,0 +1,1976 @@ +//! Code generation for the `statechart!` macro. + +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::Ident; + +use crate::ir::{HandlerKindIr, Ir, TriggerIr, VariantKind}; +use crate::parse::{During, PayloadKind}; + +/// For each user-declared leaf state that has one or more `during:` activities +/// active (either declared on it, on any ancestor, or on root), return its +/// state id and the flattened list of durings in leaf-first order. Durings +/// declared on the same state follow their declaration order. +/// +/// Leaf states with no active durings are omitted — the run loop uses its +/// default (channel+timer) path for those. +fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec)> { + let mut out = Vec::new(); + for s in &ir.states { + if !s.children.is_empty() { + continue; + } + if s.parent.is_none() { + continue; // synthetic __Root + } + let mut chain: Vec = Vec::new(); + let mut cur = Some(s.id); + while let Some(id) = cur { + for d in &ir.states[id as usize].durings { + chain.push(d.clone()); + } + cur = ir.states[id as usize].parent; + } + if !chain.is_empty() { + out.push((s.id, chain)); + } + } + out +} + +/// Emit `let dN = fn(&mut self.ctx.fN_0, &mut self.ctx.fN_1, ...);` for each +/// during in `chain`. Returns the list of binding identifiers in order so the +/// call site can reference them in the `select!`/`select_N` arguments. +fn emit_during_bindings(chain: &[During]) -> (Vec, TokenStream) { + let mut idents = Vec::with_capacity(chain.len()); + let mut stmts: Vec = Vec::with_capacity(chain.len()); + for (i, d) in chain.iter().enumerate() { + let bind = format_ident!("__hsmc_d{}", i); + let fn_name = &d.fn_name; + let field_refs = d.fields.iter().map(|f| quote! { &mut self.ctx.#f }); + stmts.push(quote! { + let #bind = #fn_name(#(#field_refs),*); + }); + idents.push(bind); + } + (idents, quote! { #(#stmts)* }) +} + +/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs +/// the active during: futures (split-borrowing context fields), then races +/// them against the event channel and the next-timer sleep via +/// `tokio::select!`. The default arm — used for leaves with no durings — is +/// emitted by the caller. +fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec { + let mut arms = Vec::new(); + for (sid, chain) in active_durings_per_leaf(ir) { + let (binds, stmts) = emit_during_bindings(&chain); + let select_arms = binds.iter().map(|b| { + quote! { ev = #b => __HsmcRace::Event(ev), } + }); + arms.push(quote! { + Some(#sid) => { + #stmts + let sleep = ::tokio::time::sleep(sleep_dur); + ::tokio::pin!(sleep); + let rx = self.__tokio_rx.as_mut().expect("hsmc: tokio channel"); + ::tokio::select! { + biased; + #(#select_arms)* + maybe = rx.recv() => match maybe { + Some(ev) => __HsmcRace::Event(ev), + None => __HsmcRace::ChannelClosed, + }, + _ = &mut sleep => __HsmcRace::Timer, + } + } + }); + } + arms +} + +/// Same as `emit_tokio_race_arms_with_channel` but for timer-only machines +/// (no `events:` declared, no channel). The default arm races only the timer. +fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec { + let mut arms = Vec::new(); + for (sid, chain) in active_durings_per_leaf(ir) { + let (binds, stmts) = emit_during_bindings(&chain); + let select_arms = binds.iter().map(|b| { + quote! { ev = #b => __HsmcRace::Event(ev), } + }); + arms.push(quote! { + Some(#sid) => { + #stmts + let sleep = ::tokio::time::sleep(sleep_dur); + ::tokio::pin!(sleep); + ::tokio::select! { + biased; + #(#select_arms)* + _ = &mut sleep => __HsmcRace::Timer, + } + } + }); + } + arms +} + +/// Build a human-readable ASCII tree of the machine's hierarchy, suitable +/// for emitting as a `STATE_CHART` const. Each state shows its default-child +/// marker, entry/exit actions (if any), during activities, and handler +/// triggers. Used for `defmt::info!(M::STATE_CHART)` or panic messages. +fn emit_state_chart_string(ir: &Ir) -> String { + let mut out = String::new(); + // Start with the machine name as the root label (root state name is + // synthetic `__Root` — don't surface it). + out.push_str(&ir.name.to_string()); + out.push('\n'); + // Top-level children of __Root: walk via __Root's children list. + let root_children: Vec = ir.states[0].children.clone(); + let default_child_of_root = ir.states[0].default_child; + for (i, cid) in root_children.iter().enumerate() { + let last = i + 1 == root_children.len(); + let is_default = Some(*cid) == default_child_of_root; + render_state(ir, *cid, "", last, is_default, &mut out); + } + out +} + +fn render_state( + ir: &Ir, + sid: u16, + prefix: &str, + is_last: bool, + is_default: bool, + out: &mut String, +) { + let s = &ir.states[sid as usize]; + let branch = if is_last { "└── " } else { "├── " }; + out.push_str(prefix); + out.push_str(branch); + if is_default { + out.push_str("[default] "); + } + out.push_str(&s.name.to_string()); + out.push('\n'); + let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " }); + // Entry actions + for (_i, aid) in s.entries.iter().enumerate() { + let name = &ir.actions[*aid as usize].name; + out.push_str(&child_prefix); + out.push_str("• entry: "); + out.push_str(&name.to_string()); + out.push('\n'); + } + // Exit actions + for aid in &s.exits { + let name = &ir.actions[*aid as usize].name; + out.push_str(&child_prefix); + out.push_str("• exit: "); + out.push_str(&name.to_string()); + out.push('\n'); + } + // Durings + for d in &s.durings { + out.push_str(&child_prefix); + out.push_str("• during: "); + out.push_str(&d.fn_name.to_string()); + out.push('('); + for (i, f) in d.fields.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + out.push_str(&f.to_string()); + } + out.push_str(")\n"); + } + // Handlers (summary) + for h in &s.handlers { + let trig = match &h.trigger { + crate::ir::TriggerIr::Event(_, name, _) => format!("on({})", name), + crate::ir::TriggerIr::Duration(tid) => { + let d = &ir.duration_triggers[*tid as usize]; + let verb = if d.repeat { "every" } else { "after" }; + format!("on({} {})", verb, d.key.split(':').next().unwrap_or("?")) + } + }; + let target = match &h.kind { + crate::ir::HandlerKindIr::Transition(tgt, _) => format!("→ {}", tgt), + crate::ir::HandlerKindIr::Action(_, name) => format!("⇒ {}", name), + }; + out.push_str(&child_prefix); + out.push_str("• "); + out.push_str(&trig); + out.push(' '); + out.push_str(&target); + out.push('\n'); + } + // Child states + let default_child_of_s = s.default_child; + for (i, cid) in s.children.iter().enumerate() { + let last = i + 1 == s.children.len(); + let is_def = Some(*cid) == default_child_of_s; + render_state(ir, *cid, &child_prefix, last, is_def, out); + } +} + +/// Return (select_fn_ident, either_variant_idents) for embassy_futures::select +/// with the given total arity. `arity` must be one of 2, 3, 4, 5, 6. +fn embassy_select_for_arity(arity: usize) -> (Ident, Vec) { + match arity { + 2 => ( + format_ident!("select"), + vec![format_ident!("First"), format_ident!("Second")], + ), + 3 => ( + format_ident!("select3"), + vec![ + format_ident!("First"), + format_ident!("Second"), + format_ident!("Third"), + ], + ), + 4 => ( + format_ident!("select4"), + vec![ + format_ident!("First"), + format_ident!("Second"), + format_ident!("Third"), + format_ident!("Fourth"), + ], + ), + 5 => ( + format_ident!("select5"), + vec![ + format_ident!("First"), + format_ident!("Second"), + format_ident!("Third"), + format_ident!("Fourth"), + format_ident!("Fifth"), + ], + ), + 6 => ( + format_ident!("select6"), + vec![ + format_ident!("First"), + format_ident!("Second"), + format_ident!("Third"), + format_ident!("Fourth"), + format_ident!("Fifth"), + format_ident!("Sixth"), + ], + ), + _ => unreachable!("embassy_select_for_arity called with unsupported arity"), + } +} + +/// Emit embassy run-loop match arms for machines with a bound channel. Arity +/// per state = active_durings + 2 (channel + timer). Embassy caps at `select6` +/// → max 4 concurrent durings on a path with events. Over-limit paths emit +/// a `compile_error!` inside the embassy-only arm so tokio codegen still +/// compiles unchanged. +fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec { + let mut arms = Vec::new(); + for (sid, chain) in active_durings_per_leaf(ir) { + let n = chain.len(); + if n + 2 > 6 { + let msg = format!( + "too many concurrent `during:` activities on the active path for state id {} \ + under the embassy feature ({} + channel + timer > 6); embassy_futures supports \ + at most 4 concurrent durings on a path with events. Combine activities into a \ + single during that uses `select` internally.", + sid, n + ); + arms.push(quote! { + Some(#sid) => { ::core::compile_error!(#msg); unreachable!() } + }); + continue; + } + let (binds, stmts) = emit_during_bindings(&chain); + let (sel_fn, variants) = embassy_select_for_arity(n + 2); + let ch_variant = &variants[n]; + let tm_variant = &variants[n + 1]; + let call_args = binds.iter() + .map(|b| quote! { #b }) + .chain(std::iter::once(quote! { rx.receive() })) + .chain(std::iter::once(quote! { timer_fut })); + let either_path = if n + 2 == 2 { + quote! { ::embassy_futures::select::Either } + } else { + let either_ty = format_ident!("Either{}", (n + 2).to_string()); + quote! { ::embassy_futures::select::#either_ty } + }; + let during_match = binds.iter().enumerate().map(|(i, _b)| { + let v = &variants[i]; + quote! { #either_path::#v(ev) => __HsmcRace::Event(ev), } + }); + arms.push(quote! { + Some(#sid) => { + #stmts + let timer_fut = async { + match next { + Some(d) => { + ::embassy_time::Timer::after( + ::embassy_time::Duration::from_micros(d.as_micros() as u64) + ).await; + } + None => ::core::future::pending::<()>().await, + } + }; + let rx = self.__embassy_rx.as_ref().expect("hsmc: embassy channel"); + match ::embassy_futures::select::#sel_fn(#(#call_args),*).await { + #(#during_match)* + #either_path::#ch_variant(ev) => __HsmcRace::Event(ev), + #either_path::#tm_variant(()) => __HsmcRace::Timer, + } + } + }); + } + arms +} + +/// Emit embassy run-loop match arms for timer-only machines (no channel). +/// Arity per state = active_durings + 1 (timer only). Max 5 concurrent +/// durings on a timer-only path. +fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec { + let mut arms = Vec::new(); + for (sid, chain) in active_durings_per_leaf(ir) { + let n = chain.len(); + if n == 0 { + continue; // falls through to default (just timer) + } + if n + 1 > 6 { + let msg = format!( + "too many concurrent `during:` activities on the active path for state id {} \ + under the embassy feature ({} + timer > 6); embassy_futures supports at most 5 \ + concurrent durings on a timer-only path.", + sid, n + ); + arms.push(quote! { + Some(#sid) => { ::core::compile_error!(#msg); unreachable!() } + }); + continue; + } + let (binds, stmts) = emit_during_bindings(&chain); + let (sel_fn, variants) = embassy_select_for_arity(n + 1); + let call_args = binds.iter() + .map(|b| quote! { #b }) + .chain(std::iter::once(quote! { timer_fut })); + let either_path = if n + 1 == 2 { + quote! { ::embassy_futures::select::Either } + } else { + let either_ty = format_ident!("Either{}", (n + 1).to_string()); + quote! { ::embassy_futures::select::#either_ty } + }; + let tm_variant = &variants[n]; + let during_match = binds.iter().enumerate().map(|(i, _b)| { + let v = &variants[i]; + quote! { #either_path::#v(ev) => __HsmcRace::Event(ev), } + }); + arms.push(quote! { + Some(#sid) => { + #stmts + let timer_fut = async { + match next { + Some(d) => { + ::embassy_time::Timer::after( + ::embassy_time::Duration::from_micros(d.as_micros() as u64) + ).await; + } + None => ::core::future::pending::<()>().await, + } + }; + match ::embassy_futures::select::#sel_fn(#(#call_args),*).await { + #(#during_match)* + #either_path::#tm_variant(()) => __HsmcRace::Timer, + } + } + }); + } + arms +} + +pub fn generate(ir: &Ir) -> TokenStream { + let machine_name = &ir.name; + let state_enum_name = format_ident!("{}State", machine_name); + let actions_trait_name = format_ident!("{}Actions", machine_name); + let action_ctx_name = format_ident!("{}ActionContext", machine_name); + let ctx_alias_name = format_ident!("{}Ctx", machine_name); + let sender_name = format_ident!("{}Sender", machine_name); + let ctx_ty = &ir.context_ty; + let ev_ty = &ir.event_ty; + + let n_states = ir.states.len(); + let n_actions = ir.actions.len(); + let n_dur_triggers = ir.duration_triggers.len(); + let max_timers = n_dur_triggers.max(1); + + // Public state enum — only user-declared states (skip __Root). + let state_variants: Vec<_> = ir + .states + .iter() + .filter(|s| s.parent.is_some()) + .map(|s| s.name.clone()) + .collect(); + + let entries_of_arms: Vec<_> = ir.states.iter().map(|s| { + let sid = s.id; + let entries_tok: Vec<_> = s.entries.iter().map(|a| quote! { #a }).collect(); + quote! { #sid => &[#(#entries_tok),*], } + }).collect(); + let exits_of_arms: Vec<_> = ir.states.iter().map(|s| { + let sid = s.id; + let exits_tok: Vec<_> = s.exits.iter().map(|a| quote! { #a }).collect(); + quote! { #sid => &[#(#exits_tok),*], } + }).collect(); + + let parent_table = ir.states.iter().map(|s| match s.parent { + Some(p) => quote! { Some(#p) }, + None => quote! { None }, + }).collect::>(); + let depth_table = ir.states.iter().map(|s| { + let d = s.depth; + quote! { #d } + }).collect::>(); + let default_child_table = ir.states.iter().map(|s| match s.default_child { + Some(c) => quote! { Some(#c) }, + None => quote! { None }, + }).collect::>(); + let state_id_to_variant_arms = ir.states.iter().filter(|s| s.parent.is_some()).map(|s| { + let id = s.id; + let nm = &s.name; + quote! { #id => #state_enum_name::#nm, } + }).collect::>(); + // Trait methods, possibly with typed params for payload-bearing actions. + let action_methods_sync = ir.actions.iter().map(|a| { + let name = &a.name; + let params = a.params.iter().map(|f| { + let n = &f.name; + let t = &f.ty; + quote! { #n: #t } + }); + quote! { fn #name(&mut self, #(#params),*); } + }).collect::>(); + let action_methods_async = ir.actions.iter().map(|a| { + let name = &a.name; + let params = a.params.iter().map(|f| { + let n = &f.name; + let t = &f.ty; + quote! { #n: #t } + }); + quote! { + fn #name(&mut self, #(#params),*) -> impl ::core::future::Future; + } + }).collect::>(); + + // Dispatch arms: for unit-signature actions call straight through; for + // payload-bearing actions, re-match on the passed event and destructure. + // Payload dispatch binds each field by reference (scrutinee is `&Ev`) + // then `.clone()`s it to produce an owned value matching the handler's + // declared signature. For `Copy` types this optimizes away. + let run_action_arms_sync = ir.actions.iter().enumerate().map(|(i, a)| { + let i = i as u16; + let name = &a.name; + if a.params.is_empty() { + quote! { #i => ::#name(ctx), } + } else { + let field_idents: Vec<_> = a.params.iter().map(|f| f.name.clone()).collect(); + let clone_args = field_idents.iter().map(|n| quote! { #n.clone() }); + let variant_arms = a.bound_variants.iter().map(|bv| { + let vname = &bv.name; + let binds = &field_idents; + let call = { + let clones = clone_args.clone(); + quote! { ::#name(ctx, #(#clones),*); } + }; + match bv.kind { + PayloadKind::Tuple => quote! { + #ev_ty::#vname(#(#binds),*) => { #call } + }, + PayloadKind::Struct => quote! { + #ev_ty::#vname { #(#binds),* } => { #call } + }, + } + }); + quote! { + #i => { + if let Some(__ev) = __event { + match __ev { + #(#variant_arms)* + _ => {} + } + } + } + } + } + }).collect::>(); + let run_action_arms_async = ir.actions.iter().enumerate().map(|(i, a)| { + let i = i as u16; + let name = &a.name; + if a.params.is_empty() { + quote! { #i => ::#name(ctx).await, } + } else { + let field_idents: Vec<_> = a.params.iter().map(|f| f.name.clone()).collect(); + let clone_args = field_idents.iter().map(|n| quote! { #n.clone() }); + let variant_arms = a.bound_variants.iter().map(|bv| { + let vname = &bv.name; + let binds = &field_idents; + let call = { + let clones = clone_args.clone(); + quote! { ::#name(ctx, #(#clones),*).await; } + }; + match bv.kind { + PayloadKind::Tuple => quote! { + #ev_ty::#vname(#(#binds),*) => { #call } + }, + PayloadKind::Struct => quote! { + #ev_ty::#vname { #(#binds),* } => { #call } + }, + } + }); + quote! { + #i => { + if let Some(__ev) = __event { + match __ev { + #(#variant_arms)* + _ => {} + } + } + } + } + } + }).collect::>(); + let duration_expr_arms = ir.duration_triggers.iter().enumerate().map(|(i, d)| { + let i = i as u16; + let expr = &d.expr; + quote! { #i => { let __d: ::hsmc::Duration = #expr; __d }, } + }).collect::>(); + let duration_repeat_arms = ir.duration_triggers.iter().enumerate().map(|(i, d)| { + let i = i as u16; + let repeat = d.repeat; + quote! { #i => #repeat, } + }).collect::>(); + let event_variant_arms = ir.event_variants.iter().enumerate().map(|(i, v)| { + let i = i as u16; + let name = &v.name; + let pat = match v.kind { + VariantKind::Unit => quote! { #ev_ty::#name }, + VariantKind::Tuple => quote! { #ev_ty::#name(..) }, + VariantKind::Struct => quote! { #ev_ty::#name { .. } }, + }; + quote! { #pat => #i, } + }).collect::>(); + let state_handler_arms = ir.states.iter().map(|s| { + use std::collections::BTreeMap; + #[derive(Default)] + struct Group { action_ids: Vec, transition_target: Option } + let mut groups: BTreeMap<(u8, u16), Group> = BTreeMap::new(); + let mut handlers = s.handlers.iter().collect::>(); + handlers.sort_by_key(|h| h.decl_index); + for h in handlers { + let key = match &h.trigger { + TriggerIr::Event(id, _, _) => (0u8, *id), + TriggerIr::Duration(id) => (1u8, *id), + }; + let g = groups.entry(key).or_default(); + match &h.kind { + HandlerKindIr::Action(aid, _) => g.action_ids.push(*aid), + HandlerKindIr::Transition(_, Some(tid)) => g.transition_target = Some(*tid), + HandlerKindIr::Transition(_, None) => {} + } + } + let sid = s.id; + let inner_arms = groups.iter().map(|((kind, tid), g)| { + let kind = *kind; + let tid = *tid; + let actions = g.action_ids.iter().map(|a| quote! { #a }); + let transition_tok = match g.transition_target { + Some(t) => quote! { Some(#t) }, + None => quote! { None }, + }; + quote! { + (#kind, #tid) => { + let actions: &'static [u16] = &[#(#actions),*]; + let target: Option = #transition_tok; + return Some((actions, target)); + } + } + }).collect::>(); + quote! { + #sid => { + match (trigger_kind, trigger_id) { + #(#inner_arms)* + _ => {} + } + } + } + }).collect::>(); + let owned_timers_arms = ir.states.iter().map(|s| { + let sid = s.id; + let ids = s.owned_timers.iter().map(|t| quote! { #t }); + quote! { #sid => &[#(#ids),*], } + }).collect::>(); + let terminate_match = if let Some(tev) = &ir.terminate_event { + // Use the kind recorded for this variant (unit/tuple/struct). + let kind = ir + .event_variants + .iter() + .find(|v| v.name == *tev) + .map(|v| v.kind) + .unwrap_or(VariantKind::Unit); + let pat = match kind { + VariantKind::Unit => quote! { #ev_ty::#tev }, + VariantKind::Tuple => quote! { #ev_ty::#tev(..) }, + VariantKind::Struct => quote! { #ev_ty::#tev { .. } }, + }; + quote! { + if matches!(__ev, #pat) { return TerminateCheck::Yes; } + } + } else { + quote! {} + }; + + let _ = n_actions; + + // Doc strings as literals for quoting. + let state_enum_doc = format!( + "Enum of every state declared in the `{}` statechart. Each variant names a user-declared state; the root is implicit and never surfaced.", + machine_name + ); + let actions_trait_doc = format!( + "Trait implemented by the user to provide action function bodies for the `{}` statechart. One method per unique action name.", + machine_name + ); + let action_ctx_doc = format!( + "Wrapper passed to action methods of the `{}` statechart. `Deref`s to the user's context and exposes [`emit()`](#method.emit) for re-entrant event emission (§4.3).", + machine_name + ); + let ctx_alias_doc = format!( + "Convenience type alias for [`{}ActionContext`] (spec §12.4).", + machine_name + ); + let machine_doc = format!( + "The `{}` state machine. Drive it with [`step()`](Self::step) or, under an async feature, `run().await`. See the crate docs for the generated API (§4.4).", + machine_name + ); + let sender_doc = format!( + "Handle for sending events into a running `{}` from other threads, tasks, or ISRs (§4.5).", + machine_name + ); + + // Optional-events branch: decide whether to emit Sender / sender() / channel + // plumbing at all. When events: was omitted the machine is timer-only and + // has no Sender type. + let emit_sender = !ir.events_omitted; + + // Synthesize the empty event type when events: was omitted. + let no_events_enum = if ir.events_omitted { + quote! { + #[doc(hidden)] + #[allow(dead_code)] + pub enum __NoEvents {} + } + } else { + quote! {} + }; + + // Race-loop match arms for each run() variant. When the active state has + // `during:` activities on its path, the arm constructs those futures by + // split-borrowing context fields and races them against the channel and + // next-timer sleep via `tokio::select!` / `embassy_futures::select`. + let tokio_race_arms_with_ch = emit_tokio_race_arms_with_channel(ir); + let tokio_race_arms_timer_only = emit_tokio_race_arms_timer_only(ir); + let embassy_race_arms_with_ch = emit_embassy_race_arms_with_channel(ir); + let embassy_race_arms_timer_only = emit_embassy_race_arms_timer_only(ir); + + // Whether any state in the machine declares at least one `during:`. When + // false, run() uses the original v0.1 pre-during code path verbatim, + // which keeps backward compatibility bit-for-bit for machines that + // never opted into the new feature. + let has_any_durings = ir.states.iter().any(|s| !s.durings.is_empty()); + + // The tokio with-channel run() body. v0.1 verbatim when no durings are + // declared; otherwise a per-state match dispatching to `select!` with + // the appropriate active during set. + let tokio_run_with_ch_body = if has_any_durings { + quote! { + #[allow(dead_code)] + enum __HsmcRace { Event(E), ChannelClosed, Timer } + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::tokio::time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + continue; + } + let sleep_dur = next.unwrap_or(::hsmc::Duration::from_secs(3600)); + let race: __HsmcRace<#ev_ty> = match self.current { + #(#tokio_race_arms_with_ch)* + _ => { + let sleep = ::tokio::time::sleep(sleep_dur); + ::tokio::pin!(sleep); + let rx = self.__tokio_rx.as_mut().expect("hsmc: tokio channel"); + ::tokio::select! { + biased; + maybe = rx.recv() => match maybe { + Some(ev) => __HsmcRace::Event(ev), + None => __HsmcRace::ChannelClosed, + }, + _ = &mut sleep => __HsmcRace::Timer, + } + } + }; + match race { + __HsmcRace::Event(ev) => { let _ = self.queue.push_back(ev); } + __HsmcRace::ChannelClosed => { if next.is_none() { break; } } + __HsmcRace::Timer => {} + } + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + } + Ok(()) + } + } else { + quote! { + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::tokio::time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + continue; + } + let sleep_dur = next.unwrap_or(::hsmc::Duration::from_secs(3600)); + let sleep = ::tokio::time::sleep(sleep_dur); + ::tokio::pin!(sleep); + let rx = self.__tokio_rx.as_mut().expect("tokio channel"); + ::tokio::select! { + maybe_ev = rx.recv() => { + match maybe_ev { + Some(ev) => { let _ = self.queue.push_back(ev); } + None => { + if next.is_none() { break; } + } + } + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + } + _ = &mut sleep => { + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + } + } + } + Ok(()) + } + }; + + // The embassy with-channel run() body. v0.1 verbatim when no durings + // are declared; otherwise a per-state match dispatching to + // embassy_futures::select_N with the appropriate active during set. + let embassy_run_with_ch_body = if has_any_durings { + quote! { + #[allow(dead_code)] + enum __HsmcRace { Event(E), ChannelClosed, Timer } + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::embassy_time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + continue; + } + let race: __HsmcRace<#ev_ty> = match self.current { + #(#embassy_race_arms_with_ch)* + _ => { + let timer_fut = async { + match next { + Some(d) => { + ::embassy_time::Timer::after( + ::embassy_time::Duration::from_micros(d.as_micros() as u64) + ).await; + } + None => ::core::future::pending::<()>().await, + } + }; + let rx = self.__embassy_rx.as_ref().expect("hsmc: embassy channel"); + match ::embassy_futures::select::select(rx.receive(), timer_fut).await { + ::embassy_futures::select::Either::First(ev) => __HsmcRace::Event(ev), + ::embassy_futures::select::Either::Second(()) => __HsmcRace::Timer, + } + } + }; + match race { + __HsmcRace::Event(ev) => { let _ = self.queue.push_back(ev); } + __HsmcRace::ChannelClosed => {} + __HsmcRace::Timer => {} + } + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + } + Ok(()) + } + } else { + quote! { + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let rx = self.__embassy_rx + .expect("hsmc: embassy channel (populated by Machine::new)"); + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::embassy_time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + continue; + } + let timer_fut = async { + match next { + Some(d) => { + ::embassy_time::Timer::after( + ::embassy_time::Duration::from_micros(d.as_micros() as u64) + ).await; + } + None => ::core::future::pending::<()>().await, + } + }; + match ::embassy_futures::select::select(rx.receive(), timer_fut).await { + ::embassy_futures::select::Either::First(ev) => { + let _ = self.queue.push_back(ev); + } + ::embassy_futures::select::Either::Second(()) => {} + } + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + } + Ok(()) + } + }; + + // State ids whose active `during:` chain is non-empty. Used by timer-only + // run() bodies to decide whether to exit when no timers are pending: if + // the current state has an active during, the run loop should keep + // awaiting rather than break (the during might yet produce an event). + let states_with_active_durings: Vec = active_durings_per_leaf(ir) + .into_iter() + .map(|(sid, _)| sid) + .collect(); + let has_active_during_arms: Vec = states_with_active_durings + .iter() + .map(|sid| quote! { Some(#sid) => true, }) + .collect(); + + // Human-readable ASCII diagram of the machine's hierarchy. Emitted as a + // `pub const STATE_CHART: &str` on the machine struct. Useful for + // `defmt::info!`, panic messages, and documentation. + let state_chart_str = emit_state_chart_string(ir); + + // Sender / sender() under tokio. With eager channel creation in + // `with_queue_capacity_internal()`, `sender()` is `&self`. + let tokio_sender_impl = if emit_sender { + quote! { + #[cfg(feature = "tokio")] + #[doc = #sender_doc] + #[derive(Clone)] + pub struct #sender_name { + tx: ::tokio::sync::mpsc::UnboundedSender<#ev_ty>, + } + #[cfg(feature = "tokio")] + impl #sender_name { + /// Send an event to the machine. Returns `Err(AlreadyTerminated)` if + /// the machine has terminated and the receiver has been dropped. + pub fn send(&self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + self.tx.send(event).map_err(|_| ::hsmc::HsmcError::AlreadyTerminated) + } + } + + #[cfg(feature = "tokio")] + impl #machine_name<__QN> { + /// Obtain a clonable, `Send` handle for pushing events into this + /// machine. The channel is eagerly created in `new()` (§4.4). + pub fn sender(&self) -> #sender_name { + #sender_name { tx: self.__tokio_tx.as_ref().expect("hsmc: tokio channel").clone() } + } + + /// Run the machine to completion on the current Tokio runtime. + pub async fn run(&mut self) -> ::core::result::Result<(), ::hsmc::HsmcError> { + #tokio_run_with_ch_body + } + } + } + } else { + // Timer-only: no Sender, no tokio channel, but keep a timer-only run(). + quote! { + #[cfg(feature = "tokio")] + impl #machine_name<__QN> { + /// Run the machine to completion, honouring timers and + /// `during:` activities. No event channel is generated + /// because this statechart declares no `events:` (§12.2). + pub async fn run(&mut self) -> ::core::result::Result<(), ::hsmc::HsmcError> { + #[allow(dead_code)] + enum __HsmcRace { Event(E), ChannelClosed, Timer } + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::tokio::time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + continue; + } + let sleep_dur = match next { + Some(d) => d, + None => { + // No timers pending. If the current state + // has no `during:` activities either, there + // is nothing left to wait on — exit. + let has_any_during: bool = match self.current { + #(#has_active_during_arms)* + _ => false, + }; + if !has_any_during { break; } + ::hsmc::Duration::from_secs(3600) + } + }; + let race: __HsmcRace<#ev_ty> = match self.current { + #(#tokio_race_arms_timer_only)* + _ => { + ::tokio::time::sleep(sleep_dur).await; + __HsmcRace::Timer + } + }; + match race { + __HsmcRace::Event(ev) => { let _ = self.queue.push_back(ev); } + __HsmcRace::ChannelClosed => {} + __HsmcRace::Timer => {} + } + let now = ::tokio::time::Instant::now(); + let elapsed = now.duration_since(last_instant); + last_instant = now; + next = self.step(elapsed).await; + } + Ok(()) + } + } + } + }; + + // Default (no feature): stub sender that documents the limitation. We + // intentionally keep it non-functional — a proper `Send + Clone` handle + // without `alloc` or a platform primitive is out of scope for v0.1. See + // the `tokio` or `embassy` features for a working `sender()`. + let default_sender_impl = if emit_sender { + quote! { + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + #[doc = #sender_doc] + /// + /// **Limitation:** without the `tokio` or `embassy` feature, the + /// default sender is a non-functional stub and + /// [`send()`](Self::send) always returns + /// [`HsmcError::AlreadyTerminated`]. Use the `tokio` or `embassy` + /// feature to get a working cross-thread sender. + #[derive(Clone)] + pub struct #sender_name { _priv: () } + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + impl #sender_name { + /// Always returns [`HsmcError::AlreadyTerminated`] under + /// default features — enable `tokio` or `embassy` for a + /// functional implementation. + pub fn send(&self, _event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + Err(::hsmc::HsmcError::AlreadyTerminated) + } + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + impl #machine_name<__QN> { + /// Non-functional stub under default features — see + /// [`#sender_name`] for the limitation. + pub fn sender(&self) -> #sender_name { #sender_name { _priv: () } } + } + } + } else { + quote! {} + }; + + // Embassy: first-class support. `Machine::new(ctx, &'static CHAN)` stores + // type-erased send/receive endpoints so the Machine struct stays free of + // a `M: RawMutex` generic. `sender()` hands out a `Copy + Send + Sync` + // newtype usable from ISRs (`try_send`) and tasks (`send().await`). + let send_assert_name = format_ident!("__hsmc_embassy_ev_send_assert_{}", machine_name); + let embassy_sender_impl = if emit_sender { + quote! { + // Compile-time assertion: the event type must be `Send` under the + // embassy feature (its sender handle is `Send + Sync`, which + // requires `E: Send`). Fails with a pointer to `#ev_ty`. + #[cfg(feature = "embassy")] + #[allow(dead_code, non_upper_case_globals)] + const #send_assert_name: fn() = || { + fn __assert_send() {} + __assert_send::<#ev_ty>(); + }; + + #[cfg(feature = "embassy")] + #[doc = #sender_doc] + /// + /// `Copy + Send + Sync`-able handle around a + /// [`embassy_sync::channel::SendDynamicSender`]. Safe to capture in + /// a `static` for ISR use and to clone across Embassy tasks. + #[derive(Clone, Copy)] + pub struct #sender_name { + inner: ::embassy_sync::channel::SendDynamicSender<'static, #ev_ty>, + } + #[cfg(feature = "embassy")] + impl #sender_name { + /// Non-blocking send. Returns + /// [`HsmcError::QueueFull`] if the channel has no room. Safe + /// from ISR context when the channel is backed by + /// `CriticalSectionRawMutex`. + #[track_caller] + pub fn try_send(self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + self.inner.try_send(event).map_err(|_| ::hsmc::HsmcError::QueueFull) + } + /// Async send. Backpressures (awaits) when the channel is + /// full. Use from Embassy tasks that must not drop events. + pub async fn send(self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + self.inner.send(event).await; + Ok(()) + } + /// Build a sender directly from a user-declared static + /// channel — convenient for ISR shims that run before the + /// machine task has spawned. The channel's event type and + /// capacity must match the machine. + pub fn from_channel<__M, const __N: usize>( + channel: &'static ::embassy_sync::channel::Channel<__M, #ev_ty, __N>, + ) -> Self + where + __M: ::embassy_sync::blocking_mutex::raw::RawMutex + + ::core::marker::Sync + + ::core::marker::Send + + 'static, + #ev_ty: ::core::marker::Send, + { + Self { inner: channel.sender().into() } + } + } + + #[cfg(feature = "embassy")] + impl #machine_name<8> { + /// Construct a new machine with the default event-queue + /// capacity of 8, bound to a user-declared static + /// `embassy_sync::channel::Channel`. The channel's capacity + /// must match the machine's `__QN` — here, 8. + pub fn new<__M>( + ctx: #ctx_ty, + channel: &'static ::embassy_sync::channel::Channel<__M, #ev_ty, 8>, + ) -> Self + where + __M: ::embassy_sync::blocking_mutex::raw::RawMutex + + ::core::marker::Sync + + ::core::marker::Send + + 'static, + #ev_ty: ::core::marker::Send, + { + let mut __self = <#machine_name<8>>::with_queue_capacity_internal(ctx); + __self.__embassy_rx = Some(channel.receiver().into()); + __self.__embassy_tx = Some(channel.sender().into()); + __self + } + /// Construct a machine with a custom event-queue capacity + /// `__N`. The channel's capacity must also be `__N`. + pub fn with_queue_capacity<__M, const __N: usize>( + ctx: #ctx_ty, + channel: &'static ::embassy_sync::channel::Channel<__M, #ev_ty, __N>, + ) -> #machine_name<__N> + where + __M: ::embassy_sync::blocking_mutex::raw::RawMutex + + ::core::marker::Sync + + ::core::marker::Send + + 'static, + #ev_ty: ::core::marker::Send, + { + let mut __self = <#machine_name<__N>>::with_queue_capacity_internal(ctx); + __self.__embassy_rx = Some(channel.receiver().into()); + __self.__embassy_tx = Some(channel.sender().into()); + __self + } + /// Construct a machine that is driven only via `send()` + + /// `step().await`. No external `Channel` is wired up, so + /// [`sender()`](Self::sender) and [`run()`](Self::run) + /// panic if called. Use this when the single task that + /// owns the machine is also its only event producer — + /// the internal queue is sufficient and a + /// `&'static Channel` would be dead weight. + pub fn new_local(ctx: #ctx_ty) -> Self { + <#machine_name<8>>::with_queue_capacity_internal(ctx) + } + } + + #[cfg(feature = "embassy")] + impl #machine_name<__QN> { + /// Channel-less variant of + /// [`with_queue_capacity`](Self::with_queue_capacity) + /// — see [`new_local`](Self::new_local). + pub fn with_queue_capacity_local(ctx: #ctx_ty) -> #machine_name<__QN> { + <#machine_name<__QN>>::with_queue_capacity_internal(ctx) + } + } + + #[cfg(feature = "embassy")] + impl #machine_name<__QN> { + /// Obtain a clonable, `Send` handle for pushing events into + /// this machine from ISRs or other Embassy tasks. The channel + /// was provided at construction. + pub fn sender(&self) -> #sender_name { + #sender_name { + inner: self.__embassy_tx + .expect("hsmc: embassy channel (populated by Machine::new)"), + } + } + + /// Run the machine to completion, awaiting events from the + /// bound channel, firing timers via `embassy_time`, and + /// polling any active `during:` activities for the current + /// state path. Returns `Ok(())` on clean termination; + /// `Err(HsmcError::QueueFull)` if an action's `emit()` + /// overflowed the internal queue. + pub async fn run(&mut self) -> ::core::result::Result<(), ::hsmc::HsmcError> { + #embassy_run_with_ch_body + } + } + } + } else { + // Timer-only machines (no `events:` declared) have no channel. Keep + // the existing timer-driven run(). + quote! { + #[cfg(feature = "embassy")] + impl #machine_name<8> { + /// Construct a timer-only machine (no `events:` declared). + pub fn new(ctx: #ctx_ty) -> Self { + <#machine_name<8>>::with_queue_capacity_internal(ctx) + } + /// Construct a timer-only machine with custom queue capacity. + pub fn with_queue_capacity(ctx: #ctx_ty) -> #machine_name<__N> { + <#machine_name<__N>>::with_queue_capacity_internal(ctx) + } + } + + #[cfg(feature = "embassy")] + impl #machine_name<__QN> { + /// Timer-only run loop (no `events:` declared) using + /// `embassy_time::Timer`, plus any `during:` activities + /// declared on the active state path. + pub async fn run(&mut self) -> ::core::result::Result<(), ::hsmc::HsmcError> { + #[allow(dead_code)] + enum __HsmcRace { Event(E), ChannelClosed, Timer } + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + let mut next = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + let mut last_instant = ::embassy_time::Instant::now(); + while !self.terminated { + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if !self.queue.is_empty() { + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + continue; + } + // No queued events. Decide whether to wait. + let has_any_during: bool = match self.current { + #(#has_active_during_arms)* + _ => false, + }; + if next.is_none() && !has_any_during { break; } + let race: __HsmcRace<#ev_ty> = match self.current { + #(#embassy_race_arms_timer_only)* + _ => { + // No durings active; just await the timer. + match next { + Some(d) => { + ::embassy_time::Timer::after( + ::embassy_time::Duration::from_micros(d.as_micros() as u64) + ).await; + } + None => ::core::future::pending::<()>().await, + } + __HsmcRace::Timer + } + }; + match race { + __HsmcRace::Event(ev) => { let _ = self.queue.push_back(ev); } + __HsmcRace::ChannelClosed => {} + __HsmcRace::Timer => {} + } + let now = ::embassy_time::Instant::now(); + let elapsed_us = now.duration_since(last_instant).as_micros(); + last_instant = now; + next = self.step(::hsmc::Duration::from_micros(elapsed_us)).await; + } + Ok(()) + } + } + } + }; + + // Embassy stored receiver/sender fields. Populated at construction time + // when the user provides an `&'static embassy_sync::channel::Channel`. + let embassy_fields = if emit_sender { + quote! { + #[cfg(feature = "embassy")] + __embassy_rx: Option<::embassy_sync::channel::SendDynamicReceiver<'static, #ev_ty>>, + #[cfg(feature = "embassy")] + __embassy_tx: Option<::embassy_sync::channel::SendDynamicSender<'static, #ev_ty>>, + } + } else { + quote! {} + }; + let embassy_ctor = if emit_sender { + quote! { + #[cfg(feature = "embassy")] + __embassy_rx: None, + #[cfg(feature = "embassy")] + __embassy_tx: None, + } + } else { + quote! {} + }; + + // Eager tokio channel fields. Always emitted when `tokio` feature is + // compiled in AND this machine has events. + let tokio_fields = if emit_sender { + quote! { + #[cfg(feature = "tokio")] + __tokio_tx: Option<::tokio::sync::mpsc::UnboundedSender<#ev_ty>>, + #[cfg(feature = "tokio")] + __tokio_rx: Option<::tokio::sync::mpsc::UnboundedReceiver<#ev_ty>>, + } + } else { + quote! {} + }; + let tokio_ctor = if emit_sender { + quote! { + #[cfg(feature = "tokio")] + __tokio_tx: { let (tx, _rx) = ::tokio::sync::mpsc::unbounded_channel(); Some(tx) }, + #[cfg(feature = "tokio")] + __tokio_rx: None, + } + } else { + quote! {} + }; + // We need to create tx+rx together; do it via a small helper. + let tokio_ctor_setup = if emit_sender { + quote! { + #[cfg(feature = "tokio")] + { + let (tx, rx) = ::tokio::sync::mpsc::unbounded_channel(); + __self.__tokio_tx = Some(tx); + __self.__tokio_rx = Some(rx); + } + } + } else { + quote! {} + }; + + quote! { + #no_events_enum + + #[doc = #state_enum_doc] + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[allow(non_camel_case_types, dead_code)] + pub enum #state_enum_name { + #(#state_variants),* + } + + #[doc = #actions_trait_doc] + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + #[allow(non_snake_case)] + pub trait #actions_trait_name { + #(#action_methods_sync)* + } + + #[doc = #actions_trait_doc] + /// + /// Under the `tokio` or `embassy` feature, action methods are + /// `async fn` so that entry/exit/event-action bodies can `.await` + /// peripheral calls (I2C flush, radio ops, etc.). Returned futures + /// must be `Send` for compatibility with multi-threaded / Send + /// executors. + #[cfg(any(feature = "tokio", feature = "embassy"))] + #[allow(non_snake_case)] + pub trait #actions_trait_name { + #(#action_methods_async)* + } + + #[doc = #action_ctx_doc] + pub struct #action_ctx_name<'a> { + ctx: &'a mut #ctx_ty, + __queue: &'a mut (dyn ::hsmc::__private::QueuePush<#ev_ty> + ::core::marker::Send), + __terminated: &'a bool, + } + + #[doc = #ctx_alias_doc] + pub type #ctx_alias_name<'a> = #action_ctx_name<'a>; + + impl<'a> ::core::ops::Deref for #action_ctx_name<'a> { + type Target = #ctx_ty; + fn deref(&self) -> &#ctx_ty { self.ctx } + } + + impl<'a> ::core::ops::DerefMut for #action_ctx_name<'a> { + fn deref_mut(&mut self) -> &mut #ctx_ty { self.ctx } + } + + impl<'a> #action_ctx_name<'a> { + /// Push an event into the internal queue. It will be processed + /// after the current event's handling completes (§2.12). Returns + /// [`HsmcError::QueueFull`] if the queue is full, or + /// [`HsmcError::AlreadyTerminated`] if the machine is shutting + /// down. + pub fn emit(&mut self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + if *self.__terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + self.__queue.push(event) + } + + /// Convenience wrapper around [`emit()`](Self::emit) that panics on + /// failure (§12.1). Use in contexts where a full queue is a bug + /// rather than a recoverable condition. + pub fn emit_or_panic(&mut self, event: #ev_ty) { + self.emit(event).expect("hsmc: event queue full"); + } + } + + #[doc = #machine_doc] + #[allow(non_snake_case, non_camel_case_types)] + pub struct #machine_name { + ctx: #ctx_ty, + current: Option, + queue: ::hsmc::__private::heapless::Deque<#ev_ty, __QN>, + timers: ::hsmc::__private::TimerTable<#max_timers>, + terminated: bool, + __overflow: bool, + #tokio_fields + #embassy_fields + } + + const __PARENT: [Option; #n_states] = [#(#parent_table),*]; + const __DEPTH: [u8; #n_states] = [#(#depth_table),*]; + const __DEFAULT_CHILD: [Option; #n_states] = [#(#default_child_table),*]; + + #[allow(dead_code, unreachable_patterns)] + fn __state_id_to_public(id: u16) -> #state_enum_name { + match id { + #(#state_id_to_variant_arms)* + _ => panic!("internal: state id {} has no public variant", id), + } + } + + #[allow(unreachable_code, unused_variables)] + fn __duration_for(tid: u16) -> ::hsmc::Duration { + match tid { + #(#duration_expr_arms)* + _ => ::hsmc::Duration::ZERO, + } + } + + #[allow(unreachable_code, unused_variables)] + fn __duration_repeats(tid: u16) -> bool { + match tid { + #(#duration_repeat_arms)* + _ => false, + } + } + + #[allow(unreachable_patterns, unused_variables)] + fn __event_to_id(ev: &#ev_ty) -> u16 { + match ev { + #(#event_variant_arms)* + _ => u16::MAX, + } + } + + #[allow(unused_variables)] + fn __handler_lookup( + state_id: u16, + trigger_kind: u8, + trigger_id: u16, + ) -> Option<(&'static [u16], Option)> { + match state_id { + #(#state_handler_arms)* + _ => {} + } + None + } + + #[allow(unused_variables)] + fn __owned_timers(state_id: u16) -> &'static [u16] { + match state_id { + #(#owned_timers_arms)* + _ => &[], + } + } + + enum TerminateCheck { Yes, No } + + #[allow(unused_variables)] + fn __check_terminate(__ev: &#ev_ty) -> TerminateCheck { + #terminate_match + TerminateCheck::No + } + + impl #machine_name<8> { + /// Construct a new machine with the default event-queue capacity of 8 (§4.4). + #[cfg(not(feature = "embassy"))] + pub fn new(ctx: #ctx_ty) -> Self { + <#machine_name<8>>::with_queue_capacity_internal(ctx) + } + /// Construct a machine with a custom event-queue capacity `__N` (§5.2). + #[cfg(not(feature = "embassy"))] + pub fn with_queue_capacity(ctx: #ctx_ty) -> #machine_name<__N> { + <#machine_name<__N>>::with_queue_capacity_internal(ctx) + } + } + + impl #machine_name<__QN> { + /// ASCII diagram of the state hierarchy, default children, entry/ + /// exit actions, `during:` activities, and handler triggers. + /// Intended for `defmt::info!`, panic messages, and docs. + /// + /// Accessible as `Self::STATE_CHART` from any impl block on the + /// machine; externally requires the turbofish (for example + /// `Machine::<8>::STATE_CHART`) since the machine carries a const + /// generic queue capacity. + pub const STATE_CHART: &'static str = #state_chart_str; + + fn with_queue_capacity_internal(ctx: #ctx_ty) -> Self { + #[allow(unused_mut)] + let mut __self = Self { + ctx, + current: None, + queue: ::hsmc::__private::heapless::Deque::new(), + timers: ::hsmc::__private::TimerTable::new(), + terminated: false, + __overflow: false, + #tokio_ctor + #embassy_ctor + }; + #tokio_ctor_setup + __self + } + + /// Returns and clears the internal-emit overflow flag. Set by + /// the generated action dispatch when `emit()` inside an action + /// fails to enqueue — `run()` consults this to surface + /// [`HsmcError::QueueFull`] out of the async loop rather than + /// silently dropping the event. Exposed for callers driving the + /// machine via `step()` directly. + pub fn take_overflow(&mut self) -> bool { + let v = self.__overflow; + self.__overflow = false; + v + } + + /// Returns `true` once the machine has processed its `terminate` event (§4.4). + pub fn is_terminated(&self) -> bool { self.terminated } + + /// Return the current leaf state. Panics if called before the + /// first `step()` (§4.4). + pub fn current_state(&self) -> #state_enum_name { + __state_id_to_public(self.current.expect("machine not started; call step() first")) + } + + /// Returns `true` when internal event queue has pending events + /// (§12.3). Useful for schedulers that want to poll `step()` + /// again eagerly. + pub fn has_pending_events(&self) -> bool { + !self.queue.is_empty() + } + + /// Consume the machine and yield the user-provided context (§4.4). + pub fn into_context(self) -> #ctx_ty { self.ctx } + + /// Borrow the user-provided context (extension beyond spec §4.4; provided for test convenience). + pub fn context(&self) -> &#ctx_ty { &self.ctx } + /// Mutably borrow the user-provided context (extension beyond spec §4.4; provided for test convenience). + pub fn context_mut(&mut self) -> &mut #ctx_ty { &mut self.ctx } + + /// Advance the machine by `elapsed` wall-clock time. Returns the + /// duration until the next timer fires, or `None` if the machine + /// has no live timers (§4.4). + /// + /// Under the `tokio` or `embassy` feature, `step()` is `async` + /// because action methods are `async fn` and must be `.await`ed + /// during dispatch. Under default features it stays sync. + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + pub fn step(&mut self, elapsed: ::hsmc::Duration) -> Option<::hsmc::Duration> { + if self.terminated { return None; } + + if self.current.is_none() { + self.enter_path(0); + return self.timers.min_remaining(); + } + + self.timers.decrement(elapsed); + + if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) { + // Re-arm repeating timers before dispatch. If the + // handler transitions out of `state`, the state's exit + // path will cancel the freshly-armed timer. + if __duration_repeats(trig) { + let d = __duration_for(trig); + self.timers.start(state, trig, d); + } + self.dispatch_trigger(state, 1, trig); + return self.timers.min_remaining(); + } + + if let Some(ev) = self.queue.pop_front() { + if matches!(__check_terminate(&ev), TerminateCheck::Yes) { + self.do_terminate(); + return None; + } + let eid = __event_to_id(&ev); + if eid != u16::MAX { + let mut cur = self.current; + while let Some(s) = cur { + if let Some((actions, target)) = __handler_lookup(s, 0, eid) { + self.run_handlers(s, actions, target, Some(&ev)); + break; + } + cur = __PARENT[s as usize]; + } + } + return self.timers.min_remaining(); + } + + self.timers.min_remaining() + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + pub async fn step(&mut self, elapsed: ::hsmc::Duration) -> Option<::hsmc::Duration> { + if self.terminated { return None; } + + if self.current.is_none() { + self.enter_path(0).await; + return self.timers.min_remaining(); + } + + self.timers.decrement(elapsed); + + if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) { + if __duration_repeats(trig) { + let d = __duration_for(trig); + self.timers.start(state, trig, d); + } + self.dispatch_trigger(state, 1, trig).await; + return self.timers.min_remaining(); + } + + if let Some(ev) = self.queue.pop_front() { + if matches!(__check_terminate(&ev), TerminateCheck::Yes) { + self.do_terminate().await; + return None; + } + let eid = __event_to_id(&ev); + if eid != u16::MAX { + let mut cur = self.current; + while let Some(s) = cur { + if let Some((actions, target)) = __handler_lookup(s, 0, eid) { + self.run_handlers(s, actions, target, Some(&ev)).await; + break; + } + cur = __PARENT[s as usize]; + } + } + return self.timers.min_remaining(); + } + + self.timers.min_remaining() + } + + /// Push an event directly into the machine's queue (synchronous, + /// single-threaded). Extension beyond spec §4.4 — the canonical + /// cross-thread path is [`sender().send()`](Self::sender). Useful + /// for tests and tight `step()` driver loops. + #[doc(hidden)] + pub fn send(&mut self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + self.queue.push_back(event).map_err(|_| ::hsmc::HsmcError::QueueFull) + } + + /// Push an event into the internal queue and drain the queue to + /// quiescence before returning. Under an async feature the step + /// loop awaits action futures; after `dispatch()` returns, + /// `current_state()` reflects the post-dispatch state. Use this + /// from the task that owns the machine when you want "event + /// processed" semantics (a drop-in replacement for the + /// `send + drain` pattern). For cross-task / ISR injection use + /// [`sender()`](Self::sender) instead. + #[cfg(any(feature = "tokio", feature = "embassy"))] + pub async fn dispatch(&mut self, event: #ev_ty) -> ::core::result::Result<(), ::hsmc::HsmcError> { + if self.terminated { return Err(::hsmc::HsmcError::AlreadyTerminated); } + self.queue.push_back(event).map_err(|_| ::hsmc::HsmcError::QueueFull)?; + // Prime state on first call. + if self.current.is_none() { + let _ = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if self.terminated { return Ok(()); } + } + while !self.queue.is_empty() { + let _ = self.step(::hsmc::Duration::ZERO).await; + if self.take_overflow() { return Err(::hsmc::HsmcError::QueueFull); } + if self.terminated { return Ok(()); } + } + Ok(()) + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn run_handlers( + &mut self, + source_state: u16, + actions: &'static [u16], + target: Option, + event: Option<&#ev_ty>, + ) { + for &aid in actions { + self.run_action(aid, event); + if self.terminated { return; } + } + if let Some(tgt) = target { + self.transition(source_state, tgt); + } + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn run_handlers( + &mut self, + source_state: u16, + actions: &'static [u16], + target: Option, + event: Option<&#ev_ty>, + ) { + for &aid in actions { + self.run_action(aid, event).await; + if self.terminated { return; } + } + if let Some(tgt) = target { + self.transition(source_state, tgt).await; + } + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn run_action(&mut self, aid: u16, event: Option<&#ev_ty>) { + let ctx = &mut self.ctx; + let mut __proxy = ::hsmc::__private::EmitProxy { + queue: &mut self.queue, + overflow: &mut self.__overflow, + }; + let queue: &mut (dyn ::hsmc::__private::QueuePush<#ev_ty> + ::core::marker::Send) = &mut __proxy; + let terminated = &self.terminated; + let mut actx = #action_ctx_name { + ctx, + __queue: queue, + __terminated: terminated, + }; + fn __dispatch( + ctx: &mut Ctx, + aid: u16, + __event: Option<&#ev_ty>, + ) { + let _ = __event; + match aid { + #(#run_action_arms_sync)* + _ => {} + } + } + __dispatch(&mut actx, aid, event); + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn run_action(&mut self, aid: u16, event: Option<&#ev_ty>) { + let ctx = &mut self.ctx; + let mut __proxy = ::hsmc::__private::EmitProxy { + queue: &mut self.queue, + overflow: &mut self.__overflow, + }; + let queue: &mut (dyn ::hsmc::__private::QueuePush<#ev_ty> + ::core::marker::Send) = &mut __proxy; + let terminated = &self.terminated; + let mut actx = #action_ctx_name { + ctx, + __queue: queue, + __terminated: terminated, + }; + async fn __dispatch( + ctx: &mut Ctx, + aid: u16, + __event: Option<&#ev_ty>, + ) { + let _ = __event; + match aid { + #(#run_action_arms_async)* + _ => {} + } + } + __dispatch(&mut actx, aid, event).await; + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn transition(&mut self, source_matched_state: u16, target: u16) { + let mut lca = Self::lca(source_matched_state, target); + if let Some(l) = lca { + if l == target { + lca = __PARENT[l as usize]; + } + } + if let Some(mut cur) = self.current { + loop { + if Some(cur) == lca { break; } + self.exit_state(cur); + match __PARENT[cur as usize] { + Some(p) => cur = p, + None => break, + } + } + } + let mut path: ::hsmc::__private::heapless::Vec = + ::hsmc::__private::heapless::Vec::new(); + let mut node = Some(target); + while node != lca && node.is_some() { + let n = node.unwrap(); + let _ = path.push(n); + node = __PARENT[n as usize]; + } + for i in (0..path.len()).rev() { + let s = path[i]; + self.enter_state_no_descent(s); + } + self.descend_defaults(target); + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn transition(&mut self, source_matched_state: u16, target: u16) { + let mut lca = Self::lca(source_matched_state, target); + if let Some(l) = lca { + if l == target { + lca = __PARENT[l as usize]; + } + } + if let Some(mut cur) = self.current { + loop { + if Some(cur) == lca { break; } + self.exit_state(cur).await; + match __PARENT[cur as usize] { + Some(p) => cur = p, + None => break, + } + } + } + let mut path: ::hsmc::__private::heapless::Vec = + ::hsmc::__private::heapless::Vec::new(); + let mut node = Some(target); + while node != lca && node.is_some() { + let n = node.unwrap(); + let _ = path.push(n); + node = __PARENT[n as usize]; + } + for i in (0..path.len()).rev() { + let s = path[i]; + self.enter_state_no_descent(s).await; + } + self.descend_defaults(target).await; + } + + fn lca(a: u16, b: u16) -> Option { + let mut set: ::hsmc::__private::heapless::Vec = + ::hsmc::__private::heapless::Vec::new(); + let mut cur = Some(a); + while let Some(x) = cur { + let _ = set.push(x); + cur = __PARENT[x as usize]; + } + let mut cur = Some(b); + while let Some(x) = cur { + if set.contains(&x) { return Some(x); } + cur = __PARENT[x as usize]; + } + None + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn enter_path(&mut self, root: u16) { + self.enter_state_no_descent(root); + self.descend_defaults(root); + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn enter_path(&mut self, root: u16) { + self.enter_state_no_descent(root).await; + self.descend_defaults(root).await; + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn enter_state_no_descent(&mut self, sid: u16) { + let entries = Self::entries_of(sid); + for &aid in entries { + self.run_action(aid, None); + if self.terminated { return; } + } + for &tid in __owned_timers(sid) { + let d = __duration_for(tid); + self.timers.start(sid, tid, d); + } + self.current = Some(sid); + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn enter_state_no_descent(&mut self, sid: u16) { + let entries = Self::entries_of(sid); + for &aid in entries { + self.run_action(aid, None).await; + if self.terminated { return; } + } + for &tid in __owned_timers(sid) { + let d = __duration_for(tid); + self.timers.start(sid, tid, d); + } + self.current = Some(sid); + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn descend_defaults(&mut self, sid: u16) { + let mut cur = sid; + while let Some(child) = __DEFAULT_CHILD[cur as usize] { + self.enter_state_no_descent(child); + cur = child; + } + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn descend_defaults(&mut self, sid: u16) { + let mut cur = sid; + while let Some(child) = __DEFAULT_CHILD[cur as usize] { + self.enter_state_no_descent(child).await; + cur = child; + } + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn exit_state(&mut self, sid: u16) { + self.timers.cancel_state(sid); + let exits = Self::exits_of(sid); + for &aid in exits { + self.run_action(aid, None); + if self.terminated { return; } + } + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn exit_state(&mut self, sid: u16) { + self.timers.cancel_state(sid); + let exits = Self::exits_of(sid); + for &aid in exits { + self.run_action(aid, None).await; + if self.terminated { return; } + } + } + + fn entries_of(sid: u16) -> &'static [u16] { + match sid { + #(#entries_of_arms)* + _ => &[], + } + } + fn exits_of(sid: u16) -> &'static [u16] { + match sid { + #(#exits_of_arms)* + _ => &[], + } + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn do_terminate(&mut self) { + if let Some(mut cur) = self.current { + loop { + self.exit_state(cur); + match __PARENT[cur as usize] { + Some(p) => cur = p, + None => break, + } + } + } + self.terminated = true; + self.current = None; + while self.queue.pop_front().is_some() {} + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn do_terminate(&mut self) { + if let Some(mut cur) = self.current { + loop { + self.exit_state(cur).await; + match __PARENT[cur as usize] { + Some(p) => cur = p, + None => break, + } + } + } + self.terminated = true; + self.current = None; + while self.queue.pop_front().is_some() {} + } + + #[cfg(not(any(feature = "tokio", feature = "embassy")))] + fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) { + if let Some((actions, target)) = __handler_lookup(state, kind, trig) { + // Timer-driven dispatch: no event payload in scope. + self.run_handlers(state, actions, target, None); + } + } + + #[cfg(any(feature = "tokio", feature = "embassy"))] + async fn dispatch_trigger(&mut self, state: u16, kind: u8, trig: u16) { + if let Some((actions, target)) = __handler_lookup(state, kind, trig) { + self.run_handlers(state, actions, target, None).await; + } + } + } + + #default_sender_impl + #tokio_sender_impl + #embassy_sender_impl + } +} + +#[allow(dead_code)] +fn _unused(_: Span, _: &Ident) {} diff --git a/hsmc-macros/src/ir.rs b/hsmc-macros/src/ir.rs new file mode 100644 index 0000000..0dacc5f --- /dev/null +++ b/hsmc-macros/src/ir.rs @@ -0,0 +1,529 @@ +//! Intermediate representation built from the parsed AST. + +use proc_macro2::Span; +use syn::{Expr, Ident, Type}; + +use crate::parse::{ + During, EventPayload, HandlerKind, PayloadField, PayloadKind, StateBody, StatechartInput, + Trigger, +}; + +pub struct Ir { + pub name: Ident, + pub context_ty: Type, + pub event_ty: Type, + /// True if the user omitted `events:` (timer-only machine). In that case + /// `event_ty` points to a synthesized empty enum and no `Sender` is emitted. + pub events_omitted: bool, + pub terminate_event: Option, + pub states: Vec, + /// Action ident → signature info (trait-method index = position in vec). + pub actions: Vec, + /// Event variants referenced anywhere, with the variant kind locked in + /// on first use (unit / tuple / struct) for correct pattern matching in + /// `__event_to_id`. + pub event_variants: Vec, + /// Duration triggers referenced anywhere, in first-seen order. + /// Each one gets a trigger id used by the timer table. + pub duration_triggers: Vec, +} + +pub struct ActionIr { + pub name: Ident, + /// Typed parameters bound from an event payload. Empty for + /// entry/exit/timer actions or actions triggered by unit-variant events. + pub params: Vec, + /// For param-bearing actions, every variant this action is registered + /// with (name + kind). Used by codegen to emit the dispatch match arm. + pub bound_variants: Vec, +} + +#[derive(Clone)] +pub struct BoundVariant { + pub name: Ident, + pub kind: PayloadKind, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum VariantKind { + Unit, + Tuple, + Struct, +} + +pub struct EventVariantIr { + pub name: Ident, + pub kind: VariantKind, + /// Number of fields in the variant (known only when the statechart + /// declares a payload binding; `None` if seen only as unit). + pub field_count: Option, +} + +pub struct DurationTrigger { + pub id: u16, + pub key: String, + pub expr: Expr, + pub repeat: bool, + pub span: Span, +} + +pub struct StateIr { + pub id: u16, + pub name: Ident, + pub parent: Option, + pub depth: u8, + pub default_child: Option, + pub entries: Vec, // action ids + pub exits: Vec, + pub handlers: Vec, + pub children: Vec, + /// Duration triggers owned by this state (trigger id). + pub owned_timers: Vec, + /// `during:` activities declared on this state, preserved in declaration + /// order. The macro emits these as concurrent `&mut`-field borrows in the + /// run loop's `select` call for any state path that includes this state. + pub durings: Vec, + pub span: Span, +} + +pub struct HandlerIr { + pub trigger: TriggerIr, + pub kind: HandlerKindIr, + pub decl_index: usize, +} + +#[derive(Clone)] +pub enum TriggerIr { + /// Event trigger. Payload is `Some` when the statechart declared typed + /// bindings at this handler site; the bindings feed the action's typed + /// handler signature. + Event(u16, Ident, Option), + Duration(u16), +} + +pub enum HandlerKindIr { + Transition(Ident, Option), + Action(u16, #[allow(dead_code)] Ident), +} + +pub fn build_ir(input: StatechartInput) -> syn::Result { + let name = input.name; + let root_body = input.body; + + let context_ty = root_body + .context_ty + .clone() + .ok_or_else(|| syn::Error::new(name.span(), "statechart missing `context:` declaration"))?; + let (event_ty, events_omitted) = match root_body.event_ty.clone() { + Some(t) => (t, false), + None => { + let ty: Type = syn::parse_quote! { __NoEvents }; + (ty, true) + } + }; + let terminate_event = root_body.terminate.as_ref().map(|(i, _)| i.clone()); + + let mut ir = Ir { + name, + context_ty, + event_ty, + events_omitted, + terminate_event, + states: Vec::new(), + actions: Vec::new(), + event_variants: Vec::new(), + duration_triggers: Vec::new(), + }; + + // Two passes: collect the set of declared state names first so + // `on(...) => Foo;` handlers can classify `Foo` as either a state + // (transition) or a handler fn (action) based on the declared set. + let mut state_names = std::collections::HashSet::new(); + collect_state_names(&root_body, &mut state_names); + + let name_span = ir.name.span(); + let root_name = Ident::new("__Root", name_span); + let root_id = alloc_state(&mut ir, root_name, None, 0, root_body.default_span.unwrap_or(name_span)); + lower_body(&mut ir, root_id, &root_body, true, &state_names)?; + + Ok(ir) +} + +fn collect_state_names( + body: &crate::parse::StateBody, + out: &mut std::collections::HashSet, +) { + for c in &body.children { + out.insert(c.name.to_string()); + collect_state_names(&c.body, out); + } +} + +fn alloc_state(ir: &mut Ir, name: Ident, parent: Option, depth: u8, span: Span) -> u16 { + let id = ir.states.len() as u16; + ir.states.push(StateIr { + id, + name, + parent, + depth, + default_child: None, + entries: Vec::new(), + exits: Vec::new(), + handlers: Vec::new(), + children: Vec::new(), + owned_timers: Vec::new(), + durings: Vec::new(), + span, + }); + id +} + +fn intern_action_unit(ir: &mut Ir, i: &Ident) -> syn::Result { + if let Some(p) = ir.actions.iter().position(|x| x.name == *i) { + if !ir.actions[p].params.is_empty() { + return Err(syn::Error::new( + i.span(), + format!( + "action `{}` was previously declared with typed event-payload \ + bindings; cannot also be used without bindings", + i + ), + )); + } + return Ok(p as u16); + } + ir.actions.push(ActionIr { + name: i.clone(), + params: Vec::new(), + bound_variants: Vec::new(), + }); + Ok((ir.actions.len() - 1) as u16) +} + +fn intern_action_with_params( + ir: &mut Ir, + i: &Ident, + params: &[PayloadField], + variant: &Ident, + variant_kind: PayloadKind, +) -> syn::Result { + if let Some(p) = ir.actions.iter().position(|x| x.name == *i) { + if ir.actions[p].params.is_empty() { + return Err(syn::Error::new( + i.span(), + format!( + "action `{}` was previously declared without bindings; \ + cannot also be used with typed event-payload bindings", + i + ), + )); + } + if !params_match(&ir.actions[p].params, params) { + return Err(syn::Error::new( + i.span(), + format!( + "action `{}` used with a different payload binding shape \ + than its first declaration", + i + ), + )); + } + // Add this variant to bound_variants if not already present. + let already = ir.actions[p] + .bound_variants + .iter() + .any(|bv| bv.name == *variant); + if !already { + ir.actions[p].bound_variants.push(BoundVariant { + name: variant.clone(), + kind: variant_kind, + }); + } + return Ok(p as u16); + } + ir.actions.push(ActionIr { + name: i.clone(), + params: params.to_vec(), + bound_variants: vec![BoundVariant { + name: variant.clone(), + kind: variant_kind, + }], + }); + Ok((ir.actions.len() - 1) as u16) +} + +fn params_match(a: &[PayloadField], b: &[PayloadField]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(x, y)| { + x.name == y.name && { + let xs = { + let t = &x.ty; + quote::quote! { #t }.to_string() + }; + let ys = { + let t = &y.ty; + quote::quote! { #t }.to_string() + }; + xs == ys + } + }) +} + +fn intern_event( + ir: &mut Ir, + i: &Ident, + payload: Option<&EventPayload>, +) -> syn::Result { + let kind = match payload { + None => VariantKind::Unit, + Some(p) => match p.kind { + PayloadKind::Tuple => VariantKind::Tuple, + PayloadKind::Struct => VariantKind::Struct, + }, + }; + let field_count = payload.map(|p| p.fields.len()); + + if let Some(p) = ir.event_variants.iter().position(|x| x.name == *i) { + let existing = &mut ir.event_variants[p]; + match (existing.kind, kind) { + (VariantKind::Unit, VariantKind::Unit) => {} + // First saw it as Unit, now a typed binding appears — upgrade + // the variant's kind. + (VariantKind::Unit, k) => { + existing.kind = k; + existing.field_count = field_count; + } + // Already knew it was tuple/struct, and this use is also typed. + // Shape must match. + (a, b) if a == b => { + if let (Some(x), Some(y)) = (existing.field_count, field_count) { + if x != y { + return Err(syn::Error::new( + i.span(), + format!( + "event variant `{}` is used with inconsistent payload field counts ({} vs {})", + i, x, y + ), + )); + } + } + } + // Already knew it was tuple/struct, this use is un-bound. Fine: + // the user is matching the variant without extracting payload + // (e.g. a transition target). The recorded kind controls the + // match pattern in `__event_to_id`; leaving it as-is still + // matches this use. + (_, VariantKind::Unit) => {} + // Conflict: tuple vs struct binding shape. + (a, b) => { + return Err(syn::Error::new( + i.span(), + format!( + "event variant `{}` is used with inconsistent kinds ({} vs {})", + i, + variant_kind_name(a), + variant_kind_name(b), + ), + )); + } + } + return Ok(p as u16); + } + + ir.event_variants.push(EventVariantIr { + name: i.clone(), + kind, + field_count, + }); + Ok((ir.event_variants.len() - 1) as u16) +} + +fn variant_kind_name(k: VariantKind) -> &'static str { + match k { + VariantKind::Unit => "unit", + VariantKind::Tuple => "tuple", + VariantKind::Struct => "struct", + } +} + +fn intern_duration( + ir: &mut Ir, + expr: &Expr, + key: &str, + repeat: bool, + span: Span, +) -> u16 { + let full_key = format!("{}:{}", key, repeat); + if let Some(dt) = ir.duration_triggers.iter().find(|d| d.key == full_key) { + return dt.id; + } + let id = ir.duration_triggers.len() as u16; + ir.duration_triggers.push(DurationTrigger { + id, + key: full_key, + expr: expr.clone(), + repeat, + span, + }); + id +} + +fn lower_body( + ir: &mut Ir, + state_id: u16, + body: &StateBody, + _is_root: bool, + state_names: &std::collections::HashSet, +) -> syn::Result<()> { + let mut entries: Vec = Vec::new(); + for i in &body.entries { + entries.push(intern_action_unit(ir, i)?); + } + let mut exits: Vec = Vec::new(); + for i in &body.exits { + exits.push(intern_action_unit(ir, i)?); + } + ir.states[state_id as usize].entries = entries; + ir.states[state_id as usize].exits = exits; + + let mut new_handlers = Vec::new(); + for h in &body.handlers { + let trig = match &h.trigger { + Trigger::Event { variant, payload } => { + let id = intern_event(ir, variant, payload.as_ref())?; + TriggerIr::Event(id, variant.clone(), payload.clone()) + } + Trigger::Duration { expr, key, span, repeat } => { + let id = intern_duration(ir, expr, key, *repeat, *span); + let owned = &mut ir.states[state_id as usize].owned_timers; + if !owned.contains(&id) { + owned.push(id); + } + TriggerIr::Duration(id) + } + }; + let kind = match &h.kind { + HandlerKind::Target(target) => classify_target(ir, target, &trig, state_names)?, + }; + new_handlers.push(HandlerIr { + trigger: trig, + kind, + decl_index: h.decl_index, + }); + } + ir.states[state_id as usize].handlers = new_handlers; + + // `during:` declarations are stored verbatim for codegen. Field-to-struct + // validation happens in codegen (we don't have access to the context + // struct's fields here) via the natural borrow-checker error on the + // generated call site — validate.rs additionally catches the obvious + // cases earlier. + ir.states[state_id as usize].durings = body.durings.clone(); + + let parent_depth = ir.states[state_id as usize].depth; + let mut child_ids = Vec::new(); + for c in &body.children { + let cid = alloc_state(ir, c.name.clone(), Some(state_id), parent_depth + 1, c.span); + child_ids.push(cid); + lower_body(ir, cid, &c.body, false, state_names)?; + } + ir.states[state_id as usize].children = child_ids.clone(); + + if let Some((dc_ident, _)) = &body.default_child { + let resolved = child_ids + .iter() + .find(|&&cid| ir.states[cid as usize].name == *dc_ident) + .copied(); + ir.states[state_id as usize].default_child = resolved; + } + + Ok(()) +} + +/// Decide whether `on(trigger) => target;` is a transition (target is a +/// declared state) or an action (target is a handler fn). Also enforce the +/// rules that only apply to one form or the other. +fn classify_target( + ir: &mut Ir, + target: &Ident, + trig: &TriggerIr, + state_names: &std::collections::HashSet, +) -> syn::Result { + let tname = target.to_string(); + if state_names.contains(&tname) { + // Transition. A repeating timer transitioning is nonsensical (the + // state exits on first fire and cancels the timer). Payload + // bindings can't feed a target state — they go to action args. + if let TriggerIr::Duration(tid) = trig { + if ir.duration_triggers[*tid as usize].repeat { + return Err(syn::Error::new( + target.span(), + "`every` timers are only valid on action handlers; \ + transitioning to a state cancels the timer on first fire", + )); + } + } + if let TriggerIr::Event(_, variant, Some(_)) = trig { + return Err(syn::Error::new( + variant.span(), + "event payload bindings are only valid when dispatching to an \ + action handler; a transition target takes no arguments", + )); + } + // State-id resolution happens later in `resolve_transitions`. + Ok(HandlerKindIr::Transition(target.clone(), None)) + } else { + // Action handler. + let aid = match trig { + TriggerIr::Event(_, variant, Some(payload)) => intern_action_with_params( + ir, + target, + &payload.fields, + variant, + payload.kind, + )?, + _ => intern_action_unit(ir, target)?, + }; + Ok(HandlerKindIr::Action(aid, target.clone())) + } +} + +/// Resolve transition targets from state idents to state ids after the tree is built. +pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> { + let mut name_map = std::collections::HashMap::new(); + for s in &ir.states { + if s.name != "__Root" { + if name_map.insert(s.name.to_string(), s.id).is_some() {} + } + } + + let n = ir.states.len(); + for i in 0..n { + let len = ir.states[i].handlers.len(); + for h in 0..len { + if let HandlerKindIr::Transition(target, slot) = &ir.states[i].handlers[h].kind { + if slot.is_some() { + continue; + } + let tname = target.to_string(); + let resolved = name_map.get(&tname).copied(); + let target_clone = target.clone(); + match resolved { + Some(id) => { + ir.states[i].handlers[h].kind = + HandlerKindIr::Transition(target_clone, Some(id)); + } + None => { + return Err(syn::Error::new( + target.span(), + format!("unknown state `{}` in transition", target), + )); + } + } + } + } + } + Ok(()) +} diff --git a/hsmc-macros/src/lib.rs b/hsmc-macros/src/lib.rs new file mode 100644 index 0000000..b19c526 --- /dev/null +++ b/hsmc-macros/src/lib.rs @@ -0,0 +1,29 @@ +//! Proc-macro backing for the `hsmc` crate. The sole public item is the +//! [`statechart!`] macro which generates a state machine from a declarative +//! description. + +use proc_macro::TokenStream; + +mod parse; +mod ir; +mod validate; +mod codegen; + +/// See the `hsmc` crate for documentation. +#[proc_macro] +pub fn statechart(input: TokenStream) -> TokenStream { + let input = proc_macro2::TokenStream::from(input); + match statechart_impl(input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn statechart_impl(input: proc_macro2::TokenStream) -> syn::Result { + let parsed = syn::parse2::(input)?; + validate::validate_parse_tree(&parsed)?; + let mut ir = ir::build_ir(parsed)?; + ir::resolve_transitions(&mut ir)?; + validate::validate(&ir)?; + Ok(codegen::generate(&ir)) +} diff --git a/hsmc-macros/src/parse.rs b/hsmc-macros/src/parse.rs new file mode 100644 index 0000000..04d1028 --- /dev/null +++ b/hsmc-macros/src/parse.rs @@ -0,0 +1,433 @@ +//! Parsing for the `statechart!` grammar. + +use proc_macro2::Span; +use syn::parse::{Parse, ParseStream}; +use syn::{braced, parenthesized, token, Expr, Ident, Token, Type}; + +pub struct StatechartInput { + pub name: Ident, + pub body: StateBody, +} + +pub struct StateBody { + /// Only present at root. + pub context_ty: Option, + pub event_ty: Option, + pub terminate: Option<(Ident, Span)>, + pub default_child: Option<(Ident, Span)>, + pub default_span: Option, // to detect duplicates + pub default_count: usize, + pub terminate_count: usize, + pub entries: Vec, + pub exits: Vec, + pub handlers: Vec, + pub children: Vec, + /// `during: fn_name(field_a, field_b);` declarations on this state. Each + /// `During` names a free async function and the root-context fields it + /// needs `&mut` access to. Field non-overlap is enforced by Rust's native + /// split borrow on the generated `select` call site; the macro emits a + /// clearer error when it can detect overlap at expansion time. + pub durings: Vec, +} + +impl StateBody { + fn new() -> Self { + Self { + context_ty: None, + event_ty: None, + terminate: None, + default_child: None, + default_span: None, + default_count: 0, + terminate_count: 0, + entries: Vec::new(), + exits: Vec::new(), + handlers: Vec::new(), + children: Vec::new(), + durings: Vec::new(), + } + } +} + +/// `during: ( , ... ) ;` — an async activity scoped to +/// a state. Runs concurrently with other durings on the active state path, +/// the external event channel, and the next timer deadline. Dropped whenever +/// any other branch of the run-loop's `select` wins or the state transitions. +#[derive(Clone)] +pub struct During { + /// Name of the free async function the macro will call. + pub fn_name: Ident, + /// Fields of the root context struct the function borrows `&mut`. + /// An empty list means the function takes no arguments. + pub fields: Vec, + /// Span of the `during:` keyword for diagnostic messages. Currently + /// unused by codegen (per-field spans come from the ident list) but + /// kept for future clearer diagnostics. + #[allow(dead_code)] + pub kw_span: Span, +} + +pub struct StateDecl { + pub name: Ident, + pub body: StateBody, + pub span: Span, +} + +#[derive(Clone)] +pub struct Handler { + pub trigger: Trigger, + pub kind: HandlerKind, + pub decl_index: usize, +} + +#[derive(Clone)] +pub enum HandlerKind { + /// Unresolved handler target: could be the name of a declared state + /// (→ transition) or the name of an action handler function (→ internal + /// transition). Classified during IR build once all state names are + /// known. + Target(Ident), +} + +#[derive(Clone)] +pub enum Trigger { + Event { + variant: Ident, + payload: Option, + }, + /// An arbitrary expression evaluating to `core::time::Duration`, together + /// with a human-readable key for dedup/diagnostics. `repeat` means the + /// timer re-arms after firing; only valid on action handlers. + Duration { + expr: Expr, + key: String, + span: Span, + repeat: bool, + }, +} + +/// Typed payload bindings attached to an event-variant trigger. +/// e.g. `action(PacketRx(rssi: i16, snr: i16)) => h;` parses to +/// `EventPayload { kind: Tuple, fields: [(rssi, i16), (snr, i16)] }`. +#[derive(Clone)] +pub struct EventPayload { + pub kind: PayloadKind, + pub fields: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PayloadKind { + Tuple, + Struct, +} + +#[derive(Clone)] +pub struct PayloadField { + pub name: Ident, + pub ty: Type, +} + +impl Trigger { + #[allow(dead_code)] + pub fn key(&self) -> String { + match self { + Trigger::Event { variant, .. } => format!("event:{}", variant), + Trigger::Duration { key, repeat, .. } => format!("dur:{}:{}", key, repeat), + } + } + #[allow(dead_code)] + pub fn span(&self) -> Span { + match self { + Trigger::Event { variant, .. } => variant.span(), + Trigger::Duration { span, .. } => *span, + } + } + #[allow(dead_code)] + pub fn display(&self) -> String { + match self { + Trigger::Event { variant, .. } => variant.to_string(), + Trigger::Duration { key, .. } => key.clone(), + } + } +} + +impl Parse for StatechartInput { + fn parse(input: ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + let content; + braced!(content in input); + let body = parse_body(&content, true)?; + Ok(StatechartInput { name, body }) + } +} + +fn parse_body(input: ParseStream, is_root: bool) -> syn::Result { + let mut body = StateBody::new(); + let mut decl_index: usize = 0; + while !input.is_empty() { + let lookahead = input.lookahead1(); + if lookahead.peek(Ident) { + let ident: Ident = input.fork().parse()?; + let name = ident.to_string(); + match name.as_str() { + "context" => { + let _: Ident = input.parse()?; + input.parse::()?; + let ty: Type = input.parse()?; + input.parse::()?; + if !is_root { + return Err(syn::Error::new(ident.span(), "`context:` is only valid at root")); + } + body.context_ty = Some(ty); + } + "events" => { + let _: Ident = input.parse()?; + input.parse::()?; + let ty: Type = input.parse()?; + input.parse::()?; + if !is_root { + return Err(syn::Error::new(ident.span(), "`events:` is only valid at root")); + } + body.event_ty = Some(ty); + } + "entry" => { + let _: Ident = input.parse()?; + input.parse::()?; + let list = parse_ident_list(input)?; + input.parse::()?; + body.entries.extend(list); + } + "exit" => { + let _: Ident = input.parse()?; + input.parse::()?; + let list = parse_ident_list(input)?; + input.parse::()?; + body.exits.extend(list); + } + "default" => { + let kw: Ident = input.parse()?; + let inner; + parenthesized!(inner in input); + let child: Ident = inner.parse()?; + input.parse::()?; + body.default_count += 1; + if body.default_child.is_none() { + body.default_child = Some((child, kw.span())); + body.default_span = Some(kw.span()); + } + } + "terminate" => { + let kw: Ident = input.parse()?; + let inner; + parenthesized!(inner in input); + let ev: Ident = inner.parse()?; + input.parse::()?; + body.terminate_count += 1; + if body.terminate.is_none() { + body.terminate = Some((ev, kw.span())); + } + } + "on" => { + // Unified handler declaration. The RHS may be a single + // target or a comma-separated list — every entry in the + // list fires atomically in declaration order. Targets + // disambiguate at IR-build time: ident names a declared + // state → transition; otherwise → handler fn. + let _: Ident = input.parse()?; + let inner; + parenthesized!(inner in input); + let trigger = parse_trigger(&inner)?; + input.parse::]>()?; + let targets = parse_ident_list(input)?; + input.parse::()?; + for target in targets { + body.handlers.push(Handler { + trigger: trigger.clone(), + kind: HandlerKind::Target(target), + decl_index, + }); + decl_index += 1; + } + } + "during" => { + // `during: fn_name ( field_a, field_b, ... ) ;` or + // `during: fn_name ;` (zero-arg). Fields name struct + // members of the root context; the macro emits + // `fn_name(&mut ctx.field_a, &mut ctx.field_b)` at the + // call site and Rust's split borrow verifies no overlap. + let kw: Ident = input.parse()?; + input.parse::()?; + let fn_name: Ident = input.parse()?; + let fields = if input.peek(token::Paren) { + let inner; + parenthesized!(inner in input); + let mut fields = Vec::new(); + while !inner.is_empty() { + fields.push(inner.parse::()?); + if inner.peek(Token![,]) { + inner.parse::()?; + } else { + break; + } + } + if !inner.is_empty() { + return Err(inner.error("unexpected tokens after field list")); + } + fields + } else { + Vec::new() + }; + input.parse::()?; + body.durings.push(During { + fn_name, + fields, + kw_span: kw.span(), + }); + } + "state" => { + let _: Ident = input.parse()?; + let sname: Ident = input.parse()?; + let content; + braced!(content in input); + let cbody = parse_body(&content, false)?; + body.children.push(StateDecl { name: sname.clone(), body: cbody, span: sname.span() }); + } + other => { + return Err(syn::Error::new(ident.span(), format!("unknown item `{}` in statechart body", other))); + } + } + } else { + return Err(lookahead.error()); + } + } + Ok(body) +} + +fn parse_ident_list(input: ParseStream) -> syn::Result> { + let mut v = Vec::new(); + v.push(input.parse()?); + while input.peek(Token![,]) { + input.parse::()?; + v.push(input.parse()?); + } + Ok(v) +} + +fn parse_trigger(input: ParseStream) -> syn::Result { + // Duration triggers must be prefixed with an explicit mode keyword: + // `every ` — repeating timer + // `after ` — one-shot timer + // + // Everything else is an event trigger: + // `Variant` — unit + // `Variant { a: T, b: U }` — struct-style payload binding + // `Variant(a: T, b: U)` — tuple-style payload binding + if input.peek(Ident) { + let fork = input.fork(); + if let Ok(id) = fork.parse::() { + if id == "every" { + let _: Ident = input.parse()?; + return parse_duration_rest(input, true); + } + if id == "after" { + let _: Ident = input.parse()?; + return parse_duration_rest(input, false); + } + } + } + + if !input.peek(Ident) || input.peek2(Token![::]) { + return Err(input.error( + "expected an event variant (`Name`, `Name(..)`, `Name { .. }`) or a \ + duration trigger prefixed with `after` or `every`", + )); + } + + let variant: Ident = input.parse()?; + + if input.is_empty() { + return Ok(Trigger::Event { + variant, + payload: None, + }); + } + + if input.peek(token::Brace) { + let inner; + braced!(inner in input); + let fields = parse_typed_fields(&inner)?; + return Ok(Trigger::Event { + variant, + payload: Some(EventPayload { + kind: PayloadKind::Struct, + fields, + }), + }); + } + + if input.peek(token::Paren) { + let inner; + parenthesized!(inner in input); + let fields = parse_typed_fields(&inner)?; + return Ok(Trigger::Event { + variant, + payload: Some(EventPayload { + kind: PayloadKind::Tuple, + fields, + }), + }); + } + + Err(input.error( + "unexpected token after event variant name; \ + expected end of trigger, `(..)`, or `{ .. }`", + )) +} + +fn parse_typed_fields(input: ParseStream) -> syn::Result> { + let mut fields = Vec::new(); + while !input.is_empty() { + let name: Ident = input.parse()?; + input.parse::()?; + let ty: Type = input.parse()?; + fields.push(PayloadField { name, ty }); + if input.peek(Token![,]) { + input.parse::()?; + } else { + break; + } + } + if !input.is_empty() { + return Err(input.error("unexpected tokens after typed payload bindings")); + } + Ok(fields) +} + +fn parse_duration_rest(input: ParseStream, repeat: bool) -> syn::Result { + let expr: Expr = input.parse()?; + let span = syn::spanned::Spanned::span(&expr); + // Bare numeric literals are rejected: readers shouldn't have to remember + // what unit a lone integer or float means. Always require an explicit + // `core::time::Duration` expression (or the re-exported `hsmc::Duration`). + if let Expr::Lit(lit) = &expr { + match &lit.lit { + syn::Lit::Int(_) | syn::Lit::Float(_) => { + return Err(syn::Error::new( + span, + "bare numeric literals are not accepted as durations; \ + use a typed expression like `Duration::from_secs(5)` or \ + `Duration::from_millis(250)`", + )); + } + _ => {} + } + } + let key = expr_to_string(&expr); + Ok(Trigger::Duration { expr, key, span, repeat }) +} + +fn expr_to_string(e: &Expr) -> String { + // Token-stream-based canonical form. Good enough for dedup keying. + let ts = quote::quote! { #e }; + ts.to_string().split_whitespace().collect() +} diff --git a/hsmc-macros/src/validate.rs b/hsmc-macros/src/validate.rs new file mode 100644 index 0000000..42a41b2 --- /dev/null +++ b/hsmc-macros/src/validate.rs @@ -0,0 +1,223 @@ +//! Compile-time validation (§6.2 / §9.9). + +use std::collections::HashMap; + +use syn::Ident; + +use crate::ir::{HandlerKindIr, Ir, TriggerIr}; +use crate::parse::{StateBody, StatechartInput}; + +pub fn validate(ir: &Ir) -> syn::Result<()> { + // Duplicate state names + let mut seen = HashMap::new(); + for s in &ir.states { + if s.name == "__Root" { + continue; + } + if let Some(_prev) = seen.insert(s.name.to_string(), s.id) { + return Err(syn::Error::new(s.span, format!("duplicate state name `{}`", s.name))); + } + } + + // Resolve transitions: done already? We need post-build resolution. + // We'll do it here because we need IR mutable; but we're given &Ir. + // Instead perform target lookup inline. + let name_map: HashMap = ir + .states + .iter() + .filter(|s| s.name != "__Root") + .map(|s| (s.name.to_string(), s.id)) + .collect(); + + for s in &ir.states { + // Empty state (not root). A state with a `during:` is not empty — + // the during is a real behavior scoped to the state's lifetime. + if s.parent.is_some() + && s.entries.is_empty() + && s.exits.is_empty() + && s.handlers.is_empty() + && s.children.is_empty() + && s.durings.is_empty() + { + return Err(syn::Error::new(s.span, format!("empty state `{}`", s.name))); + } + + // During-level checks: each `during:` declares a list of root-context + // fields it borrows `&mut`. We catch the obvious overlap cases at + // macro expansion time so the user gets a clear message rather than + // a raw rustc borrow-checker error on the generated `select` call. + // + // 1. Within a single `during:`, field names must be unique. + // 2. Across durings *on the same state*, field names must be disjoint. + // + // Cross-hierarchy overlaps (parent vs. descendant) are left to rustc + // — the borrow checker reports them accurately on the `&mut ctx.field` + // call site in codegen. + for d in &s.durings { + let mut seen_in_one = std::collections::HashSet::new(); + for f in &d.fields { + if !seen_in_one.insert(f.to_string()) { + return Err(syn::Error::new( + f.span(), + format!( + "field `{}` listed twice in `during: {}(...)` — each \ + field can only be borrowed once per during", + f, d.fn_name + ), + )); + } + } + } + let mut field_owner: std::collections::HashMap = + std::collections::HashMap::new(); + for d in &s.durings { + for f in &d.fields { + if let Some(prev) = field_owner.insert(f.to_string(), d.fn_name.clone()) { + return Err(syn::Error::new( + f.span(), + format!( + "field `{}` is borrowed by two concurrent durings on state `{}` \ + (`{}` and `{}`) — move one of them to a disjoint field or \ + combine the activities into a single during that uses `select`", + f, s.name, prev, d.fn_name + ), + )); + } + } + } + + // Children require default; default must be a direct child; no default allowed if no children. + if !s.children.is_empty() && s.default_child.is_none() { + return Err(syn::Error::new( + s.span, + format!("state `{}` has children but no `default` declaration", s.name), + )); + } + + // duplicate transitions per trigger in this state, AND transition + // target (if present) must be declared after every action handler + // on the same trigger — so the grammar's left-to-right reading + // matches execution order (actions first, then transition). + let mut trig_txn_seen: HashMap = HashMap::new(); + let mut trig_last_action_idx: HashMap = HashMap::new(); + for h in &s.handlers { + let key = match &h.trigger { + TriggerIr::Event(_, i, _) => format!("ev:{}", i), + TriggerIr::Duration(id) => format!("dur:{}", id), + }; + let trig_display = match &h.trigger { + TriggerIr::Event(_, i, _) => i.to_string(), + TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].key.clone(), + }; + let trig_span = match &h.trigger { + TriggerIr::Event(_, i, _) => i.span(), + TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].span, + }; + match &h.kind { + HandlerKindIr::Transition(target, _) => { + if let Some(_prev) = trig_txn_seen.insert(key.clone(), h.decl_index) { + return Err(syn::Error::new( + trig_span, + format!("duplicate transition on trigger `{}` in state `{}`", trig_display, s.name), + )); + } + if !name_map.contains_key(&target.to_string()) { + return Err(syn::Error::new( + target.span(), + format!("unknown state `{}` in transition", target), + )); + } + // Any action handler on the same trigger must have + // already been seen (smaller decl_index). + if let Some(&last_action_idx) = trig_last_action_idx.get(&key) { + if last_action_idx > h.decl_index { + return Err(syn::Error::new( + target.span(), + format!( + "transition to state `{}` on trigger `{}` must come after \ + every action handler on the same trigger; hsmc runs \ + all actions before the transition, so declaring the state \ + target earlier misleads the reader", + target, trig_display + ), + )); + } + } + } + HandlerKindIr::Action(_, _) => { + // If a transition on this trigger was already declared, + // this action would silently run *before* it at runtime + // — same ordering lie. Reject. + if let Some(&txn_idx) = trig_txn_seen.get(&key) { + if txn_idx < h.decl_index { + return Err(syn::Error::new( + trig_span, + format!( + "action handler on trigger `{}` must be declared before the \ + state transition target; hsmc runs all actions before the \ + transition, so placing actions after the target misleads \ + the reader", + trig_display + ), + )); + } + } + trig_last_action_idx.insert(key, h.decl_index); + } + } + } + } + Ok(()) +} + +/// Additional checks that require the original parse tree (for counting duplicate +/// `default`/`terminate` with their spans, and the "default in childless state" / +/// "default not a direct child" / "terminate in non-root" errors). +pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> { + let root_name = input.name.to_string(); + validate_body(&input.body, true, &root_name) +} + +fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Result<()> { + if body.default_count > 1 { + return Err(syn::Error::new( + body.default_span.unwrap_or_else(proc_macro2::Span::call_site), + format!("multiple `default` declarations in state `{}`", state_name), + )); + } + if body.terminate_count > 1 { + return Err(syn::Error::new( + body.terminate.as_ref().map(|(_, s)| *s).unwrap_or_else(proc_macro2::Span::call_site), + "multiple `terminate` declarations", + )); + } + if !is_root { + if let Some((_, span)) = &body.terminate { + return Err(syn::Error::new(*span, "`terminate` is only valid at the root level")); + } + } + if body.default_child.is_some() && body.children.is_empty() { + return Err(syn::Error::new( + body.default_span.unwrap(), + format!("`default` in state `{}` which has no child states", state_name), + )); + } + if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) { + // default must be a direct child + let is_child = body.children.iter().any(|c| c.name == *dc); + if !is_child { + return Err(syn::Error::new( + *dspan, + format!( + "`default({})` in state `{}`: `{}` is not a direct child of `{}`", + dc, state_name, dc, state_name + ), + )); + } + } + for c in &body.children { + let cname = c.name.to_string(); + validate_body(&c.body, false, &cname)?; + } + Ok(()) +} diff --git a/hsmc-macros/tests/ui.rs b/hsmc-macros/tests/ui.rs new file mode 100644 index 0000000..6b7dd98 --- /dev/null +++ b/hsmc-macros/tests/ui.rs @@ -0,0 +1,8 @@ +//! Compile-fail tests for `statechart!` validations (§9.9) and +//! compile-pass parse tests (spec §8). +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/*.rs"); + t.pass("tests/ui_pass/*.rs"); +} diff --git a/hsmc-macros/tests/ui/t10_1_during_duplicate_field.rs b/hsmc-macros/tests/ui/t10_1_during_duplicate_field.rs new file mode 100644 index 0000000..61f8700 --- /dev/null +++ b/hsmc-macros/tests/ui/t10_1_during_duplicate_field.rs @@ -0,0 +1,28 @@ +//! t10.1: a single `during:` listing the same field twice is a compile error. +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { Halt } + +pub struct Ctx { + pub a: u32, +} + +statechart! { +M { + context: Ctx; + events: Ev; + default(S); + terminate(Halt); + + state S { + during: tick(a, a); + } +} +} + +async fn tick(_: &mut u32, _: &mut u32) -> Ev { Ev::Halt } + +impl MActions for MActionContext<'_> {} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t10_1_during_duplicate_field.stderr b/hsmc-macros/tests/ui/t10_1_during_duplicate_field.stderr new file mode 100644 index 0000000..17db22b --- /dev/null +++ b/hsmc-macros/tests/ui/t10_1_during_duplicate_field.stderr @@ -0,0 +1,17 @@ +error: field `a` listed twice in `during: tick(...)` — each field can only be borrowed once per during + --> tests/ui/t10_1_during_duplicate_field.rs:19:25 + | +19 | during: tick(a, a); + | ^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t10_1_during_duplicate_field.rs:26:6 + | +26 | impl MActions for MActionContext<'_> {} + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t10_1_during_duplicate_field.rs:26:19 + | +26 | impl MActions for MActionContext<'_> {} + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.rs b/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.rs new file mode 100644 index 0000000..a82b2dd --- /dev/null +++ b/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.rs @@ -0,0 +1,31 @@ +//! t10.2: two `during:` declarations on the same state borrowing the same +//! field produce a clear macro-level error. +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { Halt } + +pub struct Ctx { + pub a: u32, +} + +statechart! { +M { + context: Ctx; + events: Ev; + default(S); + terminate(Halt); + + state S { + during: one(a); + during: two(a); + } +} +} + +async fn one(_: &mut u32) -> Ev { Ev::Halt } +async fn two(_: &mut u32) -> Ev { Ev::Halt } + +impl MActions for MActionContext<'_> {} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.stderr b/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.stderr new file mode 100644 index 0000000..b53f117 --- /dev/null +++ b/hsmc-macros/tests/ui/t10_2_during_overlap_same_state.stderr @@ -0,0 +1,17 @@ +error: field `a` is borrowed by two concurrent durings on state `S` (`one` and `two`) — move one of them to a disjoint field or combine the activities into a single during that uses `select` + --> tests/ui/t10_2_during_overlap_same_state.rs:21:21 + | +21 | during: two(a); + | ^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t10_2_during_overlap_same_state.rs:29:6 + | +29 | impl MActions for MActionContext<'_> {} + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t10_2_during_overlap_same_state.rs:29:19 + | +29 | impl MActions for MActionContext<'_> {} + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_1_duplicate_transition.rs b/hsmc-macros/tests/ui/t9_1_duplicate_transition.rs new file mode 100644 index 0000000..9fbd8ca --- /dev/null +++ b/hsmc-macros/tests/ui/t9_1_duplicate_transition.rs @@ -0,0 +1,26 @@ +use hsmc::{statechart, Duration}; + +#[derive(Debug, Clone)] +pub enum Ev { Go } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Red); + state Red { + on(Go) => Green; + on(Go) => Yellow; + } + state Green { entry: e; } + state Yellow { entry: e; } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_1_duplicate_transition.stderr b/hsmc-macros/tests/ui/t9_1_duplicate_transition.stderr new file mode 100644 index 0000000..3796bc7 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_1_duplicate_transition.stderr @@ -0,0 +1,25 @@ +error: duplicate transition on trigger `Go` in state `Red` + --> tests/ui/t9_1_duplicate_transition.rs:15:12 + | +15 | on(Go) => Yellow; + | ^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_1_duplicate_transition.rs:22:6 + | +22 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_1_duplicate_transition.rs:22:19 + | +22 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope + +warning: unused import: `Duration` + --> tests/ui/t9_1_duplicate_transition.rs:1:24 + | +1 | use hsmc::{statechart, Duration}; + | ^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/hsmc-macros/tests/ui/t9_2_missing_default.rs b/hsmc-macros/tests/ui/t9_2_missing_default.rs new file mode 100644 index 0000000..8a78d47 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_2_missing_default.rs @@ -0,0 +1,24 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { _U } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Parent); + state Parent { + state ChildA { entry: e; } + state ChildB { entry: e; } + } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_2_missing_default.stderr b/hsmc-macros/tests/ui/t9_2_missing_default.stderr new file mode 100644 index 0000000..84a6784 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_2_missing_default.stderr @@ -0,0 +1,17 @@ +error: state `Parent` has children but no `default` declaration + --> tests/ui/t9_2_missing_default.rs:13:11 + | +13 | state Parent { + | ^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_2_missing_default.rs:20:6 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_2_missing_default.rs:20:19 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_3_terminate_in_child.rs b/hsmc-macros/tests/ui/t9_3_terminate_in_child.rs new file mode 100644 index 0000000..371a6bf --- /dev/null +++ b/hsmc-macros/tests/ui/t9_3_terminate_in_child.rs @@ -0,0 +1,24 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { Halt } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Child); + state Child { + terminate(Halt); + entry: e; + } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_3_terminate_in_child.stderr b/hsmc-macros/tests/ui/t9_3_terminate_in_child.stderr new file mode 100644 index 0000000..33f31ea --- /dev/null +++ b/hsmc-macros/tests/ui/t9_3_terminate_in_child.stderr @@ -0,0 +1,17 @@ +error: `terminate` is only valid at the root level + --> tests/ui/t9_3_terminate_in_child.rs:14:9 + | +14 | terminate(Halt); + | ^^^^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_3_terminate_in_child.rs:20:6 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_3_terminate_in_child.rs:20:19 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_4_default_non_child.rs b/hsmc-macros/tests/ui/t9_4_default_non_child.rs new file mode 100644 index 0000000..25ca2f4 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_4_default_non_child.rs @@ -0,0 +1,25 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { _U } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Parent); + state Parent { + default(Cousin); + state Child { entry: e; } + } + state Cousin { entry: e; } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_4_default_non_child.stderr b/hsmc-macros/tests/ui/t9_4_default_non_child.stderr new file mode 100644 index 0000000..367bee9 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_4_default_non_child.stderr @@ -0,0 +1,17 @@ +error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent` + --> tests/ui/t9_4_default_non_child.rs:14:9 + | +14 | default(Cousin); + | ^^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_4_default_non_child.rs:21:6 + | +21 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_4_default_non_child.rs:21:19 + | +21 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_5_default_childless.rs b/hsmc-macros/tests/ui/t9_5_default_childless.rs new file mode 100644 index 0000000..dca4bc3 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_5_default_childless.rs @@ -0,0 +1,24 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { _U } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Leaf); + state Leaf { + default(Ghost); + entry: e; + } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_5_default_childless.stderr b/hsmc-macros/tests/ui/t9_5_default_childless.stderr new file mode 100644 index 0000000..cf124bd --- /dev/null +++ b/hsmc-macros/tests/ui/t9_5_default_childless.stderr @@ -0,0 +1,17 @@ +error: `default` in state `Leaf` which has no child states + --> tests/ui/t9_5_default_childless.rs:14:9 + | +14 | default(Ghost); + | ^^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_5_default_childless.rs:20:6 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_5_default_childless.rs:20:19 + | +20 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_6_empty_state.rs b/hsmc-macros/tests/ui/t9_6_empty_state.rs new file mode 100644 index 0000000..bd70fe4 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_6_empty_state.rs @@ -0,0 +1,22 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { _U } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(A); + state A { entry: e; } + state Empty {} +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_6_empty_state.stderr b/hsmc-macros/tests/ui/t9_6_empty_state.stderr new file mode 100644 index 0000000..35ddd4e --- /dev/null +++ b/hsmc-macros/tests/ui/t9_6_empty_state.stderr @@ -0,0 +1,17 @@ +error: empty state `Empty` + --> tests/ui/t9_6_empty_state.rs:14:11 + | +14 | state Empty {} + | ^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_6_empty_state.rs:18:6 + | +18 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_6_empty_state.rs:18:19 + | +18 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_7_duplicate_default.rs b/hsmc-macros/tests/ui/t9_7_duplicate_default.rs new file mode 100644 index 0000000..76239f0 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_7_duplicate_default.rs @@ -0,0 +1,26 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { _U } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(Parent); + state Parent { + default(A); + default(B); + state A { entry: e; } + state B { entry: e; } + } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_7_duplicate_default.stderr b/hsmc-macros/tests/ui/t9_7_duplicate_default.stderr new file mode 100644 index 0000000..26c97b7 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_7_duplicate_default.stderr @@ -0,0 +1,17 @@ +error: multiple `default` declarations in state `Parent` + --> tests/ui/t9_7_duplicate_default.rs:14:9 + | +14 | default(A); + | ^^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_7_duplicate_default.rs:22:6 + | +22 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_7_duplicate_default.rs:22:19 + | +22 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui/t9_8_unknown_target.rs b/hsmc-macros/tests/ui/t9_8_unknown_target.rs new file mode 100644 index 0000000..8655345 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_8_unknown_target.rs @@ -0,0 +1,21 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { Go } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(A); + state A { + on(Go) => NonExistent; + } +} +} + +impl MActions for MActionContext<'_> {} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_8_unknown_target.stderr b/hsmc-macros/tests/ui/t9_8_unknown_target.stderr new file mode 100644 index 0000000..bd500d1 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_8_unknown_target.stderr @@ -0,0 +1,50 @@ +warning: unexpected `cfg` condition value: `tokio` + --> tests/ui/t9_8_unknown_target.rs:8:1 + | + 8 | / statechart! { + 9 | | M { +10 | | context: Ctx; +11 | | events: Ev; +... | +17 | | } + | |_^ + | + = note: no expected values for `feature` + = note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate + = help: try referring to `statechart` crate for guidance on how handle this unexpected cfg + = help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros` + = note: see for more information about checking conditional configuration + = note: `#[warn(unexpected_cfgs)]` on by default + = note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unexpected `cfg` condition value: `embassy` + --> tests/ui/t9_8_unknown_target.rs:8:1 + | + 8 | / statechart! { + 9 | | M { +10 | | context: Ctx; +11 | | events: Ev; +... | +17 | | } + | |_^ + | + = note: no expected values for `feature` + = note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate + = help: try referring to `statechart` crate for guidance on how handle this unexpected cfg + = help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros` + = note: see for more information about checking conditional configuration + = note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0046]: not all trait items implemented, missing: `NonExistent` + --> tests/ui/t9_8_unknown_target.rs:19:1 + | + 8 | / statechart! { + 9 | | M { +10 | | context: Ctx; +11 | | events: Ev; +... | +17 | | } + | |_- `NonExistent` from trait +18 | +19 | impl MActions for MActionContext<'_> {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation diff --git a/hsmc-macros/tests/ui/t9_9_multiple_terminate.rs b/hsmc-macros/tests/ui/t9_9_multiple_terminate.rs new file mode 100644 index 0000000..2f4a4fc --- /dev/null +++ b/hsmc-macros/tests/ui/t9_9_multiple_terminate.rs @@ -0,0 +1,23 @@ +use hsmc::statechart; + +#[derive(Debug, Clone)] +pub enum Ev { Halt, Abort } + +pub struct Ctx; + +statechart! { +M { + context: Ctx; + events: Ev; + default(A); + terminate(Halt); + terminate(Abort); + state A { entry: e; } +} +} + +impl MActions for MActionContext<'_> { + fn e(&mut self) {} +} + +fn main() {} diff --git a/hsmc-macros/tests/ui/t9_9_multiple_terminate.stderr b/hsmc-macros/tests/ui/t9_9_multiple_terminate.stderr new file mode 100644 index 0000000..2d4e12b --- /dev/null +++ b/hsmc-macros/tests/ui/t9_9_multiple_terminate.stderr @@ -0,0 +1,17 @@ +error: multiple `terminate` declarations + --> tests/ui/t9_9_multiple_terminate.rs:13:5 + | +13 | terminate(Halt); + | ^^^^^^^^^ + +error[E0405]: cannot find trait `MActions` in this scope + --> tests/ui/t9_9_multiple_terminate.rs:19:6 + | +19 | impl MActions for MActionContext<'_> { + | ^^^^^^^^ not found in this scope + +error[E0425]: cannot find type `MActionContext` in this scope + --> tests/ui/t9_9_multiple_terminate.rs:19:19 + | +19 | impl MActions for MActionContext<'_> { + | ^^^^^^^^^^^^^^ not found in this scope diff --git a/hsmc-macros/tests/ui_pass/nested_states_parse.rs b/hsmc-macros/tests/ui_pass/nested_states_parse.rs new file mode 100644 index 0000000..30d1763 --- /dev/null +++ b/hsmc-macros/tests/ui_pass/nested_states_parse.rs @@ -0,0 +1,92 @@ +//! Compile-pass parse test mirroring spec §8 "Complete Example: Nested States". +//! The only deviation from the spec snippet is the outer-brace macro call syntax. +#![allow(unexpected_cfgs)] + +use hsmc::{statechart, Duration}; + +#[derive(Debug, Clone)] +pub enum MyEvent { + Event1, + Event2, + Halt, +} + +#[derive(Default)] +pub struct MyContext { + pub log: Vec, +} + +statechart! { +Name { + context: MyContext; + events: MyEvent; + + default(FirstState); + entry: mainentry, mainentry2, mainentry3; + exit: mainexit; + terminate(Halt); + + // Root-level transitions and actions + on(after Duration::from_secs(1)) => FirstState; + on(after Duration::from_secs_f64(0.99)) => asldkfj; + on(after Duration::from_secs_f64(0.99)) => asldkfj1; + on(after Duration::from_secs_f64(0.99)) => asldkfj2; + + state FirstState { + default(SubState); + entry: blah_entry; + exit: blah_exit; + + on(Event2) => SecondState; + on(Event1) => x_action; + + state SubState { + default(SubSubState); + + state SubSubState { + default(SubSubSubState); + + state SubSubSubState { + entry: subsubsub_entry; + } + } + } + } + + state SecondState { + on(Event1) => FirstState; + } + + state XSecondState { + entry: x_entry; + } + + state SomeEndState { + entry: end_entry; + exit: end_exit; + on(after Duration::from_secs_f64(0.23)) => something; + } +} +} + +impl NameActions for NameActionContext<'_> { + fn mainentry(&mut self) { self.log.push("mainentry".into()); } + fn mainentry2(&mut self) { self.log.push("mainentry2".into()); } + fn mainentry3(&mut self) { self.log.push("mainentry3".into()); } + fn mainexit(&mut self) { self.log.push("mainexit".into()); } + fn blah_entry(&mut self) { self.log.push("blah_entry".into()); } + fn blah_exit(&mut self) { self.log.push("blah_exit".into()); } + fn x_action(&mut self) { self.log.push("x_action".into()); } + fn asldkfj(&mut self) { self.log.push("asldkfj".into()); } + fn asldkfj1(&mut self) { self.log.push("asldkfj1".into()); } + fn asldkfj2(&mut self) { self.log.push("asldkfj2".into()); } + fn x_entry(&mut self) { self.log.push("x_entry".into()); } + fn end_entry(&mut self) { self.log.push("end_entry".into()); } + fn end_exit(&mut self) { self.log.push("end_exit".into()); } + fn something(&mut self) { self.log.push("something".into()); } + fn subsubsub_entry(&mut self) { self.log.push("subsubsub_entry".into()); } +} + +fn main() { + let _ = Name::new(MyContext::default()); +} diff --git a/hsmc/Cargo.toml b/hsmc/Cargo.toml new file mode 100644 index 0000000..8cb6d77 --- /dev/null +++ b/hsmc/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "hsmc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Hierarchical state machines (statecharts) with a declarative proc macro." + +[features] +default = [] +tokio = ["dep:tokio"] +embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"] + +[dependencies] +hsmc-macros = { path = "../hsmc-macros", version = "0.1.0" } +heapless = "0.8" +tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true } +embassy-sync = { version = "0.8", optional = true } +embassy-time = { version = "0.5", optional = true } +embassy-futures = { version = "0.1", optional = true } + +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies] +trybuild = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] } +ratatui = "0.28" +crossterm = { version = "0.28", features = ["event-stream"] } +futures = "0.3" + +[[example]] +name = "microwave" +required-features = ["tokio"] + +[[example]] +name = "microwave_tui" +required-features = ["tokio"] + +[[example]] +name = "during_radio" +required-features = ["tokio"] + +[[example]] +name = "embassy_full" +required-features = ["embassy"] +crate-type = ["lib"] diff --git a/hsmc/examples/during_radio.rs b/hsmc/examples/during_radio.rs new file mode 100644 index 0000000..f4de362 --- /dev/null +++ b/hsmc/examples/during_radio.rs @@ -0,0 +1,184 @@ +//! Simulated radio task demonstrating `during:` activities. +//! +//! Models a state machine with a couple of I/O loops: +//! +//! * `Idle` waits for a command; no ongoing I/O. +//! * `Receiving` has a `during: next_packet(driver, buf)` that produces a +//! `PacketRx` event every time the simulated driver yields one. +//! * `Transmitting` has a `during: await_tx_done(driver)` that waits for the +//! simulated TX to finish. +//! +//! Compared to the "imperative outer loop" pattern (see the dogfood firmware +//! radio task pre-v0.2), the task body collapses to a single +//! `machine.run().await` call — the statechart owns the control flow. +//! +//! Run with: `cargo run --example during_radio --features tokio` + +use hsmc::{statechart, Duration}; + +/// A stand-in for a real LoRa driver. Maintains a simple state so the +/// simulated RX produces deterministic packets. +pub struct SimRadio { + rx_counter: u32, + tx_pending: bool, +} + +impl SimRadio { + pub fn new() -> Self { + Self { + rx_counter: 0, + tx_pending: false, + } + } + /// Simulates waiting for a packet. Returns Some((rssi, snr)) when one + /// "arrives". In a real driver this would drive SPI + DIO interrupts. + pub async fn rx(&mut self, buf: &mut [u8; 64]) -> (i16, i16, usize) { + tokio::time::sleep(Duration::from_millis(30)).await; + self.rx_counter = self.rx_counter.wrapping_add(1); + let n = core::cmp::min(buf.len(), 4); + buf[..n].copy_from_slice(&self.rx_counter.to_le_bytes()[..n]); + (-62 + (self.rx_counter as i16 % 10), 8, n) + } + /// Starts a transmit. The `await_tx_done` during polls `tx_pending`. + pub fn begin_tx(&mut self) { + self.tx_pending = true; + } + pub async fn tx_done(&mut self) { + // In real driver: spin on DIO until TX_DONE. + tokio::time::sleep(Duration::from_millis(15)).await; + self.tx_pending = false; + } +} + +pub struct Ctx { + pub radio: SimRadio, + pub rx_buf: [u8; 64], + pub stats: RadioStats, + pub last_payload: heapless::Vec, +} + +impl Ctx { + pub fn new() -> Self { + Self { + radio: SimRadio::new(), + rx_buf: [0; 64], + stats: RadioStats::default(), + last_payload: heapless::Vec::new(), + } + } +} + +#[derive(Default, Debug)] +pub struct RadioStats { + pub rx_count: u32, + pub tx_count: u32, + pub last_rssi: i16, +} + +#[derive(Debug, Clone)] +pub enum Ev { + StartRx, + StopRx, + Transmit, + PacketRx { rssi: i16, snr: i16, len: usize }, + TxDone, + Halt, +} + +statechart! { +Radio { + context: Ctx; + events: Ev; + default(Idle); + terminate(Halt); + + state Idle { + entry: announce_idle; + on(StartRx) => Receiving; + on(Transmit) => Transmitting; + } + + state Receiving { + entry: announce_rx; + during: next_packet(radio, rx_buf); + on(PacketRx { rssi: i16, snr: i16, len: usize }) => record_packet; + on(StopRx) => Idle; + } + + state Transmitting { + entry: begin_tx_hw; + during: await_tx_done(radio); + on(TxDone) => tx_completed, Idle; + } +} +} + +/// During activity: wait for a packet and return a PacketRx event. Runs +/// every time the machine is in Receiving; dropped whenever the machine +/// leaves Receiving or any other event arrives. +async fn next_packet(radio: &mut SimRadio, buf: &mut [u8; 64]) -> Ev { + let (rssi, snr, len) = radio.rx(buf).await; + Ev::PacketRx { rssi, snr, len } +} + +/// During activity: wait for the TX to finish. Returns TxDone; then the +/// handler runs and transitions back to Idle. +async fn await_tx_done(radio: &mut SimRadio) -> Ev { + radio.tx_done().await; + Ev::TxDone +} + +impl RadioActions for RadioActionContext<'_> { + async fn announce_idle(&mut self) { + println!("[radio] → Idle"); + } + async fn announce_rx(&mut self) { + println!("[radio] → Receiving"); + } + async fn begin_tx_hw(&mut self) { + println!("[radio] → Transmitting"); + self.radio.begin_tx(); + } + async fn record_packet(&mut self, rssi: i16, snr: i16, len: usize) { + self.stats.rx_count = self.stats.rx_count.wrapping_add(1); + self.stats.last_rssi = rssi; + let copy = core::cmp::min(len, self.rx_buf.len()); + let snapshot: heapless::Vec = self.rx_buf[..copy].iter().copied().collect(); + self.last_payload = snapshot; + let count = self.stats.rx_count; + println!("[radio] rx#{} rssi={} snr={} len={}", count, rssi, snr, len); + } + async fn tx_completed(&mut self) { + self.stats.tx_count = self.stats.tx_count.wrapping_add(1); + println!("[radio] tx#{} done", self.stats.tx_count); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + println!("\n{}\n", Radio::<8>::STATE_CHART); + + let mut machine = Radio::new(Ctx::new()); + let sender = machine.sender(); + + // External driver: issue a sequence of commands. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + let _ = sender.send(Ev::StartRx); + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = sender.send(Ev::StopRx); + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = sender.send(Ev::Transmit); + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = sender.send(Ev::StartRx); + tokio::time::sleep(Duration::from_millis(80)).await; + let _ = sender.send(Ev::Halt); + }); + + machine.run().await.expect("run failed"); + let ctx = machine.into_context(); + println!( + "\nfinal stats: rx={} tx={} last_rssi={}", + ctx.stats.rx_count, ctx.stats.tx_count, ctx.stats.last_rssi + ); +} diff --git a/hsmc/examples/embassy_full.rs b/hsmc/examples/embassy_full.rs new file mode 100644 index 0000000..ef1ce6e --- /dev/null +++ b/hsmc/examples/embassy_full.rs @@ -0,0 +1,106 @@ +//! End-to-end Embassy example: ISR push, task push, timer transition, +//! and clean termination — all driven by a single `statechart!` machine +//! bound to a user-declared `static Channel`. +//! +//! This example is a `no_std` library rather than a runnable binary: it +//! contains no executor, no time-driver, and no interrupt vector so it can +//! be checked on any bare-metal target without pulling in board-specific +//! dependencies. The acceptance test is: +//! +//! ```text +//! cargo check --features embassy --example embassy_full --target thumbv7em-none-eabihf +//! ``` +//! +//! A real firmware pulls in `embassy-executor`, a time driver, and its own +//! interrupt bindings; the call sites below mirror exactly what the firmware +//! writes. + +#![no_std] +#![allow(dead_code)] + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; +use hsmc::{statechart, Duration}; + +#[derive(Debug, Clone)] +pub enum RadioEv { + StartRx, + IrqPacketReady, + StopRx, + Shutdown, +} + +pub struct RadioData { + pub packets_seen: u32, +} + +statechart! { + Radio { + context: RadioData; + events: RadioEv; + terminate(Shutdown); + default(Idle); + state Idle { + on(StartRx) => Listening; + } + state Listening { + entry: on_enter_listening; + exit: on_exit_listening; + on(IrqPacketReady) => count_packet; + on(StopRx) => Idle; + on(after Duration::from_secs(30)) => Idle; + } + } +} + +impl RadioActions for RadioActionContext<'_> { + async fn on_enter_listening(&mut self) { + // In a real firmware this might `radio.start_rx().await` or + // `display.flush().await` — actions are `async fn` under the + // embassy feature so peripheral calls can run inline. + } + async fn on_exit_listening(&mut self) {} + async fn count_packet(&mut self) { + self.packets_seen = self.packets_seen.saturating_add(1); + } +} + +// --- Capacity shared between the machine's internal emit-queue and the +// external event channel. The two must match — `Machine::new` enforces this +// at the type level via the channel's `N` const generic. +pub const QN: usize = 8; + +pub static RADIO_CHAN: Channel = Channel::new(); + +/// Entry point for the radio task — this is what `#[embassy_executor::task]` +/// would wrap in a real firmware. +pub async fn radio_task(ctx: RadioData) -> Result<(), hsmc::HsmcError> { + let mut machine = Radio::new(ctx, &RADIO_CHAN); + machine.run().await?; + // `into_context()` still works after Embassy run exits cleanly. + let _ctx = machine.into_context(); + Ok(()) +} + +/// How a *command* task would enqueue events with backpressure. +pub async fn command_task() { + let sender: RadioSender = sender_from_channel(); + let _ = sender.send(RadioEv::StartRx).await; + let _ = sender.send(RadioEv::StopRx).await; +} + +/// How a hardware ISR would push an event. `try_send` is non-blocking and +/// `RadioSender` is `Copy + Send + Sync`, so it can be captured in a +/// `static` (here we just fetch it each call for brevity). +pub fn on_radio_irq() { + let sender: RadioSender = sender_from_channel(); + // Best-effort push; drop on overflow rather than panic in an ISR. + let _ = sender.try_send(RadioEv::IrqPacketReady); +} + +/// A `RadioSender` can also be built directly from the static channel when +/// you haven't retained a reference to the `Radio` machine — e.g., an ISR +/// shim initialised before the task spawns. +fn sender_from_channel() -> RadioSender { + RadioSender::from_channel(&RADIO_CHAN) +} diff --git a/hsmc/examples/microwave.rs b/hsmc/examples/microwave.rs new file mode 100644 index 0000000..fe0ee1a --- /dev/null +++ b/hsmc/examples/microwave.rs @@ -0,0 +1,359 @@ +//! # Microwave Oven Controller — a tour of every `hsmc` feature. +//! +//! Run with: +//! +//! ```text +//! cargo run --example microwave --features tokio +//! ``` +//! +//! This single example exercises the entire public surface of the crate: +//! +//! | Feature | Where | +//! |--------------------------------------------------|-----------------------------------------| +//! | `statechart!` proc macro | the big block below | +//! | Nested states (3 levels deep) | `Running { Cooking { Heating / Paused } }` | +//! | `default(...)` descent at every level | root, `Running`, `Cooking` | +//! | Multiple entry actions (comma form) | `Idle` entry | +//! | Multiple entry actions (repeated-keyword form) | root entry | +//! | Multiple exit actions | `Paused` exit | +//! | Event-driven transitions | `StartPressed`, `StopPressed`, ... | +//! | Timer-driven transitions (`Duration`) | `Cooking` countdown, `Done` auto-reset | +//! | Event-driven actions (no state change) | `DoorOpened` beep in `Idle` | +//! | Timer-driven actions (no state change) | `Running` heartbeat tick | +//! | Actions *and* transition on same trigger | `StartPressed` in `Idle` | +//! | Multiple actions on the same trigger | `StartPressed` has two action handlers | +//! | Event bubbling up the hierarchy | `StopPressed` from `Heating` → `Running`| +//! | Cross-hierarchy transition (leaf → sibling-of-ancestor) | `StopPressed` target | +//! | Self-transition (exit + re-entry, timer restart) | `Done` on `StartPressed`? no — see `Idle` `Reset` | +//! | Transition to state with children → default descent | `StartPressed` in `Idle` → `Running` | +//! | Ancestor-timer survives child transitions | `Running` cook timer across `Heating`⇄`Paused` | +//! | `terminate(...)` at root | `PowerFailure` event | +//! | Exit cascade on terminate | logged in exit actions | +//! | Shared action name across states | `beep` used in `Idle` and `Done` | +//! | `emit()` from inside an action | `Done` auto-reset emits `StartPressed` | +//! | `with_queue_capacity::` | machine created with capacity 16 | +//! | `sender()` — cloneable, sendable across tasks | button-press + fault tasks | +//! | `run()` on the Tokio runtime | `main` awaits it | +//! | `into_context()` after termination | final report at the end | +//! | `current_state()` / `is_terminated()` | printed during shutdown | +//! | Action context: `Deref`/`DerefMut` to user ctx | every action touches `self.` | +//! +//! The oven runs a short cook job, is interrupted by a door-open (pause), +//! resumed by a door-close, completes, auto-resets via `emit`, then a simulated +//! power failure terminates the machine. + +use hsmc::{statechart, Duration}; +use std::sync::{Arc, Mutex}; + +// ---------- User context ---------- +// +// The machine never sees or pollutes this struct — it is the user's own data. +// Inside action methods we reach these fields via `Deref`/`DerefMut` on the +// generated `ActionContext`. + +#[derive(Default)] +pub struct OvenContext { + /// Human-readable transcript of everything that happened. Kept for the + /// final report printed after the machine terminates. + pub log: Vec, + /// Seconds remaining on the current cook job (mutated by actions). + pub remaining_secs: u32, + /// Simulated hardware state, shared with the outside world so the driving + /// code can inspect it without owning the machine. + pub hw: Arc>, +} + +#[derive(Default, Debug)] +pub struct Hardware { + pub door_open: bool, + pub magnetron_on: bool, + pub heater_on: bool, + pub display: String, +} + +// ---------- Events ---------- +// +// Plain user-defined enum. `Clone` is needed by the internal queue; `Debug` +// is for the transcript. + +#[derive(Debug, Clone)] +pub enum OvenEvent { + StartPressed, + StopPressed, + DoorOpened, + DoorClosed, + /// Emitted by the machine itself from a timer action in `Done`. + Reset, + /// Root-level `terminate` trigger. + PowerFailure, +} + +// ---------- Statechart ---------- +// +// Declarative structure only. No behavior. Reading this block tells you +// everything about the control flow. The macro validates it at compile time: +// missing defaults, duplicate transitions, `terminate` outside root, and +// unknown state references all become compile errors. + +statechart! { +Oven { + context: OvenContext; + events: OvenEvent; + + // Root-level entry/exit actions — repeated-keyword form. Invoked in + // declaration order on initial entry and in reverse-declaration inner→ + // outer order at termination. + entry: boot_self_test; + entry: log_boot_done; + exit: save_last_state; + exit: cut_all_power; + + // Required: which child to enter on initial step. + default(Idle); + + // Only valid at root. When this event fires in any active state, the full + // active configuration is exited (innermost → outermost, calling exit + // actions) and the machine terminates. + terminate(PowerFailure); + + state Idle { + // Comma form for multi-entry. + entry: show_ready, clear_remaining; + exit: clear_display; + + // Two actions AND a transition on the same trigger. Actions fire in + // declaration order, then the transition executes. + on(StartPressed) => record_start_request; + on(StartPressed) => prime_cook_timer; + on(StartPressed) => Running; + + // Event-driven action (no state change). + on(DoorOpened) => beep; + } + + // Composite state with its own timer (the total cook duration). The + // timer belongs to `Running` and survives transitions between its + // descendants (`Heating` ⇄ `Paused`). + state Running { + entry: start_magnetron; + exit: stop_magnetron; + default(Cooking); + + // Cook duration. This whole-oven timer keeps running while the door + // is open (the user can pause; the job clock does not). + // NOTE: in a real oven you'd pause this; here it stays to demonstrate + // ancestor-timer survival across child transitions (spec §2.13). + on(after Duration::from_millis(2500)) => Done; + + // Heartbeat every 200ms — a timer-driven action, no state change. + on(after Duration::from_millis(200)) => heartbeat; + + // Cancelling the cook job bubbles from any descendant to here. + on(StopPressed) => Idle; + + state Cooking { + entry: show_cooking; + default(Heating); + + state Heating { + entry: heater_on; + exit: heater_off; + // Door opens → pause. Sibling transition, one level up. + on(DoorOpened) => Paused; + } + + state Paused { + // Multi-line exit form. + entry: show_paused, start_pause_blink; + exit: stop_pause_blink; + exit: log_resume; + on(DoorClosed) => Heating; + } + } + } + + state Done { + // `beep` is shared with `Idle` — a single trait method serves both. + entry: beep, show_done; + exit: clear_display; + + // Timer-driven action that emits an event back into the queue. Spec + // §2.12: emitted events are processed AFTER the current handler + // finishes. Here the emitted `Reset` drives a transition below. + on(after Duration::from_millis(500)) => schedule_auto_reset; + + // Self-transition: Done → Done. Exit + re-entry runs, timer restarts. + on(StartPressed) => Done; + + // Responding to the emitted event. + on(Reset) => Idle; + } +} +} + +// ---------- Action implementations ---------- +// +// The macro generates `OvenActions` (one method per unique action name used +// anywhere above) and `OvenActionContext<'_>` (a wrapper that derefs to +// `OvenContext` and exposes `emit`). The user simply implements the trait. + +impl OvenActions for OvenActionContext<'_> { + // Root + async fn boot_self_test(&mut self) { + self.log.push("BOOT: self-test ok".into()); + } + async fn log_boot_done(&mut self) { + self.log.push("BOOT: ready".into()); + } + async fn save_last_state(&mut self) { + self.log.push("SHUTDOWN: persisted last state".into()); + } + async fn cut_all_power(&mut self) { + { + let mut hw = self.hw.lock().unwrap(); + hw.magnetron_on = false; + hw.heater_on = false; + hw.display = "-- OFF --".into(); + } + self.log.push("SHUTDOWN: power cut".into()); + } + + // Idle + async fn show_ready(&mut self) { + self.hw.lock().unwrap().display = "READY".into(); + self.log.push("IDLE: ready".into()); + } + async fn clear_remaining(&mut self) { + self.remaining_secs = 0; + } + async fn clear_display(&mut self) { + self.hw.lock().unwrap().display.clear(); + } + async fn record_start_request(&mut self) { + self.log.push("BTN: start pressed".into()); + } + async fn prime_cook_timer(&mut self) { + // Uses DerefMut to mutate the user context directly. + self.remaining_secs = 3; + let n = self.remaining_secs; + self.log.push(format!("BTN: primed {}s cook", n)); + } + async fn beep(&mut self) { + // Shared between Idle (DoorOpened) and Done (entry). + self.log.push("*BEEP*".into()); + } + + // Running + async fn start_magnetron(&mut self) { + self.hw.lock().unwrap().magnetron_on = true; + self.log.push("MAG: on".into()); + } + async fn stop_magnetron(&mut self) { + self.hw.lock().unwrap().magnetron_on = false; + self.log.push("MAG: off".into()); + } + async fn heartbeat(&mut self) { + self.log.push("tick".into()); + } + + // Cooking / Heating + async fn show_cooking(&mut self) { + self.hw.lock().unwrap().display = "COOKING".into(); + } + async fn heater_on(&mut self) { + self.hw.lock().unwrap().heater_on = true; + self.log.push("HEAT: on".into()); + } + async fn heater_off(&mut self) { + self.hw.lock().unwrap().heater_on = false; + self.log.push("HEAT: off".into()); + } + + // Paused + async fn show_paused(&mut self) { + self.hw.lock().unwrap().display = "PAUSED".into(); + } + async fn start_pause_blink(&mut self) { + self.log.push("PAUSE: blink on".into()); + } + async fn stop_pause_blink(&mut self) { + self.log.push("PAUSE: blink off".into()); + } + async fn log_resume(&mut self) { + self.log.push("PAUSE: resuming".into()); + } + + // Done + async fn show_done(&mut self) { + self.hw.lock().unwrap().display = "DONE".into(); + } + async fn schedule_auto_reset(&mut self) { + // emit() is provided by the generated ActionContext. It returns a + // Result so callers can handle queue overflow (here we panic because + // the queue is plenty big and overflow would be a bug). + self.log.push("DONE: auto-reset queued".into()); + self.emit(OvenEvent::Reset).expect("queue has capacity"); + } +} + +// ---------- Driver ---------- + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let hw = Arc::new(Mutex::new(Hardware::default())); + let ctx = OvenContext { hw: Arc::clone(&hw), ..Default::default() }; + + // `with_queue_capacity::` overrides the default queue size (8). We + // pick 16 to comfortably hold the burst of events below. + let mut oven = Oven::with_queue_capacity::<16>(ctx); + + // `sender()` returns a cheap, cloneable, Send handle — usable from other + // tasks, threads, or ISRs (under the tokio feature). + let buttons = oven.sender(); + let faults = oven.sender(); + + // Simulate a user pushing buttons and opening/closing the door. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + buttons.send(OvenEvent::StartPressed).unwrap(); + + // Open door partway through — should transition Heating → Paused. + tokio::time::sleep(Duration::from_millis(600)).await; + buttons.send(OvenEvent::DoorOpened).unwrap(); + + // Close it 400ms later — Paused → Heating. + tokio::time::sleep(Duration::from_millis(400)).await; + buttons.send(OvenEvent::DoorClosed).unwrap(); + + // Cook timer (2500ms from Running entry) fires around here → Done. + // Done's timer-action emits Reset → machine returns to Idle. + // Wait for auto-reset, then press Start again for a second cycle. + tokio::time::sleep(Duration::from_millis(2000)).await; + buttons.send(OvenEvent::StartPressed).unwrap(); + + // This time cancel via Stop before the cook completes. + tokio::time::sleep(Duration::from_millis(400)).await; + buttons.send(OvenEvent::StopPressed).unwrap(); + }); + + // Simulated fault monitor — kills the machine after the demo has played + // out. Demonstrates `terminate(PowerFailure)` and the exit cascade. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(5000)).await; + let _ = faults.send(OvenEvent::PowerFailure); + }); + + println!("=== Microwave coming online ===\n"); + oven.run().await.expect("run returned Err"); + + // After termination we can inspect final state and reclaim the context. + println!("\n=== Machine terminated ==="); + println!("is_terminated() = {}", oven.is_terminated()); + println!("hardware = {:?}", hw.lock().unwrap()); + + let ctx = oven.into_context(); + println!("\n--- Transcript ({} entries) ---", ctx.log.len()); + for (i, line) in ctx.log.iter().enumerate() { + println!("{:>3}. {}", i + 1, line); + } +} diff --git a/hsmc/examples/microwave_tui.rs b/hsmc/examples/microwave_tui.rs new file mode 100644 index 0000000..c076bad --- /dev/null +++ b/hsmc/examples/microwave_tui.rs @@ -0,0 +1,761 @@ +//! Interactive microwave oven TUI driven by `hsmc`. +//! +//! Run it: +//! +//! ```text +//! cargo run --example microwave_tui --features tokio +//! ``` +//! +//! Keys: +//! S — Start cook O — Open door +//! X — Stop / cancel C — Close door +//! K — Simulate power failure (terminates the machine) +//! Q — Quit +//! +//! This demonstrates the same statechart as `microwave.rs` but hooked up to +//! live keyboard input and a real-time ratatui UI. The state machine runs +//! on `oven.run()` in one task; a second task reads keys and forwards them +//! via a cloned `sender()`; a third task redraws the screen 30×/sec using +//! state published by entry/exit actions. + +use std::{ + collections::VecDeque, + io, + sync::{Arc, Mutex}, + time::Instant, +}; + +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::StreamExt; +use hsmc::{statechart, Duration}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph, Wrap}, + Terminal, +}; + +// ---------- Shared UI state ---------- +// +// The actions write into this through the user context. The render task +// reads it. `Arc>` keeps it cheap and thread-safe. + +#[derive(Default)] +pub struct UiState { + state_path: Vec<&'static str>, + display: String, + magnetron: bool, + heater: bool, + door_open: bool, + blink: bool, + log: VecDeque, + /// Instant at which the cook should complete. Advances by the pause + /// duration on resume so the progress bar freezes while paused. + cook_deadline: Option, + cook_total: Duration, + /// Set when we enter Paused; cleared on resume. Freezes the gauge. + paused_at: Option, + terminated: bool, +} + +impl UiState { + async fn log(&mut self, msg: impl Into) { + self.log.push_back(msg.into()); + while self.log.len() > 50 { + self.log.pop_front(); + } + } + async fn set_path(&mut self, path: &[&'static str]) { + self.state_path = path.to_vec(); + } +} + +// ---------- User context ---------- + +pub struct OvenContext { + pub ui: Arc>, +} + +// ---------- Events ---------- + +#[derive(Debug, Clone)] +pub enum OvenEvent { + StartPressed, + StopPressed, + DoorOpened, + DoorClosed, + Reset, + /// Sent by the external watchdog when the adjusted cook deadline is met. + /// Using an event instead of a static `Duration` transition lets us pause + /// and resume the cook clock — something hsmc timers can't do on their own. + CookDone, + PowerFailure, +} + +// ---------- Statechart ---------- +// +// Same shape as microwave.rs (every feature exercised) with a longer cook +// time so the user has time to interact. The cook timer lives on `Running` +// so it survives Heating⇄Paused transitions. + +const COOK_MS: u64 = 8_000; + +statechart! { +Oven { + context: OvenContext; + events: OvenEvent; + + entry: boot_self_test; + entry: log_boot_done; + exit: save_last_state; + exit: cut_all_power; + default(Idle); + terminate(PowerFailure); + + // Root-level door tracking. Any state whose local handler shadows these + // must repeat `mark_door_*` alongside its own handlers. + on(DoorOpened) => mark_door_open; + on(DoorClosed) => mark_door_closed; + + // Idle is split so Start can only fire when the door is closed. This is + // the HSM equivalent of a guard — a structural check rather than a + // boolean predicate. + state Idle { + // `sync_door` runs after the default descent completes handling, then + // emits DoorOpened if the door is physically open. That re-aligns the + // machine with reality when we land in Idle from Running (e.g. Stop + // pressed while door is open). The emitted event is processed after + // the entry cascade, so DoorClosed → DoorOpen happens cleanly. + entry: enter_idle, sync_door; + default(DoorClosed); + + state DoorClosed { + entry: show_ready; + // Local handlers, so we must repeat mark_door_open here. + on(DoorOpened) => mark_door_open; + on(DoorOpened) => DoorOpen; + + on(StartPressed) => record_start; + on(StartPressed) => prime_cook; + on(StartPressed) => Running; + } + + state DoorOpen { + entry: show_close_door; + on(DoorClosed) => mark_door_closed; + on(DoorClosed) => DoorClosed; + // Pressing Start while door is open: beep + log, no transition. + on(StartPressed) => warn_door_open; + } + } + + state Running { + entry: enter_running, start_cook_clock; + exit: stop_cook_clock; + default(Cooking); + + on(after Duration::from_millis(250)) => heartbeat; + on(StopPressed) => Idle; + on(CookDone) => Done; + + state Cooking { + entry: enter_cooking, show_cooking; + default(Heating); + + state Heating { + // Magnetron + heater live on Heating so they turn off the + // instant we leave for Paused. + entry: enter_heating, start_magnetron, heater_on; + exit: heater_off, stop_magnetron; + on(DoorOpened) => mark_door_open; + on(DoorOpened) => pause_cook_clock; + on(DoorOpened) => Suspended; + } + + // Suspended = "cook is paused". Two substates express the classic + // microwave rule: closing the door alone does NOT resume cooking + // — the user must press Start. Pressing Stop (handled by Running) + // cancels entirely back to Idle. + state Suspended { + entry: enter_suspended, start_pause_blink; + exit: stop_pause_blink; + default(DoorAjar); + + state DoorAjar { + entry: show_paused; + on(DoorClosed) => mark_door_closed; + on(DoorClosed) => AwaitingResume; + } + + state AwaitingResume { + entry: show_press_start; + on(DoorOpened) => mark_door_open; + on(DoorOpened) => DoorAjar; + + // Actions fire before the transition, so the clock is + // resumed and the log line written before we re-enter + // Heating (which turns the magnetron back on). + on(StartPressed) => log_resume; + on(StartPressed) => resume_cook_clock; + on(StartPressed) => Heating; + } + } + } + } + + state Done { + entry: enter_done, beep, show_done; + exit: exit_done; + + on(after Duration::from_millis(2000)) => schedule_auto_reset; + on(StartPressed) => Done; + on(Reset) => Idle; + } +} +} + +// ---------- Actions ---------- +// +// Every method is either publishing to the shared UI state or emitting an +// event back into the machine. + +impl OvenActions for OvenActionContext<'_> { + // Root + async fn boot_self_test(&mut self) { self.ui.lock().unwrap().log("BOOT self-test ok"); } + async fn log_boot_done(&mut self) { self.ui.lock().unwrap().log("BOOT ready"); } + async fn save_last_state(&mut self){ self.ui.lock().unwrap().log("SHUTDOWN persisted state"); } + async fn cut_all_power(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.magnetron = false; + ui.heater = false; + ui.display = "-- OFF --".into(); + ui.terminated = true; + ui.log("SHUTDOWN power cut"); + } + + // Door tracking (root-level) + async fn mark_door_open(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.door_open = true; + } + async fn mark_door_closed(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.door_open = false; + } + + // Idle + async fn enter_idle(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Idle"]); + } + async fn sync_door(&mut self) { + let door_open = self.ui.lock().unwrap().door_open; + if door_open { + self.ui.lock().unwrap().log("IDLE door still open — syncing"); + let _ = self.emit(OvenEvent::DoorOpened); + } + } + async fn show_ready(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Idle", "DoorClosed"]); + ui.display = "READY".into(); + ui.log("IDLE ready"); + } + async fn show_close_door(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Idle", "DoorOpen"]); + ui.display = "CLOSE DOOR".into(); + ui.log("IDLE door is open"); + } + async fn warn_door_open(&mut self) { + self.ui.lock().unwrap().log("WARN can't start — door open"); + } + async fn record_start(&mut self) { self.ui.lock().unwrap().log("BTN start pressed"); } + async fn prime_cook(&mut self) { self.ui.lock().unwrap().log("BTN primed cook"); } + async fn beep(&mut self) { self.ui.lock().unwrap().log("*BEEP*"); } + + // Running + async fn enter_running(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running"]); + } + async fn start_magnetron(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.magnetron = true; + ui.log("MAG on"); + } + async fn stop_magnetron(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.magnetron = false; + ui.log("MAG off"); + } + async fn start_cook_clock(&mut self) { + let mut ui = self.ui.lock().unwrap(); + let total = Duration::from_millis(COOK_MS); + ui.cook_total = total; + ui.cook_deadline = Some(Instant::now() + total); + ui.paused_at = None; + } + async fn stop_cook_clock(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.cook_deadline = None; + ui.paused_at = None; + } + async fn pause_cook_clock(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.paused_at = Some(Instant::now()); + ui.log("CLOCK paused"); + } + async fn resume_cook_clock(&mut self) { + let mut ui = self.ui.lock().unwrap(); + if let (Some(paused_at), Some(deadline)) = (ui.paused_at, ui.cook_deadline) { + let skipped = paused_at.elapsed(); + ui.cook_deadline = Some(deadline + skipped); + ui.log(format!("CLOCK resume (+{}ms)", skipped.as_millis())); + } + ui.paused_at = None; + } + async fn heartbeat(&mut self) { /* UI gauge animates on its own */ } + + // Cooking / Heating + async fn enter_cooking(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running", "Cooking"]); + } + async fn show_cooking(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.display = "COOKING".into(); + ui.blink = false; + } + async fn enter_heating(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running", "Cooking", "Heating"]); + ui.blink = false; + ui.display = "COOKING".into(); + } + async fn heater_on(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.heater = true; + ui.log("HEAT on"); + } + async fn heater_off(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.heater = false; + ui.log("HEAT off"); + } + + // Suspended (paused) + async fn enter_suspended(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running", "Cooking", "Suspended"]); + } + async fn show_paused(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "DoorAjar"]); + ui.display = "PAUSED".into(); + } + async fn show_press_start(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "AwaitingResume"]); + ui.display = "PRESS START".into(); + ui.log("PAUSE door closed — press Start to resume"); + } + async fn start_pause_blink(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.blink = true; + ui.log("PAUSE blink on"); + } + async fn stop_pause_blink(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.blink = false; + ui.log("PAUSE blink off"); + } + async fn log_resume(&mut self) { self.ui.lock().unwrap().log("PAUSE resuming"); } + + // Done + async fn enter_done(&mut self) { + let mut ui = self.ui.lock().unwrap(); + ui.set_path(&["Oven", "Done"]); + } + async fn show_done(&mut self) { + self.ui.lock().unwrap().display = "DONE!".into(); + } + async fn exit_done(&mut self) { self.ui.lock().unwrap().log("DONE exit"); } + async fn schedule_auto_reset(&mut self) { + self.ui.lock().unwrap().log("DONE auto-reset queued"); + self.emit(OvenEvent::Reset).expect("queue has room"); + } +} + +// ---------- Rendering ---------- + +fn draw(frame: &mut ratatui::Frame<'_>, ui: &UiState) { + let size = frame.area(); + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(16), + Constraint::Length(10), + Constraint::Length(3), + ]) + .split(size); + + // --- Title bar --- + let title = if ui.terminated { + Line::from(vec![ + Span::styled(" MICROWAVE ", Style::new().bg(Color::Red).fg(Color::White).bold()), + Span::raw(" "), + Span::styled("TERMINATED", Style::new().fg(Color::Red).bold()), + ]) + } else { + Line::from(vec![ + Span::styled(" MICROWAVE ", Style::new().bg(Color::Yellow).fg(Color::Black).bold()), + Span::raw(" hsmc statechart demo · press Q to quit"), + ]) + }; + frame.render_widget(Paragraph::new(title), root[0]); + + // --- Main area: oven on the left, status on the right --- + let main = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(root[1]); + + draw_oven(frame, main[0], ui); + draw_status(frame, main[1], ui); + + // --- Log --- + let log_lines: Vec = ui.log.iter().rev().take(8).rev() + .map(|s| Line::from(s.as_str())) + .collect(); + let log = Paragraph::new(log_lines) + .block(Block::default().title(" Event log ").borders(Borders::ALL)) + .wrap(Wrap { trim: false }); + frame.render_widget(log, root[2]); + + // --- Keys --- + let keys = Line::from(vec![ + key_hint("S", "Start"), + Span::raw(" "), + key_hint("X", "Stop"), + Span::raw(" "), + key_hint("O", "Open door"), + Span::raw(" "), + key_hint("C", "Close door"), + Span::raw(" "), + key_hint("K", "Kill (power fail)"), + Span::raw(" "), + key_hint("Q", "Quit"), + ]); + frame.render_widget( + Paragraph::new(keys).block(Block::default().borders(Borders::ALL).title(" Controls ")), + root[3], + ); +} + +fn key_hint(k: &str, label: &str) -> Span<'static> { + Span::from(format!("[{}] {}", k, label)).fg(Color::Cyan) +} + +fn draw_oven(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) { + let block = Block::default().borders(Borders::ALL).title(" Oven "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(inner); + + // Door indicator line + let door_line = if ui.door_open { + Span::styled(" ┌───── DOOR OPEN ─────┐", Style::new().fg(Color::Yellow).bold()) + } else { + Span::styled(" ┌───── door closed ─────┐", Style::new().fg(Color::DarkGray)) + }; + frame.render_widget(Paragraph::new(Line::from(door_line)), rows[0]); + + // Big display + let display_style = if ui.terminated { + Style::new().fg(Color::Red).bold() + } else if ui.blink && (Instant::now().elapsed().as_millis() / 300) % 2 == 0 { + Style::new().fg(Color::Yellow).bold() + } else if ui.display == "DONE!" { + Style::new().fg(Color::Green).bold() + } else if ui.display == "COOKING" { + Style::new().fg(Color::LightRed).bold() + } else { + Style::new().fg(Color::Cyan).bold() + }; + let big = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" ┃ {:^14} ┃", ui.display), + display_style, + )), + Line::from(Span::styled(" ┃ ┃", Style::new().fg(Color::DarkGray))), + Line::from(Span::styled(" ┗━━━━━━━━━━━━━━━━━━┛", Style::new().fg(Color::DarkGray))), + ]); + frame.render_widget(big, rows[1]); + + // Progress gauge for cook — freezes while paused. + let (ratio, label, color) = if let Some(deadline) = ui.cook_deadline { + let now = ui.paused_at.unwrap_or_else(Instant::now); + let total = ui.cook_total; + let remaining = deadline.saturating_duration_since(now); + let elapsed = total.saturating_sub(remaining); + let r = (elapsed.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0); + let label = format!("{:.1}s / {:.1}s{}", + elapsed.as_secs_f64(), + total.as_secs_f64(), + if ui.paused_at.is_some() { " (paused)" } else { "" }, + ); + let color = if ui.paused_at.is_some() { Color::Yellow } else { Color::LightRed }; + (r, label, color) + } else { + (0.0, "—".to_string(), Color::DarkGray) + }; + let gauge = Gauge::default() + .block(Block::default().borders(Borders::NONE)) + .gauge_style(Style::new().fg(color)) + .ratio(ratio) + .label(label); + frame.render_widget(gauge, rows[3]); +} + +fn draw_status(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) { + let block = Block::default().borders(Borders::ALL).title(" Status "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let path = if ui.state_path.is_empty() { + "—".to_string() + } else { + ui.state_path.join(" › ") + }; + + let bullet = |on: bool| { + if on { Span::styled("●", Style::new().fg(Color::Green).bold()) } + else { Span::styled("○", Style::new().fg(Color::DarkGray)) } + }; + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" State path: ", Style::new().fg(Color::DarkGray)), + Span::styled(path, Style::new().fg(Color::White).bold()), + ]), + Line::from(""), + Line::from(vec![ + Span::raw(" Magnetron "), + bullet(ui.magnetron), + Span::raw(if ui.magnetron { " ON " } else { " off" }), + ]), + Line::from(vec![ + Span::raw(" Heater "), + bullet(ui.heater), + Span::raw(if ui.heater { " ON " } else { " off" }), + ]), + Line::from(vec![ + Span::raw(" Door "), + if ui.door_open { + Span::styled("OPEN ", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + } else { + Span::styled("CLOSED", Style::new().fg(Color::DarkGray)) + }, + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" hsmc tip: ", Style::new().fg(Color::DarkGray)), + Span::raw("events bubble · timers don't"), + ]), + ]; + frame.render_widget(Paragraph::new(lines), inner); +} + +// ---------- Main ---------- + +#[tokio::main(flavor = "current_thread")] +async fn main() -> io::Result<()> { + // Shared UI state. + let ui = Arc::new(Mutex::new(UiState::default())); + + // Build the machine. + let ctx = OvenContext { ui: Arc::clone(&ui) }; + let mut oven = Oven::with_queue_capacity::<16>(ctx); + let sender = oven.sender(); + let fault_sender = oven.sender(); + let cook_sender = oven.sender(); + + // Raw-mode terminal. + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Render loop — 30 fps. + let ui_for_render = Arc::clone(&ui); + let (render_stop_tx, mut render_stop_rx) = tokio::sync::oneshot::channel::<()>(); + let render_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(33)); + loop { + tokio::select! { + _ = &mut render_stop_rx => break, + _ = interval.tick() => { + let snapshot = { + let g = ui_for_render.lock().unwrap(); + UiSnapshot::from(&*g) + }; + let _ = terminal.draw(|f| draw(f, &snapshot.into())); + } + } + } + // Restore terminal. + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); + terminal + }); + + // Cook-clock watchdog. Polls shared UI state; when we're not paused and + // the deadline has passed, fires a single CookDone event and then sleeps + // until the deadline is re-armed. The machine discards CookDone if the + // current state doesn't handle it (e.g. after StopPressed). + let ui_for_watchdog = Arc::clone(&ui); + let watchdog_task = tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(50)).await; + let should_fire = { + let u = ui_for_watchdog.lock().unwrap(); + if u.terminated { return; } + match (u.cook_deadline, u.paused_at) { + (Some(deadline), None) => Instant::now() >= deadline, + _ => false, + } + }; + if should_fire { + let _ = cook_sender.send(OvenEvent::CookDone); + // Give the machine a beat to process and clear cook_deadline + // (via Running's exit → stop_cook_clock) before we check again. + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + }); + + // Input loop. + let ui_for_input = Arc::clone(&ui); + let input_task = tokio::spawn(async move { + let mut events = EventStream::new(); + while let Some(Ok(ev)) = events.next().await { + if let Event::Key(k) = ev { + if k.kind != KeyEventKind::Press { continue; } + let code = k.code; + let msg = match code { + KeyCode::Char('s') | KeyCode::Char('S') => Some(OvenEvent::StartPressed), + KeyCode::Char('x') | KeyCode::Char('X') => Some(OvenEvent::StopPressed), + KeyCode::Char('o') | KeyCode::Char('O') => Some(OvenEvent::DoorOpened), + KeyCode::Char('c') | KeyCode::Char('C') => Some(OvenEvent::DoorClosed), + KeyCode::Char('k') | KeyCode::Char('K') => Some(OvenEvent::PowerFailure), + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { + // Quit: cleanly terminate the machine via PowerFailure. + let _ = fault_sender.send(OvenEvent::PowerFailure); + break; + } + _ => None, + }; + if let Some(m) = msg { + ui_for_input.lock().unwrap().log(format!("KEY -> {:?}", m)); + let _ = sender.send(m); + } + } + } + }); + + // Drive the state machine on the main task. + let _ = oven.run().await; + + // Let the UI show the TERMINATED banner briefly. + tokio::time::sleep(Duration::from_millis(700)).await; + + // Stop the render loop. + let _ = render_stop_tx.send(()); + input_task.abort(); + watchdog_task.abort(); + let mut terminal = render_task.await.expect("render task"); + + // Belt-and-braces teardown in case the render loop didn't get to it. + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); + + // Final transcript. + let ui = ui.lock().unwrap(); + println!("\nFinal state: {}", ui.state_path.join(" › ")); + println!("Events: {}", ui.log.len()); + for line in ui.log.iter() { + println!(" {}", line); + } + Ok(()) +} + +// Snapshot so we don't hold the Mutex across the render. +struct UiSnapshot { + state_path: Vec<&'static str>, + display: String, + magnetron: bool, + heater: bool, + door_open: bool, + blink: bool, + log: VecDeque, + cook_deadline: Option, + cook_total: Duration, + paused_at: Option, + terminated: bool, +} +impl From<&UiState> for UiSnapshot { + fn from(u: &UiState) -> Self { + Self { + state_path: u.state_path.clone(), + display: u.display.clone(), + magnetron: u.magnetron, + heater: u.heater, + door_open: u.door_open, + blink: u.blink, + log: u.log.clone(), + cook_deadline: u.cook_deadline, + cook_total: u.cook_total, + paused_at: u.paused_at, + terminated: u.terminated, + } + } +} +impl From for UiState { + fn from(s: UiSnapshot) -> Self { + Self { + state_path: s.state_path, + display: s.display, + magnetron: s.magnetron, + heater: s.heater, + door_open: s.door_open, + blink: s.blink, + log: s.log, + cook_deadline: s.cook_deadline, + cook_total: s.cook_total, + paused_at: s.paused_at, + terminated: s.terminated, + } + } +} diff --git a/hsmc/src/error.rs b/hsmc/src/error.rs new file mode 100644 index 0000000..a404c30 --- /dev/null +++ b/hsmc/src/error.rs @@ -0,0 +1,20 @@ +/// Errors returned by the `hsmc` runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HsmcError { + /// The event queue is full. The event was not enqueued. + QueueFull, + /// The machine has already terminated. No further operations are valid. + AlreadyTerminated, +} + +impl core::fmt::Display for HsmcError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + HsmcError::QueueFull => f.write_str("event queue is full"), + HsmcError::AlreadyTerminated => f.write_str("machine has already terminated"), + } + } +} + +#[cfg(feature = "tokio")] +impl std::error::Error for HsmcError {} diff --git a/hsmc/src/lib.rs b/hsmc/src/lib.rs new file mode 100644 index 0000000..924d41e --- /dev/null +++ b/hsmc/src/lib.rs @@ -0,0 +1,254 @@ +//! # hsmc +//! +//! Hierarchical state machines (statecharts) with a declarative proc-macro front-end. +//! +//! See the crate's spec (`docs/000. original-hsmc-spec.md`) for v0.1 semantics +//! and the top-level README for the v0.2 additions (`during:`, optional +//! `events:`, `dispatch()`, `STATE_CHART`). +//! +//! ## Macro invocation syntax +//! +//! The `statechart!` macro uses Rust's standard proc-macro call syntax — +//! the machine name goes *inside* the outer brace/parenthesis, because Rust +//! does not permit tokens between `!` and the macro delimiter: +//! +//! ```ignore +//! use hsmc::{statechart, Duration}; +//! +//! # #[derive(Default)] struct MyContext; +//! # #[derive(Debug, Clone)] enum MyEvent { Go } +//! statechart! { +//! MyMachine { +//! context: MyContext; +//! events: MyEvent; +//! default(Idle); +//! state Idle { on(Go) => Busy; } +//! state Busy { } +//! } +//! } +//! ``` +//! +//! ## `during:` activities (v0.2) +//! +//! Any state can declare one or more `during:` activities — async functions +//! that run while the state is on the active path and produce events: +//! +//! ```ignore +//! state Receiving { +//! during: next_packet(lora, rx_buf); +//! on(PacketRx { rssi: i16 }) => count_packet; +//! on(StopRx) => Idle; +//! } +//! +//! async fn next_packet(lora: &mut LoRaDriver, buf: &mut [u8; 256]) -> Ev { +//! lora.rx(buf).await.into() +//! } +//! ``` +//! +//! The macro emits `next_packet(&mut ctx.lora, &mut ctx.rx_buf)` in the +//! generated run loop's `select` call, and Rust's native split-borrow +//! verifies at compile time that concurrent durings don't borrow +//! overlapping fields. +//! +//! ### Cancel-safety contract +//! +//! A `during:` future **will** be dropped at any `.await` point whenever a +//! handler fires, a timer expires, an external event arrives, or the state +//! transitions. Write durings as cancel-safe state machines: every `.await` +//! must be a clean resume point where dropping the future leaves borrowed +//! fields in a valid, re-enterable state. Prefer performing mutations +//! *after* awaited I/O completes rather than straddling await points. +//! +//! ## A note on `unexpected_cfgs` warnings +//! +//! The code emitted by `statechart!` contains `#[cfg(feature = "tokio")]` / +//! `#[cfg(feature = "embassy")]` gates so that feature-specific items (the +//! `Sender`, `run()`) compile only under the matching feature of the `hsmc` +//! crate. Rust 1.80+ `check-cfg` evaluates those attributes in the context +//! of the *calling* crate — so if your crate doesn't itself declare features +//! named `tokio` or `embassy`, rustc will emit `unexpected_cfgs` warnings at +//! every `statechart!` invocation. To silence them, add either: +//! +//! ```ignore +//! #![allow(unexpected_cfgs)] // at the crate root, or +//! ``` +//! +//! or, in your `Cargo.toml`: +//! +//! ```toml +//! [lints.rust] +//! unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] } +//! ``` +//! +//! This is a known limitation of proc-macros that emit cfg-gated code. +//! +//! ## Out of scope (spec §11) +//! +//! The following are explicitly NOT part of v0.1 and should not be added +//! without revisiting the spec: guard conditions, orthogonal / parallel +//! regions, history states (shallow or deep), deferred events, internal +//! transitions, state-local context / per-state data, visual rendering or +//! diagram export, runtime statechart modification, event priorities beyond +//! FIFO, and inter-machine communication primitives. + +#![cfg_attr(not(any(test, feature = "tokio")), no_std)] + +#[cfg(all(feature = "tokio", feature = "embassy"))] +compile_error!("hsmc features `tokio` and `embassy` are mutually exclusive"); + +pub use core::time::Duration; +pub use hsmc_macros::statechart; + +mod error; +pub use error::HsmcError; + +#[doc(hidden)] +pub mod __private { + //! Implementation details used by code generated by the `statechart!` macro. + //! Not part of the public API. + pub use heapless; + + pub use crate::error::HsmcError; + + /// A simple bounded event queue built on `heapless::Deque`. + /// Capacity is a const generic. + pub struct EventQueue { + inner: heapless::Deque, + } + + impl EventQueue { + pub const fn new() -> Self { + Self { inner: heapless::Deque::new() } + } + pub fn push(&mut self, ev: E) -> Result<(), HsmcError> { + self.inner.push_back(ev).map_err(|_| HsmcError::QueueFull) + } + pub fn pop(&mut self) -> Option { + self.inner.pop_front() + } + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + pub fn clear(&mut self) { + while self.inner.pop_front().is_some() {} + } + } + + impl Default for EventQueue { + fn default() -> Self { Self::new() } + } + + /// A fixed-capacity timer table: `(state_index, trigger_index, remaining_nanos)`. + pub struct TimerTable { + pub entries: heapless::Vec, + } + + #[derive(Clone, Copy, Debug)] + pub struct TimerEntry { + pub state: u16, + pub trigger: u16, + pub remaining_ns: u128, + } + + impl TimerTable { + pub const fn new() -> Self { + Self { entries: heapless::Vec::new() } + } + pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) { + let ns = duration.as_nanos(); + // Replace existing entry for same (state, trigger) — resets on re-entry. + for e in self.entries.iter_mut() { + if e.state == state && e.trigger == trigger { + e.remaining_ns = ns; + return; + } + } + let _ = self.entries.push(TimerEntry { state, trigger, remaining_ns: ns }); + } + pub fn cancel_state(&mut self, state: u16) { + self.entries.retain(|e| e.state != state); + } + pub fn cancel_one(&mut self, state: u16, trigger: u16) { + self.entries.retain(|e| !(e.state == state && e.trigger == trigger)); + } + pub fn decrement(&mut self, elapsed: core::time::Duration) { + let ns = elapsed.as_nanos(); + for e in self.entries.iter_mut() { + e.remaining_ns = e.remaining_ns.saturating_sub(ns); + } + } + /// Returns (state, trigger) of first expired timer, picking the innermost + /// (deepest) state, ties broken by declaration order. + pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> { + let mut best: Option = None; + for (i, e) in self.entries.iter().enumerate() { + if e.remaining_ns == 0 { + match best { + None => best = Some(i), + Some(bi) => { + let cur = depth[e.state as usize]; + let prev = depth[self.entries[bi].state as usize]; + if cur > prev { + best = Some(i); + } + } + } + } + } + if let Some(i) = best { + let e = self.entries.swap_remove(i); + Some((e.state, e.trigger)) + } else { None } + } + pub fn min_remaining(&self) -> Option { + self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| { + let secs = (ns / 1_000_000_000) as u64; + let rem = (ns % 1_000_000_000) as u32; + core::time::Duration::new(secs, rem) + }) + } + } + + /// Convert an f64 seconds value to a Duration (runtime, non-const). + pub fn duration_from_secs_f64(secs: f64) -> core::time::Duration { + core::time::Duration::from_nanos((secs * 1_000_000_000.0) as u64) + } + + /// Type-erased push interface over the event queue, so the generated + /// `ActionContext` does not need to be parameterized by the queue capacity. + pub trait QueuePush { + fn push(&mut self, ev: E) -> Result<(), HsmcError>; + } + + impl QueuePush for heapless::Deque { + fn push(&mut self, ev: E) -> Result<(), HsmcError> { + self.push_back(ev).map_err(|_| HsmcError::QueueFull) + } + } + + /// Proxy used by generated code to route `emit()` pushes through the + /// internal deque while recording overflow on the machine. When the deque + /// rejects a push, the `overflow` flag is set and further pushes within + /// the same tick short-circuit to `Err(QueueFull)` without reaching the + /// deque. `run()` inspects this flag each iteration and surfaces the + /// failure rather than letting it be silently swallowed by action code. + pub struct EmitProxy<'a, E, const N: usize> { + pub queue: &'a mut heapless::Deque, + pub overflow: &'a mut bool, + } + + impl<'a, E, const N: usize> QueuePush for EmitProxy<'a, E, N> { + fn push(&mut self, ev: E) -> Result<(), HsmcError> { + if *self.overflow { + return Err(HsmcError::QueueFull); + } + match self.queue.push_back(ev) { + Ok(()) => Ok(()), + Err(_) => { + *self.overflow = true; + Err(HsmcError::QueueFull) + } + } + } + } +} diff --git a/hsmc/tests/behavior.rs b/hsmc/tests/behavior.rs new file mode 100644 index 0000000..658b43b --- /dev/null +++ b/hsmc/tests/behavior.rs @@ -0,0 +1,1702 @@ +//! Behavior tests corresponding to §9 of the spec. +//! +//! Gated to non-async features: under `tokio`/`embassy`, action methods are +//! `async fn` and `step()` is `async`, so these tests — which drive the +//! machine synchronously — would fail to compile. Async behavior is covered +//! by `tokio_run.rs` and, on target hardware, the firmware integration tests. +#![cfg(not(any(feature = "tokio", feature = "embassy")))] + +use hsmc::{statechart, Duration}; + +// Shared test context. +#[derive(Default)] +pub struct TestCtx { + pub log: Vec, +} + +impl TestCtx { + fn logs(&self) -> Vec<&str> { + self.log.iter().map(|s| s.as_str()).collect() + } +} + +// ---- T1.1: default state entry on first step ---- +mod t1_1 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _Unused } + + statechart! { +T11 { + context: TestCtx; + events: Ev; + default(Red); + entry: do_post; + state Red { + entry: red_on; + exit: red_off; + } + } + } + + impl T11Actions for T11ActionContext<'_> { + fn do_post(&mut self) { self.log.push("do_post".into()); } + fn red_on(&mut self) { self.log.push("red_on".into()); } + fn red_off(&mut self) { self.log.push("red_off".into()); } + } + + #[test] + fn t1_1_initial_entry() { + let mut m = T11::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["do_post", "red_on"]); + assert_eq!(m.current_state(), T11State::Red); + assert!(!m.is_terminated()); + } +} + +// ---- T1.2: nested default descent ---- +mod t1_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _Unused } + + statechart! { +T12 { + context: TestCtx; + events: Ev; + default(A); + entry: r_entry; + state A { + entry: a_entry; + default(B); + state B { + entry: b_entry; + default(C); + state C { entry: c_entry; } + } + } + } + } + + impl T12Actions for T12ActionContext<'_> { + fn r_entry(&mut self) { self.log.push("r_entry".into()); } + fn a_entry(&mut self) { self.log.push("a_entry".into()); } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn c_entry(&mut self) { self.log.push("c_entry".into()); } + } + + #[test] + fn t1_2_nested_default() { + let mut m = T12::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["r_entry", "a_entry", "b_entry", "c_entry"]); + assert_eq!(m.current_state(), T12State::C); + } +} + +// ---- T2.1 / T2.6: event-driven sibling transition + self transition ---- +mod t2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { GoGreen, Reset } + + statechart! { +T2A { + context: TestCtx; + events: Ev; + default(Red); + state Red { + entry: red_on; + exit: red_off; + on(GoGreen) => Green; + on(Reset) => Red; + on(after Duration::from_millis(200)) => Green; + } + state Green { + entry: green_on; + exit: green_off; + } + } + } + + impl T2AActions for T2AActionContext<'_> { + fn red_on(&mut self) { self.log.push("red_on".into()); } + fn red_off(&mut self) { self.log.push("red_off".into()); } + fn green_on(&mut self) { self.log.push("green_on".into()); } + fn green_off(&mut self) { self.log.push("green_off".into()); } + } + + #[test] + fn t2_1_sibling_transition() { + let mut m = T2A::new(TestCtx::default()); + m.step(Duration::ZERO); // initial + m.send(Ev::GoGreen).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T2AState::Green); + let logs = m.context().logs(); + assert!(logs.contains(&"red_off")); + assert!(logs.contains(&"green_on")); + } + + #[test] + fn t2_6_self_transition() { + let mut m = T2A::new(TestCtx::default()); + m.step(Duration::ZERO); + m.context_mut().log.clear(); + // Advance 100ms — timer to Green (at 200ms) has 100ms left and must not fire. + m.step(Duration::from_millis(100)); + assert_eq!(m.current_state(), T2AState::Red); + // Self-transition resets the 200ms timer. + m.send(Ev::Reset).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T2AState::Red); + assert_eq!(m.context().logs(), ["red_off", "red_on"]); + // Only 150ms elapsed since the timer restart — no transition yet. + m.step(Duration::from_millis(150)); + assert_eq!(m.current_state(), T2AState::Red); + // 60 more ms pushes past the 200ms deadline. + m.step(Duration::from_millis(60)); + assert_eq!(m.current_state(), T2AState::Green); + } +} + +// ---- T3.3: actions fire before transition on same trigger ---- +mod t3_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T33 { + context: TestCtx; + events: Ev; + default(Red); + state Red { + exit: red_off; + on(Go) => log_go; + on(Go) => prepare; + on(Go) => Green; + } + state Green { entry: green_on; } + } + } + + impl T33Actions for T33ActionContext<'_> { + fn log_go(&mut self) { self.log.push("log_go".into()); } + fn prepare(&mut self) { self.log.push("prepare".into()); } + fn red_off(&mut self) { self.log.push("red_off".into()); } + fn green_on(&mut self) { self.log.push("green_on".into()); } + } + + #[test] + fn t3_3_actions_before_transition() { + let mut m = T33::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["log_go", "prepare", "red_off", "green_on"]); + assert_eq!(m.current_state(), T33State::Green); + } +} + +// ---- T2.2 / T8.1: Timer-driven transition ---- +mod t2_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _Unused } + + statechart! { +T22 { + context: TestCtx; + events: Ev; + default(Red); + state Red { on(after Duration::from_secs(5)) => Green; exit: red_off; } + state Green { entry: green_on; } + } + } + + impl T22Actions for T22ActionContext<'_> { + fn red_off(&mut self) { self.log.push("red_off".into()); } + fn green_on(&mut self) { self.log.push("green_on".into()); } + } + + #[test] + fn t2_2_timer_transition() { + let mut m = T22::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T22State::Red); + m.step(Duration::from_secs(4)); + assert_eq!(m.current_state(), T22State::Red); + m.step(Duration::from_secs(1)); + assert_eq!(m.current_state(), T22State::Green); + } +} + +// ---- T4.2: event bubbles to ancestor ---- +mod t4_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo } + + statechart! { +T42 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + exit: a_exit; + on(Foo) => D; + state B { + default(C); + exit: b_exit; + state C { exit: c_exit; } + } + } + state D { entry: d_entry; } + } + } + + impl T42Actions for T42ActionContext<'_> { + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + fn c_exit(&mut self) { self.log.push("c_exit".into()); } + fn d_entry(&mut self) { self.log.push("d_entry".into()); } + } + + #[test] + fn t4_2_bubble() { + let mut m = T42::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T42State::C); + m.send(Ev::Foo).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T42State::D); + assert_eq!(m.context().logs(), ["c_exit", "b_exit", "a_exit", "d_entry"]); + } +} + +// ---- T6.1: terminate ---- +mod t6_1 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Halt } + + statechart! { +T61 { + context: TestCtx; + events: Ev; + default(A); + terminate(Halt); + exit: root_exit; + state A { + default(B); + exit: a_exit; + state B { + default(C); + exit: b_exit; + state C { exit: c_exit; } + } + } + } + } + + impl T61Actions for T61ActionContext<'_> { + fn root_exit(&mut self) { self.log.push("root_exit".into()); } + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + fn c_exit(&mut self) { self.log.push("c_exit".into()); } + } + + #[test] + fn t6_1_terminate() { + let mut m = T61::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Halt).unwrap(); + m.step(Duration::ZERO); + assert!(m.is_terminated()); + assert_eq!(m.context().logs(), ["c_exit", "b_exit", "a_exit", "root_exit"]); + assert!(m.step(Duration::ZERO).is_none()); + } +} + +// ---- T7.1: emit ---- +mod t7_1 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo, Bar } + + statechart! { +T71 { + context: TestCtx; + events: Ev; + default(A); + state A { + on(Foo) => emit_bar; + on(Bar) => B; + } + state B { entry: b_entry; } + } + } + + impl T71Actions for T71ActionContext<'_> { + fn emit_bar(&mut self) { + self.log.push("emit_bar".into()); + self.emit(Ev::Bar).unwrap(); + } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + } + + #[test] + fn t7_1_emit() { + let mut m = T71::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Foo).unwrap(); + m.step(Duration::ZERO); // processes Foo, emits Bar + assert_eq!(m.current_state(), T71State::A); + m.step(Duration::ZERO); // processes Bar + assert_eq!(m.current_state(), T71State::B); + assert_eq!(m.context().logs(), ["emit_bar", "b_entry"]); + } +} + +// ---- T1.3: machine not started until first step ---- +mod t1_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T13 { + context: TestCtx; + events: Ev; + default(Red); + entry: r_entry; + state Red { entry: red_on; } + } + } + + impl T13Actions for T13ActionContext<'_> { + fn r_entry(&mut self) { self.log.push("r_entry".into()); } + fn red_on(&mut self) { self.log.push("red_on".into()); } + } + + #[test] + fn t1_3_not_started() { + let m = T13::new(TestCtx::default()); + assert!(!m.is_terminated()); + assert!(m.context().log.is_empty()); + } +} + +// ---- T2.3 / T8.2: timer cancelled on state exit ---- +mod t2_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { EarlyExit } + + statechart! { +T23 { + context: TestCtx; + events: Ev; + default(Red); + state Red { + on(after Duration::from_secs(5)) => Green; + on(EarlyExit) => Yellow; + } + state Green { entry: green_on; } + state Yellow { entry: yellow_on; } + } + } + + impl T23Actions for T23ActionContext<'_> { + fn green_on(&mut self) { self.log.push("green_on".into()); } + fn yellow_on(&mut self) { self.log.push("yellow_on".into()); } + } + + #[test] + fn t2_3_timer_cancel_on_exit() { + let mut m = T23::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(3)); + m.send(Ev::EarlyExit).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T23State::Yellow); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T23State::Yellow); + } +} + +// ---- T2.4: cross-hierarchy transition ---- +mod t2_4 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Jump } + + statechart! { +T24 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + exit: a_exit; + state B { + default(C); + exit: b_exit; + state C { + exit: c_exit; + on(Jump) => D; + } + } + } + state D { entry: d_entry; } + } + } + + impl T24Actions for T24ActionContext<'_> { + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + fn c_exit(&mut self) { self.log.push("c_exit".into()); } + fn d_entry(&mut self) { self.log.push("d_entry".into()); } + } + + #[test] + fn t2_4_cross_hierarchy() { + let mut m = T24::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Jump).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T24State::D); + assert_eq!(m.context().logs(), ["c_exit", "b_exit", "a_exit", "d_entry"]); + } +} + +// ---- T2.5: transition to state with children descends to default ---- +mod t2_5 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T25 { + context: TestCtx; + events: Ev; + default(A); + state A { on(Go) => B; } + state B { + default(C); + entry: b_entry; + state C { + default(D); + entry: c_entry; + state D { entry: d_entry; } + } + } + } + } + + impl T25Actions for T25ActionContext<'_> { + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn c_entry(&mut self) { self.log.push("c_entry".into()); } + fn d_entry(&mut self) { self.log.push("d_entry".into()); } + } + + #[test] + fn t2_5_descend_to_default() { + let mut m = T25::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T25State::D); + assert_eq!(m.context().logs(), ["b_entry", "c_entry", "d_entry"]); + } +} + +// ---- T2.7: transition from child to sibling of parent ---- +mod t2_7 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T27 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + exit: a_exit; + state B { exit: b_exit; on(Go) => C; } + } + state C { entry: c_entry; } + } + } + + impl T27Actions for T27ActionContext<'_> { + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + fn c_entry(&mut self) { self.log.push("c_entry".into()); } + } + + #[test] + fn t2_7_child_to_uncle() { + let mut m = T27::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T27State::C); + assert_eq!(m.context().logs(), ["b_exit", "a_exit", "c_entry"]); + } +} + +// ---- T2.8: transition to an ancestor ---- +mod t2_8 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Up } + + statechart! { +T28 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + entry: a_entry; + exit: a_exit; + state B { + entry: b_entry; + exit: b_exit; + on(Up) => A; + } + } + } + } + + impl T28Actions for T28ActionContext<'_> { + fn a_entry(&mut self) { self.log.push("a_entry".into()); } + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + } + + #[test] + fn t2_8_transition_to_ancestor() { + let mut m = T28::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["a_entry", "b_entry"]); + m.context_mut().log.clear(); + m.send(Ev::Up).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T28State::B); + assert_eq!(m.context().logs(), ["b_exit", "a_exit", "a_entry", "b_entry"]); + } +} + +// ---- T3.1 / T3.2: actions in declaration order, no state change ---- +mod t3_12 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Beep } + + statechart! { +T312 { + context: TestCtx; + events: Ev; + default(Red); + state Red { + on(Beep) => honk1; + on(Beep) => honk2; + on(Beep) => honk3; + } + } + } + + impl T312Actions for T312ActionContext<'_> { + fn honk1(&mut self) { self.log.push("honk1".into()); } + fn honk2(&mut self) { self.log.push("honk2".into()); } + fn honk3(&mut self) { self.log.push("honk3".into()); } + } + + #[test] + fn t3_1_t3_2_actions() { + let mut m = T312::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Beep).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["honk1", "honk2", "honk3"]); + assert_eq!(m.current_state(), T312State::Red); + } +} + +// ---- T3.4: timer-driven action ---- +mod t3_4 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T34 { + context: TestCtx; + events: Ev; + default(Red); + state Red { + on(after Duration::from_millis(500)) => heartbeat; + } + } + } + + impl T34Actions for T34ActionContext<'_> { + fn heartbeat(&mut self) { self.log.push("beat".into()); } + } + + #[test] + fn t3_4_timer_action() { + let mut m = T34::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_millis(500)); + assert_eq!(m.context().logs(), ["beat"]); + assert_eq!(m.current_state(), T34State::Red); + // Should not restart. + m.step(Duration::from_millis(500)); + m.step(Duration::from_millis(500)); + assert_eq!(m.context().logs(), ["beat"]); + } +} + +// ---- T4.1 / T4.3 / T4.4 / T4.5 ---- +mod t4_misc { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo, Unknown } + + statechart! { +T4M { + context: TestCtx; + events: Ev; + default(A); + on(Foo) => root_handler; + state A { + default(B); + state B { + default(C); + state C { entry: c_entry; } + } + } + } + } + + impl T4MActions for T4MActionContext<'_> { + fn root_handler(&mut self) { self.log.push("root".into()); } + fn c_entry(&mut self) { /* nop */ } + } + + #[test] + fn t4_3_bubble_to_root() { + let mut m = T4M::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Foo).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["root"]); + } + + #[test] + fn t4_5_unknown_event_discarded() { + let mut m = T4M::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Unknown).unwrap(); + m.step(Duration::ZERO); + assert!(m.context().log.is_empty()); + assert_eq!(m.current_state(), T4MState::C); + } +} + +mod t4_14 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo } + + statechart! { +T414 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(C); + on(Foo) => E; + state C { on(Foo) => D; } + } + state D { entry: d_entry; } + state E { entry: e_entry; } + } + } + + impl T414Actions for T414ActionContext<'_> { + fn d_entry(&mut self) { self.log.push("d_entry".into()); } + fn e_entry(&mut self) { self.log.push("e_entry".into()); } + } + + #[test] + fn t4_1_and_t4_4_leaf_shadows() { + let mut m = T414::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T414State::C); + m.send(Ev::Foo).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T414State::D); + assert_eq!(m.context().logs(), ["d_entry"]); + } +} + +// ---- T4.6: timer triggers do NOT bubble ---- +mod t4_6 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T46 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + on(after Duration::from_secs(10)) => D; + state B { + default(C); + state C { entry: c_entry; } + } + } + state D { entry: d_entry; } + } + } + + impl T46Actions for T46ActionContext<'_> { + fn d_entry(&mut self) { self.log.push("d_entry".into()); } + fn c_entry(&mut self) { /* nop */ } + } + + #[test] + fn t4_6_timer_fires_for_owner() { + let mut m = T46::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T46State::C); + m.step(Duration::from_secs(10)); + assert_eq!(m.current_state(), T46State::D); + } +} + +// ---- T5.1 / T5.2 / T5.3 / T5.4 ordering ---- +mod t5 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T5X { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + entry: alpha, beta; + entry: gamma; + exit: cleanup1; + exit: cleanup2; + state B { + entry: b_entry; + exit: b_exit; + on(Go) => Z; + } + } + state Z { entry: z_entry; } + } + } + + impl T5XActions for T5XActionContext<'_> { + fn alpha(&mut self) { self.log.push("alpha".into()); } + fn beta(&mut self) { self.log.push("beta".into()); } + fn gamma(&mut self) { self.log.push("gamma".into()); } + fn cleanup1(&mut self) { self.log.push("cleanup1".into()); } + fn cleanup2(&mut self) { self.log.push("cleanup2".into()); } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn b_exit(&mut self) { self.log.push("b_exit".into()); } + fn z_entry(&mut self) { self.log.push("z_entry".into()); } + } + + #[test] + fn t5_1_t5_3_entry_order() { + let mut m = T5X::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["alpha", "beta", "gamma", "b_entry"]); + } + + #[test] + fn t5_2_t5_4_exit_order() { + let mut m = T5X::new(TestCtx::default()); + m.step(Duration::ZERO); + m.context_mut().log.clear(); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["b_exit", "cleanup1", "cleanup2", "z_entry"]); + } +} + +// ---- T5.5: LCA not exited/re-entered ---- +mod t5_5 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T55 { + context: TestCtx; + events: Ev; + default(Parent); + state Parent { + default(A); + entry: parent_entry; + exit: parent_exit; + on(after Duration::from_secs(10)) => Somewhere; + state A { entry: a_entry; exit: a_exit; on(Go) => B; } + state B { entry: b_entry; } + } + state Somewhere { entry: somewhere_entry; } + } + } + + impl T55Actions for T55ActionContext<'_> { + fn parent_entry(&mut self) { self.log.push("parent_entry".into()); } + fn parent_exit(&mut self) { self.log.push("parent_exit".into()); } + fn a_entry(&mut self) { self.log.push("a_entry".into()); } + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn somewhere_entry(&mut self) { self.log.push("somewhere_entry".into()); } + } + + #[test] + fn t5_5_lca_stays_active() { + let mut m = T55::new(TestCtx::default()); + // After initial entry, Parent's 10s timer is running. Burn 3s first. + let next = m.step(Duration::ZERO); + assert!(next.is_some()); + m.step(Duration::from_secs(3)); + m.context_mut().log.clear(); + m.send(Ev::Go).unwrap(); + let next_after = m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["a_exit", "b_entry"]); + // Parent's timer should still be live with ~7s remaining — not reset to 10s. + let remaining = next_after.expect("parent timer still armed"); + assert!( + remaining <= Duration::from_secs(7) + Duration::from_millis(50), + "parent timer should not have been restarted; remaining={:?}", + remaining + ); + assert!( + remaining >= Duration::from_secs(6), + "parent timer should have ~7s left; remaining={:?}", + remaining + ); + } +} + +// ---- T6.2: terminated machine rejects events ---- +mod t6_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Halt, Other } + + statechart! { +T62 { + context: TestCtx; + events: Ev; + default(A); + terminate(Halt); + state A { entry: a_entry; } + } + } + + impl T62Actions for T62ActionContext<'_> { + fn a_entry(&mut self) { self.log.push("a".into()); } + } + + #[test] + fn t6_2_rejects_after_term() { + let mut m = T62::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Halt).unwrap(); + m.step(Duration::ZERO); + assert!(m.is_terminated()); + let r = m.send(Ev::Other); + assert!(r.is_err()); + } +} + +// ---- T6.4: step() after termination ---- +mod t6_4 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Halt } + + statechart! { +T64 { + context: TestCtx; + events: Ev; + default(A); + terminate(Halt); + state A { entry: a_entry; } + } + } + + impl T64Actions for T64ActionContext<'_> { + fn a_entry(&mut self) { self.log.push("a".into()); } + } + + #[test] + fn t6_4_step_after_term() { + let mut m = T64::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Halt).unwrap(); + m.step(Duration::ZERO); + let before = m.context().logs().len(); + let r = m.step(Duration::from_secs(5)); + assert!(r.is_none()); + assert_eq!(m.context().logs().len(), before); + } +} + +// ---- T7.2: emit during entry/exit ---- +mod t7_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go, Cleanup } + + statechart! { +T72 { + context: TestCtx; + events: Ev; + default(A); + on(Cleanup) => do_cleanup; + state A { + exit: emit_cleanup; + on(Go) => B; + } + state B { entry: b_entry; } + } + } + + impl T72Actions for T72ActionContext<'_> { + fn emit_cleanup(&mut self) { + self.log.push("a_exit".into()); + self.emit(Ev::Cleanup).unwrap(); + } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + fn do_cleanup(&mut self) { self.log.push("do_cleanup".into()); } + } + + #[test] + fn t7_2_emit_during_transition() { + let mut m = T72::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); // process Go; A_exit emits Cleanup; B_entry + assert_eq!(m.current_state(), T72State::B); + m.step(Duration::ZERO); // process Cleanup + assert_eq!(m.context().logs(), ["a_exit", "b_entry", "do_cleanup"]); + } +} + +// ---- T7.3: queue overflow from emit ---- +mod t7_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Start, Tick } + + statechart! { +T73 { + context: TestCtx; + events: Ev; + default(A); + state A { + on(Start) => flood; + } + } + } + + impl T73Actions for T73ActionContext<'_> { + fn flood(&mut self) { + // Queue capacity is 8 (via with_queue_capacity). Emit 9 times. + let mut errors = 0; + for _ in 0..9 { + if self.emit(Ev::Tick).is_err() { errors += 1; } + } + self.log.push(format!("err={}", errors)); + } + } + + #[test] + fn t7_3_queue_overflow() { + let mut m = T73::with_queue_capacity::<8>(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Start).unwrap(); + m.step(Duration::ZERO); + assert!(m.context().log.iter().any(|s| s.starts_with("err="))); + let got = m.context().log.last().unwrap().clone(); + assert_ne!(got, "err=0"); + } +} + +// ---- T8.3: parent timer survives child transitions ---- +mod t8_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T83 { + context: TestCtx; + events: Ev; + default(Parent); + state Parent { + default(ChildA); + exit: parent_exit; + on(after Duration::from_secs(10)) => Done; + state ChildA { + exit: childa_exit; + on(after Duration::from_secs(3)) => ChildB; + } + state ChildB { exit: childb_exit; entry: childb_entry; } + } + state Done { entry: done_entry; } + } + } + + impl T83Actions for T83ActionContext<'_> { + fn parent_exit(&mut self) { self.log.push("parent_exit".into()); } + fn childa_exit(&mut self) { self.log.push("childa_exit".into()); } + fn childb_exit(&mut self) { self.log.push("childb_exit".into()); } + fn childb_entry(&mut self) { self.log.push("childb_entry".into()); } + fn done_entry(&mut self) { self.log.push("done_entry".into()); } + } + + #[test] + fn t8_3_parent_timer_survives() { + let mut m = T83::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(3)); + assert_eq!(m.current_state(), T83State::ChildB); + m.step(Duration::from_secs(7)); + assert_eq!(m.current_state(), T83State::Done); + let logs = m.context().logs(); + // Expect childb_exit, parent_exit, done_entry in that order at the end. + let tail: Vec<&str> = logs.iter().rev().take(3).rev().cloned().collect(); + assert_eq!(tail, ["childb_exit", "parent_exit", "done_entry"]); + } +} + +// ---- T8.4: timer restarts on re-entry ---- +mod t8_4 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T84 { + context: TestCtx; + events: Ev; + default(Red); + state Red { on(after Duration::from_secs(5)) => Green; } + state Green { on(after Duration::from_secs(5)) => Red; } + } + } + + impl T84Actions for T84ActionContext<'_> {} + + #[test] + fn t8_4_timer_restart() { + let mut m = T84::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T84State::Green); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T84State::Red); + m.step(Duration::from_secs(4)); + assert_eq!(m.current_state(), T84State::Red); + m.step(Duration::from_secs(1)); + assert_eq!(m.current_state(), T84State::Green); + } +} + +// ---- T8.4b: alternation via shared trigger survives a round-trip to a +// sibling subtree and back (regression for firmware splash bug). ---- +mod t8_4b { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { + Go, + Back, + } + + statechart! { + T84b { + context: TestCtx; + events: Ev; + default(On); + + state On { + on(Go) => Listening; + on(Back) => Splash; + default(Splash); + + state Splash { + default(LearnMore); + state LearnMore { + entry: enter_lm; + on(after Duration::from_secs(5)) => Info; + } + state Info { + entry: enter_info; + on(after Duration::from_secs(5)) => LearnMore; + } + } + + state Dashboard { + default(Listening); + state Listening { + entry: enter_listening; + } + } + } + } + } + + impl T84bActions for T84bActionContext<'_> { + fn enter_lm(&mut self) { + self.log.push("LM".into()); + } + fn enter_info(&mut self) { + self.log.push("INFO".into()); + } + fn enter_listening(&mut self) { + self.log.push("LISTEN".into()); + } + } + + #[test] + fn round_trip_restarts_alternation() { + let mut m = T84b::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T84bState::LearnMore); + + // Fresh alternation works. + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T84bState::Info); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T84bState::LearnMore); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T84bState::Info); + + // Jump out to a sibling subtree then back. + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T84bState::Listening); + + m.send(Ev::Back).unwrap(); + m.step(Duration::ZERO); + assert_eq!( + m.current_state(), + T84bState::LearnMore, + "after Back we should land in Splash's default (LearnMore)" + ); + + // Now alternation must work again — this is the firmware bug. + m.step(Duration::from_secs(5)); + assert_eq!( + m.current_state(), + T84bState::Info, + "LearnMore's 5s timer should fire after re-entry" + ); + m.step(Duration::from_secs(5)); + assert_eq!( + m.current_state(), + T84bState::LearnMore, + "Info's 5s timer should fire after its first re-entry" + ); + } +} + +// ---- T8.5: multiple timers in different hierarchy levels ---- +mod t8_5 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T85 { + context: TestCtx; + events: Ev; + default(A); + state A { + default(B); + on(after Duration::from_secs(2)) => ping; + state B { on(after Duration::from_secs(5)) => C; } + state C { entry: c_entry; } + } + } + } + + impl T85Actions for T85ActionContext<'_> { + fn ping(&mut self) { self.log.push("ping".into()); } + fn c_entry(&mut self) { self.log.push("c_entry".into()); } + } + + #[test] + fn t8_5_multi_level_timers() { + let mut m = T85::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(2)); + assert_eq!(m.current_state(), T85State::B); + assert_eq!(m.context().logs(), ["ping"]); + m.step(Duration::from_secs(3)); + assert_eq!(m.current_state(), T85State::C); + // Verify A's timer does not re-fire + m.step(Duration::from_secs(10)); + let count = m.context().log.iter().filter(|s| s.as_str() == "ping").count(); + assert_eq!(count, 1); + } +} + +// ---- T10.2: timer-only machine ---- +mod t10_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum NoEvents {} + + statechart! { +TimerOnly { + context: TestCtx; + events: NoEvents; + default(A); + state A { on(after Duration::from_secs(1)) => B; entry: a_e; } + state B { on(after Duration::from_secs(1)) => A; entry: b_e; } + } + } + + impl TimerOnlyActions for TimerOnlyActionContext<'_> { + fn a_e(&mut self) { self.log.push("a".into()); } + fn b_e(&mut self) { self.log.push("b".into()); } + } + + #[test] + fn t10_2_timer_only() { + let mut m = TimerOnly::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(1)); + m.step(Duration::from_secs(1)); + assert_eq!(m.context().logs(), ["a", "b", "a"]); + } +} + +// ---- T10.3: deeply nested ---- +mod t10_3 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +Deep { + context: TestCtx; + events: Ev; + default(A); + entry: root_e; + state A { + default(B); + entry: a_e; + state B { + default(C); + entry: b_e; + state C { + default(D); + entry: c_e; + state D { + default(E); + entry: d_e; + state E { entry: e_e; } + } + } + } + } + } + } + + impl DeepActions for DeepActionContext<'_> { + fn root_e(&mut self) { self.log.push("root".into()); } + fn a_e(&mut self) { self.log.push("a".into()); } + fn b_e(&mut self) { self.log.push("b".into()); } + fn c_e(&mut self) { self.log.push("c".into()); } + fn d_e(&mut self) { self.log.push("d".into()); } + fn e_e(&mut self) { self.log.push("e".into()); } + } + + #[test] + fn t10_3_deep_descent() { + let mut m = Deep::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), DeepState::E); + assert_eq!(m.context().logs(), ["root", "a", "b", "c", "d", "e"]); + } +} + +// ---- T10.4: chain of emits ---- +mod t10_4 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo, Bar, Baz } + + statechart! { +T104 { + context: TestCtx; + events: Ev; + default(A); + state A { + on(Foo) => emit_bar; + on(Bar) => emit_baz; + on(Baz) => B; + } + state B { entry: b_entry; } + } + } + + impl T104Actions for T104ActionContext<'_> { + fn emit_bar(&mut self) { self.emit(Ev::Bar).unwrap(); self.log.push("bar".into()); } + fn emit_baz(&mut self) { self.emit(Ev::Baz).unwrap(); self.log.push("baz".into()); } + fn b_entry(&mut self) { self.log.push("b".into()); } + } + + #[test] + fn t10_4_emit_chain() { + let mut m = T104::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Foo).unwrap(); + for _ in 0..5 { m.step(Duration::ZERO); } + assert_eq!(m.current_state(), T104State::B); + assert_eq!(m.context().logs(), ["bar", "baz", "b"]); + } +} + +// ---- T10.5: emit during termination exit cascade ---- +mod t10_5 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Halt, Cleanup } + + statechart! { +T105 { + context: TestCtx; + events: Ev; + default(A); + terminate(Halt); + on(Cleanup) => do_cleanup; + state A { exit: a_exit; } + } + } + + impl T105Actions for T105ActionContext<'_> { + fn a_exit(&mut self) { + self.log.push("a_exit".into()); + // emit should be a no-op / ignored after termination begins + let _ = self.emit(Ev::Cleanup); + } + fn do_cleanup(&mut self) { self.log.push("do_cleanup".into()); } + } + + #[test] + fn t10_5_emit_during_term() { + let mut m = T105::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Halt).unwrap(); + m.step(Duration::ZERO); + assert!(m.is_terminated()); + // Keep stepping; do_cleanup should NOT run + for _ in 0..3 { m.step(Duration::ZERO); } + assert!(!m.context().logs().contains(&"do_cleanup")); + } +} + +// ---- T10.6: transition from root level ---- +mod t10_6 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T106 { + context: TestCtx; + events: Ev; + default(Deep1); + entry: root_entry; + exit: root_exit; + on(after Duration::from_secs(60)) => FirstState; + state Deep1 { + default(Deep2); + exit: d1_exit; + state Deep2 { exit: d2_exit; } + } + state FirstState { entry: first_entry; } + } + } + + impl T106Actions for T106ActionContext<'_> { + fn root_entry(&mut self) { self.log.push("root_entry".into()); } + fn root_exit(&mut self) { self.log.push("root_exit".into()); } + fn d1_exit(&mut self) { self.log.push("d1_exit".into()); } + fn d2_exit(&mut self) { self.log.push("d2_exit".into()); } + fn first_entry(&mut self) { self.log.push("first_entry".into()); } + } + + #[test] + fn t10_6_root_transition() { + let mut m = T106::new(TestCtx::default()); + m.step(Duration::ZERO); + m.context_mut().log.clear(); + m.step(Duration::from_secs(60)); + assert_eq!(m.current_state(), T106State::FirstState); + // Root is LCA of itself → NOT exited; only Deep1/Deep2 exit + let logs = m.context().logs(); + assert!(!logs.contains(&"root_exit")); + assert_eq!(logs, ["d2_exit", "d1_exit", "first_entry"]); + } +} + +// ---- T10.7: into_context after termination ---- +mod t10_7 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Halt } + + statechart! { +T107 { + context: TestCtx; + events: Ev; + default(A); + terminate(Halt); + state A { entry: a_e; } + } + } + + impl T107Actions for T107ActionContext<'_> { + fn a_e(&mut self) { self.log.push("hello".into()); } + } + + #[test] + fn t10_7_into_context() { + let mut m = T107::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Halt).unwrap(); + m.step(Duration::ZERO); + assert!(m.is_terminated()); + let ctx = m.into_context(); + assert_eq!(ctx.log, vec!["hello".to_string()]); + } +} + +// ---- T10.1: same action name used in multiple states ---- +mod t10_1 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Go } + + statechart! { +T101 { + context: TestCtx; + events: Ev; + default(A); + state A { entry: shared_setup; on(Go) => B; } + state B { entry: shared_setup; } + } + } + + impl T101Actions for T101ActionContext<'_> { + fn shared_setup(&mut self) { self.log.push("shared".into()); } + } + + #[test] + fn t10_1_shared_action() { + let mut m = T101::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Go).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.context().logs(), ["shared", "shared"]); + } +} + +// ---- T8.1: timer starts on state entry (dedicated per spec §9.8) ---- +mod t8_1 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { _U } + + statechart! { +T81 { + context: TestCtx; + events: Ev; + default(Red); + state Red { on(after Duration::from_secs(5)) => Green; } + state Green { entry: green_on; } + } + } + + impl T81Actions for T81ActionContext<'_> { + fn green_on(&mut self) { self.log.push("green_on".into()); } + } + + #[test] + fn t8_1_timer_starts_on_entry() { + let mut m = T81::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_millis(4900)); + assert_eq!(m.current_state(), T81State::Red); + m.step(Duration::from_millis(100)); + assert_eq!(m.current_state(), T81State::Green); + } +} + +// ---- T8.2: timer cancelled on state exit (dedicated per spec §9.8) ---- +mod t8_2 { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Interrupt } + + statechart! { +T82 { + context: TestCtx; + events: Ev; + default(Red); + state Red { + on(after Duration::from_secs(5)) => Green; + on(Interrupt) => Yellow; + } + state Green { entry: green_on; } + state Yellow { entry: yellow_on; } + } + } + + impl T82Actions for T82ActionContext<'_> { + fn green_on(&mut self) { self.log.push("green_on".into()); } + fn yellow_on(&mut self) { self.log.push("yellow_on".into()); } + } + + #[test] + fn t8_2_timer_cancelled_on_exit() { + let mut m = T82::new(TestCtx::default()); + m.step(Duration::ZERO); + m.step(Duration::from_secs(2)); + m.send(Ev::Interrupt).unwrap(); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), T82State::Yellow); + m.step(Duration::from_secs(5)); + assert_eq!(m.current_state(), T82State::Yellow); + assert!(!m.context().logs().contains(&"green_on")); + } +} + +// ---- B1: emit_or_panic convenience (§12.1) ---- +mod b1_emit_or_panic { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo, Bar } + + statechart! { +B1M { + context: TestCtx; + events: Ev; + default(A); + state A { + on(Foo) => emit_bar; + on(Bar) => B; + } + state B { entry: b_entry; } + } + } + + impl B1MActions for B1MActionContext<'_> { + fn emit_bar(&mut self) { + self.log.push("emit_bar".into()); + self.emit_or_panic(Ev::Bar); + } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + } + + #[test] + fn b1_emit_or_panic() { + let mut m = B1M::new(TestCtx::default()); + m.step(Duration::ZERO); + m.send(Ev::Foo).unwrap(); + m.step(Duration::ZERO); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), B1MState::B); + assert_eq!(m.context().logs(), ["emit_bar", "b_entry"]); + } +} + +// ---- B2: has_pending_events (§12.3) ---- +mod b2_has_pending { + use super::*; + #[derive(Debug, Clone)] + pub enum Ev { Foo } + + statechart! { +B2M { + context: TestCtx; + events: Ev; + default(A); + state A { on(Foo) => noop; } + } + } + + impl B2MActions for B2MActionContext<'_> { + fn noop(&mut self) { self.log.push("noop".into()); } + } + + #[test] + fn b2_has_pending_events() { + let mut m = B2M::new(TestCtx::default()); + m.step(Duration::ZERO); + assert!(!m.has_pending_events()); + m.send(Ev::Foo).unwrap(); + assert!(m.has_pending_events()); + m.step(Duration::ZERO); + assert!(!m.has_pending_events()); + } +} + +// ---- B5: optional `events:` for timer-only machines (§12.2) ---- +mod b5_no_events { + use super::*; + + statechart! { +B5M { + context: TestCtx; + default(A); + state A { on(after Duration::from_millis(100)) => B; exit: a_exit; } + state B { entry: b_entry; } + } + } + + impl B5MActions for B5MActionContext<'_> { + fn a_exit(&mut self) { self.log.push("a_exit".into()); } + fn b_entry(&mut self) { self.log.push("b_entry".into()); } + } + + #[test] + fn b5_timer_only_no_events() { + let mut m = B5M::new(TestCtx::default()); + m.step(Duration::ZERO); + assert_eq!(m.current_state(), B5MState::A); + m.step(Duration::from_millis(100)); + assert_eq!(m.current_state(), B5MState::B); + assert_eq!(m.context().logs(), ["a_exit", "b_entry"]); + } +} diff --git a/hsmc/tests/dispatch_drain.rs b/hsmc/tests/dispatch_drain.rs new file mode 100644 index 0000000..f17e196 --- /dev/null +++ b/hsmc/tests/dispatch_drain.rs @@ -0,0 +1,51 @@ +//! `m.dispatch(ev).await` pushes an event AND drains the internal queue to +//! quiescence before returning. After it returns, `current_state()` reflects +//! the post-dispatch state — the replacement for the old `send + drain` +//! helper that firmware users hand-rolled. + +#![cfg(feature = "tokio")] + +use hsmc::statechart; + +#[derive(Default)] +pub struct Ctx { + pub transitioned: u32, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Go, + Halt, +} + +statechart! { +DispatchM { + context: Ctx; + events: Ev; + default(Start); + terminate(Halt); + + state Start { + on(Go) => Next; + } + state Next { + entry: mark_entered; + } +} +} + +impl DispatchMActions for DispatchMActionContext<'_> { + async fn mark_entered(&mut self) { + self.transitioned = self.transitioned.wrapping_add(1); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn dispatch_drains_before_returning() { + let mut m = DispatchM::new(Ctx::default()); + // One call does: prime state, enqueue Go, process it, reach Next, + // run mark_entered, all before returning. + m.dispatch(Ev::Go).await.unwrap(); + assert_eq!(m.current_state(), DispatchMState::Next); + assert_eq!(m.context().transitioned, 1); +} diff --git a/hsmc/tests/during_basic.rs b/hsmc/tests/during_basic.rs new file mode 100644 index 0000000..ee56f5a --- /dev/null +++ b/hsmc/tests/during_basic.rs @@ -0,0 +1,75 @@ +//! `during:` activity: a single async function scoped to a state, repeatedly +//! producing events while the state is active. This test verifies that the +//! during fires, the generated event dispatches through the handler chain, +//! and a transition out of the state cancels the during. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx { + pub tick_field: u32, + pub seen_tick: u32, + pub log: Vec, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Tick, + Halt, +} + +statechart! { +DuringM { + context: Ctx; + events: Ev; + default(Running); + terminate(Halt); + + state Running { + during: ticker(tick_field); + on(Tick) => saw_tick; + } +} +} + +async fn ticker(tick_field: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_millis(10)).await; + *tick_field = tick_field.wrapping_add(1); + Ev::Tick +} + +impl DuringMActions for DuringMActionContext<'_> { + async fn saw_tick(&mut self) { + self.seen_tick = self.seen_tick.wrapping_add(1); + let n = self.seen_tick; + self.log.push(format!("tick#{}", n)); + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn during_fires_repeatedly_until_halt() { + let mut m = DuringM::new(Ctx::default()); + let sender = m.sender(); + // Let a few ticks happen, then halt. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(45)).await; + let _ = sender.send(Ev::Halt); + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + let res = res.expect("run() hung past timeout"); + assert!(res.is_ok(), "run() returned error: {:?}", res); + assert!(m.is_terminated()); + let ctx = m.into_context(); + // We slept for 45ms and the during fires every 10ms — expect ≥3 ticks + // before Halt is dispatched. (The first dispatch happens at t≈10ms.) + assert!( + ctx.seen_tick >= 3, + "expected ≥3 ticks, got {} (log={:?})", + ctx.seen_tick, + ctx.log + ); + // Field mutation is observed. + assert_eq!(ctx.tick_field, ctx.seen_tick); +} diff --git a/hsmc/tests/during_cancel.rs b/hsmc/tests/during_cancel.rs new file mode 100644 index 0000000..577a320 --- /dev/null +++ b/hsmc/tests/during_cancel.rs @@ -0,0 +1,98 @@ +//! Transition cancels an in-flight `during:` future: the future is dropped +//! when the machine leaves the state, and the future's progress (counted by +//! a cancel-on-drop side effect) reflects the cancellation. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +/// A cancel-aware guard that bumps `dropped_count` when dropped before +/// completion. Used by the during to prove it was cancelled by a transition. +pub struct DropGuard<'a> { + counter: &'a mut u32, + completed: bool, +} + +impl<'a> Drop for DropGuard<'a> { + fn drop(&mut self) { + if !self.completed { + *self.counter = self.counter.wrapping_add(1); + } + } +} + +#[derive(Default)] +pub struct Ctx { + pub drop_count: u32, + pub ticked: u32, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Leave, + Halt, +} + +statechart! { +CancelM { + context: Ctx; + events: Ev; + default(Inside); + terminate(Halt); + + state Inside { + during: slow_tick(drop_count); + on(Leave) => Outside; + } + + state Outside { + on(Halt) => Outside; // placeholder so state isn't empty + } +} +} + +/// A slow activity: creates a drop guard, sleeps 500ms (much longer than +/// our test window), and would bump `ticked` only on successful completion. +/// When cancelled, the guard's Drop increments drop_count. +async fn slow_tick(drop_count: &mut u32) -> Ev { + let mut guard = DropGuard { + counter: drop_count, + completed: false, + }; + tokio::time::sleep(Duration::from_millis(500)).await; + guard.completed = true; + // unreachable in this test; the transition drops us before 500ms + Ev::Leave +} + +impl CancelMActions for CancelMActionContext<'_> { + // no handler work needed +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn transition_cancels_during_future() { + let mut m = CancelM::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn({ + let s = sender.clone(); + async move { + // Transition out at 20ms, well before the during's 500ms sleep. + tokio::time::sleep(Duration::from_millis(20)).await; + let _ = s.send(Ev::Leave); + // Then terminate. + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = s.send(Ev::Halt); + } + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + assert!(res.expect("run hung").is_ok()); + let ctx = m.into_context(); + // During was cancelled → DropGuard fired → drop_count >= 1. + assert!( + ctx.drop_count >= 1, + "expected cancel to trigger DropGuard, got drop_count={}", + ctx.drop_count + ); + // The during never completed so ticked should be 0. + assert_eq!(ctx.ticked, 0); +} diff --git a/hsmc/tests/during_concurrent.rs b/hsmc/tests/during_concurrent.rs new file mode 100644 index 0000000..807d2f3 --- /dev/null +++ b/hsmc/tests/during_concurrent.rs @@ -0,0 +1,84 @@ +//! Multiple `during:` activities on the same state. Verifies that split-borrow +//! lets each during hold `&mut` to disjoint fields of the context, that the +//! generated `select_N` call compiles, and that the expected during fires +//! when given enough time. +//! +//! Note on starvation: when several durings race via `select`, the shortest +//! one always wins and the others are dropped. That's the documented +//! cancel-safety contract. The test reflects that by only asserting that the +//! fast during fires and the slow one *can* fire eventually (not that it +//! fires deterministically within the test window). + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx { + pub fast_field: u32, + pub slow_field: u32, + pub seen_fast: u32, + pub seen_slow: u32, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Fast, + Slow, + Halt, +} + +statechart! { +Concurrent { + context: Ctx; + events: Ev; + default(Running); + terminate(Halt); + + state Running { + during: fast(fast_field); + during: slow(slow_field); + on(Fast) => on_fast; + on(Slow) => on_slow; + } +} +} + +async fn fast(f: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_millis(5)).await; + *f = f.wrapping_add(1); + Ev::Fast +} + +async fn slow(s: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_millis(15)).await; + *s = s.wrapping_add(1); + Ev::Slow +} + +impl ConcurrentActions for ConcurrentActionContext<'_> { + async fn on_fast(&mut self) { + self.seen_fast = self.seen_fast.wrapping_add(1); + } + async fn on_slow(&mut self) { + self.seen_slow = self.seen_slow.wrapping_add(1); + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn both_durings_compile_and_fast_fires() { + let mut m = Concurrent::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(80)).await; + let _ = sender.send(Ev::Halt); + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + assert!(res.expect("run hung").is_ok()); + let ctx = m.into_context(); + // Fast (5ms period) wins its race consistently against slow (15ms). + // This documents the starvation property: in select-drop semantics, + // the faster during starves the slower one. + assert!(ctx.seen_fast >= 4, "expected fast ≥4, got {}", ctx.seen_fast); + assert_eq!(ctx.fast_field, ctx.seen_fast); +} diff --git a/hsmc/tests/during_hierarchical.rs b/hsmc/tests/during_hierarchical.rs new file mode 100644 index 0000000..eb01cd0 --- /dev/null +++ b/hsmc/tests/during_hierarchical.rs @@ -0,0 +1,85 @@ +//! `during:` activities declared on ancestor states run concurrently with +//! activities on descendant states. The active during set is the union of +//! every during on the current state path. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx { + pub parent_field: u32, + pub child_field: u32, + pub saw_parent: u32, + pub saw_child: u32, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Parent, + Child, + Halt, +} + +statechart! { +Hierarchy { + context: Ctx; + events: Ev; + default(Outer); + terminate(Halt); + + state Outer { + during: parent_tick(parent_field); + default(Inner); + on(Parent) => on_parent; + on(Child) => on_child; + + state Inner { + during: child_tick(child_field); + } + } +} +} + +async fn parent_tick(f: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_millis(7)).await; + *f = f.wrapping_add(1); + Ev::Parent +} + +async fn child_tick(f: &mut u32) -> Ev { + tokio::time::sleep(Duration::from_millis(5)).await; + *f = f.wrapping_add(1); + Ev::Child +} + +impl HierarchyActions for HierarchyActionContext<'_> { + async fn on_parent(&mut self) { + self.saw_parent = self.saw_parent.wrapping_add(1); + } + async fn on_child(&mut self) { + self.saw_child = self.saw_child.wrapping_add(1); + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn both_parent_and_child_durings_active() { + let mut m = Hierarchy::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = sender.send(Ev::Halt); + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + assert!(res.expect("run hung").is_ok()); + let ctx = m.into_context(); + // The child during (5ms) is faster than the parent (7ms), so in this + // select-drop model the child wins most iterations. We assert the + // fastest fires enough times; the parent is allowed to starve. + assert!( + ctx.saw_child >= 3, + "expected child ≥3, got {}", + ctx.saw_child + ); + assert_eq!(ctx.child_field, ctx.saw_child); +} diff --git a/hsmc/tests/state_chart_const.rs b/hsmc/tests/state_chart_const.rs new file mode 100644 index 0000000..63d5ea5 --- /dev/null +++ b/hsmc/tests/state_chart_const.rs @@ -0,0 +1,65 @@ +//! Every generated machine exposes a `STATE_CHART: &'static str` constant +//! containing an ASCII tree of its hierarchy. Useful for `defmt::info!`, +//! panic messages, and documentation. + +#![cfg(not(any(feature = "tokio", feature = "embassy")))] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx; + +#[derive(Debug, Clone)] +pub enum Ev { + Go, + Back, + Halt, +} + +statechart! { +Diag { + context: Ctx; + events: Ev; + default(Idle); + terminate(Halt); + + state Idle { + entry: enter_idle; + on(Go) => Running; + } + state Running { + default(Fast); + on(Back) => Idle; + state Fast { + on(after Duration::from_millis(10)) => Slow; + } + state Slow { + on(after Duration::from_millis(20)) => Fast; + } + } +} +} + +impl DiagActions for DiagActionContext<'_> { + fn enter_idle(&mut self) {} +} + +#[test] +fn state_chart_is_non_empty_and_names_states() { + // The machine carries a const generic queue capacity; use turbofish to + // reach the associated const externally. + let chart = Diag::<8>::STATE_CHART; + // Starts with the machine name. + assert!(chart.starts_with("Diag\n"), "chart: {:?}", chart); + // Names every declared state. + assert!(chart.contains("Idle")); + assert!(chart.contains("Running")); + assert!(chart.contains("Fast")); + assert!(chart.contains("Slow")); + // Marks the default child on the root and on Running. + assert!(chart.contains("[default] Idle")); + assert!(chart.contains("[default] Fast")); + // Shows entry action and handlers. + assert!(chart.contains("entry: enter_idle")); + assert!(chart.contains("on(Go)")); +} diff --git a/hsmc/tests/state_chart_preview.rs b/hsmc/tests/state_chart_preview.rs new file mode 100644 index 0000000..5eec9ae --- /dev/null +++ b/hsmc/tests/state_chart_preview.rs @@ -0,0 +1,54 @@ +//! Prints the generated STATE_CHART string. Useful as a visual check +//! during development. Run with `cargo test --test state_chart_preview -- +//! --nocapture`. + +#![cfg(not(any(feature = "tokio", feature = "embassy")))] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx; + +#[derive(Debug, Clone)] +pub enum Ev { + Go, + Halt, +} + +statechart! { +Preview { + context: Ctx; + events: Ev; + default(Idle); + terminate(Halt); + + state Idle { + entry: on_idle; + on(Go) => Running; + } + state Running { + entry: on_run; + exit: leave_run; + default(Fast); + on(Halt) => Running; + + state Fast { + on(after Duration::from_millis(10)) => Slow; + } + state Slow { + on(after Duration::from_millis(20)) => Fast; + } + } +} +} + +impl PreviewActions for PreviewActionContext<'_> { + fn on_idle(&mut self) {} + fn on_run(&mut self) {} + fn leave_run(&mut self) {} +} + +#[test] +fn preview_state_chart() { + println!("\n{}", Preview::<8>::STATE_CHART); +} diff --git a/hsmc/tests/timer_only.rs b/hsmc/tests/timer_only.rs new file mode 100644 index 0000000..d899e06 --- /dev/null +++ b/hsmc/tests/timer_only.rs @@ -0,0 +1,59 @@ +//! Timer-only statechart: no `events:` declaration. The machine is driven +//! purely by timer triggers. No `Sender` is generated; `run()` races +//! timers and any `during:` activities. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct BlinkCtx { + pub on_count: u32, + pub off_count: u32, +} + +statechart! { +Blinker { + context: BlinkCtx; + default(On); + + state On { + entry: bump_on; + on(after Duration::from_millis(10)) => Off; + } + state Off { + entry: bump_off; + on(after Duration::from_millis(10)) => On; + } +} +} + +impl BlinkerActions for BlinkerActionContext<'_> { + async fn bump_on(&mut self) { + self.on_count = self.on_count.wrapping_add(1); + } + async fn bump_off(&mut self) { + self.off_count = self.off_count.wrapping_add(1); + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn timer_only_blinker_alternates() { + let mut m = Blinker::new(BlinkCtx::default()); + // Cap run time — the blinker has no terminate event, so we stop it + // externally via timeout. + let _ = tokio::time::timeout(Duration::from_millis(100), m.run()).await; + let ctx = m.into_context(); + // Entered On once on default descent, then Off, then On, ... Over 100ms + // with a 10ms period, we should see roughly 5 entries of each. + assert!( + ctx.on_count >= 3, + "expected ≥3 on_count, got {}", + ctx.on_count + ); + assert!( + ctx.off_count >= 3, + "expected ≥3 off_count, got {}", + ctx.off_count + ); +} diff --git a/hsmc/tests/tokio_async_actions.rs b/hsmc/tests/tokio_async_actions.rs new file mode 100644 index 0000000..1a2db37 --- /dev/null +++ b/hsmc/tests/tokio_async_actions.rs @@ -0,0 +1,65 @@ +//! Proves that action bodies can `.await` real futures under the `tokio` +//! feature, that transitions are committed only after all action futures +//! complete, and that `emit()` inside an async action still enqueues. +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx { + pub log: Vec, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Go, + Halt, +} + +statechart! { +AsyncM { + context: Ctx; + events: Ev; + default(Idle); + terminate(Halt); + state Idle { + on(Go) => Working; + } + state Working { + entry: on_enter_working; + exit: on_exit_working; + } +} +} + +impl AsyncMActions for AsyncMActionContext<'_> { + async fn on_enter_working(&mut self) { + // A real `.await` that suspends the task. + tokio::time::sleep(Duration::from_millis(5)).await; + self.log.push("entered_after_sleep".into()); + // emit() inside an async action must enqueue synchronously. + let _ = self.emit(Ev::Halt); + } + async fn on_exit_working(&mut self) { + tokio::time::sleep(Duration::from_millis(1)).await; + self.log.push("exited_after_sleep".into()); + } +} + +#[tokio::test] +async fn async_actions_await_and_emit() { + let mut m = AsyncM::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1)).await; + let _ = sender.send(Ev::Go); + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + assert!(res.expect("hang").is_ok()); + assert!(m.is_terminated()); + let ctx = m.into_context(); + // Entry action's await completed before the emitted Halt was processed. + assert!(ctx.log.iter().any(|s| s == "entered_after_sleep")); + // Exit action also ran via Halt → terminate path. + assert!(ctx.log.iter().any(|s| s == "exited_after_sleep")); +} diff --git a/hsmc/tests/tokio_nested_alternation.rs b/hsmc/tests/tokio_nested_alternation.rs new file mode 100644 index 0000000..9462d43 --- /dev/null +++ b/hsmc/tests/tokio_nested_alternation.rs @@ -0,0 +1,142 @@ +//! Regression: a nested `default`-chain toggle (Splash.{LearnMore, Info}) must +//! keep alternating after the parent subtree has been exited to a sibling +//! (Dashboard) and re-entered via the parent's default. +//! +//! The firmware saw: on fresh boot, alternation between the two substates +//! worked perfectly. After one round-trip out to Dashboard and back, +//! alternation stalled. This test reproduces the scenario in async `run()` +//! so we can see it fail without hardware. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct Ctx { + pub log: Arc>>, +} + +#[derive(Debug, Clone)] +pub enum Ev { + Go, + Back, + Halt, +} + +statechart! { + M { + context: Ctx; + events: Ev; + default(On); + terminate(Halt); + + state On { + on(Go) => Listening; + on(Back) => Splash; + default(Splash); + + state Splash { + default(LearnMore); + state LearnMore { + entry: enter_lm; + on(after Duration::from_secs(5)) => Info; + } + state Info { + entry: enter_info; + on(after Duration::from_secs(5)) => LearnMore; + } + } + + state Dashboard { + default(Listening); + state Listening { + entry: enter_listening; + // Repeating tick that matches the firmware's + // `on(every 1s) => advance_sparkline, paint_listening`. + on(every Duration::from_millis(1000)) => tick_listen; + } + } + } + } +} + +impl MActions for MActionContext<'_> { + async fn enter_lm(&mut self) { + self.log.lock().unwrap().push("LM"); + } + async fn enter_info(&mut self) { + self.log.lock().unwrap().push("INFO"); + } + async fn enter_listening(&mut self) { + self.log.lock().unwrap().push("LISTEN"); + } + async fn tick_listen(&mut self) { + // Intentionally mirror the firmware: the tick handler does a + // short async await (simulating a display flush). This is the + // kind of thing that lengthens `step()` and exposes subtle + // timing bugs in the run loop. + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn nested_alternation_survives_round_trip() { + let log = Arc::new(Mutex::new(Vec::new())); + let mut m = M::new(Ctx { log: log.clone() }); + let sender = m.sender(); + + // Driver: exercise the full scenario, then halt. + tokio::spawn(async move { + // Let LM alternate with INFO for three cycles. + tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO + tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM + tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO + + // Jump out to Listening. The machine will now tick every 1s + // with a 20ms-await tick handler — the firmware's actual shape. + let _ = sender.send(Ev::Go); + // Let several ticks fire (simulates a short RX session). + tokio::time::sleep(Duration::from_millis(3500)).await; + + // Back to Splash. Splash's default should land us in LM. + let _ = sender.send(Ev::Back); + tokio::time::sleep(Duration::from_millis(50)).await; + + // Regression: alternation must continue after re-entry. + tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO + tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM + + // Second round-trip to be sure it wasn't a one-off. + let _ = sender.send(Ev::Go); + tokio::time::sleep(Duration::from_millis(2500)).await; + let _ = sender.send(Ev::Back); + tokio::time::sleep(Duration::from_millis(50)).await; + + tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO + tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM + + let _ = sender.send(Ev::Halt); + }); + + let res = tokio::time::timeout(Duration::from_secs(300), m.run()) + .await + .expect("run() hung"); + assert!(res.is_ok(), "run() returned error: {:?}", res); + + let log = log.lock().unwrap(); + // Between LISTEN events, LM and INFO must alternate perfectly. + // Expected overall: LM INFO LM INFO LISTEN LM INFO LM LISTEN LM INFO LM. + assert_eq!( + &log[..], + &[ + "LM", "INFO", "LM", "INFO", // fresh-boot alternation ×3 + "LISTEN", // Go #1 + "LM", "INFO", "LM", // Back #1 + two 5s ticks + "LISTEN", // Go #2 + "LM", "INFO", "LM", // Back #2 + two 5s ticks + ][..], + "log did not match expected sequence: {:?}", + &log[..] + ); +} diff --git a/hsmc/tests/tokio_repeat_and_payload.rs b/hsmc/tests/tokio_repeat_and_payload.rs new file mode 100644 index 0000000..e8d3b74 --- /dev/null +++ b/hsmc/tests/tokio_repeat_and_payload.rs @@ -0,0 +1,115 @@ +//! Integration test covering the two grammar extensions added together: +//! +//! 1. Repeating timers via `on(every ) => handler;` and the +//! optional `after ` explicit-one-shot form. +//! 2. Event payload bindings via `on(Variant(a: T, b: U)) => handler;` +//! and `on(Variant { a: T, b: U }) => handler;`, which destructure +//! into typed action-handler parameters. + +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; +use std::sync::{Arc, Mutex}; + +#[derive(Default, Clone)] +pub struct Ctx { + /// Count of ticks fired from the repeating timer. + pub ticks: Arc>, + /// Last RSSI/SNR pair seen via the tuple-variant payload binding. + pub last_rx: Arc>>, + /// Last (freq, bw) seen via the struct-variant payload binding. + pub last_cfg: Arc>>, +} + +#[derive(Debug, Clone)] +pub enum Ev { + PacketRx(i16, i16), + ConfigChanged { freq: u32, bw: u32 }, + Halt, +} + +statechart! { + Tk { + context: Ctx; + events: Ev; + default(Running); + terminate(Halt); + state Running { + // Repeating timer (advances the tick counter). + on(every Duration::from_millis(10)) => on_tick; + // Explicit one-shot form — `after` is a synonym for bare duration. + // Included to verify parsing; target state is the same so nothing + // observable happens. + on(after Duration::from_secs(60)) => Running; + // Payload-bearing event actions. + on(PacketRx(rssi: i16, snr: i16)) => record_rx; + on(ConfigChanged { freq: u32, bw: u32 }) => record_cfg; + } + } +} + +impl TkActions for TkActionContext<'_> { + async fn on_tick(&mut self) { + *self.ticks.lock().unwrap() += 1; + } + async fn record_rx(&mut self, rssi: i16, snr: i16) { + *self.last_rx.lock().unwrap() = Some((rssi, snr)); + } + async fn record_cfg(&mut self, freq: u32, bw: u32) { + *self.last_cfg.lock().unwrap() = Some((freq, bw)); + } +} + +#[tokio::test] +async fn repeating_timer_fires_many_times() { + let mut m = Tk::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(120)).await; + let _ = sender.send(Ev::Halt); + }); + let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + let ctx = m.into_context(); + let ticks = *ctx.ticks.lock().unwrap(); + // 10ms timer running for ~120ms should fire at least 5 times. We give + // loose bounds to tolerate scheduler jitter under `cargo test`. + assert!( + ticks >= 5 && ticks <= 30, + "expected 5..=30 ticks, got {}", + ticks + ); +} + +#[tokio::test] +async fn tuple_variant_payload_binding_dispatches() { + let mut m = Tk::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + let _ = sender.send(Ev::PacketRx(-42, 7)); + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = sender.send(Ev::Halt); + }); + let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + let ctx = m.into_context(); + assert_eq!(*ctx.last_rx.lock().unwrap(), Some((-42, 7))); +} + +#[tokio::test] +async fn struct_variant_payload_binding_dispatches() { + let mut m = Tk::new(Ctx::default()); + let sender = m.sender(); + tokio::spawn(async move { + let _ = sender.send(Ev::ConfigChanged { + freq: 915_000_000, + bw: 125_000, + }); + tokio::time::sleep(Duration::from_millis(10)).await; + let _ = sender.send(Ev::Halt); + }); + let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + let ctx = m.into_context(); + assert_eq!( + *ctx.last_cfg.lock().unwrap(), + Some((915_000_000, 125_000)) + ); +} diff --git a/hsmc/tests/tokio_run.rs b/hsmc/tests/tokio_run.rs new file mode 100644 index 0000000..50cbf5c --- /dev/null +++ b/hsmc/tests/tokio_run.rs @@ -0,0 +1,49 @@ +//! Integration test for async `run()` under the `tokio` feature (T6.3). +#![cfg(feature = "tokio")] + +use hsmc::{statechart, Duration}; + +#[derive(Default)] +pub struct Ctx { pub log: Vec } + +#[derive(Debug, Clone)] +pub enum Ev { Halt } + +statechart! { +TokM { + context: Ctx; + events: Ev; + default(A); + terminate(Halt); + state A { entry: a_entry; exit: a_exit; } +} +} + +impl TokMActions for TokMActionContext<'_> { + async fn a_entry(&mut self) { + tokio::task::yield_now().await; + self.log.push("a_entry".into()); + } + async fn a_exit(&mut self) { + tokio::task::yield_now().await; + self.log.push("a_exit".into()); + } +} + +#[tokio::test] +async fn t6_3_run_returns_after_terminate() { + let mut m = TokM::new(Ctx::default()); + let sender = m.sender(); + // Trigger terminate from another task. + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + let _ = sender.send(Ev::Halt); + }); + let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await; + let res = res.expect("run() hung past timeout"); + assert!(res.is_ok()); + assert!(m.is_terminated()); + let ctx = m.into_context(); + assert!(ctx.log.contains(&"a_entry".to_string())); + assert!(ctx.log.contains(&"a_exit".to_string())); +}