├── .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 | ![node-settings](images/node-settings.png) 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 | ![flow-minimal](images/flow-minimal.png) 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 | ![flow-simple](images/flow.png) 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 | ![flow-with-feeback](images/flow-feedback.png) 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 | ![change-data-object](images/change-data-object.png) 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 | ![compatibility mode](images/flow-with-rbe.png) 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 | ![compatibility mode](images/rbe-configuration.png) 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 | ![node-appearance](images/node-appearance.png) 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 | ![node-settings](images/node-settings.png) 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 | ![flow-minimal](images/flow-minimal.png) 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 | --------------------------------------------------------------------------------