Give this a try. The Valve Motor Drive function is the bit that does the work. Setup the constants at the top to match your motor. It expects a value in msg.payload between 0 and 1 which matches the pid node.
If the topic is set to __reset
then that forces the drive to the end stop.
The inject nodes are for injecting test values to see if it works.
You can inject new values at any time, even if it is currently moving, and it will immediately start moving to that position unless it is still doing the initial drive in which case it will complete that then go to whatever input was last requested.
The bit at the bottom is a motor simulator that draws a chart showing where the motor currently is. The constants in there need to be set to match those in the VMD function.
Test it first just with the inject nodes and the chart to see if it looks ok, then if that is ok connect up the motor, but leave the chart on too, and see if that is ok. Then you can try connecting it to the pid node, if you are at that stage in the build.
Any time it is told to go to position 0 or 1 it will add on the overtravel time to help it to stay in sync. If a non-endstop value is given while that is happening then it will start the move to the new position, unless it is during a Reset operation as described above.
If you set the Reset inject to fire on startup then it will initialise when node-red is started.
The third output on the function node outputs diagnostics which may be helpful if there are problems.
[{"id":"a06f9666.6605b8","type":"function","z":"a6823d70.1263e","name":"Valve Motor Drive","func":"// msg.payload should be a required position in the range 0 to 1\n// or a message with topic \"__reset\" which resets it\n// Output 1 is the close output, 2 is the open output\n// output 3 has diagnostic messages to help with debug\n\n// set these up to match your motor\nconst travelTime = 10000 // travel time in milliseconds\nconst overTravelTime = 2000 // additional time to travel to make sure end stop is reached, milliseconds\nconst minMove = 0.01 // the minimum distance to travel\nconst on = 0 // values to send for motor on/off\nconst off = 1\nconst onInitialise = \"close\" // set this to \"close\", \"open\", or \"none\"\n// if onInitialise is set to \"none\" then set this to initial position to be assumed. Range 0 to 1\n// otherwise it doesn't matter \nconst initialPosition = 0\n\n// reset context if topic is __reset\nif (msg.topic == \"__reset\") {\n let data = context.get(\"data\") || {}\n diags(\"reset\")\n if (data.timer) {\n clearTimeout(data.timer)\n data.timer = 0\n }\n drive(0)\n context.set(\"data\", null)\n // setup required position after initialise\n if (onInitialise === \"close\") {\n msg.payload = 0\n } else if (onInitialise === \"open\") {\n msg.payload = 1\n } else {\n // no movement required on initialise so set it to the assumed position\n msg.payload = initialPosition\n }\n} \n\nlet data = context.get(\"data\") || {initialise: true}\n\nconst now = new Date().getTime()\n\nif (data.initialise) {\n if (onInitialise == \"open\" || onInitialise == \"close\") {\n doInitialise()\n } else {\n // drive to end stop not required on startup\n data.initialise = false\n data.currentPosition = initialPosition\n drive(0)\n }\n}\n\n// msg.payload contains required position\ndoSetRequiredPosition(msg.payload)\n\ncontext.set(\"data\", data)\nreturn \n\nfunction doInitialise() {\n diags(\"doInitialise\")\n // have we started initialising?\n if (data.timer) {\n // yes, so do nothing\n diags(\"initialise running\")\n } else {\n // haven't started initialising yet\n diags(\"starting initialise\")\n let pos // required position\n // setup required position and pretend currently at the other end, to force full move\n if (onInitialise == \"close\") {\n pos = 0\n data.currentPosition = 1\n } else { // must be open\n pos = 1\n data.currentPosition = 0\n }\n doMove(pos) // start the move\n }\n}\n\nfunction doSetRequiredPosition(pos) {\n // required position is in msg.payload\n diags(`set pos ${pos}`)\n data.requiredPosition = pos\n if (!data.initialise) {\n // start it moving\n doMove(data.requiredPosition)\n }\n}\n\nfunction doMove(position) {\n // initiates a drive from current position to passed in position\n // update the current position in case already moving\n updatePosition()\n diags(`current: ${data.currentPosition}, required: ${position}`)\n const driveDistance = position - data.currentPosition\n let driveTime = Math.abs( driveDistance * travelTime ) // msec\n const direction = Math.sign( driveDistance )\n // if moving to end stop then add on the extra to ensure gets there\n if (position === 0 || position === 1) {\n diags(\"adding overtravel\")\n driveTime += overTravelTime\n }\n diags(`distance: ${driveDistance}, time: ${driveTime}, dir: ${direction}`)\n // clear timer for any existing motion\n if (data.timer) {\n diags(\"Already moving\")\n clearTimeout(data.timer)\n data.timer = 0\n }\n // start move, or ensure stopped if already there, or close enough\n if (Math.abs(driveDistance) > minMove) {\n // start it and stop after timeout\n drive(direction)\n data.travelStartTime = new Date().getTime()\n data.travelDirection = direction\n data.timer = setTimeout( function() {\n // diags(\"timeout\")\n drive(0)\n // fetch data again in case it has changed\n data = context.get(\"data\")\n // update the current position\n updatePosition()\n data.timer = 0\n if (data.initialise) {\n // finished initialising, clear flag\n data.initialise = false\n // and start move to required position if one has been given\n if (data.requiredPosition) {\n doSetRequiredPosition(data.requiredPosition)\n }\n }\n context.set(\"data\", data)\n }, driveTime)\n } else {\n drive(0)\n data.initialise = false\n }\n}\n\nfunction updatePosition() {\n // updates the current position if necessary\n if (data.timer) {\n const now = new Date().getTime()\n const timeTravelled = now - data.travelStartTime\n data.currentPosition += timeTravelled/travelTime * data.travelDirection\n // update travel start time in case called again\n data.travelStartTime = now\n // clamp to 0:1\n data.currentPosition = Math.min(Math.max(data.currentPosition, 0), 1);\n diags(`updated position: ${data.currentPosition}`)\n node.status(`${data.currentPosition}`)\n }\n}\n\n// given direction to drive, or 0 for stop\nfunction drive( direction ) {\n let op1 = off\n let op2 = off\n if (direction > 0) {\n diags(\"open\")\n op2 = on\n node.status(\"open\")\n } else if (direction < 0) {\n diags(\"close\")\n op1 = on\n node.status(\"close\")\n } else {\n diags(\"stop\")\n node.status(\"stop\")\n }\n node.send([{payload: op1}, {payload: op2}])\n}\n\nfunction diags(str) {\n node.send([null, null, {payload: str}])\n}","outputs":3,"noerr":0,"initialize":"","finalize":"","x":370,"y":220,"wires":[["b84beb4e.92c988","735f7613.f180e8"],["60a0554a.d4abbc","5c005eb3.30a5b"],["41373436.efd2f4"]]},{"id":"b84beb4e.92c988","type":"debug","z":"a6823d70.1263e","name":"CLOSE","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":540,"y":140,"wires":[]},{"id":"60a0554a.d4abbc","type":"debug","z":"a6823d70.1263e","name":"OPEN","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":530,"y":320,"wires":[]},{"id":"aab1bd22.72634","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0.1","payloadType":"num","x":90,"y":120,"wires":[["a06f9666.6605b8"]]},{"id":"3063bbb1.7ad46c","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0.5","payloadType":"num","x":90,"y":160,"wires":[["a06f9666.6605b8"]]},{"id":"832e0919.c1b8b8","type":"inject","z":"a6823d70.1263e","name":"Reset","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"__reset","payload":"","payloadType":"date","x":90,"y":340,"wires":[["a06f9666.6605b8"]]},{"id":"41373436.efd2f4","type":"debug","z":"a6823d70.1263e","name":"DIAGS","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":350,"y":340,"wires":[]},{"id":"85c32710.7cf33","type":"function","z":"a6823d70.1263e","name":"Motor simulator","func":"// if topic is open or close then payload contains combined values from VMD \n// otherwise this is a trigger to update the output\n// set these up to match your motor\nconst travelTime = 10000 // travel time in milliseconds\nconst on = 0 // values to send for motor on/off\nconst off = 1\nconst startPos = 0.5 // assumed start pos\n\nlet data = context.get(\"data\") || {pos: startPos, lastTime: new Date().getTime(), dir: 0}\n\nupdatePosition()\nif (msg.topic === \"open\" || msg.topic === \"close\") {\n if (msg.payload.open == off && msg.payload.close == off ) {\n data.dir = 0\n } else if (msg.payload.open == on) {\n data.dir = 1\n } else {\n data.dir = -1\n }\n}\n\nmsg.payload = data.pos\ncontext.set(\"data\", data)\nreturn msg;\n\nfunction updatePosition() {\n const now = new Date().getTime()\n deltaT = now - data.lastTime\n data.pos += deltaT/travelTime*data.dir\n data.pos = Math.min(Math.max(data.pos, 0), 1);\n data.lastTime = now\n}","outputs":1,"noerr":0,"initialize":"","finalize":"","x":480,"y":500,"wires":[["40d90aa8.f0ea14"]]},{"id":"94f6aa43.1dd0c","type":"join","z":"a6823d70.1263e","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","accumulate":true,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":310,"y":500,"wires":[["85c32710.7cf33"]]},{"id":"735f7613.f180e8","type":"link out","z":"a6823d70.1263e","name":"","links":["ae12793f.b5aa38"],"x":535,"y":200,"wires":[]},{"id":"5c005eb3.30a5b","type":"link out","z":"a6823d70.1263e","name":"","links":["dff075f6.329a7"],"x":535,"y":240,"wires":[]},{"id":"dff075f6.329a7","type":"link in","z":"a6823d70.1263e","name":"","links":["5c005eb3.30a5b"],"x":75,"y":520,"wires":[["6bdc8f0.9652df"]]},{"id":"ae12793f.b5aa38","type":"link in","z":"a6823d70.1263e","name":"","links":["735f7613.f180e8"],"x":75,"y":480,"wires":[["8430a6a.725de58"]]},{"id":"6bdc8f0.9652df","type":"change","z":"a6823d70.1263e","name":"open","rules":[{"t":"set","p":"topic","pt":"msg","to":"open","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":170,"y":520,"wires":[["94f6aa43.1dd0c"]]},{"id":"8430a6a.725de58","type":"change","z":"a6823d70.1263e","name":"close","rules":[{"t":"set","p":"topic","pt":"msg","to":"close","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":170,"y":480,"wires":[["94f6aa43.1dd0c"]]},{"id":"ad4ded39.57dbb8","type":"inject","z":"a6823d70.1263e","name":"trigger","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"1","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":340,"y":540,"wires":[["85c32710.7cf33"]]},{"id":"fe308e8b.19a588","type":"ui_chart","z":"a6823d70.1263e","name":"","group":"61285987.c20328","order":4,"width":0,"height":0,"label":"chart","chartType":"line","legend":"false","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"0","ymax":"1","removeOlder":1,"removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"x":710,"y":580,"wires":[[]]},{"id":"40d90aa8.f0ea14","type":"change","z":"a6823d70.1263e","name":"","rules":[{"t":"set","p":"topic","pt":"msg","to":"Position","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":510,"y":580,"wires":[["fe308e8b.19a588"]]},{"id":"53418660.8ec89","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1","payloadType":"num","x":90,"y":280,"wires":[["a06f9666.6605b8"]]},{"id":"d7bebc9d.9eae5","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0","payloadType":"num","x":90,"y":80,"wires":[["a06f9666.6605b8"]]},{"id":"ab1c19b4.2a346","type":"inject","z":"a6823d70.1263e","name":"Clear","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"[]","payloadType":"json","x":550,"y":620,"wires":[["fe308e8b.19a588"]]},{"id":"f61d461a.77501","type":"comment","z":"a6823d70.1263e","name":"Motor Simulator","info":"","x":300,"y":420,"wires":[]},{"id":"dd261e02.b6ea18","type":"comment","z":"a6823d70.1263e","name":"Close output","info":"","x":650,"y":200,"wires":[]},{"id":"b01803b4.af96b8","type":"comment","z":"a6823d70.1263e","name":"Open output","info":"","x":650,"y":240,"wires":[]},{"id":"9a4798a2.d18198","type":"comment","z":"a6823d70.1263e","name":"Set constants here to match VMD function","info":"","x":570,"y":460,"wires":[]},{"id":"82f11909.396448","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0.508","payloadType":"num","x":90,"y":200,"wires":[["a06f9666.6605b8"]]},{"id":"37c15095.10c73","type":"inject","z":"a6823d70.1263e","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0.512","payloadType":"num","x":90,"y":240,"wires":[["a06f9666.6605b8"]]},{"id":"61285987.c20328","type":"ui_group","name":"Main","tab":"e7c46d5e.a1283","order":1,"disp":true,"width":"6","collapse":false},{"id":"e7c46d5e.a1283","type":"ui_tab","name":"Dashboard","icon":"dashboard"}]