Skip to content

Basic uibuilder RIOT example displaying values, switch and select box

Colin Law edited this page Oct 16, 2017 · 5 revisions

Here is an example using RIOT with uibuilder to show values from MQTT (or elsewhere) and send on messages using a switch and select box.

In the dependencies section of ~/.node-red/package.json add (along with whatever you normally have):

"dependencies": {
   "node-red-contrib-uibuilder": "*",
   "riot": "^3.7.3"
 }

In the module.exports part of your settings.js file, add this:

 uibuilder: {
   userVendorPackages: ['riot'],
   debug: false
 }

In ~/.node-red run

npm install node-red-contrib-uibuilder riot

Restart node-red.
Copy the flow below and import it into a tab in node-red, and deploy. Browse to <ip>:1880/home you should see the default uibuilder welcome page, and deploying it should have created a ~/.node-red/uibuilder/home folder, and in the src subfolder, it should have created index.css, index.html, index.js and manifest.json. If you want a url other than home then change it in the uibuilder node and re-deploy.

Flow:
```json
[{"id":"7be939b5.af86c8","type":"uibuilder","z":"251597c3.8396e8","name":"","topic":"","url":"home","fwdInMessages":false,"allowScripts":false,"allowStyles":false,"debugFE":false,"x":310.5,"y":163,"wires":[["dbee3905.275c7"]]},{"id":"c3d15ed.7ce93a","type":"debug","z":"251597c3.8396e8","name":"","active":true,"console":"false","complete":"payload","x":662,"y":76,"wires":[]},{"id":"e52b1aa4.1090e8","type":"inject","z":"251597c3.8396e8","name":"topic/one On","topic":"topic/one","payload":"On","payloadType":"str","repeat":"","crontab":"","once":false,"x":397,"y":354,"wires":[["738e30b9.71b4e"]]},{"id":"b42be742.a8c12","type":"inject","z":"251597c3.8396e8","name":"topic/one Off","topic":"topic/one","payload":"Off","payloadType":"str","repeat":"","crontab":"","once":false,"x":401,"y":385,"wires":[["738e30b9.71b4e"]]},{"id":"dbee3905.275c7","type":"switch","z":"251597c3.8396e8","name":"","property":"command","propertyType":"msg","rules":[{"t":"null"},{"t":"else"}],"checkall":"true","outputs":2,"x":448.5,"y":163,"wires":[["738e30b9.71b4e","c3d15ed.7ce93a"],["4f0391b0.b04868"]]},{"id":"4f0391b0.b04868","type":"function","z":"251597c3.8396e8","name":"Topic repeater","func":"// Given messages containing topic/payload values this saves the latest payload for each topic\n// and passes the message on.\n// If it receives a message with msg.command set to 'reload' it retransmits each saved pair as\n// individual messages.\n// If it receives a message with msg.command set to 'reset' it removes all saved data\nif (msg.command && msg.command === 'reload') {\n    // a reload message so re-send all messages saved\n    var keys = context.keys();\n    for (var i = 0; i < keys.length; i++) {\n        node.send({topic: keys[i], payload: context.get(keys[i])});\n    }\n    msg = null;\n} else if (msg.command && msg.command === 'reset') {\n    // a reset command so remove all saved messages\n    var keys = context.keys();\n    for (var i = 0; i < keys.length; i++) {\n        context.set(keys[i], undefined);\n    }\n    msg= null;\n} else {\n    // a normal message so add/update the payload for this topic, provided there is a topic\n    if (msg.topic) {\n        context.set(msg.topic, msg.payload);\n    }\n}\n// pass on the message unless it has been nulled\nreturn msg;\n","outputs":"1","noerr":0,"x":338.5,"y":265,"wires":[["7be939b5.af86c8"]]},{"id":"1bceb41a.73e694","type":"inject","z":"251597c3.8396e8","name":"topic/two 123.456","topic":"topic/two","payload":"123.456","payloadType":"num","repeat":"","crontab":"","once":false,"x":419,"y":440,"wires":[["738e30b9.71b4e"]]},{"id":"faab15e6.f82ec8","type":"inject","z":"251597c3.8396e8","name":"topic/two -73.5","topic":"topic/two","payload":"-73.5","payloadType":"num","repeat":"","crontab":"","once":false,"x":409,"y":472,"wires":[["738e30b9.71b4e"]]},{"id":"1c8cf52.85b7a0b","type":"link in","z":"251597c3.8396e8","name":"Simulated  MQTT In","links":["738e30b9.71b4e"],"x":83.5,"y":264,"wires":[["4f0391b0.b04868"]]},{"id":"738e30b9.71b4e","type":"link out","z":"251597c3.8396e8","name":"Simulated MQTT Out","links":["1c8cf52.85b7a0b"],"x":737.5,"y":136,"wires":[]},{"id":"a4c7955b.911278","type":"comment","z":"251597c3.8396e8","name":"Simulated MQTT Out","info":"","x":664.5,"y":184,"wires":[]},{"id":"1911ff6b.e20249","type":"comment","z":"251597c3.8396e8","name":"Simulated MQTT In","info":"","x":95,"y":209,"wires":[]}]

Replace index.html with the following:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">

  <!-- See https://goo.gl/OOhYW5 -->
  <link rel="manifest" href="manifest.json">
  <meta name="theme-color" content="#3f51b5">

  <!-- Used if adding to homescreen for Chrome on Android. Fallback for manifest.json -->
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="application-name" content="Node-RED UI Builder">

  <!-- Used if adding to homescreen for Safari on iOS -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="Node-RED UI Builder">

  <!-- Homescreen icons for Apple mobile use if required
      <link rel="apple-touch-icon" href="/images/manifest/icon-48x48.png">
      <link rel="apple-touch-icon" sizes="72x72" href="/images/manifest/icon-72x72.png">
      <link rel="apple-touch-icon" sizes="96x96" href="/images/manifest/icon-96x96.png">
      <link rel="apple-touch-icon" sizes="144x144" href="/images/manifest/icon-144x144.png">
      <link rel="apple-touch-icon" sizes="192x192" href="/images/manifest/icon-192x192.png">
  -->

  <title>Node-RED UI Builder</title>
  <meta name="description" content="Node-RED UI Builder">

  <link rel="icon" href="images/node-blue.ico">

  <!-- OPTIONAL: Normalize is used to make things the same across browsers. Index is for your styles -->
  <link rel="stylesheet" href="vendor/normalize.css/normalize.css">
  <link rel="stylesheet" href="index.css">
</head>
<body>
  <!-- the riot application tag contains everything -->
  <application></application>

  <!-- Socket.IO is loaded only once for all instances -->
  <script src="/uibuilder/socket.io/socket.io.js"></script>

  <!-- include riot.js and the compiler Note no leading / -->
  <script type="text/javascript" src="vendor/riot/riot+compiler.min.js"></script>

  <script src="uibuilderfe.min.js"></script>
  <script src="index.js"></script>
  <!-- application and other tags are specified in external files -->
  <script data-src="dashboard-tags.tag" type="riot/tag"></script>
  <script data-src="application-tags.tag" type="riot/tag"></script>
</body>
</html>

Replace index.js with:

/*global document,window,uibuilder */

/**
 *   uibuilder: The main global object containing the following...
 *     Methods:
 *       .onChange(attribute, callbackFn) - listen for changes to attribute and execute callback when it changes
 *       .get(attribute)        - Get any available attribute
 *       .set(attribute, value) - Set any available attribute (can't overwrite internal attributes)
 *       .msg                   - Shortcut to get the latest value of msg. Equivalent to uibuilder.get('msg')
 *       .send(msg)             - Shortcut to send a msg back to Node-RED manually
 *       .debug(true/false)     - Turn on/off debugging
 *       .uiDebug(type,msg)     - Utility function: Send debug msg to console (type=[log,info,warn,error,dir])
 *     Attributes with change events (only accessible via .get method except for msg)
 *       .msg          - Copy of the last msg sent from Node-RED over Socket.IO
 *       .sentMsg      - Copy of the last msg sent by us to Node-RED
 *       .ctrlMsg      - Copy of the last control msg received by us from Node-RED (Types: ['shutdown','server connected'])
 *       .msgsReceived - How many standard messages have we received
 *       .msgsSent     - How many messages have we sent
 *       .msgsCtrl     - How many control messages have we received
 *       .ioConnected  - Is Socket.IO connected right now? (true/false)
 *     Attributes without change events
 *           (only accessible via .get method, reload page to get changes, don't change unless you know what you are doing)
 *       .debug       - true/false, controls debug console logging output
 *       ---- You are not likely to need any of these ----
 *       .version     - check the current version of the uibuilder code
 *       .ioChannels  - List of the channel names in use [uiBuilderControl, uiBuilderClient, uiBuilder]
 *       .retryMs     - starting retry ms period for manual socket reconnections workaround
 *       .retryFactor - starting delay factor for subsequent reconnect attempts
 *       .ioNamespace - Get the namespace from the current URL
 *       .ioPath      - make sure client uses Socket.IO version from the uibuilder module (using path)
 *       .ioTransport - ['polling', 'websocket']
 */

// this is needed if you want to bundle everything up with webpack
if (typeof require != "undefined") {
  var uibuilder = require('node-red-contrib-uibuilder/nodes/src/uibuilderfe.js')
  var riot = require('riot')
  require("./index.css")
  require("./application-tags.tag")
}

// wait for the document to be loaded
window.onload = function() {
    // mount all the tags
    riot.mount('*')

    // Catch new messages from node red
    uibuilder.onChange('msg', function(newMsg){
      // all we need to do is tell riot to update all the data
      riot.update()
    })

    // Catch Socket.IO connect/disconnect events, connected will be true or false
    uibuilder.onChange('ioConnected', function(connected){
      if (connected) {
        // Send a reload message to indicate a browser window
        // has opened so all the topics will be reloaded.
        // Since we are using riot we also have to do that when all the tags
        // have been mounted, in the top level tag on mount (application-tags.tag)
        // as here may be too early if it is the initial connect. It won't matter
        // that this is done twice
        uibuilder.send({command: 'reload', payload: 'Browser connected'})
      }
      riot.update()
    })

}

Create the file application-tags.tag which contains the application:

if (typeof require != "undefined") {
  var uibuilder = require('node-red-contrib-uibuilder/nodes/src/uibuilderfe.js')
  require("./dashboard-tags.tag")
}
// define the tag that contains everything
<application>
  <div id="app">
    <h4>{this.ioConnected}</h4>

    <!-- a text value driven by a topic -->
    <p>Text value, topic/one: <dblt-value topic="topic/one"></dblt-value></p>
    <!-- a numeric value driven by a topic, displayed to two decimal places -->
    <p>Numeric value, topic/two: <dblt-value topic="topic/two" dp="2"></dblt-value></p>
    <!-- a switch with current value -->
    <p>Switch, current value: <dblt-value topic="topic/three"></dblt-value>&nbsp; <dblt-switch topic="topic/three"></dblt-switch></p>

    <!-- a dropdown selection, first define the values and text -->
    <script>
      this.mode_selections = [
        {value: '0', text: 'Off'},
        {value: '1', text: 'Intermittent'},
        {value: '2', text: 'On'}
      ]
    </script>
    <div>Mode: <dblt-select topic = "topic/four" selections = {mode_selections}></dblt-select></div>

  </div>
  <script>
    // this is done when riot.update() is called, we just have to refresh any data that
    // is shown by the application tag
    this.on('update', function() {
      this.ioConnected = uibuilder.get("ioConnected") ? "Connected" : "Disconnected"
    })

    // include this in just the top level tag of the application
    this.on('mount', function() {
      // The application tab is mounted, so all its children should be, send a reload
      // message to get node-red to send us the latest values
      uibuilder.send({command: 'reload', payload: 'Browser connected'})
      this.ioConnected = uibuilder.get("ioConnected") ? "Connected" : "Disconnected"
      this.update()
     })

  </script>
</application>

And finally, dashboard-tags.tag which contains the definition of the low level objects:

if (typeof require != "undefined") {
  var uibuilder = require('node-red-contrib-uibuilder/nodes/src/uibuilderfe.js')
}

<!--
  Given a topic this displays the current payload value for that topic
  If dp is provided then the value is coerced to a number and then formatted with
  the specified number of decimal places
-->
<dblt-value>
  <span>{this.payload || "."}</span>

  <script>
  this.on('update', function() {
    var msg = uibuilder.get("msg")
    if (msg.topic == opts.topic) {
      this.payload = opts.dp ? Number(msg.payload).toFixed(opts.dp) : msg.payload
    }
  })
  </script>
</dblt-value>

<!--
  Given a topic and an array of selections this displays a select box with the
  text for the value passed in the payload as the current selection. If the user
  changes the selection that the value is sent in a message with the topic.
  If the extras option is provided then the sent message will containg the property extras
  with the value given. This can be useful, for example, for specifying alternative MQTT servers.
  The selections array can be setup and the tag invoked using something like
  <script>
    this.mode_selections = [
      {value: '0', text: 'Off'},
      {value: '1', text: 'Intermittent'},
      {value: '2', text: 'On'}
    ]
  </script>
  <dblt-select topic = "some/topic" selections = {mode_selections}></dblt-select>
-->
<dblt-select>
  <select value={selected} onchange={ selection_changed } >
    <option each={ item in opts.selections } value={item.value}>{item.text}</option>
  </select>

  <script>
    this.selected = ''

    this.on('update', function() {
      var msg = uibuilder.get("msg")
      if (msg.topic == opts.topic) {
        this.selected = msg.payload
      }
    })

    this.selection_changed = function(e) {
      this.selected = e.target.value
      uibuilder.send({topic: opts.topic, payload: e.target.value, extras: opts.extras})
    }

  </script>
</dblt-select>

<!--
  Given a topic this displays a switch showing the state from the payload value for that topic
  The default expected is "On" and "Off" payload values.
  If the switch is clicked then a message is sent with the specified topic and the payload
  containing the new state.
  Options on and off may be provided to provide alternative on/off strings
  If the extras option is provided then the sent message will containg the property extras
  with the value given. This can be useful, for example, for specifying alternative MQTT servers.
-->
<dblt-switch>
  <label class="switch">
    <input type="checkbox" checked="{this.checked}" onclick={switch_clicked}>
      <span class="slider round"></span>
    </input>
  </label>

  <script>
  this.on_text = opts.on || "On"
  this.off_text = opts.off || "Off"
  this.on('update', function() {
    var msg = uibuilder.get("msg")
    if (msg.topic == opts.topic) {
      this.checked = msg.payload === this.on_text
    }
  })

  this.switch_clicked = function(e) {
    this.checked = !this.checked
    uibuilder.send({topic: opts.topic, payload: (this.checked ? this.on_text : this.off_text), extras: opts.extras})
  }
  </script>

  <style>
  /* The switch - the box around the slider */
  .switch {
    position: relative;
    display: inline-block;
    width: 36px;
    height: 20px;
  }

  /* Hide default HTML checkbox */
  .switch input {display:none;}

  /* The slider */
  .slider {
    position: absolute;
    cursor: pointer;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: #ccc;
    -webkit-transition: .4s;
    transition: .4s;
  }

  .slider:before {
    position: absolute;
    content: "";
    height: 15px;
    width: 15px;
    left: 3px;
    bottom: 3px;
    background-color: white;
    -webkit-transition: .2s;
    transition: .2s;
  }

  input:checked + .slider {
    background-color: #2196F3;
  }

  input:focus + .slider {
    box-shadow: 0 0 1px #2196F3;
  }

  input:checked + .slider:before {
    -webkit-transform: translateX(15px);
    -ms-transform: translateX(15px);
    transform: translateX(15px);
  }

  /* Rounded sliders */
  .slider.round {
    border-radius: 34px;
  }

  .slider.round:before {
    border-radius: 50%;
  }

  </style>
</dblt-switch>

Refresh the browser you should be up and running.

I have been very surprised how much can be achieved with so little code, with uibuilder (thanks to Julian Knight) and riot doing all the hard work in the background.

One issue that had to be overcome is that when the browser is refreshed or initially connected (or when another browser is connected) that there is no history so the initial states of all the controls will be unknown. To overcome this, in index.js when the socket connection is made it sends a message with msg.command set. In the flow this is sensed by the switch node and is sent on to the Topic repeater function node. This node is designed to remember the latest payload values for all topics sent to it. When it sees the reload command it re-sends a message for each topic, which goes to the uibuilder node and populates the controls with the latest values. There is one final twist to this in that it is possible for the connection to be made before riot has fully instantiated all the tags, in which case the messages may arrive too early. To cope with this, the reload message is also sent from the on mount handler in the top level tag (the application tag). On mount is called for the top level tag after all child tags have been instantiated so at that time all controls will be populated. I decided to include both calls as, if successful, the first call will populate them quicker and the fact that the messages will be sent twice is of little, if any, consequence.

If anyone who knows more about css than I do would like to provide the css for a fancier looking switch then please do.

Colin Law

Clone this wiki locally