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:
56 files changed
+11770
No files matched your search
+10
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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[..]
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
Reference in new issue
Block a user