- Notifications
You must be signed in to change notification settings - Fork33
Hierarchical state machines for designing event-driven systems
License
mdeloof/statig
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Hierarchical state machines for designing event-driven systems.
Features
- Hierarchical state machines
- State-local storage
- Compatible with
#![no_std], state machines are defined in ROM and no heap memory allocations. - (Optional) macro's for reducing boilerplate.
- Support for generics.
- Support for async actions and handlers.
Overview
A simple blinky state machine:
┌─────────────────────────┐│ Blinking │◀─────────┐│ ┌───────────────┐ │ ││ ┌─▶│ LedOn │──┐ │ ┌───────────────┐│ │ └───────────────┘ │ │ │ NotBlinking ││ │ ┌───────────────┐ │ │ └───────────────┘│ └──│ LedOff │◀─┘ │ ▲│ └───────────────┘ │──────────┘└─────────────────────────┘#[derive(Default)]pubstructBlinky;pubenumEvent{TimerElapsed,ButtonPressed}#[state_machine(initial ="State::led_on()")]implBlinky{#[state(superstate ="blinking")]fnled_on(event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>Transition(State::led_off()), _ =>Super}}#[state(superstate ="blinking")]fnled_off(event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>Transition(State::led_on()), _ =>Super}}#[superstate]fnblinking(event:&Event) ->Outcome<State>{match event{Event::ButtonPressed =>Transition(State::not_blinking()), _ =>Super}}#[state]fnnot_blinking(event:&Event) ->Outcome<State>{match event{Event::ButtonPressed =>Transition(State::led_on()), _ =>Super}}}fnmain(){letmut state_machine =Blinky::default().state_machine(); state_machine.handle(&Event::TimerElapsed); state_machine.handle(&Event::ButtonPressed);}
(See themacro/blinky example for the full code with comments. Or seeno_macro/blinky for a version without using macro's).
States are defined by writing methods inside theimpl block and adding the#[state] attribute to them. When an event is submitted to the state machine, the method associated with the current state will be called to process it. By default this event is mapped to theevent argument of the method.
#[state]fnled_on(event:&Event) ->Outcome<State>{Transition(State::led_off())}
Every state must return anOutcome. AnOutcome can be one of three things:
Handled: The event has been handled.Transition: Transition to another state.Super: Defer the event to the parent superstate.
Superstates allow you to create a hierarchy of states. States can defer an event to their superstate by returning theSuper outcome.
#[state(superstate ="blinking")]fnled_on(event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>Transition(State::led_off()),Event::ButtonPressed =>Super}}#[superstate]fnblinking(event:&Event) ->Outcome<State>{match event{Event::ButtonPressed =>Transition(State::not_blinking()), _ =>Super}}
Superstates can themselves also have superstates.
Actions run when entering or leaving states during a transition.
#[state(entry_action ="enter_led_on", exit_action ="exit_led_on")]fnled_on(event:&Event) ->Outcome<State>{Transition(State::led_off())}#[action]fnenter_led_on(){println!("Entered on");}#[action]fnexit_led_on(){println!("Exited on");}
If the type on which your state machine is implemented has any fields, you can access them inside all states, superstates or actions.
#[state]fnled_on(&mutself,event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>{self.led =false;Transition(State::led_off())} _ =>Super}}
Or alternatively, setled inside the entry action.
#[action]fnenter_led_off(&mutself){self.led =false;}
Sometimes you have data that only exists in a certain state. Instead of adding this data to the shared storage and potentially having to unwrap anOption<T>, you can add it as an input to your state handler.
#[state]fnled_on(counter:&mutu32,event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>{*counter -=1;if*counter ==0{Transition(State::led_off())}else{Handled}}Event::ButtonPressed =>Transition(State::led_on(10))}}
counter is only available in theled_on state but can also be accessed in its superstates and actions.
When state machines are used in a larger systems it can sometimes be necessary to pass in an external mutable context. By default this context is mapped to thecontext argument of the method.
#[state]fnled_on(context:&mutContext,event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>{ context.do_something();Handled} _ =>Super}}
You will then be required to use thehandle_with_context method to submit events to the state machine.
state_machine.handle_with_context(&Event::TimerElapsed,&mut context);
For logging purposes you can define various callbacks that will be called at specific points during state machine execution.
before_dispatchis called before an event is dispatched to a specific state or superstate.after_dispatchis called after an event is dispatched to a specific state or superstate.before_transitionis called before a transition has occurred.after_transitionis called after a transition has occurred.
#[state_machine( initial ="State::on()", before_dispatch ="Self::before_dispatch", after_dispatch ="Self::after_dispatch", before_transition ="Self::before_transition", after_transition ="Self::after_transition", state(derive(Debug)), superstate(derive(Debug)))]implBlinky{ ...}implBlinky{fnbefore_dispatch(&mutself,state:StateOrSuperstate<Blinky>,event:&Event,_context:&mut()){println!("before dispatched `{:?}` to `{:?}`", event, state);}fnafter_dispatch(&mutself,state:StateOrSuperstate<Blinky>,event:&Event,_context:&mut()){println!("after dispatched `{:?}` to `{:?}`", event, state);}fnbefore_transition(&mutself,source:&State,target:&State,_context:&mut()){println!("before transitioned from `{:?}` to `{:?}`", source, target);}fnafter_transition(&mutself,source:&State,target:&State,_context:&mut()){println!("after transitioned from `{:?}` to `{:?}`", source, target);}}
All handlers and actions can be made async. (This requires theasync feature to be enabled).
#[state_machine(initial ="State::led_on()")]implBlinky{#[state]asyncfnled_on(event:&Event) ->Outcome<State>{match event{Event::TimerElapsed =>Transition(State::led_off()), _ =>Super}}}
The#[state_machine] macro will then automatically detect that async functions are being usedand generate an async state machine.
asyncfnmain(){letmut state_machine =Blinky::default().state_machine(); state_machine.handle(&Event::TimerElapsed).await; state_machine.handle(&Event::ButtonPressed).await;}
A lot of the implementation details are dealt with by the#[state_machine] macro, but it's always valuable to understand what's happening behind the scenes. Furthermore, you'll see that the generated code is actually pretty straight-forward and could easily be written by hand, so if you prefer to avoid using macro's this is totally feasible.
The goal ofstatig is to represent a hierarchical state machine. Conceptually a hierarchical state machine can be thought of as a tree.
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ Top └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ ┌────────────┴────────────┐ │ │ ┌─────────────────────┐ ╔═════════════════════╗ │ Blinking │ ║ NotBlinking ║ │─────────────────────│ ╚═════════════════════╝ │ counter: &'a usize │ └─────────────────────┘ │ ┌────────────┴────────────┐ │ │╔═════════════════════╗ ╔═════════════════════╗║ LedOn ║ ║ LedOff ║║─────────────────────║ ║─────────────────────║║ counter: usize ║ ║ counter: usize ║╚═════════════════════╝ ╚═════════════════════╝Nodes at the edge of the tree are called leaf-states and are represented by anenum instatig. If data only exists in a particular state we can give that state ownership of the data. This is referred to as 'state-local storage'. For examplecounter only exists in theLedOn andLedOff state.
enumState{LedOn{counter:usize},LedOff{counter:usize},NotBlinking}
States such asBlinking are called superstates. They define shared behavior of their child states. Superstates are also represented by an enum, but instead of owning their data, they borrow it from the underlying state.
enumSuperstate<'sub>{Blinking{counter:&'subusize}}
The association between states and their handlers is then expressed in theState andSuperstate traits with thecall_handler() method.
impl statig::State<Blinky>forState{fncall_handler(&mutself,blinky:&mutBlinky,event:&Event,context:&mut()) ->Outcome<Self>{matchself{State::LedOn{ counter} => blinky.led_on(counter, event),State::LedOff{ counter} => blinky.led_off(counter, event),State::NotBlinking => blinky.not_blinking(event)}}}impl statig::Superstate<Blinky>forSuperstate{fncall_handler(&mutself,blinky:&mutBlinky,event:&Event,context:&mut()) ->Outcome<Self>{matchself{Superstate::Blinking{ counter} => blinky.blinking(counter, event),}}}
The association between states and their actions is expressed in a similar fashion.
impl statig::State<Blinky>forState{ ...fncall_entry_action(&mutself,blinky:&mutBlinky,context:&mut()){matchself{State::LedOn{ counter} => blinky.enter_led_on(counter),State::LedOff{ counter} => blinky.enter_led_off(counter),State::NotBlinking => blinky.enter_not_blinking()}}fncall_exit_action(&mutself,blinky:&mutBlinky,context:&mut()){matchself{State::LedOn{ counter} => blinky.exit_led_on(counter),State::LedOff{ counter} => blinky.exit_led_off(counter),State::NotBlinking => blinky.exit_not_blinking()}}}impl statig::Superstate<Blinky>forSuperstate{ ...fncall_entry_action(&mutself,blinky:&mutBlinky,context:&mut()){matchself{Superstate::Blinking{ counter} => blinky.enter_blinking(counter),}}fncall_exit_action(&mutself,blinky:&mutBlinky,context:&mut()){matchself{Superstate::Blinking{ counter} => blinky.exit_blinking(counter),}}}
The tree structure of states and their superstates is expressed in thesuperstate method of theState andSuperstate trait.
impl statig::State<Blinky>forState{ ...fnsuperstate(&mutself) ->Option<Superstate<'_>>{matchself{State::LedOn{ counter} =>Some(Superstate::Blinking{ counter}),State::LedOff{ counter} =>Some(Superstate::Blinking{ counter}),State::NotBlinking =>None}}}impl<'sub> statig::Superstate<Blinky>forSuperstate<'sub>{ ...fnsuperstate(&mutself) ->Option<Superstate<'_>>{matchself{Superstate::Blinking{ ..} =>None}}}
When an event arrives,statig will first dispatch it to the current leaf state. If this state returns aSuper outcome, it will then be dispatched to that state's superstate, which in turn returns its own outcome. Every time an event is deferred to a superstate,statig will traverse upwards in the graph until it reaches theTop state. This is an implicit superstate that will consider every event as handled.
In case the returned outcome is aTransition,statig will perform a transition sequence by traversing the graph from the current source state to the target state by taking the shortest possible path. When this path is going upwards from the source state, every state that is passed will have itsexit action executed. And then similarly when going downward, every state that is passed will have itsentry action executed.
For example when transitioning from theLedOn state to theNotBlinking state the transition sequence looks like this:
- Exit the
LedOnstate - Exit the
Blinkingstate - Enter the
NotBlinkingstate
For comparison, the transition from theLedOn state to theLedOff state looks like this:
- Exit the
LedOnstate - Enter the
LedOffstate
We don't execute the exit or entry action ofBlinking as this superstate is shared between theLedOn andLedOff state.
Entry and exit actions also have access to state-local storage, but note that exit actions operate on state-local storage of the source state and that entry actions operate on the state-local storage of the target state.
For example changing the value ofcounter in the exit action ofLedOn will have no effect on the value ofcounter in theLedOff state.
Finally, theStateMachine trait is implemented on the type that will be used for the shared storage.
implIntoStateMachineforBlinky{typeState =State;typeSuperstate<'sub> =Superstate<'sub>;typeEvent<'evt> =Event;typeContext<'ctx> =Context;fninitial() ->Self::State{State::off(10)}}
Short answer: nothing.#[state_machine] simply parses the underlyingimpl block and derives some code based on its content and adds it to your source file. Your code will still be there, unchanged. In fact#[state_machine] could have been a derive macro, but at the moment Rust only allows derive macros to be used on enums and structs. If you'd like to see what the generated code looks like take a look at the testwith andwithout macros.
I would say they serve a different purpose. Thetypestate pattern is very useful for designing an API as it is able to enforce the validity of operations at compile time by making each state a unique type. Butstatig is designed to model a dynamic system where events originate externally and the order of operations is determined at run time. More concretely, this means that the state machine is going to sit in a loop where events are read from a queue and submitted to the state machine using thehandle() method. If we want to do the same with a state machine that uses the typestate pattern we'd have to use an enum to wrap all our different states and match events to operations on these states. This means extra boilerplate code for little advantage as the order of operations is unknown so it can't be checked at compile time. On the other handstatig gives you the ability to create a hierarchy of states which I find to be invaluable as state machines grow in complexity.
The idea for this library came from reading the bookPractical UML Statecharts in C/C++. I highly recommend it if you want to learn how to use state machines to design complex systems.
About
Hierarchical state machines for designing event-driven systems
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Uh oh!
There was an error while loading.Please reload this page.
Contributors11
Uh oh!
There was an error while loading.Please reload this page.