├── .gitignore
├── .npmignore
├── Changelog.txt
├── LICENSE
├── MANUAL.md
├── README.md
├── dist
├── finite-state-machine-node.html
├── finite-state-machine-node.js
└── statemachine.js
├── examples
├── CompatibleOutputsFlow.json
├── FeedbackStateMachineFlow.json
├── MinimalStateMachineFlow.json
└── SimpleStateMachineFlow.json
├── gulpfile.js
├── images
├── change-data-object.png
├── flow-feedback.png
├── flow-minimal.png
├── flow-simple.png
├── flow-with-rbe.png
├── flow.png
├── node-appearance.png
├── node-settings.png
└── rbe-configuration.png
├── package-lock.json
├── package.json
├── src
├── finite-state-machine-node.html
├── finite-state-machine-node.js
├── stateMachineGraph.js
└── statemachine.js
└── tests
├── node.spec.js
└── statemachine.spec.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | images/*
2 | src/*
3 | tests/*
--------------------------------------------------------------------------------
/Changelog.txt:
--------------------------------------------------------------------------------
1 | v2.1.3
2 | (2022-01-10)
3 |
4 | Changes:
5 | - Fixed fsm definition edit bug, that wouldnt preserve any changes.
6 |
7 | -----------------------------------------------------------------------------------------------------------
8 |
9 | v2.1.2
10 | (2021-12-21)
11 |
12 | Changes:
13 | - Fixed fsm definition edit bug for node-red 2.1.4
14 |
15 | -----------------------------------------------------------------------------------------------------------
16 |
17 | v2.1.0
18 | (2021-11-09)
19 |
20 | Changes:
21 | - BREAKING CHANGES: transition messages that contain a data object now need to have the data set in
22 | msg.data instead of msg.payload
23 | - updated examples accordingly
24 | - fixed recursion bug of the trigger object
25 | - fixed recursion bug of the data object
26 |
27 | -----------------------------------------------------------------------------------------------------------
28 | v1.1.0
29 | (2020-09-03)
30 |
31 | Changes / Improvements / Optimisations:
32 | - Examples moved to subdir. Moving example flows to the example subdir gives the option to import them via the import function.
33 |
34 | Bug Fixes:
35 | - None.
36 |
37 | Known/Outstanding Issues:
38 | - None.
39 |
40 | For all additional issues that may appear after release, please see the following link for active tickets:
41 | https://github.com/lutzer/node-red-contrib-finite-statemachine/issues.
42 |
43 | -----------------------------------------------------------------------------------------------------------
44 |
45 | v1.0.4
46 | (2020-06-08)
47 |
48 | Changes / Improvements / Optimisations:
49 | - Add licencse information.
50 |
51 | Bug Fixes:
52 | - None.
53 |
54 | Known/Outstanding Issues:
55 | - None.
56 |
57 | -----------------------------------------------------------------------------------------------------------
58 |
59 | v1.0.3
60 | (2020-05-12)
61 |
62 | Changes / Improvements / Optimisations:
63 | - Removed usage of class properties and minified dist files.
64 |
65 | Bug Fixes:
66 | - None.
67 |
68 | Known/Outstanding Issues:
69 | - None.
70 |
71 | -----------------------------------------------------------------------------------------------------------
72 |
73 | v1.0.2
74 | (2020-05-02)
75 |
76 | Changes / Improvements / Optimisations:
77 | - None.
78 |
79 | Bug Fixes:
80 | - Documentation | Changed link to README.md. Fixes broken link on https://flows.nodered.org/node/node-red-contrib-finite-statemachine.
81 |
82 | Known/Outstanding Issues:
83 | - None.
84 |
85 | -----------------------------------------------------------------------------------------------------------
86 | v1.0.1
87 | (2020-05-01)
88 |
89 | Changes / Improvements / Optimisations:
90 | - Node behaviour | Added validation function for fsm definitons.
91 | - Documentation | README.md divided into README.md and MANUAL.md to have a better quick overview in README.md.
92 |
93 | Bug Fixes:
94 | - Reset topic | Bugfix: On reset initial state was not set correctly.
95 |
96 | -----------------------------------------------------------------------------------------------------------
97 |
98 | v1.0.0
99 | (2020-04-30)
100 |
101 | Changes / Improvements / Optimisations:
102 | - Ouputs | Changed from 3 to 1 output. See documentation if you used the 2nd or 3rd output before how to get the same function with an rbe node. Additionally added option to always send state.
103 | - Node behaviour "data object" | Added new functionality so that the "data" object can be set within a state of the statemachine (via definition of the transition table). Up to now, the "data" object could only be modified via a JSON message at the input.
104 |
105 | Bug Fixes:
106 | - System | Resolved an issue were the chosen CPU governor was not applied correctly. Many thanks to @bbsixzz for reporting the issue and @Joulinar for providing the solution: https://github.com/MichaIng/DietPi/issues/3299.
107 |
108 | Known/Outstanding Issues:
109 | - None.
110 |
111 | For all additional issues that may appear after release, please see the following link for active tickets:
112 | https://github.com/lutzer/node-red-contrib-finite-statemachine/issues.
113 |
114 | -----------------------------------------------------------------------------------------------------------
115 |
116 | v0.2.11
117 | (2020-04-22)
118 |
119 | Changes / Improvements / Optimisations:
120 | - Documentation | Improved documentation with more examples and Node-RED code export snippets.
121 |
122 | -----------------------------------------------------------------------------------------------------------
123 |
124 | v0.2.10
125 | (2019-07-09)
126 |
127 | Changes / Improvements / Optimisations:
128 | - Node properties | Improved transition graph: Automatic visualisation adjust to different window sizes.
129 |
130 | -----------------------------------------------------------------------------------------------------------
131 |
132 | v0.2.09
133 | (2019-07-07)
134 |
135 | Changes / Improvements / Optimisations:
136 | - Documentation | Node usage description added.
137 |
138 | -----------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 by Lutz Reiter
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANUAL.md:
--------------------------------------------------------------------------------
1 |
2 | # Node Red State Machine Manual
3 |
4 | ### Table of contents
5 | 1. [Installation](#installation)
6 | 1.1 [In Node-RED](#installation_in_node-red)
7 | 1.2 [In a shell](#installation_in_a_shell)
8 | 2. [Usage](#usage)
9 | 2.1 [Node configuration](#node_conifguration)
10 | 2.2 [Input](#input)
11 | 2.3 [Output](#output)
12 | 2.4 [Handling of the data object](#handling_of_the_data_object)
13 | 2.5 [Further information](#further_information)
14 | 3. [Example flows](#example_flows)
15 | 3.1 [Minimal state machine](#minimal_state_machine)
16 | 3.2 [Simple state machine with data object](#simple_state_machine_with_data_object)
17 | 3.3 [State machine with feedback](#state_machine_with_feedback)
18 | 3.4 [Changing the data object](#changing_the_data_object)
19 | 4. [Development](#development)
20 | 5. [Hints for upgrading from earlier versions](#hints_for_upgrading)
21 |
22 |
23 |
24 | ## Installation
25 |
26 |
27 | ### In Node-RED
28 |
29 | * Via Manage Palette -> Search for "node-red-contrib-finite-statemachine"
30 |
31 |
32 | ### In a shell
33 |
34 | * go to the Node-RED installation folder, in OS X it's usually: `~/.node-red`
35 | * run `npm install node-red-contrib-finite-statemachine`
36 |
37 |
38 |
39 | ## Usage
40 |
41 |
42 | ### Node Configuration
43 | 
44 | **Fig. 1:** Node properties
45 |
46 |
47 |
48 | #### Basic FSM structure
49 | The statemachine of `finite state machine` is defined by a JSON object within the line *FSM* (Finite State Machine):
50 |
51 | - *state* holds the initial state. It shall contain a *status* field.
52 | - *transitions* holds the possible states as keys (shown as upper case strings). As values it contains one or more key/value pairs, consisting of the transition string (lower case strings) and the resulting state.
53 | - additional *data* fields are optional.
54 |
55 | ```json
56 | {
57 | "state": {
58 | "status": "IDLE"
59 | },
60 | "transitions": {
61 | "IDLE": {
62 | "run": "RUNNING"
63 | },
64 | "RUNNING": {
65 | "stop": "IDLE",
66 | "set": "RUNNING"
67 | }
68 | }
69 | }
70 | ```
71 | **Fig. 2:** Basic FSM structure definition (only with transitions)
72 |
73 |
74 |
75 | #### Optional *data* object
76 | The optional *data* object may be used in the (initial) "state" definition as well as in every transition definition. Whenever a valid transition occurs the *data* portion of its transition definition is handled.
77 | In addition, transitions with and without *data* definitions may be mixed arbitrarily.
78 |
79 | Fig. 4 shows *data* definition portions within the *state* object and in transitions.
80 |
81 | ```json
82 | "state": {
83 | "status": "IDLE",
84 | "data": {
85 | "x": 99
86 | }
87 | }
88 | ```
89 |
90 | ```json
91 | "transitions": {
92 | "RUNNING": {
93 | "stop": {
94 | "status": "IDLE",
95 | "data": {
96 | "x": 0
97 | }
98 | },
99 | "set": "RUNNING"
100 | }
101 | }
102 | ```
103 | **Fig. 3:** *data* object portions
104 |
105 |
106 |
107 |
108 | ### Input
109 |
110 | The input topics of the `finite state machine` are defined by the transition table setup in the [node configuration](#node_conifguration).
111 |
112 | - sending a `msg` to the node containing a `msg.topic` set to a defined transition string triggers a state change.
113 | - `msg.control`= *reset* sets the machine to its initial state (*"state"*)
114 | - `msg.control`= *sync* is used to set the state manually. Its payload needs to be a JSON object, containing a *status* field
115 | - `msg.control`= *query* triggers a state query event and the current state is sent to the output of the state machine. The option *Always send state change* needs to be enabled for this.
116 |
117 |
118 |
119 | ### Output
120 |
121 | The output of `finite state machine` sends a `msg` whenever there is a valid transition.
122 | Remark: This also may be a valid transition without any state change.
123 |
124 | The output contains:
125 | - *payload.status*: Outputs the state of the FSM.
126 | - *payload.data*: Outputs the *data* object of the FSM.
127 | - *trigger*: Contains the original message that triggerd the state change.
128 |
129 |
130 |
131 |
132 |
133 | ### Handling of the *"data"* object
134 | - The *data* object within the "state" definition initializes the *data* object at the first start of the flow.
135 | - The contents of the *data* object within the "transitions" definition sets the *data* object at the according transition.
136 | - The contents of the *data* object may also be changed or extended by sending a `msg` with a valid transition (within `msg.topic`) containing the field *data* with a JSON object.
137 |
138 | **Note:** Sending a `msg` without a valid transition cannot change the *data* object (see example below).
139 |
140 |
141 |
142 | ### Further information
143 | Check Node-REDs info panel to see more information on how to use the state machine.
144 |
145 |
146 |
147 | ## Example flows
148 | ***
149 | **Remark**: Example flows are present in the examples subdirectory. In Node-RED they can be imported via the import function and then selecting *Examples* in the vertical tab menue.
150 | ***
151 |
152 |
153 | ### Minimal state machine
154 |
155 | This example shows a state machine with two states without any *data*-object.
156 |
157 | There is only one `msg.topic` ("toggleState") which toggles between the two states IDLE and RUNNING.
158 |
159 |
160 | ```json
161 | {
162 | "state": {
163 | "status": "IDLE"
164 | },
165 | "transitions": {
166 | "IDLE": {
167 | "toggleState": "RUNNING"
168 | },
169 | "RUNNING": {
170 | "toggleState": "IDLE"
171 | }
172 | }
173 | }
174 | ```
175 | **Fig. 4:** Minimal state machine JSON object
176 |
177 |
178 | 
179 | [**MinimalStateMachineFlow.json**](examples/MinimalStateMachineFlow.json)
180 |
181 | **Fig. 5:** Minimal state machine
182 |
183 |
184 |
185 | ### Simple state machine with data object
186 |
187 | Set finite state machine definiton to:
188 |
189 | ```json
190 | {
191 | "state": {
192 | "status": "IDLE",
193 | "data": {
194 | "x": 99
195 | }
196 | },
197 | "transitions": {
198 | "IDLE": {
199 | "run": {
200 | "status": "RUNNING",
201 | "data": {
202 | "x": 42
203 | }
204 | }
205 | },
206 | "RUNNING": {
207 | "stop": {
208 | "status": "IDLE",
209 | "data": {
210 | "x": 0
211 | }
212 | },
213 | "set": "RUNNING"
214 | }
215 | }
216 | }
217 | ```
218 | **Fig. 6:** Simple state machine JSON object
219 |
220 | #### State description
221 | This example gives a state machine with two states (IDLE, RUNNING) and three transitions. Two of them (*run*, *stop*) change between the two states, the third (*set*) is used only to externally change the *data* object contents in the state RUNNING via an input `msg` with an appropriate `msg.topic` = "set".
222 |
223 |
224 | 
225 | [**SimpleStateMachineFlow.json**](examples/SimpleStateMachineFlow.json)
226 |
227 | **Fig. 7:** Simple state machine
228 |
229 |
230 |
231 | ### State machine with feedback
232 |
233 | Set finite state machine definiton to:
234 |
235 | ```json
236 | {
237 | "state": {
238 | "status": "IDLE"
239 | },
240 | "transitions": {
241 | "IDLE": {
242 | "run": "RUNNING"
243 | },
244 | "RUNNING": {
245 | "stop": "IDLE"
246 | }
247 | }
248 | }
249 | ```
250 | **Fig. 8:** State machine with feedback JSON object
251 |
252 | This example gives a self-stopping behaviour after a defined amount of time: Transition *run* triggers the state machine to *state* RUNNING, the feedback loop activates the transition *stop* after a delay of 5 seconds so that the state machine changes back to *state* IDLE.
253 |
254 | 
255 | [**FeedbackStateMachineFlow.json**](examples/FeedbackStateMachineFlow.json)
256 |
257 | **Fig. 9:** State machine with feedback
258 |
259 |
260 |
261 | ### Changing the "data" object
262 |
263 | During a valid transition the *data* object can be changed or extended via the nodes input (externally) or changed via the node transition definition (internally).
264 |
265 | Sending a `msg` containing no valid transition within the `msg.topic` cannot lead to any *data* object changes.
266 |
267 | Therefore see example "Simple state machine with data object" above: To be able to change the *data* object externally in the *state* RUNNING, the transition *set* is defined: This (valid) transition does not change the *state* but may change the *data* object within *state* RUNNING like the two lower injections do.
268 | In the example the definition of the upper set injection is like follows:
269 |
270 | 
271 | **Fig. 10:** Properties of a `msg`with a JSON *data* object
272 |
273 | As can seen this changes the present "data" object element "x" to a numerical value of '2' and adds an additional "data" object element "name" with the string "peter".
274 |
275 |
276 |
277 |
278 | ## Development
279 |
280 | * run `npm install`
281 | * install grunt `npm install -g grunt-cli`
282 | * build with `npm run build`
283 |
284 |
285 |
286 |
287 | ## Hints for upgrading from node versions 0.2.11 and earlier to version 1.x.x
288 | The `finite state machine` node of earlier versions contained three different outputs. In the actual node there is only one output present. Typically only this one output is needed.
289 | If one needs to have the other two output functions there is the possibility of "emulating" them via the node `rbe` (*Report by Exception* node). This node is able to filter the output in a manner that the old additional two outputs are present.
290 |
291 | 
292 | [**CompatibleOutputsFlow.json**](examples/CompatibleOutputsFlow.json)
293 |
294 | **Fig. 11:** Flow with `rbe` node generating compatible outputs
295 |
296 | As an example the `rbe`node *stateChanged* may be configured like shown in Fig. 16.
297 |
298 | 
299 | **Fig. 12:** Configuration of the `rbe` node
300 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Node Red State Machine
3 | A finite state machine (FSM) implementation for node red. Displays also a graphical representation of the state machine.
4 | 
5 |
6 | ## Installation
7 |
8 | ### In Node-RED
9 | * Via Manage Palette -> Search for "node-red-contrib-finite-statemachine"
10 |
11 | ### In a shell
12 | * go to the Node-RED installation folder, in OS X it's usually: `~/.node-red`
13 | * run `npm install node-red-contrib-finite-statemachine`
14 |
15 | ## Usage
16 |
17 | You can find detailed usage information in the [Usage Manual](https://github.com/lutzer/node-red-contrib-finite-statemachine/blob/master/MANUAL.md).
18 |
19 | ### Node Configuration
20 | 
21 |
22 | #### Basic FSM structure
23 | The statemachine of `finite state machine` is defined by a JSON object within the line *FSM* (Finite State Machine):
24 |
25 | - *state* holds the initial state. It shall contain a *status* field.
26 | - *transitions* holds the possible states as keys (shown as upper case strings). As values it contains one or more key/value pairs, consisting of the transition string (lower case strings) and the resulting state.
27 | - additional *data* fields are optional. (See [Usage Manual](https://github.com/lutzer/node-red-contrib-finite-statemachine/blob/master/MANUAL.md))
28 |
29 | ```json
30 | {
31 | "state": {
32 | "status": "IDLE"
33 | },
34 | "transitions": {
35 | "IDLE": {
36 | "run": "RUNNING"
37 | },
38 | "RUNNING": {
39 | "stop": "IDLE",
40 | "set": "RUNNING"
41 | }
42 | }
43 | }
44 | ```
45 |
46 | ### Input
47 | The input topics of the `finite state machine` are defined by the transition table setup in the node configuration.
48 |
49 | - sending a `msg` to the node containing a `msg.topic` set to a defined transition string triggers a state change.
50 | - `msg.control`= *reset* sets the machine to its initial state (*"state"*)
51 | - `msg.control`= *sync* is used to set the state manually. Its payload needs to be a JSON object, containing a *status* field
52 | - `msg.control`= *query* triggers a state query event and the current state is sent to the output of the state machine. The option *Always send state change* needs to be enabled for this.
53 |
54 | ### Output
55 |
56 | The output of `finite state machine` sends a `msg` whenever there is a valid transition.
57 | Remark: This also may be a valid transition without any state change.
58 |
59 | The *payload* contains:
60 | - *status*: Outputs the state of the FSM.
61 | - *data*: Outputs the *data* object of the FSM. Read more about the data object in the [Usage Manual](https://github.com/lutzer/node-red-contrib-finite-statemachine/blob/master/MANUAL.md).
62 |
63 |
64 | ### Further information
65 | Check Node-REDs info panel to see more information on how to configure the state machine.
66 |
67 |
68 | ## Example
69 | ***
70 | **Remark**: Example flows are present in the examples subdirectory. In Node-RED they can be imported via the import function and then selecting *Examples* in the vertical tab menue.
71 | ***
72 |
73 | For more examples, read the [Usage Manual](https://github.com/lutzer/node-red-contrib-finite-statemachine/blob/master/MANUAL.md).
74 |
75 | ### Minimal state machine
76 |
77 | This example shows a state machine with two states. There is only one `msg.topic` ("toggleState") which toggles between the two states IDLE and RUNNING.
78 |
79 |
80 | ```json
81 | {
82 | "state": {
83 | "status": "IDLE"
84 | },
85 | "transitions": {
86 | "IDLE": {
87 | "toggleState": "RUNNING"
88 | },
89 | "RUNNING": {
90 | "toggleState": "IDLE"
91 | }
92 | }
93 | }
94 | ```
95 | 
96 | [**MinimalStateMachineFlow.json**](examples/MinimalStateMachineFlow.json)
97 |
98 | ## Changelog
99 |
100 | [Changelog.txt](Changelog.txt)
101 |
102 |
103 |
104 |
105 | ## Development
106 |
107 | * run `npm install`
108 | * install grunt `npm install -g grunt-cli`
109 | * build with `npm run build`
110 | * create link in node-red folder by running `npm install ` within the node-red install directory
111 |
--------------------------------------------------------------------------------
/dist/finite-state-machine-node.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
35 |
36 |
114 |
--------------------------------------------------------------------------------
/dist/finite-state-machine-node.js:
--------------------------------------------------------------------------------
1 | const{StateMachine:StateMachine}=require("./statemachine.js"),{distinctUntilChanged:distinctUntilChanged,tap:tap}=require("rxjs/operators"),_=require("lodash"),FSM_NAME="finite-state-machine";module.exports=function(t){t.nodes.registerType(FSM_NAME,function(e){t.nodes.createNode(this,e);var a=this,i=this.context();try{i.machine=new StateMachine(JSON.parse(e.fsmDefinition)),s(i.machine.getState().status)}catch(t){return a.status({fill:"red",shape:"ring",text:"no valid definitions"}),void a.warn("no valid definitions")}function n(t=null){a.send([t])}function s(t){a.status({fill:"green",shape:"dot",text:"state: "+t})}i.allChangeListener=i.machine.observable.pipe(e.sendStateWithoutChange?tap():distinctUntilChanged((t,e)=>_.isEqual(t.state,e.state))).subscribe(({state:t,trigger:e})=>{s(t.status),n({topic:"state",payload:t,trigger:_.omit(e,"trigger")})}),e.sendInitialState&&setTimeout(()=>{n({topic:"state",payload:i.machine.getState()})},100),a.on("input",function(t){if("reset"!==t.control)if("sync"!==t.control){"query"===t.control&&i.machine.queryState();var n={type:t.topic,data:_.isObject(t.data)?t.data:{}};try{i.machine.triggerAction(n,t)}catch(i){e.showTransitionErrors&&a.error({code:i.code,msg:i.message},t)}}else try{i.machine.setState(t.payload)}catch(e){a.error({code:e.code,msg:e.message},t)}else i.machine.reset()}),a.on("close",function(){i.stateChangeListener.unsubscribe()})})};
--------------------------------------------------------------------------------
/dist/statemachine.js:
--------------------------------------------------------------------------------
1 | const _=require("lodash"),{fromJS:fromJS}=require("immutable"),{Subject:Subject}=require("rxjs"),{share:share}=require("rxjs/operators");class StatemachineError extends Error{constructor(t,s="0"){super(),this.message=t,this.code=s}}function parseTransitionEntry(t){return _.isString(t)?{status:t,data:{}}:_.isObject(t)?{status:t.status,data:t.data||{}}:void 0}class StateMachine{constructor(t){if(this.subject=new Subject,!_.isObject(t.state))throw new StatemachineError("No inital state specified.",1);if(!_.isObject(t.transitions))throw new StatemachineError("No transitions specified.",2);if(!_.isString(t.state.status))throw new StatemachineError("state must contain a status field of type string",3);this._initialState=_.extend({data:{}},t.state),_.values(t.transitions).forEach(t=>{_.values(t).forEach(t=>{if(!_.isString(t)&&!_.has(t,"status"))throw new StatemachineError("Transition table has no status field: "+JSON.stringify(t),4)})}),this._transitions=t.transitions,this._state=fromJS(this._initialState),this.subject.next({state:this._state.toJS()})}triggerAction(t,s){if(!_.isString(t.type))throw new StatemachineError("action must contain a type.",14);let e=this.getCurrentTransitions();if(_.isEmpty(e))throw new StatemachineError("no possible transitions to go to a new state.",11);if(!_.has(e,t.type))throw new StatemachineError("transition not possible from the current state.",12);let a=parseTransitionEntry(e[t.type]),i=Object.assign({},a.data,_.isObject(t.data)?t.data:{});this._state=this._state.set("status",a.status).mergeDeep({data:i}),this.subject.next({state:this._state.toJS(),trigger:s})}getCurrentTransitions(){return _.get(this._transitions,this._state.get("status"))||{}}getAllTransitions(){let t=_.reduce(_.values(this._transitions),(t,s)=>(t.push(..._.keys(s)),t),[]);return _.uniq(t)}reset(){this._state=fromJS(this._initialState),this.subject.next({state:this._state.toJS(),action:"reset"})}queryState(){this.subject.next({state:this._state.toJS()})}getState(){return this._state.toJS()}setState(t){if(!_.has(t,"status"))throw new StatemachineError("the state needs to contain a status field",4);if(!_.has(this._transitions,t.status))throw new StatemachineError("status does not exist in transition table",5);this._state=fromJS(t),this.subject.next({state:this._state.toJS()})}get observable(){return this.subject.pipe(share())}}exports.StateMachine=StateMachine;
--------------------------------------------------------------------------------
/examples/CompatibleOutputsFlow.json:
--------------------------------------------------------------------------------
1 | [{"id":"cd27ad32.fb2ce8","type":"tab","label":"State machine with rbe","disabled":false,"info":""},{"id":"741f9b7e.bd3d34","type":"finite-state-machine","z":"cd27ad32.fb2ce8","name":"","fsmDefinition":"{\"state\":{\"status\":\"IDLE\",\"data\":{\"x\":5}},\"transitions\":{\"IDLE\":{\"toggle\":\"RUNNING\",\"set\":\"IDLE\"},\"RUNNING\":{\"toggle\":\"IDLE\"}}}","sendInitialState":false,"sendStateWithoutChange":false,"showTransitionErrors":true,"x":480,"y":300,"wires":[["2c5a6aeb.c6e35e","22380426.fd5b5c","fd0fdc38.21c4c8","19579af6.c76055"]]},{"id":"6f9447ed.5d58","type":"inject","z":"cd27ad32.fb2ce8","name":"set { \"x\" :6 }","props":[{"p":"data","v":"{ \"x\" :6 }","vt":"json"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"set","x":230,"y":340,"wires":[["741f9b7e.bd3d34"]]},{"id":"3e18afe6.ce355","type":"inject","z":"cd27ad32.fb2ce8","name":"set { \"x\" :7 }","props":[{"p":"data","v":"{ \"x\" :7 }","vt":"json"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"set","x":230,"y":380,"wires":[["741f9b7e.bd3d34"]]},{"id":"d8ffa74c.efa948","type":"inject","z":"cd27ad32.fb2ce8","name":"","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"toggle","payload":"","payloadType":"str","x":250,"y":300,"wires":[["741f9b7e.bd3d34"]]},{"id":"8ee732f2.01075","type":"debug","z":"cd27ad32.fb2ce8","name":"","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"payload.data","targetType":"msg","x":930,"y":280,"wires":[]},{"id":"2c5a6aeb.c6e35e","type":"rbe","z":"cd27ad32.fb2ce8","name":"dataChanged","func":"rbei","gap":"","start":"","inout":"out","property":"payload.data","x":720,"y":280,"wires":[["8ee732f2.01075"]]},{"id":"22380426.fd5b5c","type":"rbe","z":"cd27ad32.fb2ce8","name":"stateChanged","func":"rbei","gap":"","start":"","inout":"out","property":"payload.status","x":720,"y":340,"wires":[["6abdae73.1aaac"]]},{"id":"6abdae73.1aaac","type":"debug","z":"cd27ad32.fb2ce8","name":"","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"payload.status","targetType":"msg","x":930,"y":340,"wires":[]},{"id":"2d6a156e.5f98ca","type":"inject","z":"cd27ad32.fb2ce8","name":"","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"set","payload":"","payloadType":"str","x":250,"y":260,"wires":[["741f9b7e.bd3d34"]]},{"id":"fd0fdc38.21c4c8","type":"debug","z":"cd27ad32.fb2ce8","name":"","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"payload.status","targetType":"msg","x":730,"y":200,"wires":[]},{"id":"19579af6.c76055","type":"debug","z":"cd27ad32.fb2ce8","name":"","active":false,"tosidebar":true,"console":false,"tostatus":true,"complete":"payload.data","targetType":"msg","x":730,"y":140,"wires":[]}]
--------------------------------------------------------------------------------
/examples/FeedbackStateMachineFlow.json:
--------------------------------------------------------------------------------
1 | [{"id":"854a9f95.2f9f7","type":"tab","label":"State machine with feedback flow","disabled":false,"info":""},{"id":"47aa9b50.b6825c","type":"finite-state-machine","z":"854a9f95.2f9f7","name":"","fsmDefinition":"{\"state\":{\"status\":\"IDLE\",\"data\":{\"x\":5}},\"transitions\":{\"IDLE\":{\"run\":\"RUNNING\"},\"RUNNING\":{\"stop\":\"IDLE\",\"set\":\"RUNNING\"}}}","sendInitialState":false,"showTransitionErrors":true,"x":480,"y":160,"wires":[["a8262434.cf7498","95e4cda8ef7f4206"]]},{"id":"87fbee09.255fe8","type":"inject","z":"854a9f95.2f9f7","name":"","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"run","payload":"","payloadType":"str","x":270,"y":160,"wires":[["47aa9b50.b6825c"]]},{"id":"a8262434.cf7498","type":"switch","z":"854a9f95.2f9f7","name":"onRUNNING","property":"payload.status","propertyType":"msg","rules":[{"t":"eq","v":"RUNNING","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":690,"y":140,"wires":[["1bb1822b.773f76"]]},{"id":"1bb1822b.773f76","type":"delay","z":"854a9f95.2f9f7","name":"delay 5s","pauseType":"delay","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"outputs":1,"x":880,"y":320,"wires":[["983f9b74.7bcbd8"]]},{"id":"983f9b74.7bcbd8","type":"change","z":"854a9f95.2f9f7","name":"set msg.topic to stop","rules":[{"t":"set","p":"topic","pt":"msg","to":"stop","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":220,"y":260,"wires":[["47aa9b50.b6825c"]]},{"id":"a0a931fb.60bc4","type":"comment","z":"854a9f95.2f9f7","name":"sending topic \"run\" will trigger the machine which is stopped 5 seconds later","info":"","x":390,"y":100,"wires":[]},{"id":"95e4cda8ef7f4206","type":"debug","z":"854a9f95.2f9f7","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":890,"y":160,"wires":[]}]
--------------------------------------------------------------------------------
/examples/MinimalStateMachineFlow.json:
--------------------------------------------------------------------------------
1 | [{"id":"70d24837.fa7bb8","type":"finite-state-machine","z":"9b6215d1.9ceba8","name":"","fsmDefinition":"{\"state\":{\"status\":\"IDLE\"},\"transitions\":{\"IDLE\":{\"toggleState\":\"RUNNING\"},\"RUNNING\":{\"toggleState\":\"IDLE\"}}}","sendInitialState":false,"showTransitionErrors":true,"x":480,"y":240,"wires":[["236d8d.5ef6da74"]]},{"id":"236d8d.5ef6da74","type":"debug","z":"9b6215d1.9ceba8","name":"","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","x":720,"y":240,"wires":[]},{"id":"701f2b41.e4ecb4","type":"inject","z":"9b6215d1.9ceba8","name":"","topic":"toggleState","payload":"","payloadType":"str","repeat":"","crontab":"","once":false,"onceDelay":0.1,"x":250,"y":240,"wires":[["70d24837.fa7bb8"]]},{"id":"a75e8c2f.0b2378","type":"comment","z":"9b6215d1.9ceba8","name":"sending topic \"toggleState\" toggles between the two states","info":"","x":420,"y":180,"wires":[]}]
2 |
--------------------------------------------------------------------------------
/examples/SimpleStateMachineFlow.json:
--------------------------------------------------------------------------------
1 | [{"id":"70d12b2e.625c9c","type":"tab","label":"Simple state machine with data object","disabled":false,"info":""},{"id":"a0edf135.e14588","type":"finite-state-machine","z":"70d12b2e.625c9c","name":"","fsmDefinition":"{\"state\":{\"status\":\"IDLE\",\"data\":{\"x\":99}},\"transitions\":{\"IDLE\":{\"run\":{\"status\":\"RUNNING\",\"data\":{\"x\":42}}},\"RUNNING\":{\"stop\":{\"status\":\"IDLE\",\"data\":{\"x\":0}},\"set\":\"RUNNING\"}}}","sendInitialState":false,"sendStateWithoutChange":false,"showTransitionErrors":true,"x":600,"y":260,"wires":[["cb19a198.11647","b1877454.ee2168"]]},{"id":"9befd239.ea94b","type":"inject","z":"70d12b2e.625c9c","name":"reset","props":[{"p":"control","v":"reset","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","x":210,"y":120,"wires":[["a0edf135.e14588"]]},{"id":"456671ae.4a071","type":"comment","z":"70d12b2e.625c9c","name":"sending topic \"reset\" will set the state machine to its initial state","info":"","x":380,"y":80,"wires":[]},{"id":"bcc44c08.7c6d08","type":"inject","z":"70d12b2e.625c9c","name":"","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"run","payload":"","payloadType":"str","x":210,"y":240,"wires":[["a0edf135.e14588"]]},{"id":"201e40f8.865b2","type":"inject","z":"70d12b2e.625c9c","name":"data = {\"x\" : 2, \"name\" : \"peter\"}","props":[{"p":"data","v":"{\"x\" : 2, \"name\" : \"peter\"}","vt":"json"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"set","x":290,"y":400,"wires":[["a0edf135.e14588"]]},{"id":"61fd9c83.dd95fc","type":"inject","z":"70d12b2e.625c9c","name":"data = {\"y\" : 3}","props":[{"p":"data","v":"{\"y\" : 3}","vt":"json"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"set","x":240,"y":440,"wires":[["a0edf135.e14588"]]},{"id":"c6add2b4.c41ee","type":"comment","z":"70d12b2e.625c9c","name":"any other topic will trigger a transition","info":"","x":290,"y":200,"wires":[]},{"id":"a4c93b9f.44d3f","type":"inject","z":"70d12b2e.625c9c","name":"","repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"stop","payload":"","payloadType":"str","x":210,"y":280,"wires":[["a0edf135.e14588"]]},{"id":"3b5f9a43.cdf68e","type":"comment","z":"70d12b2e.625c9c","name":"by sending a JSON object as payload you can add data to the state","info":"","x":380,"y":360,"wires":[]},{"id":"cb19a198.11647","type":"debug","z":"70d12b2e.625c9c","name":"","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload.status","targetType":"msg","statusVal":"payload.status","statusType":"auto","x":880,"y":220,"wires":[]},{"id":"b1877454.ee2168","type":"debug","z":"70d12b2e.625c9c","name":"","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload.data","targetType":"msg","x":880,"y":300,"wires":[]}]
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const gulp = require('gulp');
2 | const path = require('path');
3 | const htmlmin = require('gulp-htmlmin');
4 | var inlinesource = require('gulp-inline-source');
5 | var minifyInline = require('gulp-minify-inline');
6 | const minify = require('gulp-minify');
7 |
8 | const paths = {
9 | src: path.join(__dirname, 'src'),
10 | build: path.join(__dirname, 'dist'),
11 | nodeHtml: path.join(__dirname, 'src/finite-state-machine-node.html'),
12 | copyFiles: [path.join(__dirname, 'src/statemachine.js'), path.join(__dirname, 'src/finite-state-machine-node.js')]
13 | };
14 |
15 | function buildHtml() {
16 | return gulp.src(paths.nodeHtml)
17 | .pipe(inlinesource({compress: 'true'}))
18 | .pipe(htmlmin({ collapseWhitespace: false, minifyCSS: true, minifyJS: true }))
19 | .pipe(minifyInline())
20 | .pipe(gulp.dest(paths.build));
21 | }
22 |
23 | function buildJs() {
24 | return gulp.src(paths.copyFiles)
25 | .pipe(minify({
26 | noSource: true,
27 | ext: {
28 | min: '.js'
29 | }
30 | }))
31 | .pipe(gulp.dest(paths.build));
32 | }
33 |
34 |
35 | gulp.task('build', gulp.series(buildJs, buildHtml));
36 |
37 |
--------------------------------------------------------------------------------
/images/change-data-object.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/change-data-object.png
--------------------------------------------------------------------------------
/images/flow-feedback.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/flow-feedback.png
--------------------------------------------------------------------------------
/images/flow-minimal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/flow-minimal.png
--------------------------------------------------------------------------------
/images/flow-simple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/flow-simple.png
--------------------------------------------------------------------------------
/images/flow-with-rbe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/flow-with-rbe.png
--------------------------------------------------------------------------------
/images/flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/flow.png
--------------------------------------------------------------------------------
/images/node-appearance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/node-appearance.png
--------------------------------------------------------------------------------
/images/node-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/node-settings.png
--------------------------------------------------------------------------------
/images/rbe-configuration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lutzer/node-red-contrib-finite-statemachine/27826d1e6bb81ff1e0973c753b93d8e848150e1f/images/rbe-configuration.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-red-contrib-finite-statemachine",
3 | "version": "2.1.3",
4 | "description": "A finite state machine implementation for node red.",
5 | "author": "Lutz Reiter",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/lutzer/node-red-contrib-finite-statemachine.git"
10 | },
11 | "scripts": {
12 | "test": "mocha tests/*.spec.js",
13 | "build": "gulp build"
14 | },
15 | "keywords": [
16 | "node-red",
17 | "fsm",
18 | "statemachine",
19 | "state-machine",
20 | "finite-state-machine"
21 | ],
22 | "node-red": {
23 | "nodes": {
24 | "finite-state-machine": "dist/finite-state-machine-node.js"
25 | }
26 | },
27 | "dependencies": {
28 | "immutable": "^3.8.2",
29 | "lodash": "^4.17.15",
30 | "rxjs": "^6.5.5"
31 | },
32 | "devDependencies": {
33 | "gulp": "^4.0.2",
34 | "gulp-htmlmin": "^5.0.1",
35 | "gulp-inline-source": "^4.0.0",
36 | "gulp-minify": "^3.1.0",
37 | "gulp-minify-inline": "^1.1.0",
38 | "merge-stream": "^1.0.1",
39 | "node-red": "^2.1.3",
40 | "node-red-node-test-helper": "^0.2.7"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/finite-state-machine-node.html:
--------------------------------------------------------------------------------
1 |
46 |
47 |
134 |
135 |
165 |
166 |
244 |
--------------------------------------------------------------------------------
/src/finite-state-machine-node.js:
--------------------------------------------------------------------------------
1 | const { StateMachine } = require('./statemachine.js');
2 | const { distinctUntilChanged, tap } = require('rxjs/operators');
3 | const _ = require('lodash');
4 |
5 | const FSM_NAME = 'finite-state-machine';
6 |
7 | module.exports = function (RED) {
8 | function StateMachineNode (config) {
9 | RED.nodes.createNode(this, config);
10 |
11 | var node = this;
12 | var nodeContext = this.context();
13 |
14 |
15 | // create new state machine
16 | try {
17 | nodeContext.machine = new StateMachine(JSON.parse(config.fsmDefinition));
18 | setNodeStatus(nodeContext.machine.getState().status)
19 | } catch (err) {
20 | node.status({fill: 'red', shape: 'ring', text: 'no valid definitions'});
21 | node.warn('no valid definitions')
22 | return;
23 | }
24 |
25 | // react to all changes
26 | nodeContext.allChangeListener = nodeContext.machine.observable.pipe(
27 | config.sendStateWithoutChange ? tap() : distinctUntilChanged( (curr,prev) => _.isEqual(curr.state, prev.state)) )
28 | .subscribe(({state, trigger}) => {
29 | setNodeStatus(state.status)
30 | sendOutput({
31 | topic: 'state',
32 | payload: state,
33 | trigger: _.omit(trigger, "trigger") // prevent recursive adding of trigger object
34 | });
35 | });
36 |
37 | // send initial state after 100ms
38 | if (config.sendInitialState) {
39 | setTimeout( () => {
40 | sendOutput({
41 | topic: 'state',
42 | payload: nodeContext.machine.getState()
43 | });
44 | },100);
45 | }
46 |
47 | node.on('input', function (msg) {
48 | if (msg.control === 'reset') {
49 | nodeContext.machine.reset();
50 | return;
51 | } else if (msg.control === 'sync') {
52 | try {
53 | nodeContext.machine.setState(msg.payload);
54 | } catch (err) {
55 | node.error({ code: err.code, msg: err.message}, msg);
56 | }
57 | return;
58 | } else if (msg.control === 'query') {
59 | nodeContext.machine.queryState()
60 | }
61 |
62 | var action = {
63 | type: msg.topic,
64 | data : _.isObject(msg.data) ? msg.data : {}
65 | }
66 | try {
67 | nodeContext.machine.triggerAction(action, msg);
68 | } catch (err) {
69 | if (config.showTransitionErrors) {
70 | node.error({ code: err.code, msg: err.message}, msg);
71 | }
72 | }
73 | });
74 |
75 | node.on('close', function () {
76 | nodeContext.stateChangeListener.unsubscribe();
77 | });
78 |
79 | function sendOutput(state = null) {
80 | node.send([state])
81 | }
82 |
83 | function setNodeStatus(state) {
84 | node.status({fill: 'green', shape: 'dot', text: 'state: ' + state});
85 | }
86 | }
87 | RED.nodes.registerType(FSM_NAME, StateMachineNode);
88 | };
89 |
--------------------------------------------------------------------------------
/src/stateMachineGraph.js:
--------------------------------------------------------------------------------
1 | /* global d3 */
2 | /* exported stateMachineGraph */
3 |
4 | function parseFsmDefinition(definition) {
5 | Object.keys(definition.transitions).forEach((state) => {
6 | return Object.keys(definition.transitions[state]).forEach( (transition) => {
7 | if (typeof definition.transitions[state][transition] == 'object')
8 | definition.transitions[state][transition] = definition.transitions[state][transition].status
9 | })
10 | })
11 | return definition
12 | }
13 |
14 | // VECTOR OPERATIONS
15 | function vectorAdd (v1, v2) {
16 | return { x: v1.x + v2.x, y: v1.y + v2.y };
17 | }
18 |
19 | function vectorMultiply (v1, s) {
20 | return { x: v1.x * s, y: v1.y * s };
21 | }
22 |
23 | function vectorLength (v1) {
24 | return Math.sqrt(v1.x * v1.x + v1.y * v1.y);
25 | }
26 |
27 | function vectorSubtract (v1, v2) {
28 | return vectorAdd(v1, vectorMultiply(v2, -1));
29 | }
30 |
31 | function vectorNormalize (v1) {
32 | var length = vectorLength(v1)
33 | if (length == 0)
34 | return v1;
35 | else
36 | return vectorMultiply(v1, 1 / length);
37 | }
38 |
39 | /* draws a graphical visualisation of the statemachine with d3 */
40 | var stateMachineGraph = function (definition, canvasWidth, canvasHeight) {
41 |
42 | var canvas = d3.select('#fsm-graph');
43 |
44 | // clear canvas
45 | canvas.selectAll('svg').remove();
46 |
47 | // create svg canvas
48 | var svg = canvas.append('svg')
49 | .attr('height', canvasHeight)
50 | .attr('width', canvasWidth)
51 | .call(d3.behavior.zoom().on("zoom", onZoom))
52 | .append("g")
53 |
54 | // get size
55 | var size = canvas.node().getBoundingClientRect();
56 | var width = size.width;
57 | var height = size.height;
58 |
59 | try {
60 | definition = parseFsmDefinition(definition)
61 | } catch (e) {
62 | return
63 | }
64 |
65 | var CIRCLE_RADIUS = 40;
66 |
67 | var lineFunction = d3.svg.line()
68 | .x(function (d) { return d.x ? d.x : 0; })
69 | .y(function (d) { return d.y ? d.y : 0; })
70 | .interpolate('basis');
71 |
72 | var states = Object.keys(definition.transitions);
73 |
74 | //convert definition to nodes and links
75 | var data = {
76 | nodes: states.map(function (state) {
77 | return { name: state, active: false };
78 | }),
79 | transitions: states.map(function (state, index) {
80 | var result = [];
81 | var transitionNames = Object.keys(definition.transitions[state]);
82 | transitionNames.forEach(function (val, i) {
83 | var target = states.findIndex(function (elem) {
84 | return elem === definition.transitions[state][val];
85 | });
86 | if (target > -1) {
87 | result.push({ source: index, target: target, name: val, offset: i });
88 | }
89 | });
90 | return result;
91 | }).flat()
92 | };
93 |
94 | // place nodes in circle
95 | data.nodes.forEach(function (value, index) {
96 | value.x = width / 2 + Math.cos(index / data.nodes.length * Math.PI * 2) * width / 4;
97 | value.y = height / 2 + Math.sin(index / data.nodes.length * Math.PI * 2) * height / 4;
98 | });
99 |
100 | data.transitions.forEach(function (value) {
101 | data.nodes[value.source].active = true;
102 | data.nodes[value.target].active = true;
103 | });
104 |
105 | var forceLinks = data.transitions;
106 |
107 | // define arrow heads
108 | var defs = svg.append('defs');
109 | defs.append('marker')
110 | .attr({
111 | 'id': 'arrow',
112 | 'viewBox': '0 -5 10 10',
113 | 'refX': 10,
114 | 'refY': 0,
115 | 'markerWidth': 10,
116 | 'markerHeight': 10,
117 | 'orient': 'auto'
118 | }).append('path')
119 | .attr('d', 'M0,-5L10,0L0,5')
120 | .attr('class', 'arrowHead');
121 |
122 | var force = d3.layout.force()
123 | .size([width, height])
124 | .nodes(data.nodes)
125 | .links(forceLinks)
126 | .linkDistance(CIRCLE_RADIUS * 5)
127 | .gravity(0.1)
128 | .charge(-2000);
129 |
130 | // ADD TRANSITION CURVES
131 | var curves = svg.selectAll('path.transition-curve').data(data.transitions);
132 |
133 | curves.enter()
134 | .append('path')
135 | .attr({
136 | class: 'transition-curve',
137 | 'marker-end': 'url(#arrow)'
138 | });
139 |
140 | curves.exit().remove();
141 |
142 | // ADD TRANSITION LABELS
143 | var lineLabels = svg.selectAll('text.transition-labels').data(data.transitions);
144 |
145 | lineLabels.enter()
146 | .append('text')
147 | .attr({
148 | 'text-anchor': 'middle',
149 | class: 'transition-labels'
150 | })
151 | .text(function (d) { return d.name; });
152 |
153 | // ADD NODES
154 | var nodes = svg.selectAll('g.state-element').data(data.nodes);
155 |
156 | var nodeDrag = d3.behavior.drag()
157 | .on('dragstart', dragstart)
158 | .on('drag', dragmove)
159 | .on('dragend', dragend);
160 |
161 | var stateElement = nodes.enter()
162 | .append('g')
163 | .attr({
164 | class: 'state-element',
165 | transform: function (data) {
166 | return 'translate(' + data.x + ',' + data.y + ')';
167 | }
168 | });
169 |
170 | stateElement.append('circle')
171 | .attr({
172 | r: CIRCLE_RADIUS,
173 | class: function (d) { return d.active ? 'state-circle active' : 'state-circle'; }
174 | }).call(nodeDrag);
175 |
176 | stateElement.append('text')
177 | .attr({
178 | 'text-anchor': 'middle',
179 | y: 5,
180 | class: function (d) { return d.active ? 'state-circle-text active' : 'state-circle-text'; }
181 | })
182 | .text(function (data) {
183 | return data.name;
184 | });
185 | //
186 | nodes.exit().remove();
187 |
188 | force.on('tick', function () {
189 | update();
190 | });
191 |
192 | function update () {
193 | nodes.attr('transform', function (data) {
194 | return 'translate(' + data.x + ',' + data.y + ')';
195 | });
196 |
197 | curves.attr({
198 | d: function (d) {
199 |
200 | var targetIsSource = source = d.target.index == d.source.index
201 |
202 | var source = !targetIsSource ? d.source : { x : d.source.x + CIRCLE_RADIUS * .7, y: d.source.y };
203 | var target = !targetIsSource ? d.target : { x : d.target.x - CIRCLE_RADIUS * .7, y: d.target.y };
204 |
205 | // middle point
206 | var mp = vectorMultiply(vectorAdd(target, source), 0.5)
207 |
208 | // orthagonal
209 | var orth = vectorNormalize({
210 | x: (target.y - source.y),
211 | y: -(target.x - source.x)
212 | });
213 |
214 | var curveMp = !targetIsSource ? vectorAdd(mp, vectorMultiply(orth, CIRCLE_RADIUS + 10)) : vectorAdd(mp, vectorMultiply(orth, CIRCLE_RADIUS + 50));
215 |
216 | // intersect point with target circle
217 | var endpoint = vectorSubtract(target, vectorMultiply(vectorNormalize(vectorSubtract(target, curveMp)), CIRCLE_RADIUS));
218 |
219 | var coords = [ source, curveMp, endpoint ];
220 | return lineFunction(coords);
221 | }
222 | });
223 |
224 | lineLabels.attr('transform', function (d) {
225 |
226 | var targetIsSource = source = d.target.index == d.source.index
227 |
228 | var source = !targetIsSource ? d.source : { x : d.source.x + CIRCLE_RADIUS * .7, y: d.source.y };
229 | var target = !targetIsSource ? d.target : { x : d.target.x - CIRCLE_RADIUS * .7, y: d.target.y };
230 |
231 | var mp = vectorMultiply(vectorAdd(target, source), 0.5)
232 |
233 | var orth = vectorNormalize({
234 | x: (target.y - source.y),
235 | y: -(target.x - source.x)
236 | });
237 |
238 | var curveMp = !targetIsSource ? vectorAdd(mp, vectorMultiply(orth, CIRCLE_RADIUS + 10)) : vectorAdd(mp, vectorMultiply(orth, CIRCLE_RADIUS + 50));
239 |
240 | // rotation
241 | var sub = vectorSubtract(target, source);
242 |
243 | var angle = Math.atan2(sub.y, sub.x) * 180 / Math.PI;
244 |
245 | // dont have text upside down
246 | angle = angle > 90 ? angle - 180 : angle;
247 | angle = angle < -90 ? angle + 180 : angle;
248 |
249 | return 'translate(' + curveMp.x + ',' + curveMp.y + ') rotate(' + angle + ')';
250 | });
251 | }
252 |
253 | function onZoom () {
254 | svg.attr("transform", "translate(" + d3.event.translate + ")" + " scale(" + d3.event.scale + ")")
255 | }
256 |
257 | function dragmove (d) {
258 | d.x += d3.event.x;
259 | d.y += d3.event.y;
260 | update();
261 | force.resume();
262 | }
263 |
264 | function dragstart (d, i) {
265 | force.resume();
266 | }
267 |
268 | function dragend (d, i) {
269 | force.resume();
270 | }
271 | force.start();
272 | };
273 |
--------------------------------------------------------------------------------
/src/statemachine.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash');
2 | const { fromJS } = require('immutable');
3 | const { Subject } = require('rxjs');
4 | const { share } = require("rxjs/operators");
5 |
6 | class StatemachineError extends Error {
7 | constructor (msg, errorCode = '0') {
8 | super();
9 |
10 | // Custom debugging information
11 | this.message = msg;
12 | this.code = errorCode;
13 | }
14 | }
15 |
16 | function parseTransitionEntry(transition) {
17 | if (_.isString(transition))
18 | return { status : transition, data : {}}
19 | else if (_.isObject(transition))
20 | return { status : transition.status, data: transition.data || {} }
21 | }
22 |
23 | class StateMachine {
24 |
25 | //subject = null
26 |
27 | constructor (options) {
28 | this.subject = new Subject()
29 |
30 | if (!_.isObject(options.state)) {
31 | throw new StatemachineError('No inital state specified.', 1);
32 | }
33 |
34 | if (!_.isObject(options.transitions)) {
35 | throw new StatemachineError('No transitions specified.', 2);
36 | }
37 |
38 | if (!_.isString(options.state.status)) {
39 | throw new StatemachineError('state must contain a status field of type string', 3);
40 | }
41 |
42 | this._initialState = _.extend({ data: {} }, options.state);
43 |
44 | // validate transition table entries
45 | _.values(options.transitions).forEach( (stateTransitions) => {
46 | _.values(stateTransitions).forEach( (state) => {
47 | if (!_.isString(state) && !_.has(state, 'status'))
48 | throw new StatemachineError('Transition table has no status field: ' + JSON.stringify(state), 4);
49 | })
50 | })
51 |
52 | this._transitions = options.transitions;
53 |
54 | // set initial state
55 | this._state = fromJS( this._initialState );
56 |
57 | // publish initial state
58 | this.subject.next({ state: this._state.toJS() });
59 | }
60 |
61 | triggerAction (action, msg) {
62 | if (!_.isString(action.type)) {
63 | throw new StatemachineError('action must contain a type.', 14);
64 | }
65 |
66 | let possibleActions = this.getCurrentTransitions();
67 |
68 | if (_.isEmpty(possibleActions)) {
69 | throw new StatemachineError('no possible transitions to go to a new state.', 11);
70 | }
71 |
72 | if (!_.has(possibleActions, action.type)) {
73 | throw new StatemachineError('transition not possible from the current state.', 12);
74 | }
75 |
76 |
77 | let transition = parseTransitionEntry(possibleActions[action.type])
78 |
79 | // check if there are additional data arguments supplied
80 | let data = Object.assign({}, transition.data, _.isObject(action.data) ? action.data : {})
81 |
82 | // set new state
83 | this._state = this._state.set('status', transition.status).mergeDeep({ data: data });
84 |
85 | // publish new state
86 | this.subject.next({ state: this._state.toJS(), trigger: msg});
87 | }
88 |
89 | getCurrentTransitions () {
90 | let possibleActions = _.get(this._transitions, this._state.get('status'));
91 | return possibleActions || {};
92 | }
93 |
94 | getAllTransitions () {
95 | let transitions = _.reduce(_.values(this._transitions), (result, value) => {
96 | result.push(..._.keys(value));
97 | return result;
98 | }, []);
99 |
100 | return _.uniq(transitions);
101 | }
102 |
103 | reset () {
104 | this._state = fromJS(this._initialState);
105 | this.subject.next({ state: this._state.toJS(), action: 'reset' });
106 | }
107 |
108 |
109 | queryState () {
110 | this.subject.next({ state: this._state.toJS() });
111 | }
112 |
113 | getState () {
114 | return this._state.toJS();
115 | }
116 |
117 | setState (state) {
118 | if (!_.has(state, 'status'))
119 | throw new StatemachineError('the state needs to contain a status field', 4);
120 | if (!_.has(this._transitions, state.status))
121 | throw new StatemachineError('status does not exist in transition table', 5);
122 | this._state = fromJS(state)
123 | this.subject.next({ state: this._state.toJS() });
124 | }
125 |
126 | get observable () {
127 | return this.subject.pipe(share())
128 | }
129 | };
130 |
131 | exports.StateMachine = StateMachine;
132 |
--------------------------------------------------------------------------------
/tests/node.spec.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const helper = require("node-red-node-test-helper");
3 | const { fromEvent } = require('rxjs');
4 | const { promisify } = require("util");
5 | const _ = require('lodash');
6 |
7 | helper.init(require.resolve('node-red'));
8 |
9 | const load = function(helper, node, flow) {
10 | return new Promise( (resolve, reject) => {
11 | helper.load(node, flow, (err) => {
12 | if (err) {
13 | reject(err)
14 | return
15 | }
16 | resolve()
17 | })
18 | })
19 | }
20 |
21 | const listen = function(node, event) {
22 | return new Promise( (resolve) => {
23 | node.on(event, (msg) => {
24 | resolve(msg)
25 | })
26 | })
27 | }
28 |
29 | const timeout = function(timeout) {
30 | return new Promise((resolve) => {
31 | setTimeout(() => {
32 | resolve();
33 | }, timeout)
34 | })
35 | }
36 |
37 | describe('Node Tests', function () {
38 |
39 | beforeEach(function (done) {
40 | helper.startServer(done);
41 | })
42 |
43 | afterEach(function (done) {
44 | helper.unload();
45 | helper.stopServer(done);
46 | })
47 |
48 | const StateMachineNode = require('./../src/finite-state-machine-node');
49 |
50 | const definitions = {
51 | state: { status: 'STOPPED' },
52 | transitions: {
53 | 'STOPPED': {
54 | 'push': 'RUNNING',
55 | 'hit': 'BROKEN',
56 | 'set': 'STOPPED'
57 | },
58 | 'RUNNING': {
59 | 'pull': 'STOPPED',
60 | 'hit': 'BROKEN',
61 | 'restart' : 'RUNNING'
62 | },
63 | 'BROKEN': {
64 | 'fix': 'STOPPED'
65 | }
66 | }
67 | }
68 |
69 | it('should create statemachine node', async function() {
70 | var flow = [
71 | { id: 'statemachine', type: 'finite-state-machine', name: "test" }
72 | ];
73 |
74 | await load(helper, StateMachineNode, flow)
75 | var n = helper.getNode("statemachine");
76 | n.should.have.property('name', 'test');
77 | });
78 |
79 | it('should display green status on statemachine node', async function() {
80 |
81 | var flow = [
82 | {
83 | id: 'statemachine',
84 | type: 'finite-state-machine',
85 | fsmDefinition: JSON.stringify(definitions),
86 | sendInitialState:false,
87 | sendStateWithoutChange:false,
88 | showTransitionErrors:true
89 | }
90 | ];
91 |
92 | await load(helper, StateMachineNode, flow)
93 | var n = helper.getNode('statemachine');
94 | var status = n.status.args[0][0]
95 | assert(status.fill == 'green')
96 | });
97 |
98 | it('should display red status on wrong fsm', async function() {
99 |
100 | var flow = [
101 | {
102 | id: 'statemachine',
103 | type: 'finite-state-machine',
104 | fsmDefinition: '',
105 | sendInitialState:false,
106 | sendStateWithoutChange:false,
107 | showTransitionErrors:true
108 | }
109 | ];
110 |
111 | await load(helper, StateMachineNode, flow)
112 | var n = helper.getNode('statemachine');
113 | var status = n.status.args[0][0]
114 | assert(status.fill == 'red')
115 | });
116 |
117 | it('should trigger transition', async function() {
118 |
119 | var flow = [
120 | {
121 | id: 'statemachine',
122 | type: 'finite-state-machine',
123 | fsmDefinition: JSON.stringify(definitions),
124 | sendInitialState:false,
125 | sendStateWithoutChange:false,
126 | showTransitionErrors:true,
127 | wires: [['out']]
128 | },
129 | { id: 'out', type: 'helper'}
130 | ];
131 |
132 | await load(helper, StateMachineNode, flow)
133 | var n = helper.getNode('statemachine');
134 | var out = helper.getNode('out');
135 |
136 | n.receive({ topic: 'push'})
137 |
138 | let msg = await listen(out,'input')
139 | assert(msg.payload.status == 'RUNNING')
140 | });
141 |
142 | it('should reset to inital state on control:reset', async function() {
143 |
144 | var flow = [
145 | {
146 | id: 'statemachine',
147 | type: 'finite-state-machine',
148 | fsmDefinition: JSON.stringify(definitions),
149 | sendInitialState:false,
150 | sendStateWithoutChange:false,
151 | showTransitionErrors:true,
152 | wires: [['out']]
153 | },
154 | { id: 'out', type: 'helper'}
155 | ];
156 |
157 | await load(helper, StateMachineNode, flow)
158 | var n = helper.getNode('statemachine');
159 | var out = helper.getNode('out');
160 |
161 | n.receive({ topic: 'push'})
162 | let msg = await listen(out,'input')
163 | assert(msg.payload.status == 'RUNNING')
164 |
165 | n.receive({ control: 'reset'})
166 | msg = await listen(out,'input')
167 | assert(msg.payload.status == 'STOPPED')
168 | });
169 |
170 | it('msg should change data object of statemachine', async function() {
171 |
172 | var flow = [
173 | {
174 | id: 'statemachine',
175 | type: 'finite-state-machine',
176 | fsmDefinition: JSON.stringify(definitions),
177 | sendInitialState:false,
178 | sendStateWithoutChange:false,
179 | showTransitionErrors:true,
180 | wires: [['out']]
181 | },
182 | { id: 'out', type: 'helper'}
183 | ];
184 |
185 | await load(helper, StateMachineNode, flow)
186 | var n = helper.getNode('statemachine');
187 | var out = helper.getNode('out');
188 |
189 | n.receive({ topic: 'push', data: { x: 5} })
190 |
191 | let msg = await listen(out,'input')
192 | assert.equal(msg.payload.data.x, 5)
193 | });
194 |
195 | it('statemachine should generate output when data changed, but not state changed', async function() {
196 |
197 | var flow = [
198 | {
199 | id: 'statemachine',
200 | type: 'finite-state-machine',
201 | fsmDefinition: JSON.stringify(definitions),
202 | sendInitialState:false,
203 | sendStateWithoutChange:false,
204 | showTransitionErrors:true,
205 | wires: [['out']]
206 | },
207 | { id: 'out', type: 'helper'}
208 | ];
209 |
210 | await load(helper, StateMachineNode, flow)
211 | var n = helper.getNode('statemachine');
212 | var out = helper.getNode('out');
213 |
214 | n.receive({ topic: 'set', data: { x: 99} })
215 |
216 | let msg = await listen(out,'input')
217 | assert.equal(msg.payload.data.x, 99)
218 | });
219 |
220 | it('should not output when state is unchanged and sendStateWithoutChange:false', async function() {
221 |
222 | var flow = [
223 | {
224 | id: 'statemachine',
225 | type: 'finite-state-machine',
226 | fsmDefinition: JSON.stringify(definitions),
227 | sendInitialState:false,
228 | sendStateWithoutChange:false,
229 | showTransitionErrors:true,
230 | wires: [['out']]
231 | },
232 | { id: 'out', type: 'helper'}
233 | ];
234 |
235 | await load(helper, StateMachineNode, flow)
236 | var n = helper.getNode('statemachine');
237 | var out = helper.getNode('out');
238 |
239 | n.receive({ topic: 'push' })
240 | let msg = await listen(out,'input')
241 | assert(msg.payload.status == 'RUNNING')
242 |
243 | n.receive({ topic: 'restart' })
244 | msg = await Promise.race([listen(out,'input'), timeout(200)])
245 | assert(msg === undefined)
246 | });
247 |
248 | it('should send output when state is unchanged and sendStateWithoutChange:true', async function() {
249 |
250 | var flow = [
251 | {
252 | id: 'statemachine',
253 | type: 'finite-state-machine',
254 | fsmDefinition: JSON.stringify(definitions),
255 | sendInitialState:false,
256 | sendStateWithoutChange:true,
257 | showTransitionErrors:true,
258 | wires: [['out']]
259 | },
260 | { id: 'out', type: 'helper'}
261 | ];
262 |
263 | await load(helper, StateMachineNode, flow)
264 | var n = helper.getNode('statemachine');
265 | var out = helper.getNode('out');
266 |
267 | n.receive({ topic: 'push' })
268 | let msg = await listen(out,'input')
269 | assert(msg.payload.status == 'RUNNING')
270 |
271 | n.receive({ topic: 'restart' })
272 | msg = await Promise.race([listen(out,'input'), timeout(200)])
273 | assert(msg.payload.status == 'RUNNING')
274 | });
275 |
276 | it('should send initial state when sendInitialState:true', async function() {
277 |
278 | var flow = [
279 | {
280 | id: 'statemachine',
281 | type: 'finite-state-machine',
282 | fsmDefinition: JSON.stringify(definitions),
283 | sendInitialState:true,
284 | sendStateWithoutChange:true,
285 | showTransitionErrors:true,
286 | wires: [['out']]
287 | },
288 | { id: 'out', type: 'helper'}
289 | ];
290 |
291 | await load(helper, StateMachineNode, flow)
292 | var n = helper.getNode('statemachine');
293 | var out = helper.getNode('out');
294 |
295 | let msg = await listen(out,'input')
296 | assert(msg.payload.status == 'STOPPED')
297 | });
298 |
299 | it('should not send initial state when sendInitialState:false', async function() {
300 |
301 | var flow = [
302 | {
303 | id: 'statemachine',
304 | type: 'finite-state-machine',
305 | fsmDefinition: JSON.stringify(definitions),
306 | sendInitialState:false,
307 | sendStateWithoutChange:true,
308 | showTransitionErrors:true,
309 | wires: [['out']]
310 | },
311 | { id: 'out', type: 'helper'}
312 | ];
313 |
314 | await load(helper, StateMachineNode, flow)
315 | var n = helper.getNode('statemachine');
316 | var out = helper.getNode('out');
317 |
318 | msg = await Promise.race([listen(out,'input'), timeout(200)])
319 | assert(msg === undefined)
320 | });
321 |
322 | it('should be able to sync the state on control:sync', async function() {
323 |
324 | var flow = [
325 | {
326 | id: 'statemachine',
327 | type: 'finite-state-machine',
328 | fsmDefinition: JSON.stringify(definitions),
329 | sendInitialState:false,
330 | sendStateWithoutChange:false,
331 | showTransitionErrors:true,
332 | wires: [['out']]
333 | },
334 | { id: 'out', type: 'helper'}
335 | ];
336 |
337 | await load(helper, StateMachineNode, flow)
338 | var n = helper.getNode('statemachine');
339 | var out = helper.getNode('out');
340 |
341 | n.receive({ control: 'sync', payload: { status: 'RUNNING'} })
342 |
343 | let msg = await listen(out,'input')
344 | assert(msg.payload.status == 'RUNNING')
345 | });
346 |
347 | it('should be able to query the state on control:query', async function() {
348 |
349 | var flow = [
350 | {
351 | id: 'statemachine',
352 | type: 'finite-state-machine',
353 | fsmDefinition: JSON.stringify(definitions),
354 | sendInitialState:false,
355 | sendStateWithoutChange:false,
356 | showTransitionErrors:true,
357 | wires: [['out']]
358 | },
359 | { id: 'out', type: 'helper'}
360 | ];
361 |
362 | await load(helper, StateMachineNode, flow)
363 | var n = helper.getNode('statemachine');
364 | var out = helper.getNode('out');
365 |
366 | n.receive({ control: 'query' })
367 |
368 | let msg = await listen(out,'input')
369 | assert(msg.payload.status == "STOPPED" )
370 | });
371 |
372 | it('should pass original message through when triggering a transition', async function() {
373 |
374 | var flow = [
375 | {
376 | id: 'statemachine',
377 | type: 'finite-state-machine',
378 | fsmDefinition: JSON.stringify(definitions),
379 | sendInitialState:false,
380 | sendStateWithoutChange:false,
381 | showTransitionErrors:true,
382 | wires: [['out']]
383 | },
384 | { id: 'out', type: 'helper'}
385 | ];
386 |
387 | await load(helper, StateMachineNode, flow)
388 | var n = helper.getNode('statemachine');
389 | var out = helper.getNode('out');
390 |
391 | const randVal = Math.random()
392 | n.receive({ topic: 'push', someValue: randVal })
393 |
394 | let msg = await listen(out,'input')
395 |
396 | assert(msg.payload.status == "RUNNING")
397 | assert(msg.trigger.someValue == randVal)
398 | });
399 |
400 | it('should accept wrong transition and dont do anything', async function() {
401 |
402 | var flow = [
403 | {
404 | id: 'statemachine',
405 | type: 'finite-state-machine',
406 | fsmDefinition: JSON.stringify(definitions),
407 | sendInitialState:false,
408 | sendStateWithoutChange:false,
409 | showTransitionErrors:true,
410 | wires: [['out']]
411 | },
412 | { id: 'out', type: 'helper'}
413 | ];
414 |
415 | await load(helper, StateMachineNode, flow)
416 | var n = helper.getNode('statemachine');
417 | var out = helper.getNode('out');
418 |
419 | const randVal = Math.random()
420 | n.receive({ topic: 'invalidtransition'})
421 |
422 | msg = await Promise.race([listen(out,'input'), timeout(200)])
423 | assert(msg === undefined)
424 | });
425 |
426 | it('state object should not grow recursively when looped', async function() {
427 |
428 | var flow = [
429 | {
430 | id: 'statemachine',
431 | type: 'finite-state-machine',
432 | fsmDefinition: JSON.stringify(definitions),
433 | sendInitialState:false,
434 | sendStateWithoutChange:false,
435 | showTransitionErrors:true,
436 | wires: [['out']]
437 | },
438 | { id: 'out', type: 'helper'}
439 | ];
440 |
441 | await load(helper, StateMachineNode, flow)
442 | var n = helper.getNode('statemachine')
443 | var out = helper.getNode('out')
444 |
445 | n.receive({ topic: 'push'})
446 | let msg = await listen(out,'input')
447 |
448 | // input messages recursivly and check that length is not changing
449 | let lastLength = null
450 | for (i=0;i<10;i++) {
451 | msg.topic = i % 2 == 0 ? "pull" : "push"
452 | n.receive(msg)
453 | msg = await listen(out,'input')
454 |
455 | let l = JSON.stringify(msg).length;
456 | if (lastLength) {
457 | assert.equal(lastLength,l)
458 | lastLength = l
459 | }
460 | }
461 | });
462 |
463 | })
--------------------------------------------------------------------------------
/tests/statemachine.spec.js:
--------------------------------------------------------------------------------
1 | /* global describe it */
2 |
3 | const _ = require('lodash');
4 | const assert = require('assert');
5 | const { StateMachine } = require('../src/statemachine.js');
6 |
7 | const createTestMachine = () => {
8 | return new StateMachine({
9 | state: { status: 'STOPPED' },
10 | transitions: {
11 | 'STOPPED': {
12 | 'push': 'RUNNING',
13 | 'hit': 'BROKEN'
14 | },
15 | 'RUNNING': {
16 | 'pull': 'STOPPED',
17 | 'hit': 'BROKEN'
18 | },
19 | 'BROKEN': {
20 | 'fix': 'STOPPED'
21 | }
22 | }
23 | });
24 | };
25 |
26 | const createDataTestMachine = () => {
27 | return new StateMachine({
28 | state: { status: 'STOPPED', data : { val : 'init'} },
29 | transitions: {
30 | 'STOPPED': {
31 | 'push': { status: 'RUNNING' },
32 | 'hit': { status :'BROKEN', data: { val: 'broken', reason: 'hit' } }
33 | },
34 | 'RUNNING': {
35 | 'pull': 'STOPPED',
36 | 'hit': 'BROKEN'
37 | },
38 | 'BROKEN': {
39 | 'fix': { status: 'STOPPED', data : { val : undefined, reason: undefined } }
40 | }
41 | }
42 | });
43 | }
44 |
45 | describe('Statemachine Tests', function () {
46 | it('should be able to create a statemachine', () => {
47 | let fsm = new StateMachine({
48 | state: { status: 'TEST' },
49 | transitions: {}
50 | });
51 | assert(fsm);
52 | });
53 |
54 | it('should be able to create a statemachine and get the possible transitions', () => {
55 | let fsm = new StateMachine({
56 | state: { status: 'INITIAL'},
57 | transitions: {
58 | 'INITIAL': {
59 | 'push': 'RUNNING',
60 | 'hit': 'BROKEN'
61 | }
62 | }
63 | });
64 |
65 | let transitions = fsm.getCurrentTransitions();
66 | assert(_.isEqual(_.keys(transitions), ['push', 'hit']));
67 | });
68 |
69 | it('should throw an error with the wrong transition table', () => {
70 | assert.throws( () => {
71 | new StateMachine({
72 | state: { status: 'INITIAL'},
73 | transitions: {
74 | 'INITIAL': {
75 | 'push': 2,
76 | 'hit': 'BROKEN'
77 | }
78 | }
79 | });
80 | }, Error)
81 |
82 | assert.throws( () => {
83 | new StateMachine({
84 | state: { status: 'INITIAL'},
85 | transitions: {
86 | 'INITIAL': {
87 | 'push': { invalid : 0 },
88 | 'hit': 'BROKEN'
89 | }
90 | }
91 | });
92 | }, Error)
93 | });
94 |
95 | it('should be able to transition to a new state', () => {
96 | let fsm = createTestMachine();
97 |
98 | fsm.triggerAction({ type: 'push' });
99 |
100 | let newState = fsm.getState();
101 |
102 | assert(_.isEqual(newState, {status: 'RUNNING', data: {} }));
103 | });
104 |
105 | it('should be able to transition to a new state and then reset', () => {
106 | let fsm = createTestMachine();
107 |
108 | fsm.triggerAction({ type: 'push' });
109 |
110 | assert.equal(fsm.getState().status, 'RUNNING');
111 |
112 | fsm.reset();
113 |
114 | assert.equal(fsm.getState().status, 'STOPPED');
115 | });
116 |
117 | it('should be able to add aditoninal data to state', () => {
118 | let fsm = createTestMachine();
119 |
120 | fsm.triggerAction({ type: 'push', data: { x: 5 } });
121 |
122 | let newState = fsm.getState();
123 | assert.equal(newState.data.x, 5);
124 | });
125 |
126 | it('should be able to add aditoninal data to state and then update it', () => {
127 | let fsm = createTestMachine();
128 |
129 | fsm.triggerAction({ type: 'push', data: { x: 1, y: 1 } });
130 |
131 | fsm.triggerAction({ type: 'pull', data: { x: 2, z: 2 } });
132 |
133 | let newState = fsm.getState();
134 | assert( _.isEqual(newState.data, { x: 2, y: 1, z : 2}));
135 | });
136 |
137 | it('should throw an error if transition is not defined', () => {
138 | let fsm = createTestMachine();
139 |
140 | try {
141 | fsm.triggerAction({ type: 'test' });
142 | assert.fail();
143 | } catch (e) {
144 | assert.equal(e.code, 12);
145 | }
146 | });
147 |
148 | it('should throw an error if transition is not possible', () => {
149 | let fsm = createTestMachine();
150 |
151 | try {
152 | fsm.triggerAction({ type: 'pull' });
153 | assert.fail();
154 | } catch (e) {
155 | assert.equal(e.code, 12);
156 | }
157 | });
158 |
159 | it('should be able to subscribe to state change', (done) => {
160 | let fsm = createTestMachine();
161 |
162 | fsm.observable.subscribe( ({state}) => {
163 | assert.equal(state.status, "RUNNING");
164 | done();
165 | })
166 |
167 | fsm.triggerAction({ type: 'push' });
168 | })
169 |
170 | it('should set data as defined in the transition table', () => {
171 | let fsm = createDataTestMachine();
172 |
173 | fsm.triggerAction({ type: 'push' });
174 | assert.equal(fsm.getState().data.val, 'init');
175 | assert.equal(fsm.getState().status, 'RUNNING');
176 |
177 | fsm.triggerAction({ type: 'pull'})
178 | assert.equal(fsm.getState().status, 'STOPPED');
179 |
180 | fsm.triggerAction({ type: 'hit'})
181 | assert.equal(fsm.getState().data.val, 'broken');
182 | assert.equal(fsm.getState().data.reason, 'hit');
183 | assert.equal(fsm.getState().status, 'BROKEN');
184 |
185 | fsm.triggerAction({ type: 'fix'})
186 | assert.equal(fsm.getState().data.val, undefined);
187 | assert.equal(fsm.getState().data.reason, undefined);
188 | assert.equal(fsm.getState().status, 'STOPPED');
189 |
190 | });
191 |
192 | it('should merge action data with transition table data', () => {
193 | let fsm = createDataTestMachine();
194 |
195 | fsm.triggerAction({ type: 'push', data : { test : 'pushData' } });
196 | assert.equal(fsm.getState().data.val, 'init');
197 | assert.equal(fsm.getState().data.test, 'pushData');
198 | assert.equal(fsm.getState().status, 'RUNNING');
199 |
200 | fsm.triggerAction({ type: 'pull'})
201 | assert.equal(fsm.getState().status, 'STOPPED');
202 |
203 | fsm.triggerAction({ type: 'hit', data : { val : 'hitData' }})
204 | assert.equal(fsm.getState().data.val, 'hitData');
205 | assert.equal(fsm.getState().data.reason, 'hit');
206 | assert.equal(fsm.getState().status, 'BROKEN');
207 |
208 | });
209 |
210 | it('setState() should be able to set the state manualy', () => {
211 | let fsm = createDataTestMachine();
212 |
213 | fsm.setState({ status: 'RUNNING'})
214 | assert.equal(fsm.getState().status, 'RUNNING')
215 | });
216 |
217 | it('setState() should throw an error if set to a not existing state', () => {
218 | let fsm = createTestMachine();
219 |
220 | try {
221 | fsm.setState({ status: 'WALKING'})
222 | assert.fail();
223 | } catch (e) {
224 | assert.equal(e.code, 5);
225 | }
226 | });
227 |
228 | it('setState() should throw an error if there is no status property', () => {
229 | let fsm = createTestMachine();
230 |
231 | try {
232 | fsm.setState({ foo: 'WALKING'})
233 | assert.fail();
234 | } catch (e) {
235 | assert.equal(e.code, 4);
236 | }
237 | });
238 | });
239 |
--------------------------------------------------------------------------------