Instrumentation modbus

L’instrumentation consiste ici à équiper un véhicule électrique de sondes afin de mesurer la vitesse, la température du moteur ainsi que sa vitesse de rotation. Les sondes sont construites autour de cartes Arduino. Les cartes Arduino sont reliées à un bus RS485. Une application Node-red embarquée dans un Raspberry Pi permet de communiquer via le protocole modBus avec les cartes Arduino. Les données recueillies sont affichées sur un tableau de bord : une page web développée elle aussi avec Node-red équipée de jauges.

Une Carte Raspberry équipée de node-red se comporte comme un maitre modBus. Une carte Arduino effectue une mesure et se comporte comme un esclave modBus. Le maitre interroge cycliquement la carte Arduino qui envoie alors ses résultats. La trame modBus est constituée d’un paquet de 7 données (voir le tableau dans le code Arduino). Node-red récupère ces données et les encapsule dans un objet json qui est stocké dans le contexte du flux. Une page web est fabriquée à l’aide de node-red cette page affiche une jauge grâce au script sonicGauge. Des requêtes Ajax à intervalles réguliers récupèrent l’objet stocké dans le contexte, ce qui permet la mise à jour de la jauge en temps réel.

Vidéos

La playlist suivante contient 4 vidéos :

  1. Présentation du projet
  2. Intégration du nœud Slave Modbus, extraction des données et sauvegarde dans le contexte de flux.
  3. Création d’une page web exploitant le contexte de flux à l’aide d’AJAX.
  4. Intégration de sonicGauge pour afficher les valeurs.

Code Arduino

La bibliothèque ModbusRtu.h est accessible ici : https://github.com/smarmengol/Modbus-Master-Slave-for-Arduino

Le code Arduino simule une acquisition de température allant de 0 à 42°C ce manière cyclique.

#include <ModbusRtu.h>
#include <SoftwareSerial.h>
#define SERIAL 0
#define MODBUS_ADR 1
#define TXEN 3
#define ID 0xCAFE
#define UNITE 0x09 // 
#define TYPE 0x03
#define MIN 0
#define MAX 100 
// data array for modbus network sharing
// format : {Id_appareil,id_unite,id_type,min,max,valEntiere,valDeci}
// 
// id_appareil : numéro unique
/* 

╔═════════╦════════════════╦══════════╦═════════╦═════╦═════╦════════════╦═════════╗
║  index  ║       0        ║    1     ║    2    ║  3  ║  4  ║     5      ║    6    ║
╠═════════╬════════════════╬══════════╬═════════╬═════╬═════╬════════════╬═════════╣
║ trame   ║ id_appareil    ║ id_unite ║ id_type ║ min ║ max ║ valEntiere ║ valDeci ║
╠═════════╬════════════════╬══════════╬═════════╬═════╬═════╬════════════╬═════════╣
║ exemple ║ 51966 (0xCAFE) ║ 9        ║ 3       ║ 5   ║ 95  ║ 33         ║ 90      ║
╚═════════╩════════════════╩══════════╩═════════╩═════╩═════╩════════════╩═════════╝

tableau des unité :
╔══════════╦══════╦═════╦══════╦═══╦════╦═══╦═══╦═════════╦═══════════╦════╗
║ id_unite ║  0   ║  1  ║  2   ║ 3 ║ 4  ║ 5 ║ 6 ║    7    ║     8     ║ 9  ║
╠══════════╬══════╬═════╬══════╬═══╬════╬═══╬═══╬═════════╬═══════════╬════╣
║ unité    ║ sans ║ m/s ║ Km/h ║ m ║ km ║ s ║ h ║ Tours/s ║ Tours/min ║ °C ║
╚══════════╩══════╩═════╩══════╩═══╩════╩═══╩═══╩═════════╩═══════════╩════╝
tableau des types :
╔═════════╦══════╦══════╦═════╦═════════╗
║ id_type ║  0   ║  1   ║  2  ║    3    ║
╠═════════╬══════╬══════╬═════╬═════════╣
║ Type    ║ Char ║ Byte ║ Int ║ decimal ║
╚═════════╩══════╩══════╩═════╩═════════╝
*/
// min :                       valeur minimum
// max :                       valeur max                             
// valEntiere:                 valeur entière
// valDeci :                   valeur decimale
 
uint16_t mesure[] = { ID, UNITE, TYPE, MIN, MAX, 0, 0 };
/**
 *  Modbus object declaration
 *  u8id : node id = 0 for master, = 1..247 for slave
 *  u8serno : serial port (use 0 for Serial)
 *  u8txenpin : 0 for RS-232 and USB-FTDI 
 *               or any pin number > 1 for RS-485
 */
Modbus slave(MODBUS_ADR,SERIAL,TXEN); // this is slave @1 and RS-232 or USB-FTDI
unsigned long previousMillis = 0;        // will store last time LED was updated
 
// constants won't change :
const long interval = 75;
void setup() {
  slave.begin( 19200 ); // baud-rate at 19200
}
 
void loop() {
        static long temp = 0;
        unsigned long currentMillis = millis();
 
        if (currentMillis - previousMillis >= interval) {
                // save the last time you blinked the LED
                previousMillis = currentMillis;
                temp += 10;
                int entier = temp / 100;
                int decimal = temp - (entier *100);
                mesure[5] = entier;
                mesure[6] = decimal;
                if (temp>4200)
                {
                        temp = 0;
                }
 
        }
 
  slave.poll( mesure,sizeof(mesure) );
}

Fichier JS du nœud “data2obj”

/* 

╔═════════╦════════════════╦══════════╦═════════╦═════╦═════╦════════════╦═════════╗
║  index  ║       0        ║    1     ║    2    ║  3  ║  4  ║     5      ║    6    ║
╠═════════╬════════════════╬══════════╬═════════╬═════╬═════╬════════════╬═════════╣
║ trame   ║ id_appareil    ║ id_unite ║ id_type ║ min ║ max ║ valEntiere ║ valDeci ║
╠═════════╬════════════════╬══════════╬═════════╬═════╬═════╬════════════╬═════════╣
║ exemple ║ 51966 (0xCAFE) ║ 9        ║ 3       ║ 5   ║ 95  ║ 33         ║ 90      ║
╚═════════╩════════════════╩══════════╩═════════╩═════╩═════╩════════════╩═════════╝

tableau des unité :
╔═══════╦══════╦═════╦══════╦═══╦════╦═══╦═══╦═════════╦═══════════╦════╗
║ index ║  0   ║  1  ║  2   ║ 3 ║ 4  ║ 5 ║ 6 ║    7    ║     8     ║ 9  ║
╠═══════╬══════╬═════╬══════╬═══╬════╬═══╬═══╬═════════╬═══════════╬════╣
║ unité ║ sans ║ m/s ║ Km/h ║ m ║ km ║ s ║ h ║ Tours/s ║ Tours/min ║ °C ║
╚═══════╩══════╩═════╩══════╩═══╩════╩═══╩═══╩═════════╩═══════════╩════╝
tableau des types :
╔═══════╦══════╦══════╦═════╦═════════╗
║ index ║  0   ║  1   ║  2  ║    3    ║
╠═══════╬══════╬══════╬═════╬═════════╣
║ Type  ║ Char ║ Byte ║ Int ║ decimal ║
╚═══════╩══════╩══════╩═════╩═════════╝
par exemple data = [ 51966, 9, 3, 5, 95, 33, 90 ]
id = 51966 = 0xCAFE
unité = 9 => °C
type = 3 => decimal
min = 5 => minimum indiqué sur la gauge
max = 95 => max indiqué sur la gauge
valEntiere = 33 et vaDeci = 90 => tempétature = 33.90°C

format de l'objet de sortie :
4 propriétés : unite, valeur, min et max 
{ "unite": "°C", "valeur": 33.9, "min": 5, "max": 95 }
*/
var data = msg.payload;
var obj={};
var unite = ['','m/s','km/h','m','km','s','h','tours/s','tours/min','°C'];

if(data[0]==0xCAFE)
{
    obj.unite=unite[data[1]];
    if(data[2]==3){
        obj.valeur= (data[5]*100 + data[6])/100.0;
    }
    obj.min=data[3];
    obj.max=data[4];
}
flow.set('message',obj);
msg.payload=obj;
return msg;

Fichier html du noeud “html”

<script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.9.0/jquery.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
<script>{{{script}}}</script>
<div id='thermometre' class="gauge"></div>
<script>
    var thermometre= $('#thermometre').SonicGauge();
    
    $.getJSON(
    "/data",
    function(data){
        var options ={};
        options.label= data.unite;
        options.start = {angle: -225, num: data.min};
        options.end		= {angle: 45, num: data.max};
        thermometre.SonicGauge('setOptions', options);
        thermometre.SonicGauge ('draw');
    });
                
                
setInterval(function () {
$.getJSON(
    "/data",
    function(data){
        thermometre.SonicGauge('val',data.valeur);
    });
},200);


</script>

Script SonicGauge à coller dans le nœud “script”

/**
 * Sonic Gauge jQuery Plugin v0.3.0
 * jQuery plugin to create and display SVG gauges using RaphaelJS
 * 
 * Copyright (c) 2013 Andy Burton (http://andyburton.co.uk)
 * GitHub https://github.com/andyburton/Sonic-Gauge
 * 
 * Licensed under the MIT license (http://andyburton.co.uk/license/mit.txt)
 */

(function(b){var a={init:function(c){if(!this.length){return this}this.options=b.extend(true,{},b.fn.SonicGauge.defaultOptions);this.settings={};this.SonicGauge("setOptions",c);this.SonicGauge("draw");return this},setOptions:function(c){if(c){b.extend(true,this.options,c)}this.settings.canvas_d=this.options.diameter;this.settings.canvas_r=this.settings.canvas_d/2;this.settings.speedo_d=this.settings.canvas_d-this.options.margin*2;this.settings.speedo_r=this.settings.speedo_d/2;this.settings.increment=(this.options.end.angle-this.options.start.angle)/(this.options.end.num-this.options.start.num);return this},draw:function(){var f=this;this.width(this.settings.canvas_d);this.height(this.settings.canvas_d);this.gauge=Raphael(this.attr("id"),this.settings.canvas_d,this.settings.canvas_d);var e=this.gauge.circle(this.settings.canvas_r,this.settings.canvas_r,this.settings.speedo_r).attr(this.options.style.outline);var i=this.settings.canvas_r;var h=this.settings.canvas_r-(this.settings.canvas_r/4);if(typeof this.options.label=="object"){if(this.options.label.margin_x){i+=this.options.label.margin_x}if(this.options.label.margin_y){h+=this.options.label.margin_y}}else{if(typeof this.options.label=="string"){this.options.label={value:this.options.label}}}if(this.options.label){var d=this.gauge.text(i,h,this.options.label.value).attr(this.options.style.label)}this.sectors=[];b.each(this.options.sectors,function(n){this.style=b.extend(true,f.options.style.sector,this.style);if(!(isNaN(this.start)||isNaN(this.end))){var p=f.settings.increment*(this.start-f.options.start.num)+f.options.start.angle;var m=f.settings.increment*(this.end-f.options.start.num)+f.options.start.angle;var j=this.radius?this.radius:f.settings.speedo_r;var q=Math.PI/180;var l=f.settings.canvas_r+j*Math.cos(p*q),k=f.settings.canvas_r+j*Math.cos(m*q),t=f.settings.canvas_r+j*Math.sin(p*q),s=f.settings.canvas_r+j*Math.sin(m*q);var o=f.gauge.path(["M",f.settings.canvas_r,f.settings.canvas_r,"L",k,s,"A",j,j,0,+(m-p>180),0,l,t,"z"]).attr(this.style);f.sectors.push(o)}});var g=[];b.each(this.options.markers,function(){if(this.line){if(!this.line.width){this.line.width=10}if(!this.line.height){this.line.height=1}var v=f.gauge.rect(f.settings.canvas_r+f.settings.speedo_r-this.line.width,f.settings.canvas_r-Math.floor(this.line.height/2)).attr(this.line).hide()}var n=1;while(this.gap<1){n*=10;this.gap*=10}var j=f.options.start.num*n;var l=f.options.end.num*n;for(var o=j;o<=l;o+=this.gap){var k=n>1?o/n:o;if(this.toFixed){k=k.toFixed(this.toFixed)}if(this.toPrecision){k=k.toPrecision(this.toPrecision)}var t=f.settings.increment*(k-j)+f.options.start.angle;if(t+Math.abs(f.options.start.angle)>=360){t=(t+Math.abs(f.options.start.angle))%360+f.options.start.angle}if(b.inArray(t,g)>=0){continue}g.push(t);if(this.line){var p=v.clone().rotate(t,f.settings.canvas_r,f.settings.canvas_r)}if(this.text){if(!this.text.space){this.text.space=0}var m=k;if(typeof this.value=="object"){if(this.value.divide){m/=this.value.divide}if(this.value.multiply){m*=this.value.multiply}}var q=t.toRadians();var s=f.settings.canvas_r+(this.text.space+f.settings.speedo_r)*Math.cos(q);var r=f.settings.canvas_r+(this.text.space+f.settings.speedo_r)*Math.sin(q);var u=f.gauge.text(s,r,m).attr(this.text)}}if(this.line){v.remove()}});if(this.options.digital){this.digital=b("<div>").addClass("digital").css({"margin-top":Math.ceil(this.settings.speedo_r/2),width:"20%","font-family":"Arial","font-size":20,color:"#fff","text-align":"center",border:"2px solid #777","border-radius":10,padding:5,"background-color":"#111"}).css(this.options.digital).text(this.options.default_num).appendTo(this).center()}this.needles=[];b.each(this.options.needles,function(k){if(!this.default_num){this.default_num=f.options.default_num}this.style=b.extend(true,f.options.style.needle,this.style);var m=this.default_num-f.options.start.num;if(typeof this.value=="object"){if(this.value.divide){m/=this.value.divide}if(this.value.multiply){m*=this.value.multiply}}var j=f.settings.increment*m+f.options.start.angle;var l=f.gauge.rect(f.settings.canvas_r,f.settings.canvas_r,f.settings.speedo_r).attr(this.style).transform("r"+j+","+f.settings.canvas_r+","+f.settings.canvas_r);f.needles.push(l)});if(typeof this.options.style.center=="object"){var c=this.gauge.circle(this.settings.canvas_r,this.settings.canvas_r,this.options.style.center.diameter).attr(this.options.style.center)}this.trigger("drawn");return this},val:function(e){if(this.digital){var c=e;if(this.options.digital_toFixed){c=c.toFixed(this.options.digital_toFixed)}if(this.options.digital_toPrecision){c=c.toPrecision(this.options.digital_toPrecision)}this.digital.text(c)}var d=this;b.each(this.needles,function(h){var f=e;if(typeof d.options.needles[h].value=="object"){var g=d.options.needles[h].value;if(g.divide){f/=g.divide}if(g.multiply){f*=g.multiply}}this.animate({transform:"r"+(d.settings.increment*(f-d.options.start.num)+d.options.start.angle)+","+d.settings.canvas_r+","+d.settings.canvas_r},d.options.animation_speed)});this.trigger("update",e);return this}};b.fn.SonicGauge=function(c){if(a[c]){return a[c].apply(this,Array.prototype.slice.call(arguments,1))}else{if(typeof c==="object"||!c){return a.init.apply(this,arguments)}else{b.error("Method "+c+" does not exist on jQuery.SonicGauge")}}};b.fn.SonicGauge.defaultOptions={margin:35,diameter:350,start:{angle:-225,num:0},end:{angle:45,num:100},default_num:0,animation_speed:1000,digital:{},digital_toFixed:0,needles:[{}],sectors:[{}],markers:[{gap:10,line:{width:20,stroke:"none",fill:"#eeeeee"},text:{space:22,"text-anchor":"middle",fill:"#333333","font-size":18}},{gap:5,line:{width:8,stroke:"none",fill:"#999999"}}],style:{outline:{fill:"#333333",stroke:"#555555","stroke-width":8},center:{fill:"#eeeeee",diameter:10},needle:{height:1,stroke:"none",fill:"#cc0000"},label:{"text-anchor":"middle",fill:"#fff","font-size":16}}}})(jQuery);if(typeof(Number.prototype.toRadians)==="undefined"){Number.prototype.toRadians=function(){return this*Math.PI/180}}if(typeof(Number.prototype.decimalPlaces)==="undefined"){Number.prototype.decimalPlaces=function(){return(this.toFixed(20)).replace(/^-?\d*\.?|0+$/g,"").length}}if(typeof(jQuery.fn.center)==="undefined"){jQuery.fn.center=function(a){if(typeof a==="undefined"){a=this.parent()}a.css("position","relative");return this.css("position","absolute").css({top:Math.max(0,((a.height()-this.outerHeight())/2)+a.scrollTop()),left:Math.max(0,((a.width()-this.outerWidth())/2)+a.scrollLeft())})}};

Le code complet du flow node-red : http://flows.nodered.org/flow/f0d416178add2b981ac21afeaf0bc0f7