diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ad777..1b4f0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this workspace are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## 0.5.1 — 2026-04-29 + +Docs and licensing cleanup. No code or behavior changes. + +- **License simplified to MIT.** The crate was previously dual-licensed + MIT OR Apache-2.0; it is now MIT-only. `LICENSE-APACHE` removed, + `LICENSE-MIT` renamed to `LICENSE`, workspace `license` field is + now `"MIT"`. Copyright (c) 2026 Stephen Waits <steve@waits.net>. +- **README rewritten reader-first.** Restructured top-to-bottom so a + reader who's never used statecharts can follow the document linearly: + a working Blinker chart is now the first concrete artifact; the + Quickstart sits directly under it; the larger Radio example + (events / payloads / `during:`) follows; the formal eight-rule + semantics live below all of that as reference rather than as a + prerequisite. The "What it does" feature list is rewritten in + user-benefit-first language. The "What `hsmc` deliberately does NOT + have" table is promoted from a sub-section to its own top-level + section. No semantic changes — the formal rules' content is + untouched, only repositioned. +- **Tokio integration story corrected.** The README previously implied + driving a chart under tokio required `tokio::task::LocalSet`. The + normal path is `#[tokio::main(flavor = "current_thread")]` — one + line, no extra wrapping, and how the existing examples have always + worked. `LocalSet` no longer appears in the README at all. +- **`microwave_tui` example dropped.** The ratatui/crossterm TUI + variant of the microwave was overkill for a library example and + pulled in three dev-dependencies (`ratatui`, `crossterm`, `futures`) + that nothing else used. The `microwave` example covers the same + feature surface in plain Rust. +- **Observability framed honestly.** Enabling `defmt` / `log` / + `tracing` output for a chart is one `trace-*` Cargo feature on + hsmc's side, plus whatever your downstream crate already does to + init its logger (e.g. `tracing_subscriber::fmt::init()` or + `env_logger::init()`) — hsmc deliberately doesn't init a logger, + per the standard Rust library convention. The Observability + section now says so directly and includes a runnable end-to-end + snippet using `tracing-subscriber`. The `trace-*` features are + also listed in the main feature-flags table so they're + discoverable at a glance. +- **Inline examples corrected.** The Quickstart Blinker now has both + `light_on` and `light_off` actions (it previously had only + `light_on`, which left the LED on the whole time). The Embassy + snippet no longer declares an unused event channel for what is a + timer-only chart. +- **Eight-rule order finalized.** The "How a chart behaves" rules are + now strictly bottom-up: active path → transitions (LCA-aware) → + up-transition rule → entry/exit ordering → `default(...)` (a + transition fired on entry) → event bubbling → timers → + emit/during/termination. Cross-references updated. + ## 0.5.0 — 2026-04-28 ### `default(...)` is a real LCA-aware transition diff --git a/Cargo.lock b/Cargo.lock index a6644d6..de08821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # 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 = "autocfg" version = "1.5.0" @@ -29,53 +23,18 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" -[[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 = "creusot-std" version = "0.9.0" @@ -103,66 +62,6 @@ 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 2.11.1", - "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 = "defmt" version = "0.3.100" @@ -178,7 +77,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" dependencies = [ - "bitflags 1.3.2", + "bitflags", "defmt-macros", ] @@ -234,12 +133,6 @@ 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" @@ -331,110 +224,18 @@ 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" @@ -450,17 +251,6 @@ 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" @@ -487,27 +277,18 @@ dependencies = [ "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.5.0" +version = "0.5.1" dependencies = [ - "crossterm", "defmt 0.3.100", "embassy-futures", "embassy-sync", "embassy-time", - "futures", "heapless 0.8.0", "hsmc-macros", "iai-callgrind", "log", - "ratatui", "tokio", "tracing", "trybuild", @@ -515,7 +296,7 @@ dependencies = [ [[package]] name = "hsmc-macros" -version = "0.5.0" +version = "0.5.1" dependencies = [ "hsmc", "proc-macro2", @@ -526,7 +307,7 @@ dependencies = [ [[package]] name = "hsmc-verification" -version = "0.5.0" +version = "0.5.1" dependencies = [ "creusot-std", "hsmc", @@ -568,12 +349,6 @@ dependencies = [ "serde", ] -[[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" @@ -581,38 +356,7 @@ 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", + "hashbrown", ] [[package]] @@ -621,66 +365,24 @@ 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" @@ -735,35 +437,6 @@ dependencies = [ "autocfg", ] -[[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" @@ -810,36 +483,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "ratatui" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" -dependencies = [ - "bitflags 2.11.1", - "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 2.11.1", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -849,37 +492,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "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 = "semver" version = "1.0.28" @@ -938,89 +550,12 @@ 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" @@ -1164,87 +699,27 @@ 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", + "windows-sys", ] -[[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" @@ -1254,70 +729,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 954fb85..5d1fdbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,9 @@ resolver = "2" members = ["hsmc", "hsmc-macros", "verification"] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2021" -license = "MIT OR Apache-2.0" +license = "MIT" authors = ["Stephen Waits "] repository = "https://github.com/swaits/hsmc" homepage = "https://github.com/swaits/hsmc" diff --git a/LICENSE-MIT b/LICENSE similarity index 100% rename from LICENSE-MIT rename to LICENSE diff --git a/LICENSE-APACHE b/LICENSE-APACHE deleted file mode 100644 index d92df63..0000000 --- a/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for describing the origin of the Work and - reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Support. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or support. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 Stephen Waits - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied. See the License for the specific language governing permissions - and limitations under the License. diff --git a/README.md b/README.md index 738e14b..ed89b8f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,98 @@ # 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. +`hsmc` lets you write the control logic of a device — a microwave, a +LoRa radio, a sensor, a UI flow — as a **statechart**: nested states +with entry/exit actions, transitions, timers, and async work, all +declared in one block. The proc macro turns it into Rust code with no +heap allocations, no dynamic dispatch, and no interior mutability. + +It targets embedded Rust on `embassy` first, with full tokio support +on the side. Same chart, both runtimes. + +## A first chart — a blinker + +```rust +use hsmc::{statechart, Duration}; + +pub struct Ctx { + pub on: bool, // mirrors the LED's current state + pub blinks: u32, // count of times we've turned it on +} + +statechart! { + Blinker { + context: Ctx; + default(Off); // start with the LED off + + state Off { + entry: light_off; + on(after Duration::from_millis(500)) => On; + } + state On { + entry: light_on; + on(after Duration::from_millis(500)) => Off; + } + } +} + +impl BlinkerActions for BlinkerActionContext<'_> { + async fn light_off(&mut self) { self.on = false; } + async fn light_on(&mut self) { self.on = true; self.blinks += 1; } +} +``` + +Two states, two timer-driven transitions, two entry actions. The +runtime arms each state's timer when you enter it and cancels it +when you leave; `default(Off)` picks the initial state. Land in +`Off`, fire `light_off`, arm the 500 ms timer; when it elapses, +transition to `On`, fire `light_on`, arm the next 500 ms timer. +Repeat. + +That's `hsmc` at its smallest: states, timers, and one entry action +per state. No event channel, no async I/O, no hierarchy yet. + +## Quickstart + +Add to `Cargo.toml`: + +```toml +[dependencies] +hsmc = { version = "0.5", features = ["embassy"] } # firmware +# or +hsmc = { version = "0.5", features = ["tokio"] } # desktop / tests +``` + +Run the Blinker under tokio: + +```rust +#[tokio::main(flavor = "current_thread")] +async fn main() { + let mut m = Blinker::new(Ctx { on: false, blinks: 0 }); + let _ = m.run().await; +} +``` + +That's the whole driver. A chart runs on a single task — `flavor = +"current_thread"` keeps tokio out of the multi-threaded scheduler so +you don't have to think about `Send` bounds on your context, your +events, or your `during:` futures. Normal Rust, no extra wrapping. + +Run it under embassy: + +```rust +#[embassy_executor::task] +async fn blink_task() { + let mut m = Blinker::new(Ctx { on: false, blinks: 0 }); + let _ = m.run().await; +} +``` + +## A bigger chart — events, payloads, async I/O + +The Blinker doesn't talk to anything outside itself. A real device +does. Here's a radio that receives commands from somewhere +(button press, RPC call, ISR), continuously listens for packets while +in the `Receiving` state, and stops when told: ```rust statechart! { @@ -25,41 +113,73 @@ statechart! { } ``` -## What it does +Three things here that the Blinker didn't have: -- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:` - actions, LCA-aware transitions, optional `default(...)` (a transition - that may target any state — descend, redirect to a sibling, bounce - to an ancestor — with a compile-time cycle check), `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). -- **Unified observability.** One observation per atom of execution - fans out at compile time to a journal (`Vec`) and/or - textual trace backends (`defmt`, `log`, `tracing`) — they share a - single vocabulary, so they cannot diverge. With no sink feature on, - every site expands to `()` (zero overhead). See - [Observability](#observability--one-journal-multiple-outputs). -- **Deterministic + mechanically verified.** The `journal` feature - records every action, transition, timer, and event in order — same - inputs, byte-identical journal. The runtime queue and timer table - carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge - mechanically (`just verify`). The integration suite pins every - spec rule with byte-equal expected-vs-actual journals, trace-format - regression checks, and per-feature behavior tests. +- **Events.** `events: Ev;` declares the input enum. External code + drives the chart with `m.sender().send(Ev::StartRx)`; in-chart + actions can `emit(Ev::Whatever)` to queue a follow-up. Handlers + pattern-match the variant and bind its payload directly: + `on(PacketRx { rssi, snr }) => record_packet;` makes `rssi` and + `snr` available to the action. +- **`during:` activities.** `next_packet(lora, rx_buf)` is an async + fn that runs while `Receiving` is active and is dropped when the + state exits. It gets `&mut` borrows of just the `lora` and + `rx_buf` fields of the context — Rust's split-borrow rules let + several `during:`s on the active path run concurrently without a + `RefCell` or `Mutex` in sight. +- **The runtime races them all.** `m.run()` simultaneously polls the + `during:`, listens on the event channel, and waits for the next + timer deadline; whichever fires first drives the next transition. + The MCU sleeps in between. -## How a chart behaves — the building blocks +For nested-state hierarchies, `emit(...)`, `terminate`, and +`on(every D)`, see the [microwave example](hsmc/examples/microwave.rs). -Eight rules, each building on the previous. Internalize these and every -edge case below falls out — you don't have to memorize anything else. +## What you get + +- **Nested states with proper transitions.** Exit a deeply nested + state and every enclosing state's exits run in the right order, no + manual bookkeeping. A transition is just `=> Target;` and the + runtime walks the tree (LCA dispatch). Includes `entry:`/`exit:` + actions, optional `default(...)` initial transitions, and + `terminate`. +- **Events with typed payloads.** Pattern-match a variant and bind + its fields right in the chart: `on(PacketRx { rssi: i16 }) => + record_packet;`. The compiler type-checks the binding against your + event enum. +- **Timers as first-class triggers.** `on(after 5s) => Next;` for a + one-shot, `on(every 100ms) => tick;` for periodic. Armed when the + declaring state is entered, cancelled when it's exited — no + `select!` plumbing in your code. +- **Async work scoped to a state's lifetime.** A `during:` is an + async fn that runs while a state is active and is dropped when you + leave. Multiple durings on the active path race concurrently and + split-borrow disjoint context fields — no `RefCell`, no `Mutex`, + no `unsafe`. +- **`m.run().await` is the whole task.** It races durings, the event + channel, and timer deadlines, and parks the executor between them. + No manual loop, no busy-waits, no `Send` headaches under + `current_thread` tokio or embassy. +- **Observability that's one feature flag away.** Add + `features = ["trace-log"]` (or `trace-defmt` / `trace-tracing`) and + every action, transition, timer, and event becomes a log line in + whichever framework your crate already uses — `defmt` for firmware, + `log` for desktop, `tracing` for structured spans. With no sink + feature, the observation calls compile to nothing. +- **Deterministic + machine-checked.** The `journal` feature records + every action, transition, timer, and event in order; identical + input produces a byte-identical journal. The runtime's event + queue and timer table are formally verified — Creusot + Why3 + + alt-ergo + z3 discharge the proofs (`just verify`). +- **No heap, no `unsafe`, no dynamic dispatch.** Pure monomorphized + code. `no_std` clean by default. Designed to fit on a Cortex-M0+ + under embassy. + +## How a chart behaves — the rules + +Eight rules, each building on the previous. Internalize these and +every edge case below falls out — you don't have to memorize anything +else. The full canonical reference lives at [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md); @@ -73,20 +193,70 @@ you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is always active until termination. The innermost active state is usually a leaf, but it can also be a -composite that has children but no `default(...)` — see rule 2. +composite that has children but no `default(...)` — see rule 5. This is the most important rule. Every rule below is downstream of it. If a behavior somewhere else seems weird, come back here — it's probably the resolution. -### 2. `default(...)` — a transition that fires immediately on entry +### 2. Transitions — exit to LCA, enter to target + +A transition moves the active path from the current innermost state to +some target state. The algorithm is uniform regardless of what +triggered it (event, timer, or default — see later rules): + +1. Find the LCA (lowest common ancestor) of current and target. +2. Walk **up** from current, exiting each state, until you hit the LCA. + The LCA itself is **not** exited. +3. Walk **down** from the LCA to target, entering each state. The LCA + is **not** re-entered. + +The LCA always exists because root is always an upper bound. State +names are globally unique across the chart, so a transition can target +**any** state — siblings, cousins, ancestors, the root itself +(`on(Trig) => MyChart;`). + +### 3. The up-transition rule — never re-enter what you never left + +If your transition target is **already on the active path** (i.e., it's +an ancestor of the innermost state), then step 3 of the transition +algorithm does nothing. You exit the states strictly between current +and target; the target itself is **not** re-entered. + +So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C` +then `B`. `A`'s entry actions don't fire. `A`'s timers don't restart. +`A`'s durings keep running. + +You cannot enter a state you never left. + +### 4. Entry / exit ordering + +Direction is set by the path: + +- **Entries** fire **outer-to-inner**: when entering down through a + path, the outermost state's entries fire first, then the next, then + the innermost's. Within a single state, entry actions fire in + declaration order. After a state's entries finish, its `during:` + activities start. +- **Exits** fire **inner-to-outer**: innermost first, then parent, then + grandparent. Within a single state, exit actions fire in declaration + order. Before a state's exits begin, its `during:` activities are + cancelled. + +So a full transition reads: cancel durings on the way out → run exits +on the way out → run entries on the way in → start durings on the way +in. + +### 5. `default(...)` — a transition that fires immediately on entry Any state **may** declare one `default(...)`. It's a transition — same -LCA-aware algorithm as any event-triggered transition (rule 4) — that -fires **immediately** after the declaring state's entry actions finish -(and its durings start). The target may be **any** state in the chart: -a child, a sibling, an ancestor, anywhere. The compiler rejects cycles -in the default graph, so the chain is always finite. +LCA-aware algorithm as rule 2 — that fires **immediately** after the +declaring state's entry actions finish (and its durings start). The +target may be **any** state in the chart: a child, a sibling, an +ancestor, anywhere. Defaults chain: if the resulting target also has +a `default(...)`, it fires too, and so on until a state with no +default is reached. The compiler rejects cycles in the default graph, +so the chain is always finite. The classic case is a composite descending to a leaf: ``` @@ -109,12 +279,17 @@ the state itself is the resting innermost active state until something explicitly transitions away. This is the "microwave" pattern: a `Standby` state with sub-modes that the chart enters only on demand. -### 3. Event bubbling — innermost first, first handler wins +A consequence of rule 3 (up-transitions): an ancestor's `default(...)` +only fires when that ancestor is freshly entered. Transitioning *up* +to an ancestor does not re-enter it, so its default does not re-fire. + +### 6. Event bubbling — innermost first, first handler wins An event arrives. Search starts at the **innermost** active state. If -that state has a handler, it runs and the event is consumed. If not, -walk up to the parent and check there. Repeat. If you reach the root -with no handler, the event is silently discarded. +that state has a handler, it runs and the event is consumed (the +handler may execute a transition per rule 2). If not, walk up to the +parent and check there. Repeat. If you reach the root with no handler, +the event is silently discarded. Three corollaries: @@ -124,58 +299,6 @@ Three corollaries: - **Timers don't bubble.** A timer belongs to the state that declared it; it fires only in that state's scope. -### 4. Transitions — exit to LCA, enter to target - -Any transition's algorithm is uniform: - -1. Find the LCA (lowest common ancestor) of current and target. -2. Walk **up** from current, exiting each state, until you hit the LCA. - The LCA itself is **not** exited. -3. Walk **down** from the LCA to target, entering each state. The LCA - is **not** re-entered. -4. If the target declares a `default(...)`, fire it (rule 2). Defaults - chain — each link is itself a full transition — until you reach a - state with no default. The compiler rejects cycles. - -The LCA always exists because root is always an upper bound. State -names are globally unique across the chart, so a transition can target -**any** state — siblings, cousins, ancestors, the root itself -(`on(Trig) => MyChart;`). - -### 5. The up-transition rule — never re-enter what you never left - -If your transition target is **already on the active path** (i.e., it's -an ancestor of the innermost state), then steps 3 and 4 of the -transition algorithm do nothing. You exit the states strictly between -current and target; the target itself is **not** re-entered. - -So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C` -then `B`. `A`'s entry actions don't fire. `A`'s `default` does not -fire (it only fires when `A` is freshly entered, which it wasn't). -`A`'s timers don't restart. `A`'s durings keep running. - -You cannot enter a state you never left. - -### 6. Entry / exit ordering - -Direction is set by the path: - -- **Entries** fire **outer-to-inner**: when entering down through a - path, the outermost state's entries fire first, then the next, then - the innermost's. Within a single state, entry actions fire in - declaration order. After a state's entries finish, its `during:` - activities start. Then, if the state declared a `default(...)`, - it fires as a transition (rule 2) — chains continue until a state - with no default. -- **Exits** fire **inner-to-outer**: innermost first, then parent, then - grandparent. Within a single state, exit actions fire in declaration - order. Before a state's exits begin, its `during:` activities are - cancelled. - -So a full transition reads: cancel durings on the way out → run exits -on the way out → run entries on the way in → start durings on the way -in. - ### 7. Timers — armed by state lifecycle A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when @@ -196,7 +319,7 @@ restarts its timers from zero. durings on the active path race; each borrows disjoint `&mut` fields of the context (Rust's split-borrow checker enforces this at compile time). -- **Termination** is just rules 6 + 4 with target = "outside the +- **Termination** is just rules 2 + 4 with target = "outside the chart": exit the entire active path inner-to-outer, then stop. Pending events drop. In-flight durings drop at their next `.await`. @@ -206,78 +329,6 @@ spells out edge cases (queue overflow surfacing, same-tick timer ties, self-transitions on composites) but the rules above cover the overwhelming majority of charts. -### What `hsmc` deliberately does NOT have - -The bet is on **simple, consistent semantics**. These features are -omitted on purpose; for each, what to do instead: - -| Missing feature | Use instead | -| ------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on | -| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state | -| History states | Store last child explicitly in your context; transition to it on re-entry | -| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state | -| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that | -| State-local context | All state in the root context; durings borrow disjoint fields | -| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine | -| Runtime statechart modification | Design every variation up front as states | - -## Quickstart - -Add to `Cargo.toml`: - -```toml -[dependencies] -hsmc = { version = "0.5", 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 @@ -394,59 +445,59 @@ For `statechart! Foo { ... }` the macro emits: ## 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`. | +| Feature | Effect | +| --------------- | -------------------------------------------------------------------------- | +| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. | +| `tokio` | Async actions + `run()`. Single-thread (`flavor = "current_thread"`). | +| `embassy` | Async actions + `run()`. Embassy's cooperative executor. `no_std`. | +| `journal` | In-memory `Vec` of every observation (replay, byte-deterministic). | +| `trace-defmt` | Auto-emit `defmt::*` log lines for every observation. | +| `trace-log` | Auto-emit `log::*` log lines for every observation. | +| `trace-tracing` | Auto-emit `tracing::*` events with structured fields. | `embassy` and `tokio` are mutually exclusive — enabling both is a compile -error. - -## 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. - -## Verification - -`hsmc` ships with a Creusot deductive-verification crate -(`verification/`) that proves the runtime's `EventQueue` and -`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3 -discharge every VC mechanically. Run: - -```bash -just verify -``` - -The recipe is self-installing (mise pulls in the pinned nightly, -opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone -gets to a green proof in one command. See -[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the -spec-rule → proof mapping. - -Beyond Creusot, the suite includes: - -- **Integration tests** — full chart behavior and per-feature - determinism, with byte-equal expected-vs-actual journals on the - `det_*` deterministic-flow tests, plus trace-format regression - tests pinning every observation verb's textual rendering. Run - with `cargo test --workspace --features tokio,journal`. -- **`cargo mutants`** — full coverage under `cargo mutants --features - tokio,journal,trace-log`. -- **Miri** — clean, no UB on the runtime crates. +error. The `trace-*` and `journal` features are independently composable +with each other and with the runtime feature; with no sink feature on, +the trace points compile to nothing (zero overhead). ## Observability — one journal, multiple outputs -Every chart emits a single observation per atom of execution. That -observation fans out to whichever sinks you compile in: +On hsmc's side, turning on observability is a single Cargo feature. +On your side, it's whatever you'd already do to wire `defmt` / `log` +/ `tracing` to an output — hsmc does not initialize a logger +(libraries shouldn't; `main` owns that decision). + +If your firmware crate already uses `defmt`, add the feature and +you're done — chart events interleave with the rest of your defmt +output: + +```toml +hsmc = { version = "0.5", features = ["embassy", "trace-defmt"] } +``` + +For a desktop crate, pick `log` or `tracing` and do that framework's +normal one-line init in `main`: + +```toml +[dependencies] +hsmc = { version = "0.5", features = ["tokio", "trace-tracing"] } +tracing-subscriber = "0.3" +``` + +```rust +#[tokio::main(flavor = "current_thread")] +async fn main() { + tracing_subscriber::fmt::init(); + let mut m = Blinker::new(Ctx { on: false, blinks: 0 }); + let _ = m.run().await; +} +``` + +Swap `trace-tracing` for `trace-log` and `tracing_subscriber::fmt::init()` +for `env_logger::init()` if you prefer the `log` ecosystem; the rest is +identical. With no sink feature enabled at all, the observation calls +compile to `()` — the runtime is byte-identical to a chart with no +instrumentation. | Feature | Sink | | --------------- | -------------------------------------------------------- | @@ -455,10 +506,9 @@ observation fans out to whichever sinks you compile in: | `trace-log` | `log::*` log lines | | `trace-tracing` | `tracing::*` events with structured fields | -With **no** sink feature on, observation is fully elided at compile -time — zero overhead. Multiple sinks can be enabled simultaneously; -the journal IS the observation stream and trace backends are textual -renderings of it, so they cannot diverge. +Multiple sinks can be enabled simultaneously; the journal IS the +observation stream and trace backends are textual renderings of it, +so they cannot diverge. The textual format is logfmt-style — greppable and regex-parseable: @@ -509,19 +559,70 @@ transition: an event (`event:VariantName`), a timer (`timer:State/timer-id`), or `internal`. Real-life captures can be diffed against expected behavior the same way the journal can. +## Examples + +| Example | Purpose | +| --------------- | --------------------------------------------------------------------- | +| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. | +| `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. + +## Verification + +`hsmc` ships with a Creusot deductive-verification crate +(`verification/`) that proves the runtime's `EventQueue` and +`TimerTable` correct against Pearlite specs. Why3 + alt-ergo + z3 +discharge every VC mechanically. Run: + +```bash +just verify +``` + +The recipe is self-installing (mise pulls in the pinned nightly, +opam, Why3, the SMT solvers, and `cargo-creusot`) so a fresh clone +gets to a green proof in one command. See +[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the +spec-rule → proof mapping. + +Beyond Creusot, the suite includes: + +- **Integration tests** — full chart behavior and per-feature + determinism, with byte-equal expected-vs-actual journals on the + `det_*` deterministic-flow tests, plus trace-format regression + tests pinning every observation verb's textual rendering. Run + with `cargo test --workspace --features tokio,journal`. +- **`cargo mutants`** — full coverage under `cargo mutants --features + tokio,journal,trace-log`. +- **Miri** — clean, no UB on the runtime crates. + +## What `hsmc` deliberately does NOT have + +The bet is on **simple, consistent semantics**. These features are +omitted on purpose; for each, what to do instead: + +| Missing feature | Use instead | +| ------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on | +| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state | +| History states | Store last child explicitly in your context; transition to it on re-entry | +| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state | +| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that | +| State-local context | All state in the root context; durings borrow disjoint fields | +| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine | +| Runtime statechart modification | Design every variation up front as states | +| Multi-threaded tokio | `flavor = "current_thread"` is the supported path — one task per chart | + +See [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md) +§ "What `hsmc` deliberately does NOT have" for the rationale on each. + ## Status Pre-1.0. Per-release breaking changes are documented in [`CHANGELOG.md`](CHANGELOG.md). -Out of scope (deliberate): guard conditions, orthogonal/parallel -regions, history states, deferred events, internal transitions, -state-local context, event priorities beyond FIFO, runtime -statechart modification, multi-threaded tokio. See -[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md) -§ "What `hsmc` deliberately does NOT have" for the rationale and -what to use instead. - ## License -Dual-licensed MIT OR Apache-2.0. +MIT. Copyright (c) 2026 Stephen Waits <steve@waits.net>. See [LICENSE](LICENSE). diff --git a/hsmc-macros/LICENSE-MIT b/hsmc-macros/LICENSE similarity index 100% rename from hsmc-macros/LICENSE-MIT rename to hsmc-macros/LICENSE diff --git a/hsmc-macros/LICENSE-APACHE b/hsmc-macros/LICENSE-APACHE deleted file mode 100644 index d92df63..0000000 --- a/hsmc-macros/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for describing the origin of the Work and - reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Support. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or support. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 Stephen Waits - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied. See the License for the specific language governing permissions - and limitations under the License. diff --git a/hsmc/Cargo.toml b/hsmc/Cargo.toml index 7821fa4..bb20f4f 100644 --- a/hsmc/Cargo.toml +++ b/hsmc/Cargo.toml @@ -11,7 +11,6 @@ readme = "../README.md" keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"] categories = ["embedded", "no-std", "asynchronous", "rust-patterns"] autoexamples = false -exclude = ["examples/microwave_tui.rs"] [features] default = [] @@ -40,7 +39,7 @@ trace-tracing = ["dep:tracing"] journal = [] [dependencies] -hsmc-macros = { path = "../hsmc-macros", version = "0.5.0" } +hsmc-macros = { path = "../hsmc-macros", version = "0.5.1" } heapless = "0.8" tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true } embassy-sync = { version = "0.8", optional = true } @@ -53,9 +52,6 @@ tracing = { version = "0.1", default-features = false, 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" [target.'cfg(target_os = "linux")'.dev-dependencies] iai-callgrind = "0.16" diff --git a/hsmc/LICENSE-MIT b/hsmc/LICENSE similarity index 100% rename from hsmc/LICENSE-MIT rename to hsmc/LICENSE diff --git a/hsmc/LICENSE-APACHE b/hsmc/LICENSE-APACHE deleted file mode 100644 index d92df63..0000000 --- a/hsmc/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for describing the origin of the Work and - reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Support. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or support. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 Stephen Waits - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied. See the License for the specific language governing permissions - and limitations under the License. diff --git a/hsmc/examples/microwave_tui.rs b/hsmc/examples/microwave_tui.rs deleted file mode 100644 index c076bad..0000000 --- a/hsmc/examples/microwave_tui.rs +++ /dev/null @@ -1,761 +0,0 @@ -//! 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, - } - } -}