Skip to content

Example Show MQTT device info using Moon with caching

Patrick edited this page Jun 22, 2020 · 3 revisions

This example shows how to create a simple page that accumulates "Homie" style MQTT topics into a table by device (rows).

It uses Moon.JS (which must be npm installed) and Mini.CSS (which is linked directly from GitHub).

Obviously, you must have an MQTT broker and need to be sending Homie style topic messages to it. Though it should be easy enough to adapt for other topic structures.

The function node is a simple caching mechanism that stores the last msg by topic. It also responds to the cache replay and cache clear messages that can be output by uibuilder.

Flow Screenshot

Flow

[{"id":"401897b8.9931e8","type":"uibuilder","z":"9974253c.de8db8","name":"","topic":"","url":"home","fwdInMessages":false,"allowScripts":true,"allowStyles":true,"debugFE":false,"copyIndex":true,"x":490,"y":700,"wires":[["d0dec491.704248"],["bbc35ca3.97343","ff280acc.207098"]]},{"id":"51a49297.42e16c","type":"debug","z":"9974253c.de8db8","name":"homie mqtt","active":false,"console":"false","complete":"payload","x":330,"y":760,"wires":[]},{"id":"669661f1.279ac","type":"mqtt in","z":"9974253c.de8db8","name":"","topic":"homie/#","qos":"2","broker":"4095ec64.ea0dd4","x":150,"y":760,"wires":[["51a49297.42e16c","889a6459.17d2f8"]]},{"id":"d0dec491.704248","type":"debug","z":"9974253c.de8db8","name":"home data out","active":false,"console":"false","complete":"true","x":680,"y":680,"wires":[]},{"id":"889a6459.17d2f8","type":"function","z":"9974253c.de8db8","name":"","func":"// Expects input msgs with topic set \n\n// saved context\nvar homeMsgs = context.get('homeMsgs') || {}\n\n// Only send to single client if needed\nvar socketId = null\nif ( msg.hasOwnProperty('_socketId') ) {\n    socketId = msg._socketId\n}\n\n// Replay cache if requested\nif ( msg.hasOwnProperty('cacheControl') && msg.cacheControl === 'REPLAY' ) {\n    for (var topic in homeMsgs) {\n        let newMsg = {\n            \"topic\": topic, \n            \"payload\": homeMsgs[topic]\n        }\n        // Only send to a single client if we can\n        if ( socketId !== null ) newMsg._socketId = socketId\n        node.send(newMsg)\n    }\n    return null\n}\n// -- else --\n// Empty cache if requested\nif ( (msg.hasOwnProperty('cacheControl') && msg.cacheControl === 'RESET')  ||\n     (msg.payload.hasOwnProperty('cacheControl') && msg.payload.cacheControl === 'RESET') ) {\n    homeMsgs = {}\n    context.set('homeMsgs', homeMsgs)\n    return null\n}\n// -- else --\n\n// ignore cacheControl and uibuilder control messages\nif ( msg.hasOwnProperty('cacheControl') || msg.hasOwnProperty('uibuilderCtrl') ) return null\n\n// Add a counter for each device name\nif ( msg.topic.endsWith('$name') ) {\n    let topic = msg.topic.replace('$name', '$count')\n    let count = homeMsgs[topic] || 0\n    count = count + 1\n    homeMsgs[topic] = count\n    let newMsg = {\n        \"topic\": topic, \n        \"payload\": count\n    }\n    // Only send to a single client if we can\n    if ( socketId !== null ) newMsg._socketId = socketId\n    node.send(newMsg)\n}\n\n// Keep the last msg.payload by topic\nhomeMsgs[msg.topic] = msg.payload\n\n// save context for next time\ncontext.set('homeMsgs', homeMsgs)\n\nreturn msg;","outputs":1,"noerr":0,"x":310,"y":700,"wires":[["401897b8.9931e8","b4850396.c9aef"]]},{"id":"bbc35ca3.97343","type":"debug","z":"9974253c.de8db8","name":"home controls","active":false,"console":"false","complete":"true","x":680,"y":720,"wires":[]},{"id":"e35dc750.bbfbe8","type":"link in","z":"9974253c.de8db8","name":"home-replay","links":["ff280acc.207098"],"x":115,"y":700,"wires":[["889a6459.17d2f8"]]},{"id":"ff280acc.207098","type":"link out","z":"9974253c.de8db8","name":"home-controls","links":["e35dc750.bbfbe8"],"x":615,"y":760,"wires":[]},{"id":"b4850396.c9aef","type":"debug","z":"9974253c.de8db8","name":"input messages","active":false,"console":"false","complete":"true","x":480,"y":640,"wires":[]},{"id":"dc4be228.e2c51","type":"inject","z":"9974253c.de8db8","name":"RESET","topic":"","payload":"{\"cacheControl\":\"RESET\"}","payloadType":"json","repeat":"","crontab":"","once":false,"x":150,"y":660,"wires":[["889a6459.17d2f8"]]},{"id":"4095ec64.ea0dd4","type":"mqtt-broker","z":"","broker":"localhost","port":"1883","clientid":"Pi2-NRlive-a","usetls":false,"compatmode":false,"keepalive":"60","cleansession":true,"willTopic":"","willQos":"0","willPayload":"","birthTopic":"","birthQos":"0","birthPayload":""}]

index.html

<!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">

    <title>Merlin HA Home Page</title>
    <meta name="description" content="Merlin HA Home Page">

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

    <!-- 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="Merlin HA">

    <!-- 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="Merlin HA">

    <!-- 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">
    --> 

    <link rel="stylesheet" href="https://gitcdn.link/repo/Chalarangelo/mini.css/master/dist/mini-default.min.css">
    <link rel="stylesheet" href="index.css" media="all">

</head><body>

    <div class="container" id="app">
        
        <header class="sticky">
            <div class="col-sm-12 col-md-10 col-md-offset-1">
                <span class="logo">House</span>
                <a href="#" class="button">Home</a>
                <a href="switches.html" class="button">Switches</a>
            </div>
        </header>
    
        <div class="row">
            <p class="col-sm-12 col-md-10 col-md-offset-1">
                This is a uibuilder test using <a href="http://moonjs.ga/">Moon.JS</a> as a front-end library.
                See the 
                <a href="https://github.com/TotallyInformation/node-red-contrib-uibuilder">node-red-contrib-uibuilder</a>
                README for details on how to use UIbuilder.
            </p>
            <div class="col-sm-12 col-md-10 col-md-offset-1 bordered rounded">

                <h2>IoT Devices - Last Update</h2>

                <table>
                    <thead>
                        <tr>
                            <th>Device ID</th><th>Last Update</th><th>Count</th><th>Location</th><th>Battery</th><th>Signal</th><th>Temperature</th><th>Humidity</th><th>Command</th>
                        </tr>            
                    </thead>
                    <tbody>
                        <tr m-for="data,topic in homie">
                            <td data-label="Device ID">{{topic}}</td>
                            <td data-label="Last Update" style="{{timeAgo(data.updated)}}">{{data.updated}}<span title="Online?">{{data.online}}</span></td>
                            <td data-label="Count">{{data.count}}</td>
                            <td data-label="Location">{{data.location}}</td>
                            <td data-label="Battery">{{data.battery}}</td>
                            <td data-label="Signal">{{data.signal}}</td>
                            <td data-label="Temperature">{{data.temperature}}</td>
                            <td data-label="Humidity">{{data.humidity}}</td>
                            <td data-label="Command">{{data.command}}<span title="Command Unit">{{data.cmdUnit}}</span></td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
        <footer class="row">
            <div class="col-sm-12 col-md-10 col-md-offset-1">
                Copyright &copy; Julian Knight 2017 | Content generated by Node-RED and MoonJS
            </div>
        </footer>
    </div>

    <!-- These MUST be in the right order. Note no leading / -->
    <!-- REQUIRED: Socket.IO is loaded only once for all instances
                   Without this, you don't get a websocket connection -->
    <script src="../uibuilder/vendor/socket.io/socket.io.js"></script>
    <!-- Note no leading / -->
    <!-- OPTIONAL: JQuery can be removed if not required -->
    <!-- <script src="../uibuilder/vendor/jquery/dist/jquery.min.js"></script> -->
    <!-- REQUIRED: Sets up Socket listeners and the msg object -->
    <!-- <script src="../uibuilder/vendor/moonjs/dist/moon.min.js"></script>    //prod version -->
    <script src="../uibuilder/vendor/moonjs/dist/moon.js"></script>  <!--  //dev version -->
    <script src="uibuilderfe.js"></script>   <!-- //dev version -->
    <!-- <script src="uibuilderfe.min.js"></script> <!--    //prod version -->
    <!-- OPTIONAL: You probably want this. Put your custom code here -->
    <script src="index.js"></script>

</body>
</html>

index.js

/*global document,window,Moon,uibuilder */
// @ts-ignore
/*
  Copyright (c) 2017 Julian Knight (Totally Information)

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at

  http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
*/
/**
 * This is based on the default, template Front-End JavaScript for uibuilder
 * It has been changed to remove JQuery and use MoonJS instead.
 * 
 * uibuilderfe.js (or uibuilderfe.min.js) exposes the following global object.
 * Note that you can use webpack or similar to bundle uibuilder with your own code.
 * 
   * uibuilder: The main global object containing the following...
   *   External Methods:
     *   .onChange(property, callbackFn) - listen for changes to property and execute callback when it changes
     *   .get(property)        - Get any available property
     *   .set(property, value) - Set any available property (can't overwrite internal properties)
     *   .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
     *   .sendCtrl(msg)        - Shortcut to send a control msg back to Node-RED manually (@since v0.4.8)
     *   .debug(true/false)    - Turn on/off debugging
     *   .uiDebug(type,msg)    - Utility function: Send debug msg to console (type=[log,info,warn,error,dir])
     *   .me()                 - Returns the self object if debugging otherwise just the current version string
     *   .autoSendReady(true/false) - If true, sends "ready for content" ctrl msg on window.load
     *                   If false, you will need to do uibuilder.sendCtrl({'type':'ready for content'}) manually
     *                   (e.g. in an app.mounted event)  @since v0.4.8a
   *
   *   All properties can be read using the .get method
   *   New properties can be added via .set method as long as the property name does not clash with anything internal.
   *   All properties (including custom) can have change events associated with them by using the .onChange method
   *
   *   Externally settable properties using special methods only
     *   .autoSendReady - see .autoSendReady method
     *   .debug         - see .debug method, also see 'server connected' control msg from server
   *
   *   Externally settable properties via the .set method
     *   .allowScript   - Allow incoming msg to contain msg.script with JavaScript that will be automatically executed
     *   .allowStyle    - Allow incoming msg to contain msg.style with CSS that will be automatically executed
     *   .removeScript  - Delete msg.code after inserting to DOM if it exists on incoming msg
     *   .removeStyle   - Delete msg.style after inserting to DOM if it exists on incoming msg
   *
   *   Externally read only properties (may be changed internally)
     *   .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 (both data and control)
     *   .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
     *   .msgsSentCtrl  - How many control messages have we sent
     *   .msgsCtrl      - How many control messages have we received
     *   .ioConnected   - Is Socket.IO connected right now? (true/false)
     *   ---- You are not likely to need any of these, they are for internal use ----
     *   .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']
     *   .timerid       - internal use only
     *   .events        - list of registered events
 */

uibuilder.start();

"use strict";

// Attach a Moon instance to html element with id "app"
const app1 = new Moon({
    el: "#app",
    data: {
        startMsg    : "Moon has started, waiting for messages",
        feVersion   : '',
        counterBtn  : 0,
        socketConnectedState: false,
        msgsReceived: 0,
        msgsControl : 0,
        msgsSent    : 0,
        msgRecvd    : '[Nothing]',
        msgSent     : '[Nothing]',
        msgCtrl     : '[Nothing]',
        inputText   : '',
        updates     : {}, // devId: {updated,}
        homie       : {} // devId: {updated,}
    }, // --- End of data --- //
    computed: {
        hLastRcvd: {
            get: function() {
                const msgRecvd = this.get('msgRecvd')
                if (typeof msgRecvd === 'string') return 'Last Message Received = ' + msgRecvd
                else return 'Last Message Received = ' + this.callMethod('syntaxHighlight', [msgRecvd])
            }
        },
        hLastSent: {
            get: function() {
                const msgSent = this.get('msgSent')
                if (typeof msgSent === 'string') return 'Last Message Sent = ' + msgSent
                else return 'Last Message Sent = ' + this.callMethod('syntaxHighlight', [msgSent])
            }
        },
        hMsgCtrl: {
            get: function() {
                const msgCtrl = this.get('msgCtrl')
                if (typeof msgCtrl === 'string') return 'Last Message Sent = ' + msgCtrl
                //else return 'Last Message Sent = ' + this.callMethod('syntaxHighlight', [msgCtrl])
                else return 'Last Message Sent = ' + JSON.stringify(msgCtrl)
            }
        },
    }, // --- End of computed --- //
    methods: {
        // Increment the button counter
        increment: function() {
            // Increment the count by one
            this.set('counterBtn', this.get('counterBtn') + 1)
            let topic = (this.get('msgRecvd')).topic || 'uibuilder/moon'
            uibuilder.send( { 'topic': topic, 'payload': { 'type': 'counterBtn', 'btnCount': this.get('counterBtn'), 'message': this.get('inputText') } } )
        },
        // Format style based on date/time
        // - only really works if the table is being regularly updated
        timeAgo: function(dt) {
            var elapsed = new Date() - Date.parse(dt)
            switch (true) {
                // < 10sec
                case elapsed <= 10000:
                    return 'background-color:green; color:white'
                    break
                // 10s to 1min
                case elapsed > 10000 && elapsed <= 60000:
                    return 'background-color:blue; color:white'
                    break
                // 1min to 10 min
                case elapsed > 60000 && elapsed < 600000:
                    return 'background-color:orange; color:white'
                    break
                // > 10 min
                default:
                    return 'background-color:red; color:white'
                    break
            }
        },
        // return formatted HTML version of JSON object
        syntaxHighlight: function(json) {
            json = JSON.stringify(json, undefined, 4)
            json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
                var cls = 'number'
                if (/^"/.test(match)) {
                    if (/:$/.test(match)) {
                        cls = 'key'
                    } else {
                        cls = 'string'
                    }
                } else if (/true|false/.test(match)) {
                    cls = 'boolean'
                } else if (/null/.test(match)) {
                    cls = 'null'
                }
                return '<span class="' + cls + '">' + match + '</span>'
            })
        } // --- End of syntaxHighlight --- //
    }, // --- End of methods --- //
    hooks: { // Available hooks: init,mounted,updated,destroyed
        
        mounted: function(){
            //console.debug('app mounted - setting up uibuilder watchers')

            var that = this

            that.set( 'feVersion', uibuilder.get('version'))

            // If msg changes - msg is updated when a standard msg is received from Node-RED over Socket.IO
            // Note that you can also listen for 'msgsReceived' as they are updated at the same time
            // but newVal relates to the attribute being listened to.
            uibuilder.onChange('msg', function(newVal){
                that.set( 'msgRecvd', newVal)

                /** If we receive an MQTT type msg with topic 'homie/<deviceId>/...
                 * NB: MoonJS <v1 if needing to set whole topic, use the form:
                 *     instance.set("updates[homie/devId]", val)
                 *     e.g. no inner quotes
                 */
                if ( newVal.hasOwnProperty('topic') ) {
                    let splitTopic = newVal.topic.split('/')
                    let devId = splitTopic[1]
                    if ( splitTopic[0] === 'homie' ) {
                        let homie = that.get('homie')
                        if ( !homie.hasOwnProperty(devId) ) {
                            homie[devId] = {'updated':' ','battery':' ','signal':' ','temperature':' ','humidity':' ','pressure':' ','light':' ','location':' ','online':' ','count':' ','command':' ','cmdUnit':' '}
                            //homie[devId] = {'battery':null}
                        }
                        if ( splitTopic[3] === '$updated' ) {
                            homie[devId].updated = newVal.payload.slice(1,20).replace('T', ' ') //replace('"','').replace('Z"','')
                        }
                        if ( splitTopic[3] === 'battery' ) {
                            homie[devId].battery = newVal.payload
                        }
                        if ( splitTopic[3] === 'signal' ) {
                            homie[devId].signal = newVal.payload
                        }
                        if ( splitTopic[3] === 'temperature' ) {
                            homie[devId].temperature = newVal.payload
                        }
                        if ( splitTopic[3] === 'humidity' ) {
                            homie[devId].humidity = newVal.payload
                        }
                        if ( splitTopic[3] === 'pressure' ) {
                            homie[devId].pressure = newVal.payload
                        }
                        if ( splitTopic[3] === 'light' ) {
                            homie[devId].light = newVal.payload
                        }
                        if ( splitTopic[3] === 'cmd' ) {
                            homie[devId].command = newVal.payload
                        }
                        if ( splitTopic[3] === 'unit' ) {
                            homie[devId].cmdUnit = ' (' + newVal.payload + ')'
                        }

                        if ( splitTopic[2] === '$location' ) {
                            homie[devId].location = newVal.payload
                        }
                        if ( splitTopic[2] === '$online' ) {
                            homie[devId].online = newVal.payload ? ' (Online)' : ' (Online)'
                        }
                        if ( splitTopic[2] === '$count' ) {
                            homie[devId].count = newVal.payload
                        }
                        //uibuilder.set('homie',homie)
                        that.set('homie', homie)
                    }
                }
            })
            // As noted, we could get the msg here too
            uibuilder.onChange('msgsReceived', function(newVal){
                //console.info('New msg sent FROM Node-RED over Socket.IO. Total Count: ', newVal)
                app1.set( 'msgsReceived', newVal)
            })


            // If a message is sent back to Node-RED
            uibuilder.onChange('sentMsg', function(newVal){
                //console.info('property sentMsg changed!', newVal)
                app1.set( 'msgSent', newVal)
            })
            uibuilder.onChange('msgsSent', function(newVal){
                //console.info('New msg sent TO Node-RED over Socket.IO. Total Count: ', newVal)
                app1.set('msgsSent', newVal )
            })


            // If we receive a control message from Node-RED
            uibuilder.onChange('ctrlMsg', function(newVal){
                console.info('msgCtrl received %o', newVal)
                app1.set( 'msgCtrl', newVal)
            })
            uibuilder.onChange('msgsCtrl', function(newVal){
                //console.info('New CONTROL msg sent FROM Node-RED over Socket.IO. Total Count: ', newVal)
                app1.set('msgsControl', newVal )
            })

            // If Socket.IO connects/disconnects
            uibuilder.onChange('ioConnected', function(newVal){
                //console.info('Socket.IO Connection Status Changed: ', newVal)
                app1.set('socketConnectedState', newVal )
            })

        } // --- End of mounted hook --- //
    } // --- End of hooks --- //
}) // --- End of app1 --- //

// EOF

index.css

body {font-family: sans-serif;}
div, p, code, pre { margin:0.3em; padding: 0.3em;}
pre .string { color: green; }
.number { color: darkorange; }
.boolean { color: blue; }
.null { color: magenta; }
.key { color: red; }
Clone this wiki locally