chore(release): 0.5.1

This commit is contained in:
swaits committed 2026-04-29 09:50:36 -06:00
1 parent 9eb1d10b28
commit 1f98a9bf5b
12 files changed
+394 -2200

No files matched your search

+50
View File
@@ -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
Generated
+6 -595
View File
@@ -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"
+2 -2
View File
@@ -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 <steve@waits.net>"]
repository = "https://github.com/swaits/hsmc"
homepage = "https://github.com/swaits/hsmc"
View File
File renamed without changes.
-201
View File
@@ -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.
+335 -234
View File
@@ -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<TraceEvent>`) 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<CriticalSectionRawMutex, __NoEvents, 8> = 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<TraceEvent>` 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 &lt;steve@waits.net&gt;. See [LICENSE](LICENSE).
File renamed without changes.
-201
View File
@@ -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.
+1 -5
View File
@@ -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"
View File
File renamed without changes.
-201
View File
@@ -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.
-761
View File
@@ -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<Mutex<_>>` 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<String>,
/// Instant at which the cook should complete. Advances by the pause
/// duration on resume so the progress bar freezes while paused.
cook_deadline: Option<Instant>,
cook_total: Duration,
/// Set when we enter Paused; cleared on resume. Freezes the gauge.
paused_at: Option<Instant>,
terminated: bool,
}
impl UiState {
async fn log(&mut self, msg: impl Into<String>) {
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<Mutex<UiState>>,
}
// ---------- 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<Line> = 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<String>,
cook_deadline: Option<Instant>,
cook_total: Duration,
paused_at: Option<Instant>,
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<UiSnapshot> 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,
}
}
}