Dynamic, Self-Updating groov Tables (with Node-RED)


#1

A lot of the time we find ourselves with tons of data available to us but we only care about seeing a particular subset, for example only the points that have recently changed or only the points that are turned on. Regardless of the application there can be a lot of value in using dynamic lists that re-sort themselves on the fly.

Here’s an example of a Node-RED flow that dynamically changes pump information being displayed in a groov View screen based on which were most recently toggled:

In this example the pump buttons on the left are static, but as they are turned on and off the most recently activated pump always appears at the top of the list on the right.
So here you can see pump #5 was turned on almost 6 minutes after pump #8, so #5 shows up at the top of the list, where pump #8 shows up at the bottom. Any pump that gets turned off is removed from the list on the right (which is why 1, 4, 6, and 9 are not listed at all).

This starts with three lists in a groov data store: the button boolean list and the two dynamic display lists – one for the pump index number and another for string status, which I am simply using for the time the button was turned on:
image

These are displayed with 10 button gadgets and 20 individual text area gadgets in groov View, then the buttons are read in as one table into Node-RED where they are checked for changes (report by exception / “RBE” node), then the code to process them begins.

The main logic here is to check to see if “on” buttons have a timestamp saved yet (did they just get turned on?), and check to see if “off” buttons have a timestamp that needs clearing (did they just get turned off?)
Once this is known, either save the timestamp using a new Date() JavaScript date object, or set the timestamp = 0 to mark which pumps are turned off.

This time list is actually a list of pairs [ index , timestamp ] so that in the next node we can sort by timestamp to get the most recent values at the top, and also sort the associated index at the same time so that the list of pump numbers is correct relative to the new time-based ordering.

Once the list is reordered we go through the list and add each new value to an array of “write objects” that uses dynamic node settings to update the data store tag values.

Here is the groov View page and a back-up of the flow: NR-flow groov-page.zip (3.4 KB)

Here is the Node-RED flow raw text:

[{"id":"4e48abcf.651304","type":"function","z":"e02f10b4.235de","name":"save button press time","func":"var count = 10; // number if items (for timestamp list length)\n\n// grab list of timestamps, initialize the list if it doesn't exist yet (if it is empty):\nvar timestamps = flow.get('btn_times') || [];\nif (timestamps.length < 1)\n    for(i = 0; i < count; i++) timestamps.push([i, 0]); // push pairs [ index , time ]\n\nfor(i = 0; i < msg.payload.length; i++) {\n    if(msg.payload[i] && (timestamps[i][1] === 0))        // button ON, previously OFF:\n        timestamps[i] = [ i, (new Date().getTime()) ];    // save     [ index, time ]\n\n    else if (!msg.payload[i] && (timestamps[i][1] > 0))   // button OFF, previously ON:\n        timestamps[i] = [ i, 0 ];                         // save     [ index,  0   ]\n}\n\nflow.set('btn_times', timestamps); // save the list\nreturn { payload : timestamps }; // also return it for sorting & displaying","outputs":1,"noerr":0,"x":660,"y":620,"wires":[["db108e6c.a0dd2"]]},{"id":"d315cd1c.b2ff2","type":"rbe","z":"e02f10b4.235de","name":"","func":"rbe","gap":"","start":"","inout":"out","property":"payload","x":470,"y":640,"wires":[["4e48abcf.651304"]]},{"id":"db108e6c.a0dd2","type":"function","z":"e02f10b4.235de","name":"order data by time","func":"timestamps = msg.payload;\ntimestamps.sort(function(a, b){ return b[1] - a[1] }); // sort by timestamp, not index.\n\n/*  \"timestamps\" is a two-dimensional array like [ [0, time], [1, time], ... [10, time] ]\n    This way when it is sorted by decreasing time we already have each associated index\n    relative to the new order, since the [index, time] pair gets sorted together.\n    Note that timestamps[i] = [index, time];    THUS        timestamps[i][0] = index;\n                                                AND         timestamps[i][1] = time;    */\n\nobjects = []; // create dynamically determined write objects to be displayed:\nfor(i = 0; i < timestamps.length; i++) {\n    // if timestamp > 0 then the button is ON; display most recently activated buttons at the top:\n    if(timestamps[i][1] > 0) {\n        objects.push({ // get the \"i\"th index for the dynamic number:\n              tagName         : 'dynamic_number',       // \"dynamic_number\" is the displayed pump #\n              tableStartIndex : i,\n              values          : timestamps[i][0] + 1    // value is index number for this timestamp, offset +1\n        });\n        // get the \"i\"th activation time for dynamic text:\n        myTime = new Date(timestamps[i][1]).toString();\n        objects.push({\n              tagName         : 'dynamic_text',\n              tableStartIndex : i,\n              values          : myTime.substring(4, myTime.length-15)\n        });\n    }\n    \n    // otherwise the button is off; clear this entry in the number and text tables:\n    else {\n        objects.push({\n              tagName         : 'dynamic_number',\n              tableStartIndex : i,\n              values          : 0               // show pump # \"0\" for disabled pumps.\n        });\n        objects.push({\n              tagName         : 'dynamic_text',\n              tableStartIndex : i,\n              values          : \"Pump disabled.\"\n        });\n    }\n}\ntimestamps.sort(function(a, b){ return a[0] - b[0] }); // resort by index.\n\nreturn { payload : objects }; // return the write objects to be displayed in groov","outputs":1,"noerr":0,"x":670,"y":660,"wires":[["2b63e375.1ea93c"]]},{"id":"573f5769.632008","type":"groov-read-ds","z":"e02f10b4.235de","dataStore":"18a28a95.517b05","tagName":"dynamic_buttons","tableStartIndex":"","tableLength":"10","value":"","valueType":"msg.payload","topic":"","topicType":"none","name":"read","x":350,"y":640,"wires":[["d315cd1c.b2ff2"]]},{"id":"2b63e375.1ea93c","type":"split","z":"e02f10b4.235de","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":850,"y":640,"wires":[["c5a66611.15c8c8"]]},{"id":"8184e55f.a7f8b8","type":"inject","z":"e02f10b4.235de","name":"0.5 s","topic":"","payload":"","payloadType":"date","repeat":".5","crontab":"","once":false,"onceDelay":0.1,"x":230,"y":640,"wires":[["573f5769.632008"]]},{"id":"c5a66611.15c8c8","type":"groov-write-ds","z":"e02f10b4.235de","dataStore":"18a28a95.517b05","tagName":"","tableStartIndex":"","value":"payload.values","valueType":"msg","name":"output","x":970,"y":640,"wires":[[]]},{"id":"18a28a95.517b05","type":"groov-data-store","z":"","project":"","dsName":"NodeRED"}]

Feel free to use and modify this project as much as you’d like, and please post any questions or comments you have.
And as always, happy coding.


#2

This is very nice.
Wait Wait Wait. Could we use this for Radio Button Logic? (Only Enable Latest Variable)


#3

Wow, I have actually made it. After thinking for a long time how PAC control can implement Radio Button.
Node-Red comes to the rescue.

I add node to get the index of the first data, returned by previous code. And feed it to button list.

[{"id":"4a68eebb.683f6","type":"function","z":"df0e428d.62bac8","name":"save button press time","func":"var count = 10; // number if items (for timestamp list length)\n\n// grab list of timestamps, initialize the list if it doesn't exist yet (if it is empty):\nvar timestamps = flow.get('btn_times') || [];\nif (timestamps.length < 1)\n    for(i = 0; i < count; i++) timestamps.push([i, 0]); // push pairs [ index , time ]\n\nfor(i = 0; i < msg.payload.length; i++) {\n    if(msg.payload[i] && (timestamps[i][1] === 0))        // button ON, previously OFF:\n        timestamps[i] = [ i, (new Date().getTime()) ];    // save     [ index, time ]\n\n    else if (!msg.payload[i] && (timestamps[i][1] > 0))   // button OFF, previously ON:\n        timestamps[i] = [ i, 0 ];                         // save     [ index,  0   ]\n}\n\nflow.set('btn_times', timestamps); // save the list\nreturn { payload : timestamps }; // also return it for sorting & displaying","outputs":1,"noerr":0,"x":640,"y":740,"wires":[["c1f823c0.400a08"]]},{"id":"b80f5374.2e6d6","type":"rbe","z":"df0e428d.62bac8","name":"","func":"rbe","gap":"","start":"","inout":"out","property":"payload","x":450,"y":760,"wires":[["4a68eebb.683f6"]]},{"id":"c1f823c0.400a08","type":"function","z":"df0e428d.62bac8","name":"order data by time","func":"timestamps = msg.payload;\ntimestamps.sort(function(a, b){ return b[1] - a[1] }); // sort by timestamp, not index.\n\n/*  \"timestamps\" is a two-dimensional array like [ [0, time], [1, time], ... [10, time] ]\n    This way when it is sorted by decreasing time we already have each associated index\n    relative to the new order, since the [index, time] pair gets sorted together.\n    Note that timestamps[i] = [index, time];    THUS        timestamps[i][0] = index;\n                                                AND         timestamps[i][1] = time;    */\n\nobjects = []; // create dynamically determined write objects to be displayed:\nfor(i = 0; i < timestamps.length; i++) {\n    // if timestamp > 0 then the button is ON; display most recently activated buttons at the top:\n    if(timestamps[i][1] > 0) {\n        objects.push({ // get the \"i\"th index for the dynamic number:\n              tagName         : 'dynamic_number',       // \"dynamic_number\" is the displayed pump #\n              tableStartIndex : i,\n              values          : timestamps[i][0] + 1    // value is index number for this timestamp, offset +1\n        });\n        // get the \"i\"th activation time for dynamic text:\n        myTime = new Date(timestamps[i][1]).toString();\n        objects.push({\n              tagName         : 'dynamic_text',\n              tableStartIndex : i,\n              values          : myTime.substring(4, myTime.length-15)\n        });\n    }\n    \n    // otherwise the button is off; clear this entry in the number and text tables:\n    else {\n        objects.push({\n              tagName         : 'dynamic_number',\n              tableStartIndex : i,\n              values          : 0               // show pump # \"0\" for disabled pumps.\n        });\n        objects.push({\n              tagName         : 'dynamic_text',\n              tableStartIndex : i,\n              values          : \"Pump disabled.\"\n        });\n    }\n}\ntimestamps.sort(function(a, b){ return a[0] - b[0] }); // resort by index.\n\nreturn { payload : objects }; // return the write objects to be displayed in groov","outputs":1,"noerr":0,"x":650,"y":780,"wires":[["242a2c.f14ca5d4","4ab05143.88bee"]]},{"id":"e2f18e55.c5917","type":"groov-read-ds","z":"df0e428d.62bac8","dataStore":"aa36f7ee.d74ee8","tagName":"dynamic_buttons","tableStartIndex":"","tableLength":"10","value":"","valueType":"msg.payload","topic":"","topicType":"none","name":"read","x":330,"y":760,"wires":[["b80f5374.2e6d6"]]},{"id":"242a2c.f14ca5d4","type":"split","z":"df0e428d.62bac8","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":830,"y":760,"wires":[["da03a7d3.5ddd5"]]},{"id":"f2702eb1.9263b","type":"inject","z":"df0e428d.62bac8","name":"0.5 s","topic":"","payload":"","payloadType":"date","repeat":".5","crontab":"","once":false,"onceDelay":0.1,"x":210,"y":760,"wires":[["e2f18e55.c5917"]]},{"id":"da03a7d3.5ddd5","type":"groov-write-ds","z":"df0e428d.62bac8","dataStore":"aa36f7ee.d74ee8","tagName":"","tableStartIndex":"","value":"payload.values","valueType":"msg","name":"output","x":950,"y":760,"wires":[[]]},{"id":"4ab05143.88bee","type":"function","z":"df0e428d.62bac8","name":"Get index of first data","func":"var latest_btn = msg.payload[0].values-1\n\ndyn_object = [];\nfor(ctr=0; ctr<10; ctr++){\n    if(ctr==latest_btn){\n        dyn_object.push({\n            tagName         : 'dynamic_buttons',\n            tableStartIndex : ctr,\n            values          : true\n        });    \n    }\n    else{\n        dyn_object.push({\n            tagName         : 'dynamic_buttons',\n            tableStartIndex : ctr,\n            values          : false\n        });\n    }\n}\n\nvar msg1 = { payload : dyn_object }\n\nreturn msg1;","outputs":2,"noerr":0,"x":640,"y":840,"wires":[["7e8296f5.bd634"],[]]},{"id":"7e8296f5.bd634","type":"split","z":"df0e428d.62bac8","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":890,"y":840,"wires":[["8ffc45a7.d02bb8"]]},{"id":"8ffc45a7.d02bb8","type":"groov-write-ds","z":"df0e428d.62bac8","dataStore":"aa36f7ee.d74ee8","tagName":"","tableStartIndex":"","value":"payload.values","valueType":"msg","name":"output","x":1030,"y":840,"wires":[[]]},{"id":"aa36f7ee.d74ee8","type":"groov-data-store","z":"","project":"","dsName":"NodeRED"}]

I haven’t try to incorporate with PAC control, but I believe it could work.


#4

This is great stuff, I never would have thought to apply it like that! Thank you for sharing.

Now that I’ve seen it in action I can’t help but think that radio buttons are just based on the “most recently activated” and don’t rely on specifically when it was toggled, so while this works great I think we can strip it down a little to get some more performance.
Rather than storing and sorting a full list of [index, timestamp] pairs then going back through them, we could just keep a list of true/false values, then when a value is toggled to “true” go ahead and write all the rest to “false” and that’s it!

I threw together this example so you can see how the stripped-down version works for you:

Here is the flow import if you want to try it out yourself:

[{"id":"b1d606e8.bd4b88","type":"split","z":"e02f10b4.235de","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"","x":750,"y":1300,"wires":[["828dcaa6.aed468"]]},{"id":"c25d662f.3c6318","type":"function","z":"e02f10b4.235de","name":"radio buttons","func":"mySize = msg.payload.length;\n\nlast_values = flow.get('button_states') || [];\nif (last_values.length < 1) // if the list doesn't exist yet, initialize it:\n    for(i = 0; i < mySize; i++) last_values.push(false);\n\ndynamic_objects = [];\n\nfor(i = 0; i < mySize; i++) {\n    if(msg.payload[i] && !last_values[i]) { // if this button *was* off, and is now on,\n        \n        for(a = 0; a < mySize; a++) {       // toggle all others off except this one.\n            dynamic_objects.push({\n                tagName         : 'dynamic_buttons',\n                tableStartIndex : a,\n                values          : (a == i) ? true : false // false for everything but >a<\n            });\n        }\n        // Since we've found the toggled button we can save the old button states and return:\n        flow.set('button_states', msg.payload);\n        return { payload : dynamic_objects };\n    }\n}\n// technically this code should never run, but just in case:\nflow.set('button_states', msg.payload);\nreturn null;","outputs":1,"noerr":0,"x":610,"y":1300,"wires":[["b1d606e8.bd4b88"]]},{"id":"828dcaa6.aed468","type":"groov-write-ds","z":"e02f10b4.235de","dataStore":"18a28a95.517b05","tagName":"","tableStartIndex":"","value":"payload.values","valueType":"msg","name":"output","x":870,"y":1300,"wires":[[]]},{"id":"d929d3d8.b697a","type":"rbe","z":"e02f10b4.235de","name":"","func":"rbe","gap":"","start":"","inout":"out","property":"payload","x":470,"y":1300,"wires":[["c25d662f.3c6318"]]},{"id":"130dede5.7e9452","type":"groov-read-ds","z":"e02f10b4.235de","dataStore":"18a28a95.517b05","tagName":"dynamic_buttons","tableStartIndex":"","tableLength":"10","value":"","valueType":"msg.payload","topic":"","topicType":"none","name":"read","x":350,"y":1300,"wires":[["d929d3d8.b697a"]]},{"id":"2663c432.b9473c","type":"inject","z":"e02f10b4.235de","name":"0.5 s","topic":"","payload":"","payloadType":"date","repeat":".5","crontab":"","once":false,"onceDelay":0.1,"x":230,"y":1300,"wires":[["130dede5.7e9452"]]},{"id":"18a28a95.517b05","type":"groov-data-store","z":"","project":"6f94d393.9ebf4c","dsName":"NodeRED"},{"id":"6f94d393.9ebf4c","type":"groov-project","z":"","address":"localhost"}]

#5

Hi Terry,
I am connecting the flow to a PAC control Int Table.
Could you modify your code to replace true, false by 1 and 0?
To test it, could you create Int Table and use check box Gadget to simulate?

I could not make the last node write to PAC Int Table,
where can I find the Tag Name, for example: tagName, tableStartIndex, values?
Maybe it is not the same for PAC write?

Thank you.


#6

Sure, it will work just as well with 1’s and 0’s as direct replacements. And dynamic settings work exactly the same with the PAC control nodes, it’s described for both the groov and PAC packages in the Node-RED info tab for each node.

With that said we can avoid dynamic write objects entirely here and simply send the full array / table of 1’s and 0’s straight to the node and eliminate that split node:


groov Page & flow: radio_buttons.zip (3.1 KB)