UmlFsm is the event-driven, hierarchical finite state machine, which implements core features of the UML State Machine:
- hierarchical nested states and orthogonal regions
- external, internal, and local transitions between states
- entry / exit / action callbacks
- extended states, guard conditions
- run-to-completion execution model: event queue, deferred event queue
In addition, it features:
- transitions guided by state history
- transitions guided by multiple target states
- async / await for all actions
- rudimentary export of the FSM to graphviz format
Implementation details:
- Written using JavaScript ES6+, (is async/await ES8?)
- Tested to work well with older browsers if transpiled by Babel
- Checked with ESLint - ES8 preset
- Custom tests cover many transition scenarios and combinations
UmlFsm could be installed via NPM:
$> npm install umlfsm
Or, it could be used as directly by including the umlfsm.js file with your project
import { UmlFsm } from 'umlfsm';
// initialize simple FSM and three states
let FSM = new UmlFsm({ id: "FSM" }),
A = FSM._fsm_create_child_state({ id: "A" }),
B = FSM._fsm_create_child_state({ id: "B" }),
C = FSM._fsm_create_child_state({ id: "C" });
// add transitions
A._fsm_add_transition({ event_id: 'change', target: B });
B._fsm_add_transition({ event_id: 'change', target: C,
action: function({ args, extended_state }) {
console.log('event parameters object', args);
console.log('extended state object', extended_state);
}
});
C._fsm_add_transition({ event_id: 'goback', target: A });
// add entry/exit callbacks
B._fsm_entry_callback = async ({ args, extended_state }) => {
console.log('entered state B');
};
B._fsm_exit_callback = async ({ args, extended_state }) => {
console.log('exited state B');
};
// auto-transit to the initial state A
FSM._fsm_start();
// emit events
// transition A->B
FSM._fsm_emit_event({ event_id: 'change' });
// transition B->C
FSM._fsm_emit_event({ event_id: 'change' });
// transition C->A
FSM._fsm_emit_event({ event_id: 'goback' });
UML Finite State Machine is initialized as follows:
import { UmlFsm, UmlFsmState } from 'umlfsm';
let FSM = new UmlFsm({ id: "FSM", extended_state: { counter: 1000 } });
States could be added to the FSM in two ways: direct or indirect
// Example: nested parallel state A containing simultaneously operated states B and C
let A = FSM._fsm_create_child_state({ id: 'A', parallel: true }),
B = A._fsm_create_child_state({ id: 'B' }),
C = A._fsm_create_child_state({ id: 'C', defer: ['evt1','evt2'] });
...alternatively, if you decide to use subclassing of states...
// parallel state
// create independent states first
let A = new UmlFsmState({ id: 'A', parallel: true }),
B = new UmlFsmState({ id: 'B' });
C = new UmlFsmState({ id: 'C' });
// then adopt states ( or your subclasses ) by another state ( or subclass of state )
FSM._fsm_adopt_child_state( A );
A._fsm_adopt_child_state( B );
A._fsm_adopt_child_state( C );
UmlFsm supports three types of transitions: external, local, internal. External transition causes "exit" of all states up to the Least Common Ancestor (LCA), then calls "entry" handler of all states down to target ( or beyond that if target is a nested/parallel state ).
Transitions could be set up as follows:
For external transitions, type parameter could be omitted.
A._fsm_add_transition({ event_id: 'A-B', target: B, type: 'external' });
...or...
assuming B is a sub-state of A, it will not perform exit from A
A._fsm_add_transition({ event_id: 'A-B', target: B, type: 'local' });
...or...
Does not trigger exit/entry callbacks, just fires action if provided
B._fsm_add_transition({ event_id: 'B-B', type: 'internal',
action: function({ args, extended_state }) {
if ( args.item === 3 ) {
--extended_state.counter;
}
}
});
...or...
B = previously accessed nested state. See classic "washer machine being turned off and then on" example for a use-case
A._fsm_add_transition({ event_id: 'change', target: B, history: true });
...or...
B._fsm_add_transition({ event_id: 'B-C',
guard: function({ args, extended_state }) {
return extended_state.counter > 500;
}
});
...or...
Transition, guided by a set of desired final states. Sometimes, it is required to enter a parallel state in non-default fashion. Imagine parallel state C with [ D0,D1,D2 ] | [ E0,E1,E2 ] states where D0 and E0 are default entry points, while you need to enter C at D2,E1 and not at D0,E0:
B._fsm_add_transition({ event_id: 'B-C', target: C, prefer: [ D2, E1 ] });
... or use combination of all above...
States may have an entry / exit callbacks attached. Callbacks have full access to the event parameters (args) and extended state:
B._fsm_entry_callback = async ({ args, extended_state }) => {
console.log('entered state B');
};
B._fsm_exit_callback = async ({ args, extended_state }) => {
console.log('exited state B');
};
Here is how events can be emitted, along with event parameters (args):
FSM._fsm_emit_event({ event_id: 'num_key', args: { code: ( "a".charCodeAt(0) ) } });
Guard Condition is a function which is specified for a transition and forbids the transition if evaluated to false. It can use event parameters or extended state to decide on the outcome.
B._fsm_add_transition({ event_id: 'B-C',
guard: function({ args, extended_state }) {
return extended_state.counter > 500;
}
});
- Initial state = upon start, FSM will automatically use local transition from the hypotetical initial state to the starting state.
- Choice = define multiple (guarded) transitions with the same name from state A to states [B,C...Z]. First allowed transition will be executed, so if (..) {} else if (...) {} else {} scenarios could be implemented.
- Fork = use regular transition into parallel state (default path), or define guided transition into parallel state indicating preferred final states via prefer flag of the transition.
- Join = define transition from parent state (parallel) to the target. Multiple nested child states will be exited to enter a single target state.
- Deep History = use transition with history flag set to true to indicate that last used state is preferred over default path upon enter.
Sometimes it is useful to export current FSM state (tree, in case of nested states) as a string, e.g. for debug purposes:
...
// start FMS in synchronous mode, let it transition
await FSM._fsm_start();
// dump initial active state
let state_as_string = FSM._fsm_get_active_state_as_string();
...
UmlFsm introduces rudimentary support for graphviz export. Not guaranteed to produce precise results (or even work) for all FSMs.
let dot = FSM._fsm_export_as_graphviz();
// ...save result as test.dot, run graph processor...
$> dot test.dot -Tpng -o test.png
// note: self-transition of a nested state is not supported yet
UmlFsm is covered under the terms of MIT License