@frangoteam/fuxa
Version:
Web-based Process Visualization (SCADA/HMI/Dashboard) software
618 lines (586 loc) • 25.7 kB
JavaScript
/**
* 'mqtt': mqtt client to manage subscription and publish
*/
;
const mqtt = require('mqtt');
const utils = require('../../utils');
const deviceUtils = require('../device-utils');
const path = require('path');
const fs = require('fs');
function MQTTclient(_data, _logger, _events, _runtime) {
var runtime = _runtime;
var data = _data; // Current data
var logger = _logger; // Logger var working = false;
var working = false; // Working flag to manage overloading polling and connection
var events = _events; // Events to commit change to runtime
var lastStatus = ''; // Last connections status
var varsValue = {}; // Tags to send to frontend { id, type, value }
var overloading = 0; // Overloading counter to mange the break connection
var lastTimestampValue; // Last Timestamp of asked values
var getProperty = null; // Function to ask property (security)
var options = {}; // MQTT client Connection options
var client = null; // MQTT client head
var browser = null; // MQTT client for browser subscription topics
var timeoutBrowser; // Timeout to manage break the browsing
var topicsMap = {}; // Map the topic subscribed, to check by on.message
var memoryTagToPublish = new Map(); // Map tag to publish, content in topics as 'tag'
var refTagToTopics = {}; // Map of Tag to Topic (with ref to other device tag)
const certificatesDir = _data.certificatesDir;
/**
* Tag with options 'pubs' for publish and 'subs' for subscription
*/
/**
* Connect the mqtt client to broker
* Emit connection status, Clear the memory Topics value
*/
this.connect = function () {
return new Promise(async function (resolve, reject) {
if (data.property && data.property.address) {
try {
if (_checkWorking(true)) {
logger.info(`'${data.name}' try to connect ${data.property.address}`, true);
options = getConnectionOptions(data.property)
options.connectTimeout = 10 * 1000;
if (getProperty) {
var result = await getProperty({ query: 'security', name: data.id });
if (result && result.value && result.value !== 'null') {
// property security mode
var property = JSON.parse(result.value);
options.clientId = property.clientId;
options.username = property.uid;
options.password = property.pwd;
if (property.cert) {
options.cert = fs.readFileSync(path.join(certificatesDir, property.cert));
}
if (property.pkey) {
options.key = fs.readFileSync(path.join(certificatesDir, property.pkey));
}
if (property.caCert) {
options.ca = fs.readFileSync(path.join(certificatesDir, property.caCert));
}
}
}
client = mqtt.connect(options.url, options);
_clearVarsValue();
client.on('connect', function () {
logger.info(`'${data.name}' connected!`);
_emitStatus('connect-ok');
_createSubscription().then(() => {
resolve();
_checkWorking(false);
}).catch(function (err) {
reject(err);
_checkWorking(false);
});
_createRefPublishReceiver();
});
client.on("offline", function () {
logger.warn(`'${data.name}' client offline ${data.property.address}`, true);
});
client.on("disconnect", function () {
logger.warn(`'${data.name}' client disconnect ${data.property.address}`, true);
});
client.on("reconnect", function (err) {
logger.warn(`'${data.name}' client reconnecting ... ${data.property.address}`, true);
});
client.on("close", function (err) {
logger.warn(`'${data.name}' client close ${data.property.address}`, true);
});
client.on("error", function (err) {
logger.error(`'${data.name}' try to connect error! ${err}`);
_checkWorking(false);
if (client && !client.connected) {
client.end(true);
}
reject(err);
});
} else {
reject();
}
} catch (err) {
logger.error(`'${data.name}' try to connect error! ${err}`);
_checkWorking(false);
_emitStatus('connect-error');
_clearVarsValue();
reject();
if (client) {
client.end(true);
}
}
} else {
logger.error(`'${data.name}' missing connection data!`);
_emitStatus('connect-failed');
_clearVarsValue();
reject();
}
});
}
/**
* Disconnect the mqtt client
* Emit connection status, Clear the memory Topics value
*/
this.disconnect = function () {
return new Promise(function (resolve, reject) {
if (client) {
client.end(true, function (err) {
if (err) {
logger.error(`'${data.name}' disconnect failure! ${err}`);
}
_checkWorking(false);
_emitStatus('connect-off');
_clearVarsValue();
resolve(true);
});
}
else {
resolve(true);
}
});
}
/**
* Take the current Topics value (only changed), emit Topics value
* Save DAQ value
*/
this.polling = async function () {
if (_checkWorking(true)) {
if (client) {
try {
var varsValueChanged = await _checkVarsChanged();
lastTimestampValue = new Date().getTime();
_emitValues(varsValue);
if (this.addDaq && !utils.isEmptyObject(varsValueChanged)) {
this.addDaq(varsValueChanged, data.name, data.id);
}
} catch (err) {
logger.error(`'${data.name}' polling error: ${err}`);
}
_checkWorking(false);
} else {
_checkWorking(false);
}
}
}
/**
* Return the result of mqtt browsing by configure it
*/
this.browse = function (topic, callback) {
return new Promise(async function (resolve, reject) {
try {
_resetBrowserTimeout();
if (!browser || !browser.connected) {
let options = getConnectionOptions(data.property);
if (getProperty) {
var result = await getProperty({ query: 'security', name: data.id });
if (result && result.value && result.value !== 'null') {
// property security mode
var property = JSON.parse(result.value);
options.clientId = property.clientId;
options.username = property.uid;
options.password = property.pwd;
}
}
browser = mqtt.connect(options.url, options);
browser.on('connect', function () {
resolve('ok');
});
browser.on('message', function (topic, msg, pkt) {
if (callback) {
callback({ topic: topic, msg: msg.toString() });
}
});
browser.on("error", function (err) {
reject(err);
});
client.on("close", function (err) {
// console.info('mqtt browser closed');
});
} else {
resolve('ok');
}
browser.subscribe(topic, function () {
});
} catch (err) {
reject('browse-error: ' + err);
}
});
}
/**
* Return if mqtt client is connected
*/
this.isConnected = function () {
return (client) ? client.connected : false;
}
/**
* Bind the DAQ store function
*/
this.bindAddDaq = function (fnc) {
this.addDaq = fnc; // Add the DAQ value to db history
}
this.addDaq = null;
/**
* Return the timestamp of last read tag operation on polling
* @returns
*/
this.lastReadTimestamp = () => {
return lastTimestampValue;
}
/**
* Set function to ask property (security)
*/
this.bindGetProperty = function (fnc) {
getProperty = fnc;
}
/**
* Load Topics attribute and map tags to publish
*/
this.load = function (_data) {
varsValue = {};
refTagToTopics = {};
data = JSON.parse(JSON.stringify(_data));
try {
var count = Object.keys(data.tags).length;
// map depending tag ids
for (var key in data.tags) {
if (data.tags[key].options && data.tags[key].options.pubs) {
data.tags[key].options.pubs.forEach(item => {
if (item.type === 'tag' && item.value) {
// topic with json data with value from other device tags
if (!memoryTagToPublish.has(item.value)) {
memoryTagToPublish.set(item.value, null);
events.emit('tag-change:subscription', item.value);
}
if (!refTagToTopics[item.value]) {
refTagToTopics[item.value] = { value: null, topics: [] };
}
refTagToTopics[item.value].topics.push(data.tags[key]);
} else if (item.type === 'value') {
// raw data (not depending from other tag)
item.value = '';
}
});
}
}
logger.info(`'${data.name}' data loaded (${count})`, true);
} catch (err) {
logger.error(`'${data.name}' load error! ${err}`);
}
}
/**
* Return Topic value { id: <name>, value: <value>, ts: <timestamp> }
*/
this.getValue = function (id) {
if (varsValue[id]) {
return { id: id, value: varsValue[id].value, ts: lastTimestampValue };
}
return null;
}
/**
* Return Topics values array { id: <name>, value: <value>, type: <type> }
*/
this.getValues = function () {
return data.tags;
}
/**
* Set the Topic value, publish to broker (coming from frontend)
*/
this.setValue = async function (tagId, value) {
if (client && client.connected) {
var tag = data.tags[tagId];
if (tag) {
if (tag.options) {
if (tag.options.pubs) {
for (const item of tag.options.pubs) {
if (item.type === 'value') {
item.value = await deviceUtils.tagRawCalculator(value, tag, runtime);
}
}
} else if (tag.options.subs && tag.options.subs.indexOf(tag.memaddress) !== -1) {
tag.value = await deviceUtils.tagRawCalculator(value, tag, runtime);
}
}
if (tag.type === 'raw') {
tag['value'] = await deviceUtils.tagRawCalculator(value, tag, runtime);
}
tag.changed = true;
_publishValues([tag]);
// logger.info(`'${data.name}' setValue(${tagId}, ${value})`, true, true);
return true;
}
}
return false;
}
/**
* Return Device status Connected/Disconnected 'connect-off', 'connect-ok', 'connect-error'
*/
this.getStatus = function () {
return lastStatus;
}
/**
* Return Topic property, not used (Topics don't have property)
*/
this.getTagProperty = function (topic) {
if (data.tags[topic]) {
return { id: topic, name: data.tags[topic].name, type: data.tags[topic].type, format: data.tags[topic].format };
} else {
return null;
}
}
/**
* Return the Daq settings of Tag
* @returns
*/
this.getTagDaqSettings = (tagId) => {
return data.tags[tagId] ? data.tags[tagId].daq : null;
}
/**
* Set Daq settings of Tag
* @returns
*/
this.setTagDaqSettings = (tagId, settings) => {
if (data.tags[tagId]) {
utils.mergeObjectsValues(data.tags[tagId].daq, settings);
}
}
/**
* Create a subscription to receive Topics value
*/
var _createSubscription = function () {
return new Promise(function (resolve, reject) {
var topics = Object.values(data.tags).map(t => t.address);
_mapTopicsAddress(Object.values(data.tags));
if (topics && topics.length) {
client.subscribe(topics, function (err) {
if (err) {
reject(err);
} else {
client.on('message', function (topicAddr, msg, pkt) {
if (topicsMap[topicAddr]) {
for (var i = 0; i < topicsMap[topicAddr].length; i++) {
var id = topicsMap[topicAddr][i].id;
var oldvalue = data.tags[id].rawValue;
data.tags[id].rawValue = msg.toString();
data.tags[id].timestamp = new Date().getTime();
data.tags[id].changed = oldvalue !== data.tags[id].rawValue;
if (data.tags[id].type === 'json' && data.tags[id].options && data.tags[id].options.subs && data.tags[id].memaddress) {
try {
var subitems = JSON.parse(data.tags[id].rawValue);
if (!utils.isNullOrUndefined(subitems[data.tags[id].memaddress])) {
data.tags[id].rawValue = subitems[data.tags[id].memaddress];
} else {
data.tags[id].rawValue = oldvalue;
}
} catch (err) {
console.error(err);
}
}
}
}
});
resolve();
}
});
} else {
resolve();
}
});
}
/**
* Create the receiver of change tag value for the dependency in payload
*/
var _createRefPublishReceiver = function () {
events.on('tag-value:changed', function (event) {
try {
if (memoryTagToPublish.has(event.id)) {
// for topic with raw data (not depending from other tag)
var oldValue = memoryTagToPublish.get(event.id);
memoryTagToPublish.set(event.id, event.value);
if (oldValue !== event.value && refTagToTopics[event.id]) {
if (refTagToTopics[event.id].topics.length) {
// for topic with json data with value from other device tags
// set the tag value in memory and publish the topic
for (var i = 0; i < refTagToTopics[event.id].topics.length; i++) {
_publishValues([refTagToTopics[event.id].topics[i]]);
}
}
}
}
} catch (err) {
console.error(err);
}
});
}
/**
* Map the topics to address (path)
* @param {*} topics
*/
var _mapTopicsAddress = function (topics) {
var tmap = {};
for (var i = 0; i < topics.length; i++) {
if (tmap[topics[i].address]) {
tmap[topics[i].address].push(topics[i]);
} else {
tmap[topics[i].address] = [topics[i]];
}
}
topicsMap = tmap;
}
/**
* Clear local Topics value by set all to null
*/
var _clearVarsValue = function () {
for (var id in varsValue) {
varsValue[id].value = null;
}
_emitValues(varsValue);
}
/**
* Return the Topics to publish that have value changed and clear value changed flag of all Topics
*/
var _checkVarsChanged = async () => {
const timestamp = new Date().getTime();
var result = {};
for (var id in data.tags) {
if (!utils.isNullOrUndefined(data.tags[id].rawValue)) {
data.tags[id].value = await deviceUtils.tagValueCompose(data.tags[id].rawValue, varsValue[id] ? varsValue[id].value : null, data.tags[id], runtime);
if (this.addDaq && deviceUtils.tagDaqToSave(data.tags[id], timestamp)) {
result[id] = data.tags[id];
}
}
data.tags[id].changed = false;
varsValue[id] = data.tags[id];
}
return result;
}
/**
* Emit the mqtt client connection status
* @param {*} status
*/
var _emitStatus = function (status) {
lastStatus = status;
events.emit('device-status:changed', { id: data.name, status: status });
}
/**
* Emit the mqtt Topics values array { id: <name>, value: <value>, type: <type> }
* @param {*} values
*/
var _emitValues = function (values) {
events.emit('device-value:changed', { id: data.name, values: values });
}
/**
* Used to manage the async connection and polling automation (that not overloading)
* @param {*} check
*/
var _checkWorking = function (check) {
if (check && working) {
overloading++;
logger.warn(`'${data.name}' working (connection || polling) overload! ${overloading}`);
// !The driver don't give the break connection
if (overloading >= 3) {
try {
if (client) client.end(true);
} catch (e) {
console.error(e);
}
} else {
return false;
}
}
working = check;
overloading = 0;
return true;
}
/**
* Reset the timeout by browse the Topics, 10 seconds to receive published Topics
*/
var _resetBrowserTimeout = function () {
if (timeoutBrowser) {
clearTimeout(timeoutBrowser);
}
timeoutBrowser = setTimeout(() => {
if (browser && browser.connected) {
browser.end(true);
}
}, 15000);
}
/**
* Publish the tags, called from polling and setValues (input in frontend)
* to publish are only tags from other devices or topics created in mqtt device (by publish section)
* @param {*} tags
*/
var _publishValues = function (tags) {
Object.keys(tags).forEach(key => {
try {
const topicOptions = { retain: true };
// publish only tags with pubs and value changed
if (tags[key].options && tags[key].options.pubs && tags[key].options.pubs.length) {
var topicTopuplish = {};
tags[key].options.pubs.forEach(item => {
var value = '';
if (item.type === 'tag' && item.value) { // tag from other devices
if (memoryTagToPublish.has(item.value)) {
value = memoryTagToPublish.get(item.value);
}
} else if (item.type === 'timestamp') { // timestamp to add in tag with value
value = new Date().toISOString();
} else if (item.type === 'value') { // tag created in mqtt device as value
value = item.value;
} else { // item.type === 'static'
value = item.value;
}
if (tags[key].type === 'json') {
topicTopuplish[item.key] = value;
} else { // payloand with row data, item.key is ''
if (topicTopuplish[0]) {
value = topicTopuplish[0] + ';' + value;
}
topicTopuplish[0] = value;
}
});
// payloand
if (tags[key].type === 'json') {
client.publish(tags[key].address, JSON.stringify(topicTopuplish), topicOptions);
} else if (topicTopuplish[0] !== undefined) { // payloand with row data
client.publish(tags[key].address, Object.values(topicTopuplish)[0].toString(), topicOptions);
}
} else if (tags[key].type === 'json' && tags[key].options && tags[key].options.subs && tags[key].options.subs.length) {
let obj = {};
obj[tags[key].memaddress] = tags[key].value;
client.publish(tags[key].address, JSON.stringify(obj), topicOptions);
} else if (tags[key].value !== undefined) { // whitout payload
client.publish(tags[key].address, tags[key].value.toString(), topicOptions);
}
} catch (err) {
console.error(err);
}
})
}
}
/**
* Return connection option from device property
* @param {*} property
*/
function getConnectionOptions(property) {
return {
url: property.address,
username: property.username,
password: property.password,
keepalive: 60,
connectTimeout: 1 * 1000,
reconnectPeriod: 0
// tls: options.encryption,
// clientId: options.clientId,
// certValidation: options.certValidation,
// subscriptions: options.subscriptions,
// certificateAuthority: options.selfSignedCertificate ? options.selfSignedCertificate.data : undefined,
// clientCertificate: options.clientCertificate ? options.clientCertificate.data : undefined,
// clientKey: options.clientKey ? options.clientKey.data : undefined,
}
}
module.exports = {
init: function (settings) {
// deviceCloseTimeout = settings.deviceCloseTimeout || 15000;
},
create: function (data, logger, events, runtime) {
return new MQTTclient(data, logger, events, runtime);
}
}