Dynamically reading a multifunction groov module with the Manage API

Using the groov Manage REST API it’s trivial to read an entire digital or analog module with just one call, but what happens when you need to read a multifunction mixed signal module that could be a combination of analog and digital I/O channels?
To solve this I’ve built a flow that combines the response from four API calls to dynamically read a multifunction module no matter how it’s configured. I’ll break it down in more detail below, but the short version is that you’ll need a map of every possible channel description, the current module configuration, all analog values, all digital values, and then a little code to determine which channel is which.

To use the example flow pasted at the bottom of this post just import the JSON into Node-RED, if running it on the device you want to read you can just use localhost like I did, but if you’re reading I/O from another device you’ll need to put the hostname or static IP address in each of the four HTTP request nodes.
You’ll also need to replace the string in the first API key inject node with an admin level API key. It MUST be an admin key to have access to the I/O via the groov Manage REST API, but this is the only place you need to put the key. (You can also have the key in a file on the device and read it in separately if you don’t want the key plaintext to be in the flow. Be mindful of this if you modify the flow then back it up / share it.)

Long version:

The top red section only needs to run once, or reran if the module is reconfigured, and the bottom blue section is run repeatedly to update the values as-needed.

Red section:
First all possible channel descriptions are collected with an HTTP GET request to /api/v1/io/descriptions/channels, which returns an object that contains a channelTypeId hexadecimal string, and the property name with the English description in the en property. Based on the value of this property it is possible to determine if a channel is analog, digital, (serial) data, generic, or disabled entirely.
While this step is taken the API key is also saved to a flow variable so that it can be reused in future API calls without needing to reenter the string.
Once this list of descriptions is parsed and saved, the module config is read in with /api/v1/io/{device}/modules/{moduleIndex}/config. In the case of a groov RIO MM1/MM2 this will return an object with a channels array property, where each channel has the channel name string and a channelType integer property. By converting this integer to a hex string it’s trivial to cross-reference this type with the channel type to find out which channels are configured as analog and which are digital. Once this list is created and saved we just need to reference it each time the module is read to know what each channel is.

Blue section:
Now that we know what each channel is the module in we can read in every single channel’s digital value and analog value. Some of these will be unused since the digital state of an analog channel is meaningless and vice versa. So once both digital and analog arrays are read in with /api/v1/io/{device}/modules/{moduleIndex}/digital/values and /api/v1/io/{device}/modules/{moduleIndex}/analog/values respectively, the channel type list can be used to determine what value to grab from which array, and those values get put in both a number-indexed array (msg.payload in my example) and a name-indexed JavaScript object (msg.names in my example) using the “combine” function node. The timestamp is also in this object that you can see an example of below – this is from my groov RIO Learning Center (MM1 and MM2 will be the same):


I’ll have the flow import below, feel free to use / modify this for your own application, just make sure you thoroughly test it before putting it into use anywhere! If you have questions about this, or do make use of it please leave a post in the thread below.
And as always, happy flowing!

[{"id":"dc191365b6bb9ab4","type":"tab","label":"RIO Dynamic Read","disabled":false,"info":""},{"id":"9eac521d37dc2da0","type":"function","z":"dc191365b6bb9ab4","name":"parse names","func":"var IDs = {};\n// Key terms to filter analog / digital:\nanalog = [\"mA\", \"Thermocouple\", \"Analog\", \"Temperature\", \"Ohm\",\n    \"Arms\", \"Vrms\", \"Freq\", \"Energy\", \"Thermistor\", \"Power\",\n    \"±\", \"0-5\", \"0-10\", \"Voltage\", \"Current\"];\ndigital = [\"Digital\", \"Switch\", \"Relay\", \"VDC\", \"VAC\"];\n// The first entry is generic, so set it separately:\nIDs[\"0x00000000\"] = \"generic\";\n// Loop through all description names:\nfor(var i in msg.payload) {\n    if(i === \"0x00000000\") continue;\n    // Check for \"disabled\" (unconfigured) channels:\n    if(msg.payload[i].name.en.includes(\"Disabled\")) {\n        IDs[i] = \"disabled\";\n        continue;\n    }\n    // Check for \"data\" (serial) channels:\n    if(msg.payload[i].name.en.includes(\"RS485\") ||\n        msg.payload[i].name.en.includes(\"RS232\") ||\n        msg.payload[i].name.en.includes(\"CAN\")) {\n        IDs[i] = \"data\";\n        continue;\n    }\n    // Check for all analog channel keywords:\n    for(var a in analog) {\n        if(msg.payload[i].name.en.includes(analog[a])) {\n            IDs[i] = \"analog\";\n            break;\n        }\n    }\n    // Check for all digital channel keywords;\n    for(var d in digital) {\n        if(msg.payload[i].name.en.includes(digital[d])) {\n            IDs[i] = \"digital\";\n            break;\n        }\n    }\n}\nmsg.IDs = IDs;\nflow.set(\"IDs\", IDs);\nmsg.headers = flow.get(\"headers\");\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":450,"y":100,"wires":[["488fc21b8992ccf2","57a4692223521707"]]},{"id":"488fc21b8992ccf2","type":"debug","z":"dc191365b6bb9ab4","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":690,"y":100,"wires":[]},{"id":"57a4692223521707","type":"http request","z":"dc191365b6bb9ab4","name":"config","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://localhost/manage/api/v1/io/local/modules/0/config","tls":"38032bf46c9e39e2","persist":false,"proxy":"","authType":"","x":270,"y":160,"wires":[["71ec33f4f572ac32"]]},{"id":"71ec33f4f572ac32","type":"function","z":"dc191365b6bb9ab4","name":"get types","func":"// channels object contains all configurations for this module.\nvar channels = msg.payload.channels;\n// IDs is an object with key = channelType, and value = analog/digital/data/generic/disabled.\nvar IDs = msg.IDs;\n// msg.types will be an array containing just the general type values from the IDs list.\nmsg.types = [];\n\n// Loop through all channelTypes for this module.\nfor (var c in channels) {\n    // Convert the channelType value from integer to hex string (0x________ (_ in upper case)).\n    var typeHex = \"0x\" + channels[c].channelType.toString(16).toUpperCase();\n    // Push the general type value for this channel type hex ID to the `msg.types` array.\n    msg.types.push(IDs[typeHex]);\n}\n\nflow.set(\"config\", msg.payload.channels);   // Save config to get channel names later.\nflow.set(\"types\", msg.types);               // Save types for the dynamic module reads.\nmsg.headers = flow.get(\"headers\");          // Set the apiKey header for an initial read.\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":440,"y":160,"wires":[["d5b1175e3681d71f","e8b6b3255b4b09f8"]]},{"id":"d5b1175e3681d71f","type":"debug","z":"dc191365b6bb9ab4","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":690,"y":160,"wires":[]},{"id":"798854fa9d651685","type":"function","z":"dc191365b6bb9ab4","name":"combine","func":"// Get the analog channel values from the `analog` API call.\n// (This array includes current analog reading and channel qualityError.)\nvar analog = msg.payload.channelValues;\n// Get the digital channel values from the `digital` API call.\n// (This array includes channel state, onLatch, offLatch, and qualityError.)\nvar digital = msg.digital.channelValues;\n// Get the channel types from the `config` API call and `get types` function.\nvar types = flow.get(\"types\") || [];\nvar config = flow.get(\"config\") || [];\nvar values = [];    // Initialize an array to list all values.\nmsg.names = {};     // Initialize an object for name:value pairs.\n// Loop through digital/analog/data/generic/disabled types.\nfor(var i in types) {\n    if(types[i] === \"digital\") values[i] = digital[i].state;\n    else if(types[i] === \"analog\") values[i] = analog[i].value;\n    else values[i] = null; // create an empty placeholder for data/generic/disabled channels.\n    // If the channel is unnamed (empty string), use \"Channel_<i>\" as a unique identifyer.\n    msg.names[(config[i].name.length > 0) ? config[i].name : \"Channel_\" + i ] = values[i];\n}\n// Set msg properties of all data for debugging.\nmsg.types = types;\nmsg.analog = analog;\nmsg.digital = digital;\nmsg.payload = values;\nmsg.datetime = new Date(msg.timestamp).toISOString();\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":560,"y":280,"wires":[["27cb0b9ca179729d"]]},{"id":"e2841041e18249bd","type":"http request","z":"dc191365b6bb9ab4","name":"analog","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://localhost/manage/api/v1/io/local/modules/0/analog/values?channels=8","tls":"38032bf46c9e39e2","persist":false,"proxy":"","authType":"","x":430,"y":280,"wires":[["798854fa9d651685"]]},{"id":"4bc595b8e46ed480","type":"change","z":"dc191365b6bb9ab4","name":"headers","rules":[{"t":"set","p":"headers","pt":"msg","to":"headers","tot":"flow"},{"t":"set","p":"digital","pt":"msg","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":280,"wires":[["e2841041e18249bd"]]},{"id":"e8b6b3255b4b09f8","type":"http request","z":"dc191365b6bb9ab4","name":"digital","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://localhost/manage/api/v1/io/local/modules/0/digital/values","tls":"38032bf46c9e39e2","persist":false,"proxy":"","authType":"","x":430,"y":220,"wires":[["4bc595b8e46ed480"]]},{"id":"bbbe3afb9ca2d6db","type":"change","z":"dc191365b6bb9ab4","name":"","rules":[{"t":"set","p":"headers","pt":"flow","to":"headers","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":60,"wires":[[]]},{"id":"27cb0b9ca179729d","type":"debug","z":"dc191365b6bb9ab4","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":690,"y":280,"wires":[]},{"id":"4c3fbe88f60ea035","type":"inject","z":"dc191365b6bb9ab4","name":"10s","props":[{"p":"timestamp","v":"","vt":"date"}],"repeat":"10","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":110,"y":220,"wires":[["4e04a01e10b81c87"]]},{"id":"4e04a01e10b81c87","type":"change","z":"dc191365b6bb9ab4","name":"headers","rules":[{"t":"set","p":"headers","pt":"msg","to":"headers","tot":"flow"}],"action":"","property":"","from":"","to":"","reg":false,"x":280,"y":220,"wires":[["e8b6b3255b4b09f8"]]},{"id":"2f64ea3aac48d775","type":"http request","z":"dc191365b6bb9ab4","name":"descriptions","method":"GET","ret":"obj","paytoqs":"ignore","url":"https://localhost/manage/api/v1/io/descriptions/channels","tls":"38032bf46c9e39e2","persist":false,"proxy":"","authType":"","x":290,"y":100,"wires":[["9eac521d37dc2da0"]]},{"id":"fa6212d1dd79d0ec","type":"inject","z":"dc191365b6bb9ab4","name":"API key","props":[{"p":"headers","v":"{\"apiKey\":\"<YOUR_API_KEY>\"}","vt":"json"},{"p":"timestamp","v":"","vt":"date"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","x":120,"y":100,"wires":[["2f64ea3aac48d775","bbbe3afb9ca2d6db"]]},{"id":"38032bf46c9e39e2","type":"tls-config","name":"","cert":"","key":"","ca":"","certname":"","keyname":"","caname":"","servername":"","verifyservercert":false,"alpnprotocol":""}]