node-red-contrib-finite-statemachine
Version:
A finite state machine implementation for node red.
114 lines (106 loc) • 10.4 kB
HTML
<style>#fsm-graph svg{background-color:#efefef;border:1px solid #ccc}#fsm-graph .state-circle{fill:#fff;stroke:#000;fill-opacity:.2;stroke-opacity:0;cursor:pointer}#fsm-graph .state-circle.active{fill-opacity:1;stroke-opacity:1}#fsm-graph .state-circle-text{fill-opacity:.2;cursor:pointer;pointer-events:none}#fsm-graph .state-circle-text.active{fill-opacity:1}#fsm-graph .transition-line{stroke:#000}#fsm-graph .transition-curve{stroke:#000;fill:none}#fsm-graph .arrow{stroke-width:5;stroke:#000;stroke-dasharray:5,5}</style>
<script type="text/javascript">function parseFsmDefinition(t){return Object.keys(t.transitions).forEach(e=>Object.keys(t.transitions[e]).forEach(r=>{"object"==typeof t.transitions[e][r]&&(t.transitions[e][r]=t.transitions[e][r].status)})),t}function vectorAdd(t,e){return{x:t.x+e.x,y:t.y+e.y}}function vectorMultiply(t,e){return{x:t.x*e,y:t.y*e}}function vectorLength(t){return Math.sqrt(t.x*t.x+t.y*t.y)}function vectorSubtract(t,e){return vectorAdd(t,vectorMultiply(e,-1))}function vectorNormalize(t){var e=vectorLength(t);return 0==e?t:vectorMultiply(t,1/e)}var stateMachineGraph=function(t,e,r){var n=d3.select("#fsm-graph");n.selectAll("svg").remove();var a=n.append("svg").attr("height",r).attr("width",e).call(d3.behavior.zoom().on("zoom",function(){a.attr("transform","translate("+d3.event.translate+") scale("+d3.event.scale+")")})).append("g"),o=n.node().getBoundingClientRect(),i=o.width,c=o.height;try{t=parseFsmDefinition(t)}catch(t){return}var s=40,u=d3.svg.line().x(function(t){return t.x?t.x:0}).y(function(t){return t.y?t.y:0}).interpolate("basis"),l=Object.keys(t.transitions),d={nodes:l.map(function(t){return{name:t,active:!1}}),transitions:l.map(function(e,r){var n=[];return Object.keys(t.transitions[e]).forEach(function(a,o){var i=l.findIndex(function(r){return r===t.transitions[e][a]});i>-1&&n.push({source:r,target:i,name:a,offset:o})}),n}).flat()};d.nodes.forEach(function(t,e){t.x=i/2+Math.cos(e/d.nodes.length*Math.PI*2)*i/4,t.y=c/2+Math.sin(e/d.nodes.length*Math.PI*2)*c/4}),d.transitions.forEach(function(t){d.nodes[t.source].active=!0,d.nodes[t.target].active=!0});var v=d.transitions;a.append("defs").append("marker").attr({id:"arrow",viewBox:"0 -5 10 10",refX:10,refY:0,markerWidth:10,markerHeight:10,orient:"auto"}).append("path").attr("d","M0,-5L10,0L0,5").attr("class","arrowHead");var f=d3.layout.force().size([i,c]).nodes(d.nodes).links(v).linkDistance(5*s).gravity(.1).charge(-2e3),x=a.selectAll("path.transition-curve").data(d.transitions);x.enter().append("path").attr({class:"transition-curve","marker-end":"url(#arrow)"}),x.exit().remove();var y=a.selectAll("text.transition-labels").data(d.transitions);y.enter().append("text").attr({"text-anchor":"middle",class:"transition-labels"}).text(function(t){return t.name});var p=a.selectAll("g.state-element").data(d.nodes),h=d3.behavior.drag().on("dragstart",function(t,e){f.resume()}).on("drag",function(t){t.x+=d3.event.x,t.y+=d3.event.y,g(),f.resume()}).on("dragend",function(t,e){f.resume()}),m=p.enter().append("g").attr({class:"state-element",transform:function(t){return"translate("+t.x+","+t.y+")"}});function g(){p.attr("transform",function(t){return"translate("+t.x+","+t.y+")"}),x.attr({d:function(t){var e=t.target.index==t.source.index,r=e?{x:t.source.x+.7*s,y:t.source.y}:t.source,n=e?{x:t.target.x-.7*s,y:t.target.y}:t.target,a=vectorMultiply(vectorAdd(n,r),.5),o=vectorAdd(a,vectorMultiply(vectorNormalize({x:n.y-r.y,y:-(n.x-r.x)}),e?s+50:s+10)),i=vectorSubtract(n,vectorMultiply(vectorNormalize(vectorSubtract(n,o)),s));return u([r,o,i])}}),y.attr("transform",function(t){var e=t.target.index==t.source.index,r=e?{x:t.source.x+.7*s,y:t.source.y}:t.source,n=e?{x:t.target.x-.7*s,y:t.target.y}:t.target,a=vectorMultiply(vectorAdd(n,r),.5),o=vectorAdd(a,vectorMultiply(vectorNormalize({x:n.y-r.y,y:-(n.x-r.x)}),e?s+50:s+10)),i=vectorSubtract(n,r),c=180*Math.atan2(i.y,i.x)/Math.PI;return c=(c=c>90?c-180:c)<-90?c+180:c,"translate("+o.x+","+o.y+") rotate("+c+")"})}m.append("circle").attr({r:s,class:function(t){return t.active?"state-circle active":"state-circle"}}).call(h),m.append("text").attr({"text-anchor":"middle",y:5,class:function(t){return t.active?"state-circle-text active":"state-circle-text"}}).text(function(t){return t.name}),p.exit().remove(),f.on("tick",function(){g()}),f.start()};</script>
<script type="text/javascript">function drawStateMachine(t,n,e){stateMachineGraph(t,n,e)}var defaultFsm={state:{status:"IDLE",data:{x:5}},transitions:{IDLE:{run:"RUNNING"},RUNNING:{stop:"IDLE"}}};RED.nodes.registerType("finite-state-machine",{category:"function",color:"#C7E9C0",defaults:{name:{value:""},fsmDefinition:{value:JSON.stringify(defaultFsm),validate:function(t){try{let n=JSON.parse(t);return n.hasOwnProperty("state")&&n.hasOwnProperty("transitions")&&n.state.hasOwnProperty("status")&&"object"==typeof n.transitions}catch(t){return!1}}},sendInitialState:{value:!1},sendStateWithoutChange:{value:!1},showTransitionErrors:{value:!0}},inputs:1,outputs:1,icon:"function.png",label:function(){return this.name||"finite-state-machine"},inputLabels:"trigger",outputLabels:["state"],oneditprepare:function(){function t(t){try{var n=JSON.parse(t);$("#fsm-graph").empty(),setTimeout(function(){drawStateMachine(n,"100%",500)},500)}catch(t){}}t(this.fsmDefinition);let n=$("#node-input-fsm-definition");n.typedInput({default:"json",types:["json"],typeField:$("#node-input-fsm-definition")}),n.typedInput("value",this.fsmDefinition),n.on("change",function(n,e,i){t(i)})},oneditsave:function(){this.fsmDefinition=$("#node-input-fsm-definition").typedInput("value")}});</script>
<script type="text/x-red" data-template-name="finite-state-machine">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-fsm-definition"><i class="icon-tag"></i> FSM</label>
<input type="text" id="node-input-fsm-definition">
</div>
<div class="form-row">
<label> </label>
<input type="checkbox" id="node-input-sendInitialState" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-sendInitialState" style="width: 70%;">Send initial state</label>
</div>
<div class="form-row">
<label> </label>
<input type="checkbox" id="node-input-sendStateWithoutChange" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-sendStateWithoutChange" style="width: 70%;">Always send state change</label>
</div>
<div class="form-row">
<label> </label>
<input type="checkbox" id="node-input-showTransitionErrors" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-showTransitionErrors" style="width: 70%;">Throw transition errors</label>
</div>
<div class="form-row">
<p>left-click: move / right-click: zoom</p>
<div id="fsm-graph">
</div>
</div>
</script>
<script type="text/x-red" data-help-name="finite-state-machine">
<p>A finite state machine implementation for node red</p>
<h3>Properties</h3>
<dl class="message-properties">
<dt>name<span class="property-type">string</span></dt>
<dd>The name of the node as displayed in the editor</dd>
<dt>FSM<span class="property-type">json </span></dt>
<dd>
The json object which descibes the state machine.
It needs to contain the initial state and possible transitions.
For more information see details below</dd>
<dt>Send initial state<span class="property-type">boolean </span></dt>
<dd>Enable to trigger sending the intital state after 100ms after the flow is started.</dd>
<dt>Always send state change<span class="property-type">boolean </span></dt>
<dd>Outputs a message with the current state if a valid transition was received by the node, even if the state remains unchanged.</dd>
<dt>Show transition errors<span class="property-type">boolean </span></dt>
<dd>Enable / disable error msgs when triggering actions. You can react on them with the <i>catch</i> node.</dd>
</dl>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>topic <span class="property-type">string</span></dt>
<dd>
- <i>[transition]</i> to trigger a state change<br/>
</dd>
<dt>data <span class="property-type">json</span> </dt>
<dd>
May contain a json object, that changes the state's data object: <code>{ "x" : 10.0, y: "test" }</code>
</dd>
<dt>control <span class="property-type">string</span></dt>
<dd>
- <i>reset</i> to reset to initial state<br/>
- <i>sync</i> to set the state manualy. payload needs to be a json object, containing a <i>status</i> field<br/>
- <i>query</i> to query a reading of the current state. "Always send state change" option needs to be enabled.<br/>
</dd>
</dl>
<h3>Outputs</h3>
<ol class="node-ports">
<p>Outputs a msg, with its payload containing a <i>status</i> and a <i>data</i> object</p>
<li>state
<dl class="message-properties">
<dt>payload <span class="property-type">json</span></dt>
<dd>Outputs the new state when there is any change in status or data object.</dd>
<dt>trigger <span class="property-type">json</span></dt>
<dd>Contains the original message that triggerd the transition.</dd>
</dl>
</li>
</ol>
<h3>Details</h3>
<p>Example json code for FSM:
<pre>
{
"state": {
"status": "IDLE",
"data" : { "x": 5 }
},
"transitions": {
"IDLE": {
"run": "RUNNING"
},
"RUNNING": {
"stop": "IDLE"
}
}
}
</pre>
<ul>
<li><i>state</i> holds the initial state. It needs to contain a <i>status</i> field and might contain a <i>data</i> object</li>
<li><i>transitions</i> holds the possible states as Keys (the upper case strings). As Values it holds one or more Key-Value Pairs, consisting of the transition (lower case strings) and the resulting state.</li>
<li>sending a msg with the topic set to the transition to the node, will trigger a state change.</li>
<li><i>reset</i> is a reserved transition to reset the machine to its initial state, so it cannot be used in the transition table.</li>
</ul>
</p>
</script>