Multiple Timer Subflow Node with status progress

Hello,
I’d like to share my subflow for setting multiple timers in an fairly easy manner:

Here is an example flow:

[{"id":"d3399d9e.0ff088","type":"subflow","name":"timers","info":"This subflow/node facilitates the setting\nof multiple timers in an easy manner.\n\n# Input Message to set timer\n\nThe subflow expects an input `msg` which\ncontains the following to set a timer:\n\n+ a `msg.payload` which will be send once the timer has finished\n+ a `msg.duration` either in the format of a number in seconds or a string in the \"hh:mm:ss\" format\n+ an optional `msg.topic` which will also be send on completion\n\n# Other Control Messages\n\nThere are also a number of other control\nmessages which the subflow accept:\n\n+ a `msg.payload` of `reset` will reset all running timers\n+ a `msg.payload` of `debug` will send an array of all set timers to the secon debug output.\n+ a `msg` with a duration in seconds as a number or a string in the hh:mm:ss format with `msg.delete` set to true will delete the first set timer with a matching duration\n\n# Configuration Settings\n\nThe subflow although offers several menu options which can be adjusted:\n\n+ there is a setting which determines if a new timer should be appended to the list of existing timers and run in parallel or if every new timer should replace the previous\n+ a setting for the output format of the duration and the time left to-go which affects both the status display and the debug messages\n+ a setting to enable/disable the progress status being shown on the node status\n+ a setting which when enabled sends a debug message every second to the second output\n\n# Debug Message Format\nThe node sends a debug message to its second output on several events or in addition every second if configured. Those events are:\n\n+ When a new timer is set\n+ When a timer completes\n+ When a timer is deleted\n+ When the timers are reset\n\nThe format of this message is an array which\nincludes one object for every timer running\nthat includes useful information about each\ntimer in this format:\n```\n{\n    \"payload\":\"ON\",\n    \"duration\":\"00:00:25\",\n    \"due\":1607330155654,\n    \"toGo\":\"00:00:25\",\n    \"topic\":\"Test\"\n}\n```\nthe `toGo` and `duration` properties will be in the format configured in the nodes settings.","category":"","in":[{"x":100,"y":140,"wires":[{"id":"fe62abda.f8f7a"}]}],"out":[{"x":840,"y":140,"wires":[{"id":"c50c6881.b2fdf","port":0}]},{"x":840,"y":200,"wires":[{"id":"12c839f6.01eed6","port":0}]}],"env":[{"name":"on_input","type":"str","value":"append","ui":{"label":{"en-US":"On new input"},"type":"select","opts":{"opts":[{"l":{"en-US":"append to list of current timers"},"v":"append"},{"l":{"en-US":"replace current timer"},"v":"replace"}]}}},{"name":"time_format","type":"str","value":"hhmmss","ui":{"label":{"en-US":"Time format of output/status"},"type":"select","opts":{"opts":[{"l":{"en-US":"hh:mm:ss"},"v":"hhmmss"},{"l":{"en-US":"seconds"},"v":"seconds"}]}}},{"name":"show_progress","type":"bool","value":"true","ui":{"label":{"en-US":"show progress in status"},"type":"checkbox"}},{"name":"send_progress","type":"bool","value":"false","ui":{"label":{"en-US":"send progress to second output"},"type":"checkbox"}}],"color":"#FDF0C2","icon":"node-red/timer.svg","status":{"x":840,"y":80,"wires":[{"id":"415b9d3f.6e5754","port":0}]}},{"id":"fe326ef6.0184b","type":"function","z":"d3399d9e.0ff088","name":"tick","func":"const timers = flow.get(\"timers\") || [];\nif (msg.delete === true || timers.length > 0) {\n    return msg;\n}\nconst start = Date.now();\nconst startCorrected = (Math.ceil(start / 1000)) * 1000;\nfunction initial() {\n    initialTimeout = startCorrected - start;\n    setTimeout(()=>{\n        tick(1000);\n        node.send({payload:\"tick\"});\n    },initialTimeout);\n}\nfunction tick(timeout) {\n    setTimeout(()=>{\n        if (flow.get(\"timers\").length > 0) {\n            const newStart = Date.now();\n            const offset = (newStart-startCorrected) % 10;\n            const newTimeout = (offset > 0) ? 1000-(offset) : 1000;\n            tick(newTimeout);\n            node.send({payload:\"tick\"});\n        }\n    },timeout)\n}\ninitial();\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":550,"y":140,"wires":[["c50c6881.b2fdf","415b9d3f.6e5754"]]},{"id":"c50c6881.b2fdf","type":"function","z":"d3399d9e.0ff088","name":"check timers","func":"const now = Date.now();\nlet timers = flow.get(\"timers\") || [];\nif (msg.payload === \"tick\") {\n    if (timers.length === 0) { return null; } \n    let done = [];\n    timers.forEach(timer => {\n        if (timer.due < now) {\n            let outputMsg = {};\n            outputMsg.payload = timer.payload;\n            if (timer.hasOwnProperty(\"topic\")) { outputMsg.topic = timer.topic }\n            node.send([outputMsg, null]);\n            done.push(timer);\n        }\n    });\n    if (done.length !== 0) {\n        done.forEach(item => {\n            let toDelete = timers.indexOf(item);\n            timers.splice(toDelete, 1);\n        });\n        node.send([null,{payload:timers}]);\n    }\n    timers.forEach((timer, index) => {\n        timers[index].toGo = Math.round((timer.due - now) / 1000);\n    });\n    if (env.get(\"send_progress\") === true && done.length === 0 ) {\n        node.send([null,{payload:timers}]);\n    }\n} else if (msg.delete) {\n    for(i=0;i<timers.length;i++) {\n        if (timers[i].duration === msg.payload) {\n            timers.splice(i, 1);\n            node.send([null,{payload:timers}]);\n            break;\n        }\n    }\n} else {\n    let newTimer = {\n        payload: msg.payload,\n        duration: msg.duration,\n        due: now + (msg.duration * 1000),\n        toGo: msg.duration\n    };\n    if (msg.hasOwnProperty(\"topic\")) { newTimer.topic = msg.topic }\n    if (env.get(\"on_input\") === \"append\") {\n        timers.push(newTimer);\n    } else {\n        timers = [newTimer];\n    }\n    node.send([null,{payload:timers}]);\n}\nflow.set(\"timers\",timers);\nreturn null;","outputs":2,"noerr":0,"initialize":"","finalize":"","x":710,"y":140,"wires":[[],["12c839f6.01eed6"]]},{"id":"415b9d3f.6e5754","type":"function","z":"d3399d9e.0ff088","name":"set status","func":"function convertFormat(input){\n    let hoursRaw = input / 3600;\n    hoursRaw = hoursRaw.toString().split(\".\");\n    let hours = hoursRaw[0];\n    let minutesRaw = (input / 60) - (Number(hours) * 60);\n    minutesRaw = minutesRaw.toString().split(\".\");\n    let minutes = minutesRaw[0];\n    let seconds = (input - (Number(hours) * 3600) - (Number(minutes) * 60)).toString();\n    if (hours.length === 1) { hours = \"0\" + hours; }\n    if (minutes.length === 1) { minutes = \"0\" + minutes; }\n    if (seconds.length === 1) { seconds = \"0\" + seconds; }\n    const output = `${hours}:${minutes}:${seconds}`;\n    return output;\n}\nconst timers = flow.get(\"timers\") || [];\nif (env.get(\"show_progress\") === true) {\n    if (timers.length !== 0) {\n        const now = Date.now();\n        let newPayload = \"\";\n        timers.forEach(timer => {\n            let toGo = Math.round((timer.due - now) / 1000);\n            let addPayload = \"\";\n            if (env.get(\"time_format\") === \"seconds\") {\n                addPayload = `${toGo}s of ${timer.duration}s to go`;\n            } else {\n                let newDuration = convertFormat(timer.duration);\n                let newToGo = convertFormat(toGo);\n                addPayload = `${newToGo} of ${newDuration} to go`;\n            }\n            if (newPayload.length !== 0) { newPayload += \", \"; }\n            newPayload += addPayload;\n        });\n        node.send({payload:newPayload});\n    } else {\n        node.send({payload:\"no timer\"});\n    }\n}\nreturn null;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":700,"y":80,"wires":[[]]},{"id":"fe62abda.f8f7a","type":"switch","z":"d3399d9e.0ff088","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"reset","vt":"str"},{"t":"eq","v":"debug","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":3,"x":210,"y":140,"wires":[["1e5213c7.d9c46c"],["1461b44c.50eae4"],["595b735c.08ff54"]]},{"id":"1e5213c7.d9c46c","type":"change","z":"d3399d9e.0ff088","name":"reset","rules":[{"t":"set","p":"timers","pt":"flow","to":"[]","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":350,"y":80,"wires":[["415b9d3f.6e5754","1461b44c.50eae4"]]},{"id":"595b735c.08ff54","type":"function","z":"d3399d9e.0ff088","name":"error checking","func":"if (typeof msg.duration === \"string\" && msg.duration.match(/[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}/g)) {\n    msg.duration = msg.duration.split(\":\");\n    msg.duration = (Number(msg.duration[0]) * 3600) + (Number(msg.duration[1]) * 60) + Number(msg.duration[2]);\n}\nif (msg.delete === true && typeof msg.payload === \"string\" && msg.payload.match(/[0-9]{2}\\:[0-9]{2}\\:[0-9]{2}/g)) {\n    msg.payload = msg.payload.split(\":\");\n    msg.payload = (Number(msg.payload[0]) * 3600) + (Number(msg.payload[1]) * 60) + Number(msg.payload[2]);\n}\nif (msg.delete !== true) {\n    if (msg.duration === undefined || typeof msg.duration !== \"number\" || msg.duration <= 0) {\n        node.warn(\"input message needs a msg.duration property which is either of type number or a string in the hh:mm:ss second format\");\n        return null;\n    } else if (msg.payload === undefined) {\n        node.warn(\"msg.payload needs to be defined\");\n        return null;\n    }\n} else if (typeof msg.payload !== \"number\" || msg.payload <= 0) {\n    node.warn(\"if msg.delete is true msg.payload needs to be of type number and coresponding to the duration of the timer to be deleted\");\n    return null;\n}\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":380,"y":140,"wires":[["fe326ef6.0184b"]]},{"id":"12c839f6.01eed6","type":"function","z":"d3399d9e.0ff088","name":"format output","func":"function convertFormat(input){\n    let hoursRaw = input / 3600;\n    hoursRaw = hoursRaw.toString().split(\".\");\n    let hours = hoursRaw[0];\n    let minutesRaw = (input / 60) - (Number(hours) * 60);\n    minutesRaw = minutesRaw.toString().split(\".\");\n    let minutes = minutesRaw[0];\n    let seconds = (input - (Number(hours) * 3600) - (Number(minutes) * 60)).toString();\n    if (hours.length === 1) { hours = \"0\" + hours; }\n    if (minutes.length === 1) { minutes = \"0\" + minutes; }\n    if (seconds.length === 1) { seconds = \"0\" + seconds; }\n    const output = `${hours}:${minutes}:${seconds}`;\n    return output;\n}\nif(env.get(\"time_format\") === \"hhmmss\") {\n    let newMsg = RED.util.cloneMessage(msg);\n    newMsg.payload.forEach(timer => {\n        timer.duration = convertFormat(timer.duration);\n        timer.toGo = convertFormat(timer.toGo);\n    });\n    return newMsg;\n}\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":700,"y":200,"wires":[[]]},{"id":"8370617b.6272a","type":"inject","z":"d3399d9e.0ff088","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":170,"y":80,"wires":[["1e5213c7.d9c46c"]]},{"id":"1461b44c.50eae4","type":"change","z":"d3399d9e.0ff088","name":"timers","rules":[{"t":"set","p":"payload","pt":"msg","to":"timers","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":350,"y":200,"wires":[["12c839f6.01eed6"]]},{"id":"84552540.c74d08","type":"inject","z":"e3b3f1c9.c82878","name":"1min10s no topic hh:mm:ss","props":[{"p":"payload"},{"p":"duration","v":"00:01:10","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"test1","payloadType":"str","x":300,"y":360,"wires":[["d2c69471.b2bd2"]]},{"id":"aed2755b.daeb18","type":"inject","z":"e3b3f1c9.c82878","name":"delete 25 second timer","props":[{"p":"payload"},{"p":"delete","v":"true","vt":"bool"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"25","payloadType":"num","x":280,"y":440,"wires":[["d2c69471.b2bd2"]]},{"id":"f61b4c2c.65d518","type":"debug","z":"e3b3f1c9.c82878","name":"complete","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":780,"y":400,"wires":[]},{"id":"8172b068.c5f2d8","type":"inject","z":"e3b3f1c9.c82878","name":"reset all timers","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"reset","payloadType":"str","x":260,"y":520,"wires":[["d2c69471.b2bd2"]]},{"id":"33935acd.233b4e","type":"inject","z":"e3b3f1c9.c82878","name":"25s in seconds format with topic","props":[{"p":"topic","vt":"str"},{"p":"duration","v":"25","vt":"num"},{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"testTopic","payload":"test2","payloadType":"str","x":310,"y":400,"wires":[["d2c69471.b2bd2"]]},{"id":"e6a26233.0b863","type":"debug","z":"e3b3f1c9.c82878","name":"debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":770,"y":440,"wires":[]},{"id":"d2c69471.b2bd2","type":"subflow:d3399d9e.0ff088","z":"e3b3f1c9.c82878","name":"","env":[],"x":550,"y":440,"wires":[["f61b4c2c.65d518"],["e6a26233.0b863"]]},{"id":"23e1c3c1.96833c","type":"inject","z":"e3b3f1c9.c82878","name":"send debug message to second output","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"debug","payloadType":"str","x":330,"y":480,"wires":[["d2c69471.b2bd2"]]}]


The subflow explains all functions in its info section and on the flow library page.
It consists only of core nodes:

It is based around second granularity tick it generates in a function node. It doesn’t use an inject node for this as it incorporates two features that differ. It doesn’t use setInterval() but instead a recursive setTimeout which compensates for drift and it only runs the tick if any timers are actually running.
You can input timers to be executed either as a number of seconds or as a string in the hh:mm:ss format. You can choose the format shown in the status and in debug messages independently from this.
Possible use cases:

  • multiple “egg timer” in combination with the dashboard or a voice assistant project like Rhasspy or Voice2json
  • wanting to easily access the progress of long running timers
  • completely input message based timer so one timer subflow could send messages to different mqtt topics after different timeouts running in parallel
  • human readable input format with the hh:mm:ss format so it’s very easy to use

As it’s a subflow feel free to have fun with it and adjust it to your needs. I hope somebody finds it useful.

Johannes

Edit:
fixed an error sending the proper payload on completion

2 Likes

This topic was automatically closed after 60 days. New replies are no longer allowed.