initial commit: hsmc v0.2 with during: activities

Hierarchical statechart codegen for Rust with:
- during: activities for state-scoped concurrent I/O
- field-list borrowing (split-borrow inside the run loop)
- embassy-first async with tokio support
- STATE_CHART const for tooling/inspection
- examples: microwave, microwave_tui, embassy_full, during_radio
This commit is contained in:
swaits committed 2026-04-16 13:03:03 -06:00
commit 0341cf9379
56 files changed
+11770

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# Rust build artifacts
/target
**/target/
# Local Claude Code config (per-host overrides)
.claude/settings.local.json
# OS / editor
.DS_Store
*.swp
Generated
+1066
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
[workspace]
resolver = "2"
members = ["hsmc", "hsmc-macros"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
+324
View File
@@ -0,0 +1,324 @@
# hsmc — Hierarchical State Machines for Rust
`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets
embedded Rust (embassy) as the primary runtime and supports `tokio` via
`LocalSet`. Machines are compiled from a `statechart!` block into
monomorphized code with no dynamic dispatch, no interior mutability, and no
heap allocations by default.
```rust
statechart! {
Radio {
context: Ctx;
events: Ev;
default(Idle);
state Idle {
on(StartRx) => Receiving;
}
state Receiving {
during: next_packet(lora, rx_buf);
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
on(StopRx) => Idle;
}
}
}
```
## What it does
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
actions, LCA-aware transitions, default-child descent, `terminate`.
- **Event-driven transitions and actions.** Typed payload bindings:
`on(PacketRx { rssi: i16 }) => handler;`.
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
`on(every Duration::from_millis(100)) => tick;`.
- **`during:` activities.** Async functions that run while a state is
active and produce events. Multiple durings can borrow disjoint fields
of the context concurrently via Rust's native split borrow — no
`RefCell`, no `Mutex`, no `UnsafeCell` in user code.
- **`m.run().await` is the whole task body.** Races active durings, the
external event channel, and timer deadlines. The MCU sleeps between
events (embassy's cooperative executor parks all futures).
- **Backward-compatible.** Every v0.1 statechart compiles under v0.2
unchanged.
## Quickstart
Add to `Cargo.toml`:
```toml
[dependencies]
hsmc = { version = "0.2", features = ["embassy"] } # or ["tokio"]
```
Minimal timer-only machine:
```rust
use hsmc::{statechart, Duration};
pub struct Ctx { pub ticks: u32 }
statechart! {
Blinker {
context: Ctx;
default(On);
state On {
entry: light_on;
on(after Duration::from_millis(500)) => Off;
}
state Off {
on(after Duration::from_millis(500)) => On;
}
}
}
impl BlinkerActions for BlinkerActionContext<'_> {
async fn light_on(&mut self) { self.ticks += 1; }
}
```
Drive it under tokio:
```rust
let mut m = Blinker::new(Ctx { ticks: 0 });
tokio::task::LocalSet::new().run_until(m.run()).await.unwrap();
```
Or under embassy:
```rust
static CHANNEL: Channel<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
function that takes `&mut` references to specific fields of the root context
and returns one event:
```rust
state Receiving {
during: next_packet(lora, rx_buf);
on(PacketRx { rssi: i16 }) => count_packet;
on(StopRx) => Idle;
}
async fn next_packet(
lora: &mut LoRaDriver,
rx_buf: &mut [u8; 256],
) -> RadioInput {
lora.rx(rx_buf).await.into()
}
```
The macro emits `next_packet(&mut ctx.lora, &mut ctx.rx_buf)` and Rust's
native split-borrow verifies the fields don't overlap with any other
concurrent during on the active path. Overlapping fields produce a
compile-time error.
### Multiple concurrent durings
A state (or any ancestor on its path) may declare more than one `during:`.
The run loop races all of them against the external channel and the next
timer deadline via `tokio::select!` / `embassy_futures::select`. Each
during borrows disjoint fields, so the split borrow is free.
```rust
state Configured {
during: heartbeat(hb_counter); // active in every Configured substate
state Receiving {
during: next_packet(lora, rx_buf);
}
}
```
### Cancel-safety contract
> A `during:` future **will** be dropped at any `.await` point when a
> handler fires, a timer expires, an external event arrives, or the state
> transitions. Write durings as cancel-safe state machines: every
> `.await` must be a clean resume point where dropping the future leaves
> the borrowed fields in a valid, re-enterable state.
**Anti-pattern:**
```rust
async fn bad(acc: &mut u32) -> Ev {
*acc += 1; // commit happens here
tokio::time::sleep(Duration::from_ms(100)).await; // ← may be dropped
*acc += 1; // may never run
Ev::Tick
}
```
**Preferred:**
```rust
async fn good(acc: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_ms(100)).await;
*acc = acc.wrapping_add(2); // all mutations after the .await
Ev::Tick
}
```
### Starvation in select-drop semantics
When several durings race, the shortest-to-complete wins every iteration
and the others are dropped without making progress. This is expected:
firmware I/O activities are naturally cancel-safe (a dropped `lora.rx()`
just stops receiving) and the starved ones would typically be
heartbeat-like tasks that deliver their events eventually.
If two durings compete for the same "always ready" resource, combine them
into a single during that uses `select` internally.
## Generated API per machine
For `statechart! Foo { ... }` the macro emits:
| Item | What it is |
|---------------------------------|----------------------------------------------------------------------|
| `FooState` | Enum of every user-declared state. |
| `FooActions` | Trait with one async fn per unique action name. User implements. |
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
| `Foo<const QN: usize = 8>` | The machine itself. |
| `FooSender` | Cloneable handle for cross-task / ISR event injection. |
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).|
### Machine methods
| Method | Notes |
|-------------------------------------|------------------------------------------------------------------|
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
| `async fn run()` | Drive the machine to completion (async features). |
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
| `fn send(ev)` | Raw push, non-draining. For tests. |
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
| `current_state()` | The active leaf state. |
| `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. |
| `into_context(self)` | Consume, return ctx. |
| `STATE_CHART` | Const ASCII diagram. |
## Feature flags
| Feature | Effect |
|-----------|-------------------------------------------------------------------------|
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.|
`embassy` and `tokio` are mutually exclusive — enabling both is a compile
error.
## Migration from v0.1
v0.1 statecharts continue to compile. New features are purely additive:
### 1. `events:` is now optional
Previously required; now omit for pure timer-only machines:
```rust
statechart! {
Blinker {
context: Ctx;
default(On);
state On { on(after Duration::from_millis(500)) => Off; }
state Off { on(after Duration::from_millis(500)) => On; }
}
}
```
### 2. `dispatch(ev).await` replaces the push+drain helper
Before (v0.1, in the dogfood firmware):
```rust
async fn push<M>(m: &mut M, ev: E) {
m.send(ev).expect("queue full");
while m.has_pending_events() {
m.step(Duration::ZERO).await;
}
}
push(&mut machine, ev).await;
```
After (v0.2):
```rust
machine.dispatch(ev).await.unwrap();
```
### 3. `during:` replaces the imperative I/O outer loop
Before — a radio task with per-state match and select:
```rust
loop {
match machine.current_state() {
RadioState::Receiving => {
match select(rx_once(&mut lora, &cfg, &mut rx_buf),
commands.receive()).await {
Either::First(r) => handle_rx(r, &mut machine, ...).await,
Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
}
}
_ => { ... }
}
}
```
After — the statechart owns the loop:
```rust
state Receiving {
during: next_packet(lora, rx_buf);
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
on(StopRx) => Idle;
}
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
let mut m = Radio::new(RadioCtx { lora, ... }, ch);
m.run().await.unwrap();
}
```
## Examples
| Example | Purpose |
|--------------------|---------------------------------------------------------------------|
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.|
| `microwave_tui` | TUI variant of microwave. |
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
| `embassy_full` | Embassy feature demonstration. |
Run `cargo run --example during_radio --features tokio` to see the
`during:` pattern end-to-end.
## Status
- v0.1 spec: fully implemented.
- v0.2 additions: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
drain-on-send.
- Out of scope: state-scoped context types (hierarchy), guard conditions,
orthogonal/parallel regions, history states, runtime statechart
modification, multi-threaded tokio.
## License
Dual-licensed MIT OR Apache-2.0.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "hsmc-macros"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Procedural macro implementation for the hsmc crate."
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full", "extra-traits"] }
quote = "1"
proc-macro2 = "1"
[dev-dependencies]
trybuild = "1"
hsmc = { path = "../hsmc" }
File diff suppressed because it is too large. Load diff
+529
View File
@@ -0,0 +1,529 @@
//! Intermediate representation built from the parsed AST.
use proc_macro2::Span;
use syn::{Expr, Ident, Type};
use crate::parse::{
During, EventPayload, HandlerKind, PayloadField, PayloadKind, StateBody, StatechartInput,
Trigger,
};
pub struct Ir {
pub name: Ident,
pub context_ty: Type,
pub event_ty: Type,
/// True if the user omitted `events:` (timer-only machine). In that case
/// `event_ty` points to a synthesized empty enum and no `Sender` is emitted.
pub events_omitted: bool,
pub terminate_event: Option<Ident>,
pub states: Vec<StateIr>,
/// Action ident → signature info (trait-method index = position in vec).
pub actions: Vec<ActionIr>,
/// Event variants referenced anywhere, with the variant kind locked in
/// on first use (unit / tuple / struct) for correct pattern matching in
/// `__event_to_id`.
pub event_variants: Vec<EventVariantIr>,
/// Duration triggers referenced anywhere, in first-seen order.
/// Each one gets a trigger id used by the timer table.
pub duration_triggers: Vec<DurationTrigger>,
}
pub struct ActionIr {
pub name: Ident,
/// Typed parameters bound from an event payload. Empty for
/// entry/exit/timer actions or actions triggered by unit-variant events.
pub params: Vec<PayloadField>,
/// For param-bearing actions, every variant this action is registered
/// with (name + kind). Used by codegen to emit the dispatch match arm.
pub bound_variants: Vec<BoundVariant>,
}
#[derive(Clone)]
pub struct BoundVariant {
pub name: Ident,
pub kind: PayloadKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum VariantKind {
Unit,
Tuple,
Struct,
}
pub struct EventVariantIr {
pub name: Ident,
pub kind: VariantKind,
/// Number of fields in the variant (known only when the statechart
/// declares a payload binding; `None` if seen only as unit).
pub field_count: Option<usize>,
}
pub struct DurationTrigger {
pub id: u16,
pub key: String,
pub expr: Expr,
pub repeat: bool,
pub span: Span,
}
pub struct StateIr {
pub id: u16,
pub name: Ident,
pub parent: Option<u16>,
pub depth: u8,
pub default_child: Option<u16>,
pub entries: Vec<u16>, // action ids
pub exits: Vec<u16>,
pub handlers: Vec<HandlerIr>,
pub children: Vec<u16>,
/// Duration triggers owned by this state (trigger id).
pub owned_timers: Vec<u16>,
/// `during:` activities declared on this state, preserved in declaration
/// order. The macro emits these as concurrent `&mut`-field borrows in the
/// run loop's `select` call for any state path that includes this state.
pub durings: Vec<During>,
pub span: Span,
}
pub struct HandlerIr {
pub trigger: TriggerIr,
pub kind: HandlerKindIr,
pub decl_index: usize,
}
#[derive(Clone)]
pub enum TriggerIr {
/// Event trigger. Payload is `Some` when the statechart declared typed
/// bindings at this handler site; the bindings feed the action's typed
/// handler signature.
Event(u16, Ident, Option<EventPayload>),
Duration(u16),
}
pub enum HandlerKindIr {
Transition(Ident, Option<u16>),
Action(u16, #[allow(dead_code)] Ident),
}
pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
let name = input.name;
let root_body = input.body;
let context_ty = root_body
.context_ty
.clone()
.ok_or_else(|| syn::Error::new(name.span(), "statechart missing `context:` declaration"))?;
let (event_ty, events_omitted) = match root_body.event_ty.clone() {
Some(t) => (t, false),
None => {
let ty: Type = syn::parse_quote! { __NoEvents };
(ty, true)
}
};
let terminate_event = root_body.terminate.as_ref().map(|(i, _)| i.clone());
let mut ir = Ir {
name,
context_ty,
event_ty,
events_omitted,
terminate_event,
states: Vec::new(),
actions: Vec::new(),
event_variants: Vec::new(),
duration_triggers: Vec::new(),
};
// Two passes: collect the set of declared state names first so
// `on(...) => Foo;` handlers can classify `Foo` as either a state
// (transition) or a handler fn (action) based on the declared set.
let mut state_names = std::collections::HashSet::new();
collect_state_names(&root_body, &mut state_names);
let name_span = ir.name.span();
let root_name = Ident::new("__Root", name_span);
let root_id = alloc_state(&mut ir, root_name, None, 0, root_body.default_span.unwrap_or(name_span));
lower_body(&mut ir, root_id, &root_body, true, &state_names)?;
Ok(ir)
}
fn collect_state_names(
body: &crate::parse::StateBody,
out: &mut std::collections::HashSet<String>,
) {
for c in &body.children {
out.insert(c.name.to_string());
collect_state_names(&c.body, out);
}
}
fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: Span) -> u16 {
let id = ir.states.len() as u16;
ir.states.push(StateIr {
id,
name,
parent,
depth,
default_child: None,
entries: Vec::new(),
exits: Vec::new(),
handlers: Vec::new(),
children: Vec::new(),
owned_timers: Vec::new(),
durings: Vec::new(),
span,
});
id
}
fn intern_action_unit(ir: &mut Ir, i: &Ident) -> syn::Result<u16> {
if let Some(p) = ir.actions.iter().position(|x| x.name == *i) {
if !ir.actions[p].params.is_empty() {
return Err(syn::Error::new(
i.span(),
format!(
"action `{}` was previously declared with typed event-payload \
bindings; cannot also be used without bindings",
i
),
));
}
return Ok(p as u16);
}
ir.actions.push(ActionIr {
name: i.clone(),
params: Vec::new(),
bound_variants: Vec::new(),
});
Ok((ir.actions.len() - 1) as u16)
}
fn intern_action_with_params(
ir: &mut Ir,
i: &Ident,
params: &[PayloadField],
variant: &Ident,
variant_kind: PayloadKind,
) -> syn::Result<u16> {
if let Some(p) = ir.actions.iter().position(|x| x.name == *i) {
if ir.actions[p].params.is_empty() {
return Err(syn::Error::new(
i.span(),
format!(
"action `{}` was previously declared without bindings; \
cannot also be used with typed event-payload bindings",
i
),
));
}
if !params_match(&ir.actions[p].params, params) {
return Err(syn::Error::new(
i.span(),
format!(
"action `{}` used with a different payload binding shape \
than its first declaration",
i
),
));
}
// Add this variant to bound_variants if not already present.
let already = ir.actions[p]
.bound_variants
.iter()
.any(|bv| bv.name == *variant);
if !already {
ir.actions[p].bound_variants.push(BoundVariant {
name: variant.clone(),
kind: variant_kind,
});
}
return Ok(p as u16);
}
ir.actions.push(ActionIr {
name: i.clone(),
params: params.to_vec(),
bound_variants: vec![BoundVariant {
name: variant.clone(),
kind: variant_kind,
}],
});
Ok((ir.actions.len() - 1) as u16)
}
fn params_match(a: &[PayloadField], b: &[PayloadField]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| {
x.name == y.name && {
let xs = {
let t = &x.ty;
quote::quote! { #t }.to_string()
};
let ys = {
let t = &y.ty;
quote::quote! { #t }.to_string()
};
xs == ys
}
})
}
fn intern_event(
ir: &mut Ir,
i: &Ident,
payload: Option<&EventPayload>,
) -> syn::Result<u16> {
let kind = match payload {
None => VariantKind::Unit,
Some(p) => match p.kind {
PayloadKind::Tuple => VariantKind::Tuple,
PayloadKind::Struct => VariantKind::Struct,
},
};
let field_count = payload.map(|p| p.fields.len());
if let Some(p) = ir.event_variants.iter().position(|x| x.name == *i) {
let existing = &mut ir.event_variants[p];
match (existing.kind, kind) {
(VariantKind::Unit, VariantKind::Unit) => {}
// First saw it as Unit, now a typed binding appears — upgrade
// the variant's kind.
(VariantKind::Unit, k) => {
existing.kind = k;
existing.field_count = field_count;
}
// Already knew it was tuple/struct, and this use is also typed.
// Shape must match.
(a, b) if a == b => {
if let (Some(x), Some(y)) = (existing.field_count, field_count) {
if x != y {
return Err(syn::Error::new(
i.span(),
format!(
"event variant `{}` is used with inconsistent payload field counts ({} vs {})",
i, x, y
),
));
}
}
}
// Already knew it was tuple/struct, this use is un-bound. Fine:
// the user is matching the variant without extracting payload
// (e.g. a transition target). The recorded kind controls the
// match pattern in `__event_to_id`; leaving it as-is still
// matches this use.
(_, VariantKind::Unit) => {}
// Conflict: tuple vs struct binding shape.
(a, b) => {
return Err(syn::Error::new(
i.span(),
format!(
"event variant `{}` is used with inconsistent kinds ({} vs {})",
i,
variant_kind_name(a),
variant_kind_name(b),
),
));
}
}
return Ok(p as u16);
}
ir.event_variants.push(EventVariantIr {
name: i.clone(),
kind,
field_count,
});
Ok((ir.event_variants.len() - 1) as u16)
}
fn variant_kind_name(k: VariantKind) -> &'static str {
match k {
VariantKind::Unit => "unit",
VariantKind::Tuple => "tuple",
VariantKind::Struct => "struct",
}
}
fn intern_duration(
ir: &mut Ir,
expr: &Expr,
key: &str,
repeat: bool,
span: Span,
) -> u16 {
let full_key = format!("{}:{}", key, repeat);
if let Some(dt) = ir.duration_triggers.iter().find(|d| d.key == full_key) {
return dt.id;
}
let id = ir.duration_triggers.len() as u16;
ir.duration_triggers.push(DurationTrigger {
id,
key: full_key,
expr: expr.clone(),
repeat,
span,
});
id
}
fn lower_body(
ir: &mut Ir,
state_id: u16,
body: &StateBody,
_is_root: bool,
state_names: &std::collections::HashSet<String>,
) -> syn::Result<()> {
let mut entries: Vec<u16> = Vec::new();
for i in &body.entries {
entries.push(intern_action_unit(ir, i)?);
}
let mut exits: Vec<u16> = Vec::new();
for i in &body.exits {
exits.push(intern_action_unit(ir, i)?);
}
ir.states[state_id as usize].entries = entries;
ir.states[state_id as usize].exits = exits;
let mut new_handlers = Vec::new();
for h in &body.handlers {
let trig = match &h.trigger {
Trigger::Event { variant, payload } => {
let id = intern_event(ir, variant, payload.as_ref())?;
TriggerIr::Event(id, variant.clone(), payload.clone())
}
Trigger::Duration { expr, key, span, repeat } => {
let id = intern_duration(ir, expr, key, *repeat, *span);
let owned = &mut ir.states[state_id as usize].owned_timers;
if !owned.contains(&id) {
owned.push(id);
}
TriggerIr::Duration(id)
}
};
let kind = match &h.kind {
HandlerKind::Target(target) => classify_target(ir, target, &trig, state_names)?,
};
new_handlers.push(HandlerIr {
trigger: trig,
kind,
decl_index: h.decl_index,
});
}
ir.states[state_id as usize].handlers = new_handlers;
// `during:` declarations are stored verbatim for codegen. Field-to-struct
// validation happens in codegen (we don't have access to the context
// struct's fields here) via the natural borrow-checker error on the
// generated call site — validate.rs additionally catches the obvious
// cases earlier.
ir.states[state_id as usize].durings = body.durings.clone();
let parent_depth = ir.states[state_id as usize].depth;
let mut child_ids = Vec::new();
for c in &body.children {
let cid = alloc_state(ir, c.name.clone(), Some(state_id), parent_depth + 1, c.span);
child_ids.push(cid);
lower_body(ir, cid, &c.body, false, state_names)?;
}
ir.states[state_id as usize].children = child_ids.clone();
if let Some((dc_ident, _)) = &body.default_child {
let resolved = child_ids
.iter()
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
.copied();
ir.states[state_id as usize].default_child = resolved;
}
Ok(())
}
/// Decide whether `on(trigger) => target;` is a transition (target is a
/// declared state) or an action (target is a handler fn). Also enforce the
/// rules that only apply to one form or the other.
fn classify_target(
ir: &mut Ir,
target: &Ident,
trig: &TriggerIr,
state_names: &std::collections::HashSet<String>,
) -> syn::Result<HandlerKindIr> {
let tname = target.to_string();
if state_names.contains(&tname) {
// Transition. A repeating timer transitioning is nonsensical (the
// state exits on first fire and cancels the timer). Payload
// bindings can't feed a target state — they go to action args.
if let TriggerIr::Duration(tid) = trig {
if ir.duration_triggers[*tid as usize].repeat {
return Err(syn::Error::new(
target.span(),
"`every` timers are only valid on action handlers; \
transitioning to a state cancels the timer on first fire",
));
}
}
if let TriggerIr::Event(_, variant, Some(_)) = trig {
return Err(syn::Error::new(
variant.span(),
"event payload bindings are only valid when dispatching to an \
action handler; a transition target takes no arguments",
));
}
// State-id resolution happens later in `resolve_transitions`.
Ok(HandlerKindIr::Transition(target.clone(), None))
} else {
// Action handler.
let aid = match trig {
TriggerIr::Event(_, variant, Some(payload)) => intern_action_with_params(
ir,
target,
&payload.fields,
variant,
payload.kind,
)?,
_ => intern_action_unit(ir, target)?,
};
Ok(HandlerKindIr::Action(aid, target.clone()))
}
}
/// Resolve transition targets from state idents to state ids after the tree is built.
pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
let mut name_map = std::collections::HashMap::new();
for s in &ir.states {
if s.name != "__Root" {
if name_map.insert(s.name.to_string(), s.id).is_some() {}
}
}
let n = ir.states.len();
for i in 0..n {
let len = ir.states[i].handlers.len();
for h in 0..len {
if let HandlerKindIr::Transition(target, slot) = &ir.states[i].handlers[h].kind {
if slot.is_some() {
continue;
}
let tname = target.to_string();
let resolved = name_map.get(&tname).copied();
let target_clone = target.clone();
match resolved {
Some(id) => {
ir.states[i].handlers[h].kind =
HandlerKindIr::Transition(target_clone, Some(id));
}
None => {
return Err(syn::Error::new(
target.span(),
format!("unknown state `{}` in transition", target),
));
}
}
}
}
}
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
//! Proc-macro backing for the `hsmc` crate. The sole public item is the
//! [`statechart!`] macro which generates a state machine from a declarative
//! description.
use proc_macro::TokenStream;
mod parse;
mod ir;
mod validate;
mod codegen;
/// See the `hsmc` crate for documentation.
#[proc_macro]
pub fn statechart(input: TokenStream) -> TokenStream {
let input = proc_macro2::TokenStream::from(input);
match statechart_impl(input) {
Ok(ts) => ts.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn statechart_impl(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
let parsed = syn::parse2::<parse::StatechartInput>(input)?;
validate::validate_parse_tree(&parsed)?;
let mut ir = ir::build_ir(parsed)?;
ir::resolve_transitions(&mut ir)?;
validate::validate(&ir)?;
Ok(codegen::generate(&ir))
}
+433
View File
@@ -0,0 +1,433 @@
//! Parsing for the `statechart!` grammar.
use proc_macro2::Span;
use syn::parse::{Parse, ParseStream};
use syn::{braced, parenthesized, token, Expr, Ident, Token, Type};
pub struct StatechartInput {
pub name: Ident,
pub body: StateBody,
}
pub struct StateBody {
/// Only present at root.
pub context_ty: Option<Type>,
pub event_ty: Option<Type>,
pub terminate: Option<(Ident, Span)>,
pub default_child: Option<(Ident, Span)>,
pub default_span: Option<Span>, // to detect duplicates
pub default_count: usize,
pub terminate_count: usize,
pub entries: Vec<Ident>,
pub exits: Vec<Ident>,
pub handlers: Vec<Handler>,
pub children: Vec<StateDecl>,
/// `during: fn_name(field_a, field_b);` declarations on this state. Each
/// `During` names a free async function and the root-context fields it
/// needs `&mut` access to. Field non-overlap is enforced by Rust's native
/// split borrow on the generated `select` call site; the macro emits a
/// clearer error when it can detect overlap at expansion time.
pub durings: Vec<During>,
}
impl StateBody {
fn new() -> Self {
Self {
context_ty: None,
event_ty: None,
terminate: None,
default_child: None,
default_span: None,
default_count: 0,
terminate_count: 0,
entries: Vec::new(),
exits: Vec::new(),
handlers: Vec::new(),
children: Vec::new(),
durings: Vec::new(),
}
}
}
/// `during: <fn_ident> ( <field_ident>, ... ) ;` — an async activity scoped to
/// a state. Runs concurrently with other durings on the active state path,
/// the external event channel, and the next timer deadline. Dropped whenever
/// any other branch of the run-loop's `select` wins or the state transitions.
#[derive(Clone)]
pub struct During {
/// Name of the free async function the macro will call.
pub fn_name: Ident,
/// Fields of the root context struct the function borrows `&mut`.
/// An empty list means the function takes no arguments.
pub fields: Vec<Ident>,
/// Span of the `during:` keyword for diagnostic messages. Currently
/// unused by codegen (per-field spans come from the ident list) but
/// kept for future clearer diagnostics.
#[allow(dead_code)]
pub kw_span: Span,
}
pub struct StateDecl {
pub name: Ident,
pub body: StateBody,
pub span: Span,
}
#[derive(Clone)]
pub struct Handler {
pub trigger: Trigger,
pub kind: HandlerKind,
pub decl_index: usize,
}
#[derive(Clone)]
pub enum HandlerKind {
/// Unresolved handler target: could be the name of a declared state
/// (→ transition) or the name of an action handler function (→ internal
/// transition). Classified during IR build once all state names are
/// known.
Target(Ident),
}
#[derive(Clone)]
pub enum Trigger {
Event {
variant: Ident,
payload: Option<EventPayload>,
},
/// An arbitrary expression evaluating to `core::time::Duration`, together
/// with a human-readable key for dedup/diagnostics. `repeat` means the
/// timer re-arms after firing; only valid on action handlers.
Duration {
expr: Expr,
key: String,
span: Span,
repeat: bool,
},
}
/// Typed payload bindings attached to an event-variant trigger.
/// e.g. `action(PacketRx(rssi: i16, snr: i16)) => h;` parses to
/// `EventPayload { kind: Tuple, fields: [(rssi, i16), (snr, i16)] }`.
#[derive(Clone)]
pub struct EventPayload {
pub kind: PayloadKind,
pub fields: Vec<PayloadField>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PayloadKind {
Tuple,
Struct,
}
#[derive(Clone)]
pub struct PayloadField {
pub name: Ident,
pub ty: Type,
}
impl Trigger {
#[allow(dead_code)]
pub fn key(&self) -> String {
match self {
Trigger::Event { variant, .. } => format!("event:{}", variant),
Trigger::Duration { key, repeat, .. } => format!("dur:{}:{}", key, repeat),
}
}
#[allow(dead_code)]
pub fn span(&self) -> Span {
match self {
Trigger::Event { variant, .. } => variant.span(),
Trigger::Duration { span, .. } => *span,
}
}
#[allow(dead_code)]
pub fn display(&self) -> String {
match self {
Trigger::Event { variant, .. } => variant.to_string(),
Trigger::Duration { key, .. } => key.clone(),
}
}
}
impl Parse for StatechartInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: Ident = input.parse()?;
let content;
braced!(content in input);
let body = parse_body(&content, true)?;
Ok(StatechartInput { name, body })
}
}
fn parse_body(input: ParseStream, is_root: bool) -> syn::Result<StateBody> {
let mut body = StateBody::new();
let mut decl_index: usize = 0;
while !input.is_empty() {
let lookahead = input.lookahead1();
if lookahead.peek(Ident) {
let ident: Ident = input.fork().parse()?;
let name = ident.to_string();
match name.as_str() {
"context" => {
let _: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let ty: Type = input.parse()?;
input.parse::<Token![;]>()?;
if !is_root {
return Err(syn::Error::new(ident.span(), "`context:` is only valid at root"));
}
body.context_ty = Some(ty);
}
"events" => {
let _: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let ty: Type = input.parse()?;
input.parse::<Token![;]>()?;
if !is_root {
return Err(syn::Error::new(ident.span(), "`events:` is only valid at root"));
}
body.event_ty = Some(ty);
}
"entry" => {
let _: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let list = parse_ident_list(input)?;
input.parse::<Token![;]>()?;
body.entries.extend(list);
}
"exit" => {
let _: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let list = parse_ident_list(input)?;
input.parse::<Token![;]>()?;
body.exits.extend(list);
}
"default" => {
let kw: Ident = input.parse()?;
let inner;
parenthesized!(inner in input);
let child: Ident = inner.parse()?;
input.parse::<Token![;]>()?;
body.default_count += 1;
if body.default_child.is_none() {
body.default_child = Some((child, kw.span()));
body.default_span = Some(kw.span());
}
}
"terminate" => {
let kw: Ident = input.parse()?;
let inner;
parenthesized!(inner in input);
let ev: Ident = inner.parse()?;
input.parse::<Token![;]>()?;
body.terminate_count += 1;
if body.terminate.is_none() {
body.terminate = Some((ev, kw.span()));
}
}
"on" => {
// Unified handler declaration. The RHS may be a single
// target or a comma-separated list — every entry in the
// list fires atomically in declaration order. Targets
// disambiguate at IR-build time: ident names a declared
// state → transition; otherwise → handler fn.
let _: Ident = input.parse()?;
let inner;
parenthesized!(inner in input);
let trigger = parse_trigger(&inner)?;
input.parse::<Token![=>]>()?;
let targets = parse_ident_list(input)?;
input.parse::<Token![;]>()?;
for target in targets {
body.handlers.push(Handler {
trigger: trigger.clone(),
kind: HandlerKind::Target(target),
decl_index,
});
decl_index += 1;
}
}
"during" => {
// `during: fn_name ( field_a, field_b, ... ) ;` or
// `during: fn_name ;` (zero-arg). Fields name struct
// members of the root context; the macro emits
// `fn_name(&mut ctx.field_a, &mut ctx.field_b)` at the
// call site and Rust's split borrow verifies no overlap.
let kw: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let fn_name: Ident = input.parse()?;
let fields = if input.peek(token::Paren) {
let inner;
parenthesized!(inner in input);
let mut fields = Vec::new();
while !inner.is_empty() {
fields.push(inner.parse::<Ident>()?);
if inner.peek(Token![,]) {
inner.parse::<Token![,]>()?;
} else {
break;
}
}
if !inner.is_empty() {
return Err(inner.error("unexpected tokens after field list"));
}
fields
} else {
Vec::new()
};
input.parse::<Token![;]>()?;
body.durings.push(During {
fn_name,
fields,
kw_span: kw.span(),
});
}
"state" => {
let _: Ident = input.parse()?;
let sname: Ident = input.parse()?;
let content;
braced!(content in input);
let cbody = parse_body(&content, false)?;
body.children.push(StateDecl { name: sname.clone(), body: cbody, span: sname.span() });
}
other => {
return Err(syn::Error::new(ident.span(), format!("unknown item `{}` in statechart body", other)));
}
}
} else {
return Err(lookahead.error());
}
}
Ok(body)
}
fn parse_ident_list(input: ParseStream) -> syn::Result<Vec<Ident>> {
let mut v = Vec::new();
v.push(input.parse()?);
while input.peek(Token![,]) {
input.parse::<Token![,]>()?;
v.push(input.parse()?);
}
Ok(v)
}
fn parse_trigger(input: ParseStream) -> syn::Result<Trigger> {
// Duration triggers must be prefixed with an explicit mode keyword:
// `every <dur>` — repeating timer
// `after <dur>` — one-shot timer
//
// Everything else is an event trigger:
// `Variant` — unit
// `Variant { a: T, b: U }` — struct-style payload binding
// `Variant(a: T, b: U)` — tuple-style payload binding
if input.peek(Ident) {
let fork = input.fork();
if let Ok(id) = fork.parse::<Ident>() {
if id == "every" {
let _: Ident = input.parse()?;
return parse_duration_rest(input, true);
}
if id == "after" {
let _: Ident = input.parse()?;
return parse_duration_rest(input, false);
}
}
}
if !input.peek(Ident) || input.peek2(Token![::]) {
return Err(input.error(
"expected an event variant (`Name`, `Name(..)`, `Name { .. }`) or a \
duration trigger prefixed with `after` or `every`",
));
}
let variant: Ident = input.parse()?;
if input.is_empty() {
return Ok(Trigger::Event {
variant,
payload: None,
});
}
if input.peek(token::Brace) {
let inner;
braced!(inner in input);
let fields = parse_typed_fields(&inner)?;
return Ok(Trigger::Event {
variant,
payload: Some(EventPayload {
kind: PayloadKind::Struct,
fields,
}),
});
}
if input.peek(token::Paren) {
let inner;
parenthesized!(inner in input);
let fields = parse_typed_fields(&inner)?;
return Ok(Trigger::Event {
variant,
payload: Some(EventPayload {
kind: PayloadKind::Tuple,
fields,
}),
});
}
Err(input.error(
"unexpected token after event variant name; \
expected end of trigger, `(..)`, or `{ .. }`",
))
}
fn parse_typed_fields(input: ParseStream) -> syn::Result<Vec<PayloadField>> {
let mut fields = Vec::new();
while !input.is_empty() {
let name: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let ty: Type = input.parse()?;
fields.push(PayloadField { name, ty });
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
} else {
break;
}
}
if !input.is_empty() {
return Err(input.error("unexpected tokens after typed payload bindings"));
}
Ok(fields)
}
fn parse_duration_rest(input: ParseStream, repeat: bool) -> syn::Result<Trigger> {
let expr: Expr = input.parse()?;
let span = syn::spanned::Spanned::span(&expr);
// Bare numeric literals are rejected: readers shouldn't have to remember
// what unit a lone integer or float means. Always require an explicit
// `core::time::Duration` expression (or the re-exported `hsmc::Duration`).
if let Expr::Lit(lit) = &expr {
match &lit.lit {
syn::Lit::Int(_) | syn::Lit::Float(_) => {
return Err(syn::Error::new(
span,
"bare numeric literals are not accepted as durations; \
use a typed expression like `Duration::from_secs(5)` or \
`Duration::from_millis(250)`",
));
}
_ => {}
}
}
let key = expr_to_string(&expr);
Ok(Trigger::Duration { expr, key, span, repeat })
}
fn expr_to_string(e: &Expr) -> String {
// Token-stream-based canonical form. Good enough for dedup keying.
let ts = quote::quote! { #e };
ts.to_string().split_whitespace().collect()
}
+223
View File
@@ -0,0 +1,223 @@
//! Compile-time validation (§6.2 / §9.9).
use std::collections::HashMap;
use syn::Ident;
use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> {
// Duplicate state names
let mut seen = HashMap::new();
for s in &ir.states {
if s.name == "__Root" {
continue;
}
if let Some(_prev) = seen.insert(s.name.to_string(), s.id) {
return Err(syn::Error::new(s.span, format!("duplicate state name `{}`", s.name)));
}
}
// Resolve transitions: done already? We need post-build resolution.
// We'll do it here because we need IR mutable; but we're given &Ir.
// Instead perform target lookup inline.
let name_map: HashMap<String, u16> = ir
.states
.iter()
.filter(|s| s.name != "__Root")
.map(|s| (s.name.to_string(), s.id))
.collect();
for s in &ir.states {
// Empty state (not root). A state with a `during:` is not empty —
// the during is a real behavior scoped to the state's lifetime.
if s.parent.is_some()
&& s.entries.is_empty()
&& s.exits.is_empty()
&& s.handlers.is_empty()
&& s.children.is_empty()
&& s.durings.is_empty()
{
return Err(syn::Error::new(s.span, format!("empty state `{}`", s.name)));
}
// During-level checks: each `during:` declares a list of root-context
// fields it borrows `&mut`. We catch the obvious overlap cases at
// macro expansion time so the user gets a clear message rather than
// a raw rustc borrow-checker error on the generated `select` call.
//
// 1. Within a single `during:`, field names must be unique.
// 2. Across durings *on the same state*, field names must be disjoint.
//
// Cross-hierarchy overlaps (parent vs. descendant) are left to rustc
// — the borrow checker reports them accurately on the `&mut ctx.field`
// call site in codegen.
for d in &s.durings {
let mut seen_in_one = std::collections::HashSet::new();
for f in &d.fields {
if !seen_in_one.insert(f.to_string()) {
return Err(syn::Error::new(
f.span(),
format!(
"field `{}` listed twice in `during: {}(...)` — each \
field can only be borrowed once per during",
f, d.fn_name
),
));
}
}
}
let mut field_owner: std::collections::HashMap<String, Ident> =
std::collections::HashMap::new();
for d in &s.durings {
for f in &d.fields {
if let Some(prev) = field_owner.insert(f.to_string(), d.fn_name.clone()) {
return Err(syn::Error::new(
f.span(),
format!(
"field `{}` is borrowed by two concurrent durings on state `{}` \
(`{}` and `{}`) — move one of them to a disjoint field or \
combine the activities into a single during that uses `select`",
f, s.name, prev, d.fn_name
),
));
}
}
}
// Children require default; default must be a direct child; no default allowed if no children.
if !s.children.is_empty() && s.default_child.is_none() {
return Err(syn::Error::new(
s.span,
format!("state `{}` has children but no `default` declaration", s.name),
));
}
// duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler
// on the same trigger — so the grammar's left-to-right reading
// matches execution order (actions first, then transition).
let mut trig_txn_seen: HashMap<String, usize> = HashMap::new();
let mut trig_last_action_idx: HashMap<String, usize> = HashMap::new();
for h in &s.handlers {
let key = match &h.trigger {
TriggerIr::Event(_, i, _) => format!("ev:{}", i),
TriggerIr::Duration(id) => format!("dur:{}", id),
};
let trig_display = match &h.trigger {
TriggerIr::Event(_, i, _) => i.to_string(),
TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].key.clone(),
};
let trig_span = match &h.trigger {
TriggerIr::Event(_, i, _) => i.span(),
TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].span,
};
match &h.kind {
HandlerKindIr::Transition(target, _) => {
if let Some(_prev) = trig_txn_seen.insert(key.clone(), h.decl_index) {
return Err(syn::Error::new(
trig_span,
format!("duplicate transition on trigger `{}` in state `{}`", trig_display, s.name),
));
}
if !name_map.contains_key(&target.to_string()) {
return Err(syn::Error::new(
target.span(),
format!("unknown state `{}` in transition", target),
));
}
// Any action handler on the same trigger must have
// already been seen (smaller decl_index).
if let Some(&last_action_idx) = trig_last_action_idx.get(&key) {
if last_action_idx > h.decl_index {
return Err(syn::Error::new(
target.span(),
format!(
"transition to state `{}` on trigger `{}` must come after \
every action handler on the same trigger; hsmc runs \
all actions before the transition, so declaring the state \
target earlier misleads the reader",
target, trig_display
),
));
}
}
}
HandlerKindIr::Action(_, _) => {
// If a transition on this trigger was already declared,
// this action would silently run *before* it at runtime
// — same ordering lie. Reject.
if let Some(&txn_idx) = trig_txn_seen.get(&key) {
if txn_idx < h.decl_index {
return Err(syn::Error::new(
trig_span,
format!(
"action handler on trigger `{}` must be declared before the \
state transition target; hsmc runs all actions before the \
transition, so placing actions after the target misleads \
the reader",
trig_display
),
));
}
}
trig_last_action_idx.insert(key, h.decl_index);
}
}
}
}
Ok(())
}
/// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" /
/// "default not a direct child" / "terminate in non-root" errors).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name)
}
fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Result<()> {
if body.default_count > 1 {
return Err(syn::Error::new(
body.default_span.unwrap_or_else(proc_macro2::Span::call_site),
format!("multiple `default` declarations in state `{}`", state_name),
));
}
if body.terminate_count > 1 {
return Err(syn::Error::new(
body.terminate.as_ref().map(|(_, s)| *s).unwrap_or_else(proc_macro2::Span::call_site),
"multiple `terminate` declarations",
));
}
if !is_root {
if let Some((_, span)) = &body.terminate {
return Err(syn::Error::new(*span, "`terminate` is only valid at the root level"));
}
}
if body.default_child.is_some() && body.children.is_empty() {
return Err(syn::Error::new(
body.default_span.unwrap(),
format!("`default` in state `{}` which has no child states", state_name),
));
}
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
// default must be a direct child
let is_child = body.children.iter().any(|c| c.name == *dc);
if !is_child {
return Err(syn::Error::new(
*dspan,
format!(
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
dc, state_name, dc, state_name
),
));
}
}
for c in &body.children {
let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?;
}
Ok(())
}
+8
View File
@@ -0,0 +1,8 @@
//! Compile-fail tests for `statechart!` validations (§9.9) and
//! compile-pass parse tests (spec §8).
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
t.pass("tests/ui_pass/*.rs");
}
@@ -0,0 +1,28 @@
//! t10.1: a single `during:` listing the same field twice is a compile error.
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt }
pub struct Ctx {
pub a: u32,
}
statechart! {
M {
context: Ctx;
events: Ev;
default(S);
terminate(Halt);
state S {
during: tick(a, a);
}
}
}
async fn tick(_: &mut u32, _: &mut u32) -> Ev { Ev::Halt }
impl MActions for MActionContext<'_> {}
fn main() {}
@@ -0,0 +1,17 @@
error: field `a` listed twice in `during: tick(...)` — each field can only be borrowed once per during
--> tests/ui/t10_1_during_duplicate_field.rs:19:25
|
19 | during: tick(a, a);
| ^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t10_1_during_duplicate_field.rs:26:6
|
26 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t10_1_during_duplicate_field.rs:26:19
|
26 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,31 @@
//! t10.2: two `during:` declarations on the same state borrowing the same
//! field produce a clear macro-level error.
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt }
pub struct Ctx {
pub a: u32,
}
statechart! {
M {
context: Ctx;
events: Ev;
default(S);
terminate(Halt);
state S {
during: one(a);
during: two(a);
}
}
}
async fn one(_: &mut u32) -> Ev { Ev::Halt }
async fn two(_: &mut u32) -> Ev { Ev::Halt }
impl MActions for MActionContext<'_> {}
fn main() {}
@@ -0,0 +1,17 @@
error: field `a` is borrowed by two concurrent durings on state `S` (`one` and `two`) — move one of them to a disjoint field or combine the activities into a single during that uses `select`
--> tests/ui/t10_2_during_overlap_same_state.rs:21:21
|
21 | during: two(a);
| ^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t10_2_during_overlap_same_state.rs:29:6
|
29 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t10_2_during_overlap_same_state.rs:29:19
|
29 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,26 @@
use hsmc::{statechart, Duration};
#[derive(Debug, Clone)]
pub enum Ev { Go }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Red);
state Red {
on(Go) => Green;
on(Go) => Yellow;
}
state Green { entry: e; }
state Yellow { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,25 @@
error: duplicate transition on trigger `Go` in state `Red`
--> tests/ui/t9_1_duplicate_transition.rs:15:12
|
15 | on(Go) => Yellow;
| ^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_1_duplicate_transition.rs:22:6
|
22 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_1_duplicate_transition.rs:22:19
|
22 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
warning: unused import: `Duration`
--> tests/ui/t9_1_duplicate_transition.rs:1:24
|
1 | use hsmc::{statechart, Duration};
| ^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,24 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
state ChildA { entry: e; }
state ChildB { entry: e; }
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: state `Parent` has children but no `default` declaration
--> tests/ui/t9_2_missing_default.rs:13:11
|
13 | state Parent {
| ^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_2_missing_default.rs:20:6
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_2_missing_default.rs:20:19
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,24 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Child);
state Child {
terminate(Halt);
entry: e;
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: `terminate` is only valid at the root level
--> tests/ui/t9_3_terminate_in_child.rs:14:9
|
14 | terminate(Halt);
| ^^^^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_3_terminate_in_child.rs:20:6
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_3_terminate_in_child.rs:20:19
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,25 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
default(Cousin);
state Child { entry: e; }
}
state Cousin { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
--> tests/ui/t9_4_default_non_child.rs:14:9
|
14 | default(Cousin);
| ^^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_4_default_non_child.rs:21:6
|
21 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_4_default_non_child.rs:21:19
|
21 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,24 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Leaf);
state Leaf {
default(Ghost);
entry: e;
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: `default` in state `Leaf` which has no child states
--> tests/ui/t9_5_default_childless.rs:14:9
|
14 | default(Ghost);
| ^^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_5_default_childless.rs:20:6
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_5_default_childless.rs:20:19
|
20 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
+22
View File
@@ -0,0 +1,22 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
state A { entry: e; }
state Empty {}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: empty state `Empty`
--> tests/ui/t9_6_empty_state.rs:14:11
|
14 | state Empty {}
| ^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_6_empty_state.rs:18:6
|
18 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_6_empty_state.rs:18:19
|
18 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,26 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
default(A);
default(B);
state A { entry: e; }
state B { entry: e; }
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: multiple `default` declarations in state `Parent`
--> tests/ui/t9_7_duplicate_default.rs:14:9
|
14 | default(A);
| ^^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_7_duplicate_default.rs:22:6
|
22 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_7_duplicate_default.rs:22:19
|
22 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,21 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Go }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
state A {
on(Go) => NonExistent;
}
}
}
impl MActions for MActionContext<'_> {}
fn main() {}
@@ -0,0 +1,50 @@
warning: unexpected `cfg` condition value: `tokio`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `embassy`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0046]: not all trait items implemented, missing: `NonExistent`
--> tests/ui/t9_8_unknown_target.rs:19:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_- `NonExistent` from trait
18 |
19 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation
@@ -0,0 +1,23 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt, Abort }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
terminate(Halt);
terminate(Abort);
state A { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,17 @@
error: multiple `terminate` declarations
--> tests/ui/t9_9_multiple_terminate.rs:13:5
|
13 | terminate(Halt);
| ^^^^^^^^^
error[E0405]: cannot find trait `MActions` in this scope
--> tests/ui/t9_9_multiple_terminate.rs:19:6
|
19 | impl MActions for MActionContext<'_> {
| ^^^^^^^^ not found in this scope
error[E0425]: cannot find type `MActionContext` in this scope
--> tests/ui/t9_9_multiple_terminate.rs:19:19
|
19 | impl MActions for MActionContext<'_> {
| ^^^^^^^^^^^^^^ not found in this scope
@@ -0,0 +1,92 @@
//! Compile-pass parse test mirroring spec §8 "Complete Example: Nested States".
//! The only deviation from the spec snippet is the outer-brace macro call syntax.
#![allow(unexpected_cfgs)]
use hsmc::{statechart, Duration};
#[derive(Debug, Clone)]
pub enum MyEvent {
Event1,
Event2,
Halt,
}
#[derive(Default)]
pub struct MyContext {
pub log: Vec<String>,
}
statechart! {
Name {
context: MyContext;
events: MyEvent;
default(FirstState);
entry: mainentry, mainentry2, mainentry3;
exit: mainexit;
terminate(Halt);
// Root-level transitions and actions
on(after Duration::from_secs(1)) => FirstState;
on(after Duration::from_secs_f64(0.99)) => asldkfj;
on(after Duration::from_secs_f64(0.99)) => asldkfj1;
on(after Duration::from_secs_f64(0.99)) => asldkfj2;
state FirstState {
default(SubState);
entry: blah_entry;
exit: blah_exit;
on(Event2) => SecondState;
on(Event1) => x_action;
state SubState {
default(SubSubState);
state SubSubState {
default(SubSubSubState);
state SubSubSubState {
entry: subsubsub_entry;
}
}
}
}
state SecondState {
on(Event1) => FirstState;
}
state XSecondState {
entry: x_entry;
}
state SomeEndState {
entry: end_entry;
exit: end_exit;
on(after Duration::from_secs_f64(0.23)) => something;
}
}
}
impl NameActions for NameActionContext<'_> {
fn mainentry(&mut self) { self.log.push("mainentry".into()); }
fn mainentry2(&mut self) { self.log.push("mainentry2".into()); }
fn mainentry3(&mut self) { self.log.push("mainentry3".into()); }
fn mainexit(&mut self) { self.log.push("mainexit".into()); }
fn blah_entry(&mut self) { self.log.push("blah_entry".into()); }
fn blah_exit(&mut self) { self.log.push("blah_exit".into()); }
fn x_action(&mut self) { self.log.push("x_action".into()); }
fn asldkfj(&mut self) { self.log.push("asldkfj".into()); }
fn asldkfj1(&mut self) { self.log.push("asldkfj1".into()); }
fn asldkfj2(&mut self) { self.log.push("asldkfj2".into()); }
fn x_entry(&mut self) { self.log.push("x_entry".into()); }
fn end_entry(&mut self) { self.log.push("end_entry".into()); }
fn end_exit(&mut self) { self.log.push("end_exit".into()); }
fn something(&mut self) { self.log.push("something".into()); }
fn subsubsub_entry(&mut self) { self.log.push("subsubsub_entry".into()); }
}
fn main() {
let _ = Name::new(MyContext::default());
}
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "hsmc"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Hierarchical state machines (statecharts) with a declarative proc macro."
[features]
default = []
tokio = ["dep:tokio"]
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
[dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.1.0" }
heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true }
embassy-time = { version = "0.5", optional = true }
embassy-futures = { version = "0.1", optional = true }
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies]
trybuild = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
futures = "0.3"
[[example]]
name = "microwave"
required-features = ["tokio"]
[[example]]
name = "microwave_tui"
required-features = ["tokio"]
[[example]]
name = "during_radio"
required-features = ["tokio"]
[[example]]
name = "embassy_full"
required-features = ["embassy"]
crate-type = ["lib"]
+184
View File
@@ -0,0 +1,184 @@
//! Simulated radio task demonstrating `during:` activities.
//!
//! Models a state machine with a couple of I/O loops:
//!
//! * `Idle` waits for a command; no ongoing I/O.
//! * `Receiving` has a `during: next_packet(driver, buf)` that produces a
//! `PacketRx` event every time the simulated driver yields one.
//! * `Transmitting` has a `during: await_tx_done(driver)` that waits for the
//! simulated TX to finish.
//!
//! Compared to the "imperative outer loop" pattern (see the dogfood firmware
//! radio task pre-v0.2), the task body collapses to a single
//! `machine.run().await` call — the statechart owns the control flow.
//!
//! Run with: `cargo run --example during_radio --features tokio`
use hsmc::{statechart, Duration};
/// A stand-in for a real LoRa driver. Maintains a simple state so the
/// simulated RX produces deterministic packets.
pub struct SimRadio {
rx_counter: u32,
tx_pending: bool,
}
impl SimRadio {
pub fn new() -> Self {
Self {
rx_counter: 0,
tx_pending: false,
}
}
/// Simulates waiting for a packet. Returns Some((rssi, snr)) when one
/// "arrives". In a real driver this would drive SPI + DIO interrupts.
pub async fn rx(&mut self, buf: &mut [u8; 64]) -> (i16, i16, usize) {
tokio::time::sleep(Duration::from_millis(30)).await;
self.rx_counter = self.rx_counter.wrapping_add(1);
let n = core::cmp::min(buf.len(), 4);
buf[..n].copy_from_slice(&self.rx_counter.to_le_bytes()[..n]);
(-62 + (self.rx_counter as i16 % 10), 8, n)
}
/// Starts a transmit. The `await_tx_done` during polls `tx_pending`.
pub fn begin_tx(&mut self) {
self.tx_pending = true;
}
pub async fn tx_done(&mut self) {
// In real driver: spin on DIO until TX_DONE.
tokio::time::sleep(Duration::from_millis(15)).await;
self.tx_pending = false;
}
}
pub struct Ctx {
pub radio: SimRadio,
pub rx_buf: [u8; 64],
pub stats: RadioStats,
pub last_payload: heapless::Vec<u8, 64>,
}
impl Ctx {
pub fn new() -> Self {
Self {
radio: SimRadio::new(),
rx_buf: [0; 64],
stats: RadioStats::default(),
last_payload: heapless::Vec::new(),
}
}
}
#[derive(Default, Debug)]
pub struct RadioStats {
pub rx_count: u32,
pub tx_count: u32,
pub last_rssi: i16,
}
#[derive(Debug, Clone)]
pub enum Ev {
StartRx,
StopRx,
Transmit,
PacketRx { rssi: i16, snr: i16, len: usize },
TxDone,
Halt,
}
statechart! {
Radio {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: announce_idle;
on(StartRx) => Receiving;
on(Transmit) => Transmitting;
}
state Receiving {
entry: announce_rx;
during: next_packet(radio, rx_buf);
on(PacketRx { rssi: i16, snr: i16, len: usize }) => record_packet;
on(StopRx) => Idle;
}
state Transmitting {
entry: begin_tx_hw;
during: await_tx_done(radio);
on(TxDone) => tx_completed, Idle;
}
}
}
/// During activity: wait for a packet and return a PacketRx event. Runs
/// every time the machine is in Receiving; dropped whenever the machine
/// leaves Receiving or any other event arrives.
async fn next_packet(radio: &mut SimRadio, buf: &mut [u8; 64]) -> Ev {
let (rssi, snr, len) = radio.rx(buf).await;
Ev::PacketRx { rssi, snr, len }
}
/// During activity: wait for the TX to finish. Returns TxDone; then the
/// handler runs and transitions back to Idle.
async fn await_tx_done(radio: &mut SimRadio) -> Ev {
radio.tx_done().await;
Ev::TxDone
}
impl RadioActions for RadioActionContext<'_> {
async fn announce_idle(&mut self) {
println!("[radio] → Idle");
}
async fn announce_rx(&mut self) {
println!("[radio] → Receiving");
}
async fn begin_tx_hw(&mut self) {
println!("[radio] → Transmitting");
self.radio.begin_tx();
}
async fn record_packet(&mut self, rssi: i16, snr: i16, len: usize) {
self.stats.rx_count = self.stats.rx_count.wrapping_add(1);
self.stats.last_rssi = rssi;
let copy = core::cmp::min(len, self.rx_buf.len());
let snapshot: heapless::Vec<u8, 64> = self.rx_buf[..copy].iter().copied().collect();
self.last_payload = snapshot;
let count = self.stats.rx_count;
println!("[radio] rx#{} rssi={} snr={} len={}", count, rssi, snr, len);
}
async fn tx_completed(&mut self) {
self.stats.tx_count = self.stats.tx_count.wrapping_add(1);
println!("[radio] tx#{} done", self.stats.tx_count);
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("\n{}\n", Radio::<8>::STATE_CHART);
let mut machine = Radio::new(Ctx::new());
let sender = machine.sender();
// External driver: issue a sequence of commands.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(5)).await;
let _ = sender.send(Ev::StartRx);
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = sender.send(Ev::StopRx);
tokio::time::sleep(Duration::from_millis(10)).await;
let _ = sender.send(Ev::Transmit);
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = sender.send(Ev::StartRx);
tokio::time::sleep(Duration::from_millis(80)).await;
let _ = sender.send(Ev::Halt);
});
machine.run().await.expect("run failed");
let ctx = machine.into_context();
println!(
"\nfinal stats: rx={} tx={} last_rssi={}",
ctx.stats.rx_count, ctx.stats.tx_count, ctx.stats.last_rssi
);
}
+106
View File
@@ -0,0 +1,106 @@
//! End-to-end Embassy example: ISR push, task push, timer transition,
//! and clean termination — all driven by a single `statechart!` machine
//! bound to a user-declared `static Channel`.
//!
//! This example is a `no_std` library rather than a runnable binary: it
//! contains no executor, no time-driver, and no interrupt vector so it can
//! be checked on any bare-metal target without pulling in board-specific
//! dependencies. The acceptance test is:
//!
//! ```text
//! cargo check --features embassy --example embassy_full --target thumbv7em-none-eabihf
//! ```
//!
//! A real firmware pulls in `embassy-executor`, a time driver, and its own
//! interrupt bindings; the call sites below mirror exactly what the firmware
//! writes.
#![no_std]
#![allow(dead_code)]
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::channel::Channel;
use hsmc::{statechart, Duration};
#[derive(Debug, Clone)]
pub enum RadioEv {
StartRx,
IrqPacketReady,
StopRx,
Shutdown,
}
pub struct RadioData {
pub packets_seen: u32,
}
statechart! {
Radio {
context: RadioData;
events: RadioEv;
terminate(Shutdown);
default(Idle);
state Idle {
on(StartRx) => Listening;
}
state Listening {
entry: on_enter_listening;
exit: on_exit_listening;
on(IrqPacketReady) => count_packet;
on(StopRx) => Idle;
on(after Duration::from_secs(30)) => Idle;
}
}
}
impl RadioActions for RadioActionContext<'_> {
async fn on_enter_listening(&mut self) {
// In a real firmware this might `radio.start_rx().await` or
// `display.flush().await` — actions are `async fn` under the
// embassy feature so peripheral calls can run inline.
}
async fn on_exit_listening(&mut self) {}
async fn count_packet(&mut self) {
self.packets_seen = self.packets_seen.saturating_add(1);
}
}
// --- Capacity shared between the machine's internal emit-queue and the
// external event channel. The two must match — `Machine::new` enforces this
// at the type level via the channel's `N` const generic.
pub const QN: usize = 8;
pub static RADIO_CHAN: Channel<CriticalSectionRawMutex, RadioEv, QN> = Channel::new();
/// Entry point for the radio task — this is what `#[embassy_executor::task]`
/// would wrap in a real firmware.
pub async fn radio_task(ctx: RadioData) -> Result<(), hsmc::HsmcError> {
let mut machine = Radio::new(ctx, &RADIO_CHAN);
machine.run().await?;
// `into_context()` still works after Embassy run exits cleanly.
let _ctx = machine.into_context();
Ok(())
}
/// How a *command* task would enqueue events with backpressure.
pub async fn command_task() {
let sender: RadioSender = sender_from_channel();
let _ = sender.send(RadioEv::StartRx).await;
let _ = sender.send(RadioEv::StopRx).await;
}
/// How a hardware ISR would push an event. `try_send` is non-blocking and
/// `RadioSender` is `Copy + Send + Sync`, so it can be captured in a
/// `static` (here we just fetch it each call for brevity).
pub fn on_radio_irq() {
let sender: RadioSender = sender_from_channel();
// Best-effort push; drop on overflow rather than panic in an ISR.
let _ = sender.try_send(RadioEv::IrqPacketReady);
}
/// A `RadioSender` can also be built directly from the static channel when
/// you haven't retained a reference to the `Radio` machine — e.g., an ISR
/// shim initialised before the task spawns.
fn sender_from_channel() -> RadioSender {
RadioSender::from_channel(&RADIO_CHAN)
}
+359
View File
@@ -0,0 +1,359 @@
//! # Microwave Oven Controller — a tour of every `hsmc` feature.
//!
//! Run with:
//!
//! ```text
//! cargo run --example microwave --features tokio
//! ```
//!
//! This single example exercises the entire public surface of the crate:
//!
//! | Feature | Where |
//! |--------------------------------------------------|-----------------------------------------|
//! | `statechart!` proc macro | the big block below |
//! | Nested states (3 levels deep) | `Running { Cooking { Heating / Paused } }` |
//! | `default(...)` descent at every level | root, `Running`, `Cooking` |
//! | Multiple entry actions (comma form) | `Idle` entry |
//! | Multiple entry actions (repeated-keyword form) | root entry |
//! | Multiple exit actions | `Paused` exit |
//! | Event-driven transitions | `StartPressed`, `StopPressed`, ... |
//! | Timer-driven transitions (`Duration`) | `Cooking` countdown, `Done` auto-reset |
//! | Event-driven actions (no state change) | `DoorOpened` beep in `Idle` |
//! | Timer-driven actions (no state change) | `Running` heartbeat tick |
//! | Actions *and* transition on same trigger | `StartPressed` in `Idle` |
//! | Multiple actions on the same trigger | `StartPressed` has two action handlers |
//! | Event bubbling up the hierarchy | `StopPressed` from `Heating` → `Running`|
//! | Cross-hierarchy transition (leaf → sibling-of-ancestor) | `StopPressed` target |
//! | Self-transition (exit + re-entry, timer restart) | `Done` on `StartPressed`? no — see `Idle` `Reset` |
//! | Transition to state with children → default descent | `StartPressed` in `Idle` → `Running` |
//! | Ancestor-timer survives child transitions | `Running` cook timer across `Heating`⇄`Paused` |
//! | `terminate(...)` at root | `PowerFailure` event |
//! | Exit cascade on terminate | logged in exit actions |
//! | Shared action name across states | `beep` used in `Idle` and `Done` |
//! | `emit()` from inside an action | `Done` auto-reset emits `StartPressed` |
//! | `with_queue_capacity::<N>` | machine created with capacity 16 |
//! | `sender()` — cloneable, sendable across tasks | button-press + fault tasks |
//! | `run()` on the Tokio runtime | `main` awaits it |
//! | `into_context()` after termination | final report at the end |
//! | `current_state()` / `is_terminated()` | printed during shutdown |
//! | Action context: `Deref`/`DerefMut` to user ctx | every action touches `self.<field>` |
//!
//! The oven runs a short cook job, is interrupted by a door-open (pause),
//! resumed by a door-close, completes, auto-resets via `emit`, then a simulated
//! power failure terminates the machine.
use hsmc::{statechart, Duration};
use std::sync::{Arc, Mutex};
// ---------- User context ----------
//
// The machine never sees or pollutes this struct — it is the user's own data.
// Inside action methods we reach these fields via `Deref`/`DerefMut` on the
// generated `ActionContext`.
#[derive(Default)]
pub struct OvenContext {
/// Human-readable transcript of everything that happened. Kept for the
/// final report printed after the machine terminates.
pub log: Vec<String>,
/// Seconds remaining on the current cook job (mutated by actions).
pub remaining_secs: u32,
/// Simulated hardware state, shared with the outside world so the driving
/// code can inspect it without owning the machine.
pub hw: Arc<Mutex<Hardware>>,
}
#[derive(Default, Debug)]
pub struct Hardware {
pub door_open: bool,
pub magnetron_on: bool,
pub heater_on: bool,
pub display: String,
}
// ---------- Events ----------
//
// Plain user-defined enum. `Clone` is needed by the internal queue; `Debug`
// is for the transcript.
#[derive(Debug, Clone)]
pub enum OvenEvent {
StartPressed,
StopPressed,
DoorOpened,
DoorClosed,
/// Emitted by the machine itself from a timer action in `Done`.
Reset,
/// Root-level `terminate` trigger.
PowerFailure,
}
// ---------- Statechart ----------
//
// Declarative structure only. No behavior. Reading this block tells you
// everything about the control flow. The macro validates it at compile time:
// missing defaults, duplicate transitions, `terminate` outside root, and
// unknown state references all become compile errors.
statechart! {
Oven {
context: OvenContext;
events: OvenEvent;
// Root-level entry/exit actions — repeated-keyword form. Invoked in
// declaration order on initial entry and in reverse-declaration inner→
// outer order at termination.
entry: boot_self_test;
entry: log_boot_done;
exit: save_last_state;
exit: cut_all_power;
// Required: which child to enter on initial step.
default(Idle);
// Only valid at root. When this event fires in any active state, the full
// active configuration is exited (innermost → outermost, calling exit
// actions) and the machine terminates.
terminate(PowerFailure);
state Idle {
// Comma form for multi-entry.
entry: show_ready, clear_remaining;
exit: clear_display;
// Two actions AND a transition on the same trigger. Actions fire in
// declaration order, then the transition executes.
on(StartPressed) => record_start_request;
on(StartPressed) => prime_cook_timer;
on(StartPressed) => Running;
// Event-driven action (no state change).
on(DoorOpened) => beep;
}
// Composite state with its own timer (the total cook duration). The
// timer belongs to `Running` and survives transitions between its
// descendants (`Heating` ⇄ `Paused`).
state Running {
entry: start_magnetron;
exit: stop_magnetron;
default(Cooking);
// Cook duration. This whole-oven timer keeps running while the door
// is open (the user can pause; the job clock does not).
// NOTE: in a real oven you'd pause this; here it stays to demonstrate
// ancestor-timer survival across child transitions (spec §2.13).
on(after Duration::from_millis(2500)) => Done;
// Heartbeat every 200ms — a timer-driven action, no state change.
on(after Duration::from_millis(200)) => heartbeat;
// Cancelling the cook job bubbles from any descendant to here.
on(StopPressed) => Idle;
state Cooking {
entry: show_cooking;
default(Heating);
state Heating {
entry: heater_on;
exit: heater_off;
// Door opens → pause. Sibling transition, one level up.
on(DoorOpened) => Paused;
}
state Paused {
// Multi-line exit form.
entry: show_paused, start_pause_blink;
exit: stop_pause_blink;
exit: log_resume;
on(DoorClosed) => Heating;
}
}
}
state Done {
// `beep` is shared with `Idle` — a single trait method serves both.
entry: beep, show_done;
exit: clear_display;
// Timer-driven action that emits an event back into the queue. Spec
// §2.12: emitted events are processed AFTER the current handler
// finishes. Here the emitted `Reset` drives a transition below.
on(after Duration::from_millis(500)) => schedule_auto_reset;
// Self-transition: Done → Done. Exit + re-entry runs, timer restarts.
on(StartPressed) => Done;
// Responding to the emitted event.
on(Reset) => Idle;
}
}
}
// ---------- Action implementations ----------
//
// The macro generates `OvenActions` (one method per unique action name used
// anywhere above) and `OvenActionContext<'_>` (a wrapper that derefs to
// `OvenContext` and exposes `emit`). The user simply implements the trait.
impl OvenActions for OvenActionContext<'_> {
// Root
async fn boot_self_test(&mut self) {
self.log.push("BOOT: self-test ok".into());
}
async fn log_boot_done(&mut self) {
self.log.push("BOOT: ready".into());
}
async fn save_last_state(&mut self) {
self.log.push("SHUTDOWN: persisted last state".into());
}
async fn cut_all_power(&mut self) {
{
let mut hw = self.hw.lock().unwrap();
hw.magnetron_on = false;
hw.heater_on = false;
hw.display = "-- OFF --".into();
}
self.log.push("SHUTDOWN: power cut".into());
}
// Idle
async fn show_ready(&mut self) {
self.hw.lock().unwrap().display = "READY".into();
self.log.push("IDLE: ready".into());
}
async fn clear_remaining(&mut self) {
self.remaining_secs = 0;
}
async fn clear_display(&mut self) {
self.hw.lock().unwrap().display.clear();
}
async fn record_start_request(&mut self) {
self.log.push("BTN: start pressed".into());
}
async fn prime_cook_timer(&mut self) {
// Uses DerefMut to mutate the user context directly.
self.remaining_secs = 3;
let n = self.remaining_secs;
self.log.push(format!("BTN: primed {}s cook", n));
}
async fn beep(&mut self) {
// Shared between Idle (DoorOpened) and Done (entry).
self.log.push("*BEEP*".into());
}
// Running
async fn start_magnetron(&mut self) {
self.hw.lock().unwrap().magnetron_on = true;
self.log.push("MAG: on".into());
}
async fn stop_magnetron(&mut self) {
self.hw.lock().unwrap().magnetron_on = false;
self.log.push("MAG: off".into());
}
async fn heartbeat(&mut self) {
self.log.push("tick".into());
}
// Cooking / Heating
async fn show_cooking(&mut self) {
self.hw.lock().unwrap().display = "COOKING".into();
}
async fn heater_on(&mut self) {
self.hw.lock().unwrap().heater_on = true;
self.log.push("HEAT: on".into());
}
async fn heater_off(&mut self) {
self.hw.lock().unwrap().heater_on = false;
self.log.push("HEAT: off".into());
}
// Paused
async fn show_paused(&mut self) {
self.hw.lock().unwrap().display = "PAUSED".into();
}
async fn start_pause_blink(&mut self) {
self.log.push("PAUSE: blink on".into());
}
async fn stop_pause_blink(&mut self) {
self.log.push("PAUSE: blink off".into());
}
async fn log_resume(&mut self) {
self.log.push("PAUSE: resuming".into());
}
// Done
async fn show_done(&mut self) {
self.hw.lock().unwrap().display = "DONE".into();
}
async fn schedule_auto_reset(&mut self) {
// emit() is provided by the generated ActionContext. It returns a
// Result so callers can handle queue overflow (here we panic because
// the queue is plenty big and overflow would be a bug).
self.log.push("DONE: auto-reset queued".into());
self.emit(OvenEvent::Reset).expect("queue has capacity");
}
}
// ---------- Driver ----------
#[tokio::main(flavor = "current_thread")]
async fn main() {
let hw = Arc::new(Mutex::new(Hardware::default()));
let ctx = OvenContext { hw: Arc::clone(&hw), ..Default::default() };
// `with_queue_capacity::<N>` overrides the default queue size (8). We
// pick 16 to comfortably hold the burst of events below.
let mut oven = Oven::with_queue_capacity::<16>(ctx);
// `sender()` returns a cheap, cloneable, Send handle — usable from other
// tasks, threads, or ISRs (under the tokio feature).
let buttons = oven.sender();
let faults = oven.sender();
// Simulate a user pushing buttons and opening/closing the door.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
buttons.send(OvenEvent::StartPressed).unwrap();
// Open door partway through — should transition Heating → Paused.
tokio::time::sleep(Duration::from_millis(600)).await;
buttons.send(OvenEvent::DoorOpened).unwrap();
// Close it 400ms later — Paused → Heating.
tokio::time::sleep(Duration::from_millis(400)).await;
buttons.send(OvenEvent::DoorClosed).unwrap();
// Cook timer (2500ms from Running entry) fires around here → Done.
// Done's timer-action emits Reset → machine returns to Idle.
// Wait for auto-reset, then press Start again for a second cycle.
tokio::time::sleep(Duration::from_millis(2000)).await;
buttons.send(OvenEvent::StartPressed).unwrap();
// This time cancel via Stop before the cook completes.
tokio::time::sleep(Duration::from_millis(400)).await;
buttons.send(OvenEvent::StopPressed).unwrap();
});
// Simulated fault monitor — kills the machine after the demo has played
// out. Demonstrates `terminate(PowerFailure)` and the exit cascade.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(5000)).await;
let _ = faults.send(OvenEvent::PowerFailure);
});
println!("=== Microwave coming online ===\n");
oven.run().await.expect("run returned Err");
// After termination we can inspect final state and reclaim the context.
println!("\n=== Machine terminated ===");
println!("is_terminated() = {}", oven.is_terminated());
println!("hardware = {:?}", hw.lock().unwrap());
let ctx = oven.into_context();
println!("\n--- Transcript ({} entries) ---", ctx.log.len());
for (i, line) in ctx.log.iter().enumerate() {
println!("{:>3}. {}", i + 1, line);
}
}
+761
View File
@@ -0,0 +1,761 @@
//! Interactive microwave oven TUI driven by `hsmc`.
//!
//! Run it:
//!
//! ```text
//! cargo run --example microwave_tui --features tokio
//! ```
//!
//! Keys:
//! S — Start cook O — Open door
//! X — Stop / cancel C — Close door
//! K — Simulate power failure (terminates the machine)
//! Q — Quit
//!
//! This demonstrates the same statechart as `microwave.rs` but hooked up to
//! live keyboard input and a real-time ratatui UI. The state machine runs
//! on `oven.run()` in one task; a second task reads keys and forwards them
//! via a cloned `sender()`; a third task redraws the screen 30×/sec using
//! state published by entry/exit actions.
use std::{
collections::VecDeque,
io,
sync::{Arc, Mutex},
time::Instant,
};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use hsmc::{statechart, Duration};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
Terminal,
};
// ---------- Shared UI state ----------
//
// The actions write into this through the user context. The render task
// reads it. `Arc<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,
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Errors returned by the `hsmc` runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HsmcError {
/// The event queue is full. The event was not enqueued.
QueueFull,
/// The machine has already terminated. No further operations are valid.
AlreadyTerminated,
}
impl core::fmt::Display for HsmcError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
HsmcError::QueueFull => f.write_str("event queue is full"),
HsmcError::AlreadyTerminated => f.write_str("machine has already terminated"),
}
}
}
#[cfg(feature = "tokio")]
impl std::error::Error for HsmcError {}
+254
View File
@@ -0,0 +1,254 @@
//! # hsmc
//!
//! Hierarchical state machines (statecharts) with a declarative proc-macro front-end.
//!
//! See the crate's spec (`docs/000. original-hsmc-spec.md`) for v0.1 semantics
//! and the top-level README for the v0.2 additions (`during:`, optional
//! `events:`, `dispatch()`, `STATE_CHART`).
//!
//! ## Macro invocation syntax
//!
//! The `statechart!` macro uses Rust's standard proc-macro call syntax —
//! the machine name goes *inside* the outer brace/parenthesis, because Rust
//! does not permit tokens between `!` and the macro delimiter:
//!
//! ```ignore
//! use hsmc::{statechart, Duration};
//!
//! # #[derive(Default)] struct MyContext;
//! # #[derive(Debug, Clone)] enum MyEvent { Go }
//! statechart! {
//! MyMachine {
//! context: MyContext;
//! events: MyEvent;
//! default(Idle);
//! state Idle { on(Go) => Busy; }
//! state Busy { }
//! }
//! }
//! ```
//!
//! ## `during:` activities (v0.2)
//!
//! Any state can declare one or more `during:` activities — async functions
//! that run while the state is on the active path and produce events:
//!
//! ```ignore
//! state Receiving {
//! during: next_packet(lora, rx_buf);
//! on(PacketRx { rssi: i16 }) => count_packet;
//! on(StopRx) => Idle;
//! }
//!
//! async fn next_packet(lora: &mut LoRaDriver, buf: &mut [u8; 256]) -> Ev {
//! lora.rx(buf).await.into()
//! }
//! ```
//!
//! The macro emits `next_packet(&mut ctx.lora, &mut ctx.rx_buf)` in the
//! generated run loop's `select` call, and Rust's native split-borrow
//! verifies at compile time that concurrent durings don't borrow
//! overlapping fields.
//!
//! ### Cancel-safety contract
//!
//! A `during:` future **will** be dropped at any `.await` point whenever a
//! handler fires, a timer expires, an external event arrives, or the state
//! transitions. Write durings as cancel-safe state machines: every `.await`
//! must be a clean resume point where dropping the future leaves borrowed
//! fields in a valid, re-enterable state. Prefer performing mutations
//! *after* awaited I/O completes rather than straddling await points.
//!
//! ## A note on `unexpected_cfgs` warnings
//!
//! The code emitted by `statechart!` contains `#[cfg(feature = "tokio")]` /
//! `#[cfg(feature = "embassy")]` gates so that feature-specific items (the
//! `Sender`, `run()`) compile only under the matching feature of the `hsmc`
//! crate. Rust 1.80+ `check-cfg` evaluates those attributes in the context
//! of the *calling* crate — so if your crate doesn't itself declare features
//! named `tokio` or `embassy`, rustc will emit `unexpected_cfgs` warnings at
//! every `statechart!` invocation. To silence them, add either:
//!
//! ```ignore
//! #![allow(unexpected_cfgs)] // at the crate root, or
//! ```
//!
//! or, in your `Cargo.toml`:
//!
//! ```toml
//! [lints.rust]
//! unexpected_cfgs = { level = "allow", check-cfg = ['cfg(feature, values("tokio", "embassy"))'] }
//! ```
//!
//! This is a known limitation of proc-macros that emit cfg-gated code.
//!
//! ## Out of scope (spec §11)
//!
//! The following are explicitly NOT part of v0.1 and should not be added
//! without revisiting the spec: guard conditions, orthogonal / parallel
//! regions, history states (shallow or deep), deferred events, internal
//! transitions, state-local context / per-state data, visual rendering or
//! diagram export, runtime statechart modification, event priorities beyond
//! FIFO, and inter-machine communication primitives.
#![cfg_attr(not(any(test, feature = "tokio")), no_std)]
#[cfg(all(feature = "tokio", feature = "embassy"))]
compile_error!("hsmc features `tokio` and `embassy` are mutually exclusive");
pub use core::time::Duration;
pub use hsmc_macros::statechart;
mod error;
pub use error::HsmcError;
#[doc(hidden)]
pub mod __private {
//! Implementation details used by code generated by the `statechart!` macro.
//! Not part of the public API.
pub use heapless;
pub use crate::error::HsmcError;
/// A simple bounded event queue built on `heapless::Deque`.
/// Capacity is a const generic.
pub struct EventQueue<E, const N: usize> {
inner: heapless::Deque<E, N>,
}
impl<E, const N: usize> EventQueue<E, N> {
pub const fn new() -> Self {
Self { inner: heapless::Deque::new() }
}
pub fn push(&mut self, ev: E) -> Result<(), HsmcError> {
self.inner.push_back(ev).map_err(|_| HsmcError::QueueFull)
}
pub fn pop(&mut self) -> Option<E> {
self.inner.pop_front()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn clear(&mut self) {
while self.inner.pop_front().is_some() {}
}
}
impl<E, const N: usize> Default for EventQueue<E, N> {
fn default() -> Self { Self::new() }
}
/// A fixed-capacity timer table: `(state_index, trigger_index, remaining_nanos)`.
pub struct TimerTable<const N: usize> {
pub entries: heapless::Vec<TimerEntry, N>,
}
#[derive(Clone, Copy, Debug)]
pub struct TimerEntry {
pub state: u16,
pub trigger: u16,
pub remaining_ns: u128,
}
impl<const N: usize> TimerTable<N> {
pub const fn new() -> Self {
Self { entries: heapless::Vec::new() }
}
pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) {
let ns = duration.as_nanos();
// Replace existing entry for same (state, trigger) — resets on re-entry.
for e in self.entries.iter_mut() {
if e.state == state && e.trigger == trigger {
e.remaining_ns = ns;
return;
}
}
let _ = self.entries.push(TimerEntry { state, trigger, remaining_ns: ns });
}
pub fn cancel_state(&mut self, state: u16) {
self.entries.retain(|e| e.state != state);
}
pub fn cancel_one(&mut self, state: u16, trigger: u16) {
self.entries.retain(|e| !(e.state == state && e.trigger == trigger));
}
pub fn decrement(&mut self, elapsed: core::time::Duration) {
let ns = elapsed.as_nanos();
for e in self.entries.iter_mut() {
e.remaining_ns = e.remaining_ns.saturating_sub(ns);
}
}
/// Returns (state, trigger) of first expired timer, picking the innermost
/// (deepest) state, ties broken by declaration order.
pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
let mut best: Option<usize> = None;
for (i, e) in self.entries.iter().enumerate() {
if e.remaining_ns == 0 {
match best {
None => best = Some(i),
Some(bi) => {
let cur = depth[e.state as usize];
let prev = depth[self.entries[bi].state as usize];
if cur > prev {
best = Some(i);
}
}
}
}
}
if let Some(i) = best {
let e = self.entries.swap_remove(i);
Some((e.state, e.trigger))
} else { None }
}
pub fn min_remaining(&self) -> Option<core::time::Duration> {
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| {
let secs = (ns / 1_000_000_000) as u64;
let rem = (ns % 1_000_000_000) as u32;
core::time::Duration::new(secs, rem)
})
}
}
/// Convert an f64 seconds value to a Duration (runtime, non-const).
pub fn duration_from_secs_f64(secs: f64) -> core::time::Duration {
core::time::Duration::from_nanos((secs * 1_000_000_000.0) as u64)
}
/// Type-erased push interface over the event queue, so the generated
/// `ActionContext` does not need to be parameterized by the queue capacity.
pub trait QueuePush<E> {
fn push(&mut self, ev: E) -> Result<(), HsmcError>;
}
impl<E, const N: usize> QueuePush<E> for heapless::Deque<E, N> {
fn push(&mut self, ev: E) -> Result<(), HsmcError> {
self.push_back(ev).map_err(|_| HsmcError::QueueFull)
}
}
/// Proxy used by generated code to route `emit()` pushes through the
/// internal deque while recording overflow on the machine. When the deque
/// rejects a push, the `overflow` flag is set and further pushes within
/// the same tick short-circuit to `Err(QueueFull)` without reaching the
/// deque. `run()` inspects this flag each iteration and surfaces the
/// failure rather than letting it be silently swallowed by action code.
pub struct EmitProxy<'a, E, const N: usize> {
pub queue: &'a mut heapless::Deque<E, N>,
pub overflow: &'a mut bool,
}
impl<'a, E, const N: usize> QueuePush<E> for EmitProxy<'a, E, N> {
fn push(&mut self, ev: E) -> Result<(), HsmcError> {
if *self.overflow {
return Err(HsmcError::QueueFull);
}
match self.queue.push_back(ev) {
Ok(()) => Ok(()),
Err(_) => {
*self.overflow = true;
Err(HsmcError::QueueFull)
}
}
}
}
}
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
//! `m.dispatch(ev).await` pushes an event AND drains the internal queue to
//! quiescence before returning. After it returns, `current_state()` reflects
//! the post-dispatch state — the replacement for the old `send + drain`
//! helper that firmware users hand-rolled.
#![cfg(feature = "tokio")]
use hsmc::statechart;
#[derive(Default)]
pub struct Ctx {
pub transitioned: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Halt,
}
statechart! {
DispatchM {
context: Ctx;
events: Ev;
default(Start);
terminate(Halt);
state Start {
on(Go) => Next;
}
state Next {
entry: mark_entered;
}
}
}
impl DispatchMActions for DispatchMActionContext<'_> {
async fn mark_entered(&mut self) {
self.transitioned = self.transitioned.wrapping_add(1);
}
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_drains_before_returning() {
let mut m = DispatchM::new(Ctx::default());
// One call does: prime state, enqueue Go, process it, reach Next,
// run mark_entered, all before returning.
m.dispatch(Ev::Go).await.unwrap();
assert_eq!(m.current_state(), DispatchMState::Next);
assert_eq!(m.context().transitioned, 1);
}
+75
View File
@@ -0,0 +1,75 @@
//! `during:` activity: a single async function scoped to a state, repeatedly
//! producing events while the state is active. This test verifies that the
//! during fires, the generated event dispatches through the handler chain,
//! and a transition out of the state cancels the during.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx {
pub tick_field: u32,
pub seen_tick: u32,
pub log: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Ev {
Tick,
Halt,
}
statechart! {
DuringM {
context: Ctx;
events: Ev;
default(Running);
terminate(Halt);
state Running {
during: ticker(tick_field);
on(Tick) => saw_tick;
}
}
}
async fn ticker(tick_field: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_millis(10)).await;
*tick_field = tick_field.wrapping_add(1);
Ev::Tick
}
impl DuringMActions for DuringMActionContext<'_> {
async fn saw_tick(&mut self) {
self.seen_tick = self.seen_tick.wrapping_add(1);
let n = self.seen_tick;
self.log.push(format!("tick#{}", n));
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn during_fires_repeatedly_until_halt() {
let mut m = DuringM::new(Ctx::default());
let sender = m.sender();
// Let a few ticks happen, then halt.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(45)).await;
let _ = sender.send(Ev::Halt);
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
let res = res.expect("run() hung past timeout");
assert!(res.is_ok(), "run() returned error: {:?}", res);
assert!(m.is_terminated());
let ctx = m.into_context();
// We slept for 45ms and the during fires every 10ms — expect ≥3 ticks
// before Halt is dispatched. (The first dispatch happens at t≈10ms.)
assert!(
ctx.seen_tick >= 3,
"expected ≥3 ticks, got {} (log={:?})",
ctx.seen_tick,
ctx.log
);
// Field mutation is observed.
assert_eq!(ctx.tick_field, ctx.seen_tick);
}
+98
View File
@@ -0,0 +1,98 @@
//! Transition cancels an in-flight `during:` future: the future is dropped
//! when the machine leaves the state, and the future's progress (counted by
//! a cancel-on-drop side effect) reflects the cancellation.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
/// A cancel-aware guard that bumps `dropped_count` when dropped before
/// completion. Used by the during to prove it was cancelled by a transition.
pub struct DropGuard<'a> {
counter: &'a mut u32,
completed: bool,
}
impl<'a> Drop for DropGuard<'a> {
fn drop(&mut self) {
if !self.completed {
*self.counter = self.counter.wrapping_add(1);
}
}
}
#[derive(Default)]
pub struct Ctx {
pub drop_count: u32,
pub ticked: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Leave,
Halt,
}
statechart! {
CancelM {
context: Ctx;
events: Ev;
default(Inside);
terminate(Halt);
state Inside {
during: slow_tick(drop_count);
on(Leave) => Outside;
}
state Outside {
on(Halt) => Outside; // placeholder so state isn't empty
}
}
}
/// A slow activity: creates a drop guard, sleeps 500ms (much longer than
/// our test window), and would bump `ticked` only on successful completion.
/// When cancelled, the guard's Drop increments drop_count.
async fn slow_tick(drop_count: &mut u32) -> Ev {
let mut guard = DropGuard {
counter: drop_count,
completed: false,
};
tokio::time::sleep(Duration::from_millis(500)).await;
guard.completed = true;
// unreachable in this test; the transition drops us before 500ms
Ev::Leave
}
impl CancelMActions for CancelMActionContext<'_> {
// no handler work needed
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn transition_cancels_during_future() {
let mut m = CancelM::new(Ctx::default());
let sender = m.sender();
tokio::spawn({
let s = sender.clone();
async move {
// Transition out at 20ms, well before the during's 500ms sleep.
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = s.send(Ev::Leave);
// Then terminate.
tokio::time::sleep(Duration::from_millis(10)).await;
let _ = s.send(Ev::Halt);
}
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
assert!(res.expect("run hung").is_ok());
let ctx = m.into_context();
// During was cancelled → DropGuard fired → drop_count >= 1.
assert!(
ctx.drop_count >= 1,
"expected cancel to trigger DropGuard, got drop_count={}",
ctx.drop_count
);
// The during never completed so ticked should be 0.
assert_eq!(ctx.ticked, 0);
}
+84
View File
@@ -0,0 +1,84 @@
//! Multiple `during:` activities on the same state. Verifies that split-borrow
//! lets each during hold `&mut` to disjoint fields of the context, that the
//! generated `select_N` call compiles, and that the expected during fires
//! when given enough time.
//!
//! Note on starvation: when several durings race via `select`, the shortest
//! one always wins and the others are dropped. That's the documented
//! cancel-safety contract. The test reflects that by only asserting that the
//! fast during fires and the slow one *can* fire eventually (not that it
//! fires deterministically within the test window).
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx {
pub fast_field: u32,
pub slow_field: u32,
pub seen_fast: u32,
pub seen_slow: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Fast,
Slow,
Halt,
}
statechart! {
Concurrent {
context: Ctx;
events: Ev;
default(Running);
terminate(Halt);
state Running {
during: fast(fast_field);
during: slow(slow_field);
on(Fast) => on_fast;
on(Slow) => on_slow;
}
}
}
async fn fast(f: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_millis(5)).await;
*f = f.wrapping_add(1);
Ev::Fast
}
async fn slow(s: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_millis(15)).await;
*s = s.wrapping_add(1);
Ev::Slow
}
impl ConcurrentActions for ConcurrentActionContext<'_> {
async fn on_fast(&mut self) {
self.seen_fast = self.seen_fast.wrapping_add(1);
}
async fn on_slow(&mut self) {
self.seen_slow = self.seen_slow.wrapping_add(1);
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn both_durings_compile_and_fast_fires() {
let mut m = Concurrent::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(80)).await;
let _ = sender.send(Ev::Halt);
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
assert!(res.expect("run hung").is_ok());
let ctx = m.into_context();
// Fast (5ms period) wins its race consistently against slow (15ms).
// This documents the starvation property: in select-drop semantics,
// the faster during starves the slower one.
assert!(ctx.seen_fast >= 4, "expected fast ≥4, got {}", ctx.seen_fast);
assert_eq!(ctx.fast_field, ctx.seen_fast);
}
+85
View File
@@ -0,0 +1,85 @@
//! `during:` activities declared on ancestor states run concurrently with
//! activities on descendant states. The active during set is the union of
//! every during on the current state path.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx {
pub parent_field: u32,
pub child_field: u32,
pub saw_parent: u32,
pub saw_child: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Parent,
Child,
Halt,
}
statechart! {
Hierarchy {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
during: parent_tick(parent_field);
default(Inner);
on(Parent) => on_parent;
on(Child) => on_child;
state Inner {
during: child_tick(child_field);
}
}
}
}
async fn parent_tick(f: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_millis(7)).await;
*f = f.wrapping_add(1);
Ev::Parent
}
async fn child_tick(f: &mut u32) -> Ev {
tokio::time::sleep(Duration::from_millis(5)).await;
*f = f.wrapping_add(1);
Ev::Child
}
impl HierarchyActions for HierarchyActionContext<'_> {
async fn on_parent(&mut self) {
self.saw_parent = self.saw_parent.wrapping_add(1);
}
async fn on_child(&mut self) {
self.saw_child = self.saw_child.wrapping_add(1);
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn both_parent_and_child_durings_active() {
let mut m = Hierarchy::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = sender.send(Ev::Halt);
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
assert!(res.expect("run hung").is_ok());
let ctx = m.into_context();
// The child during (5ms) is faster than the parent (7ms), so in this
// select-drop model the child wins most iterations. We assert the
// fastest fires enough times; the parent is allowed to starve.
assert!(
ctx.saw_child >= 3,
"expected child ≥3, got {}",
ctx.saw_child
);
assert_eq!(ctx.child_field, ctx.saw_child);
}
+65
View File
@@ -0,0 +1,65 @@
//! Every generated machine exposes a `STATE_CHART: &'static str` constant
//! containing an ASCII tree of its hierarchy. Useful for `defmt::info!`,
//! panic messages, and documentation.
#![cfg(not(any(feature = "tokio", feature = "embassy")))]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Back,
Halt,
}
statechart! {
Diag {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: enter_idle;
on(Go) => Running;
}
state Running {
default(Fast);
on(Back) => Idle;
state Fast {
on(after Duration::from_millis(10)) => Slow;
}
state Slow {
on(after Duration::from_millis(20)) => Fast;
}
}
}
}
impl DiagActions for DiagActionContext<'_> {
fn enter_idle(&mut self) {}
}
#[test]
fn state_chart_is_non_empty_and_names_states() {
// The machine carries a const generic queue capacity; use turbofish to
// reach the associated const externally.
let chart = Diag::<8>::STATE_CHART;
// Starts with the machine name.
assert!(chart.starts_with("Diag\n"), "chart: {:?}", chart);
// Names every declared state.
assert!(chart.contains("Idle"));
assert!(chart.contains("Running"));
assert!(chart.contains("Fast"));
assert!(chart.contains("Slow"));
// Marks the default child on the root and on Running.
assert!(chart.contains("[default] Idle"));
assert!(chart.contains("[default] Fast"));
// Shows entry action and handlers.
assert!(chart.contains("entry: enter_idle"));
assert!(chart.contains("on(Go)"));
}
+54
View File
@@ -0,0 +1,54 @@
//! Prints the generated STATE_CHART string. Useful as a visual check
//! during development. Run with `cargo test --test state_chart_preview --
//! --nocapture`.
#![cfg(not(any(feature = "tokio", feature = "embassy")))]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Halt,
}
statechart! {
Preview {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: on_idle;
on(Go) => Running;
}
state Running {
entry: on_run;
exit: leave_run;
default(Fast);
on(Halt) => Running;
state Fast {
on(after Duration::from_millis(10)) => Slow;
}
state Slow {
on(after Duration::from_millis(20)) => Fast;
}
}
}
}
impl PreviewActions for PreviewActionContext<'_> {
fn on_idle(&mut self) {}
fn on_run(&mut self) {}
fn leave_run(&mut self) {}
}
#[test]
fn preview_state_chart() {
println!("\n{}", Preview::<8>::STATE_CHART);
}
+59
View File
@@ -0,0 +1,59 @@
//! Timer-only statechart: no `events:` declaration. The machine is driven
//! purely by timer triggers. No `Sender` is generated; `run()` races
//! timers and any `during:` activities.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct BlinkCtx {
pub on_count: u32,
pub off_count: u32,
}
statechart! {
Blinker {
context: BlinkCtx;
default(On);
state On {
entry: bump_on;
on(after Duration::from_millis(10)) => Off;
}
state Off {
entry: bump_off;
on(after Duration::from_millis(10)) => On;
}
}
}
impl BlinkerActions for BlinkerActionContext<'_> {
async fn bump_on(&mut self) {
self.on_count = self.on_count.wrapping_add(1);
}
async fn bump_off(&mut self) {
self.off_count = self.off_count.wrapping_add(1);
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn timer_only_blinker_alternates() {
let mut m = Blinker::new(BlinkCtx::default());
// Cap run time — the blinker has no terminate event, so we stop it
// externally via timeout.
let _ = tokio::time::timeout(Duration::from_millis(100), m.run()).await;
let ctx = m.into_context();
// Entered On once on default descent, then Off, then On, ... Over 100ms
// with a 10ms period, we should see roughly 5 entries of each.
assert!(
ctx.on_count >= 3,
"expected ≥3 on_count, got {}",
ctx.on_count
);
assert!(
ctx.off_count >= 3,
"expected ≥3 off_count, got {}",
ctx.off_count
);
}
+65
View File
@@ -0,0 +1,65 @@
//! Proves that action bodies can `.await` real futures under the `tokio`
//! feature, that transitions are committed only after all action futures
//! complete, and that `emit()` inside an async action still enqueues.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx {
pub log: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Halt,
}
statechart! {
AsyncM {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
on(Go) => Working;
}
state Working {
entry: on_enter_working;
exit: on_exit_working;
}
}
}
impl AsyncMActions for AsyncMActionContext<'_> {
async fn on_enter_working(&mut self) {
// A real `.await` that suspends the task.
tokio::time::sleep(Duration::from_millis(5)).await;
self.log.push("entered_after_sleep".into());
// emit() inside an async action must enqueue synchronously.
let _ = self.emit(Ev::Halt);
}
async fn on_exit_working(&mut self) {
tokio::time::sleep(Duration::from_millis(1)).await;
self.log.push("exited_after_sleep".into());
}
}
#[tokio::test]
async fn async_actions_await_and_emit() {
let mut m = AsyncM::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(1)).await;
let _ = sender.send(Ev::Go);
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
assert!(res.expect("hang").is_ok());
assert!(m.is_terminated());
let ctx = m.into_context();
// Entry action's await completed before the emitted Halt was processed.
assert!(ctx.log.iter().any(|s| s == "entered_after_sleep"));
// Exit action also ran via Halt → terminate path.
assert!(ctx.log.iter().any(|s| s == "exited_after_sleep"));
}
+142
View File
@@ -0,0 +1,142 @@
//! Regression: a nested `default`-chain toggle (Splash.{LearnMore, Info}) must
//! keep alternating after the parent subtree has been exited to a sibling
//! (Dashboard) and re-entered via the parent's default.
//!
//! The firmware saw: on fresh boot, alternation between the two substates
//! worked perfectly. After one round-trip out to Dashboard and back,
//! alternation stalled. This test reproduces the scenario in async `run()`
//! so we can see it fail without hardware.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct Ctx {
pub log: Arc<Mutex<Vec<&'static str>>>,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Back,
Halt,
}
statechart! {
M {
context: Ctx;
events: Ev;
default(On);
terminate(Halt);
state On {
on(Go) => Listening;
on(Back) => Splash;
default(Splash);
state Splash {
default(LearnMore);
state LearnMore {
entry: enter_lm;
on(after Duration::from_secs(5)) => Info;
}
state Info {
entry: enter_info;
on(after Duration::from_secs(5)) => LearnMore;
}
}
state Dashboard {
default(Listening);
state Listening {
entry: enter_listening;
// Repeating tick that matches the firmware's
// `on(every 1s) => advance_sparkline, paint_listening`.
on(every Duration::from_millis(1000)) => tick_listen;
}
}
}
}
}
impl MActions for MActionContext<'_> {
async fn enter_lm(&mut self) {
self.log.lock().unwrap().push("LM");
}
async fn enter_info(&mut self) {
self.log.lock().unwrap().push("INFO");
}
async fn enter_listening(&mut self) {
self.log.lock().unwrap().push("LISTEN");
}
async fn tick_listen(&mut self) {
// Intentionally mirror the firmware: the tick handler does a
// short async await (simulating a display flush). This is the
// kind of thing that lengthens `step()` and exposes subtle
// timing bugs in the run loop.
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn nested_alternation_survives_round_trip() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut m = M::new(Ctx { log: log.clone() });
let sender = m.sender();
// Driver: exercise the full scenario, then halt.
tokio::spawn(async move {
// Let LM alternate with INFO for three cycles.
tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO
tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM
tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO
// Jump out to Listening. The machine will now tick every 1s
// with a 20ms-await tick handler — the firmware's actual shape.
let _ = sender.send(Ev::Go);
// Let several ticks fire (simulates a short RX session).
tokio::time::sleep(Duration::from_millis(3500)).await;
// Back to Splash. Splash's default should land us in LM.
let _ = sender.send(Ev::Back);
tokio::time::sleep(Duration::from_millis(50)).await;
// Regression: alternation must continue after re-entry.
tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO
tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM
// Second round-trip to be sure it wasn't a one-off.
let _ = sender.send(Ev::Go);
tokio::time::sleep(Duration::from_millis(2500)).await;
let _ = sender.send(Ev::Back);
tokio::time::sleep(Duration::from_millis(50)).await;
tokio::time::sleep(Duration::from_secs(5)).await; // LM→INFO
tokio::time::sleep(Duration::from_secs(5)).await; // INFO→LM
let _ = sender.send(Ev::Halt);
});
let res = tokio::time::timeout(Duration::from_secs(300), m.run())
.await
.expect("run() hung");
assert!(res.is_ok(), "run() returned error: {:?}", res);
let log = log.lock().unwrap();
// Between LISTEN events, LM and INFO must alternate perfectly.
// Expected overall: LM INFO LM INFO LISTEN LM INFO LM LISTEN LM INFO LM.
assert_eq!(
&log[..],
&[
"LM", "INFO", "LM", "INFO", // fresh-boot alternation ×3
"LISTEN", // Go #1
"LM", "INFO", "LM", // Back #1 + two 5s ticks
"LISTEN", // Go #2
"LM", "INFO", "LM", // Back #2 + two 5s ticks
][..],
"log did not match expected sequence: {:?}",
&log[..]
);
}
+115
View File
@@ -0,0 +1,115 @@
//! Integration test covering the two grammar extensions added together:
//!
//! 1. Repeating timers via `on(every <dur>) => handler;` and the
//! optional `after <dur>` explicit-one-shot form.
//! 2. Event payload bindings via `on(Variant(a: T, b: U)) => handler;`
//! and `on(Variant { a: T, b: U }) => handler;`, which destructure
//! into typed action-handler parameters.
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
use std::sync::{Arc, Mutex};
#[derive(Default, Clone)]
pub struct Ctx {
/// Count of ticks fired from the repeating timer.
pub ticks: Arc<Mutex<u32>>,
/// Last RSSI/SNR pair seen via the tuple-variant payload binding.
pub last_rx: Arc<Mutex<Option<(i16, i16)>>>,
/// Last (freq, bw) seen via the struct-variant payload binding.
pub last_cfg: Arc<Mutex<Option<(u32, u32)>>>,
}
#[derive(Debug, Clone)]
pub enum Ev {
PacketRx(i16, i16),
ConfigChanged { freq: u32, bw: u32 },
Halt,
}
statechart! {
Tk {
context: Ctx;
events: Ev;
default(Running);
terminate(Halt);
state Running {
// Repeating timer (advances the tick counter).
on(every Duration::from_millis(10)) => on_tick;
// Explicit one-shot form — `after` is a synonym for bare duration.
// Included to verify parsing; target state is the same so nothing
// observable happens.
on(after Duration::from_secs(60)) => Running;
// Payload-bearing event actions.
on(PacketRx(rssi: i16, snr: i16)) => record_rx;
on(ConfigChanged { freq: u32, bw: u32 }) => record_cfg;
}
}
}
impl TkActions for TkActionContext<'_> {
async fn on_tick(&mut self) {
*self.ticks.lock().unwrap() += 1;
}
async fn record_rx(&mut self, rssi: i16, snr: i16) {
*self.last_rx.lock().unwrap() = Some((rssi, snr));
}
async fn record_cfg(&mut self, freq: u32, bw: u32) {
*self.last_cfg.lock().unwrap() = Some((freq, bw));
}
}
#[tokio::test]
async fn repeating_timer_fires_many_times() {
let mut m = Tk::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(120)).await;
let _ = sender.send(Ev::Halt);
});
let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
let ctx = m.into_context();
let ticks = *ctx.ticks.lock().unwrap();
// 10ms timer running for ~120ms should fire at least 5 times. We give
// loose bounds to tolerate scheduler jitter under `cargo test`.
assert!(
ticks >= 5 && ticks <= 30,
"expected 5..=30 ticks, got {}",
ticks
);
}
#[tokio::test]
async fn tuple_variant_payload_binding_dispatches() {
let mut m = Tk::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
let _ = sender.send(Ev::PacketRx(-42, 7));
tokio::time::sleep(Duration::from_millis(10)).await;
let _ = sender.send(Ev::Halt);
});
let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
let ctx = m.into_context();
assert_eq!(*ctx.last_rx.lock().unwrap(), Some((-42, 7)));
}
#[tokio::test]
async fn struct_variant_payload_binding_dispatches() {
let mut m = Tk::new(Ctx::default());
let sender = m.sender();
tokio::spawn(async move {
let _ = sender.send(Ev::ConfigChanged {
freq: 915_000_000,
bw: 125_000,
});
tokio::time::sleep(Duration::from_millis(10)).await;
let _ = sender.send(Ev::Halt);
});
let _ = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
let ctx = m.into_context();
assert_eq!(
*ctx.last_cfg.lock().unwrap(),
Some((915_000_000, 125_000))
);
}
+49
View File
@@ -0,0 +1,49 @@
//! Integration test for async `run()` under the `tokio` feature (T6.3).
#![cfg(feature = "tokio")]
use hsmc::{statechart, Duration};
#[derive(Default)]
pub struct Ctx { pub log: Vec<String> }
#[derive(Debug, Clone)]
pub enum Ev { Halt }
statechart! {
TokM {
context: Ctx;
events: Ev;
default(A);
terminate(Halt);
state A { entry: a_entry; exit: a_exit; }
}
}
impl TokMActions for TokMActionContext<'_> {
async fn a_entry(&mut self) {
tokio::task::yield_now().await;
self.log.push("a_entry".into());
}
async fn a_exit(&mut self) {
tokio::task::yield_now().await;
self.log.push("a_exit".into());
}
}
#[tokio::test]
async fn t6_3_run_returns_after_terminate() {
let mut m = TokM::new(Ctx::default());
let sender = m.sender();
// Trigger terminate from another task.
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = sender.send(Ev::Halt);
});
let res = tokio::time::timeout(Duration::from_secs(2), m.run()).await;
let res = res.expect("run() hung past timeout");
assert!(res.is_ok());
assert!(m.is_terminated());
let ctx = m.into_context();
assert!(ctx.log.contains(&"a_entry".to_string()));
assert!(ctx.log.contains(&"a_exit".to_string()));
}