node-red-contrib-cip-st-ethernet-ip
Version:
A Node-RED node to interact with Allen Bradley / Rockwell PLCs using the EtherNet/IP Protocol
492 lines (399 loc) • 16.6 kB
JavaScript
/*
Copyright: (c) 2016-2020, St-One Ltda., Guilherme Francescon Cittolin <guilherme@st-one.io>
GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
*/
const net = require('net');
function nrInputShim(node, fn) {
function doErr(err) { err && node.error(err) }
node.on('input', function (msg, send, done) {
send = send || node.send;
done = done || doErr;
fn(msg, send, done);
});
}
module.exports = function (RED) {
"use strict";
const eip = require('st-ethernet-ip');
const { Controller, Tag, TagGroup, Structure, TagList, Browser, ControllerManager } = eip;
const { Types } = eip.EthernetIP.CIP.DataTypes;
const { EventEmitter } = require('events');
// ---------- Ethernet-IP Browser ----------
const browser = new Browser();
// ---------- Ethernet-IP Endpoint ----------
function generateStatus(status, val) {
let obj;
if (typeof val != 'string' && typeof val != 'number' && typeof val != 'boolean') {
val = RED._("ethip.endpoint.status.online");
}
switch (status) {
case 'online':
obj = {
fill: 'green',
shape: 'dot',
text: val.toString()
};
break;
case 'badvalues':
obj = {
fill: 'yellow',
shape: 'dot',
text: RED._("ethip.endpoint.status.badvalues")
};
break;
case 'offline':
obj = {
fill: 'red',
shape: 'dot',
text: RED._("ethip.endpoint.status.offline")
};
break;
case 'error':
obj = {
fill: 'red',
shape: 'dot',
text: RED._("ethip.endpoint.status.error")
};
break;
case 'connecting':
obj = {
fill: 'yellow',
shape: 'dot',
text: RED._("ethip.endpoint.status.connecting")
};
break;
default:
obj = {
fill: 'grey',
shape: 'dot',
text: RED._("ethip.endpoint.status.unknown")
};
}
return obj;
}
function EthIpEndpoint(config) {
EventEmitter.call(this);
const node = this;
const isVerbose = RED.settings.get('verbose');
/** @type {Map<string,eip.Structure|eip.Tag>} */
const tags = new Map();
const taglist = new TagList();
/** @type {eip.Controller} */
let plcManager;
let plc;
let status;
let reconnectTimer;
let cycleTimer;
let cycleInProgress = 0;
let tagChanged = false;
let needsWrite = [];
let connected = false;
let closing = false;
let tagGroup;
RED.nodes.createNode(this, config);
//avoids warnings when we have a lot of listener nodes
this.setMaxListeners(0);
//Create tags
config.vartable = config.vartable || {};
const timeout = parseInt(config.timeout) || 10000;
function createTags() {
if (plc) {
for (const prog of Object.keys(config.vartable)) {
for (const varname of Object.keys(config.vartable[prog])) {
if (!varname) {
//skip empty values
continue;
}
const obj = config.vartable[prog][varname];
const type = (obj.type || '').toString().toUpperCase();
const mapping = obj.mapping || varname;
const dt = Types[type] || null;
if (isVerbose) {
node.log(RED._("ethip.info.tagregister") + `: Name:[${varname}], Prog:[${prog}], Type:[${dt}](${type}), Mapping:[${mapping}]`);
}
if (!Tag.isValidTagname(varname)) {
node.warn(RED._("ethip.warn.invalidtagname", { name: varname }));
continue;
}
if (!Tag.isValidTagname(mapping)) {
// Probably need a more apropriate test than isValidTagname
node.warn(RED._("ethip.warn.invalidmappingname", { name: mapping }));
mapping = null;
}
const tagName = prog ? `Program:${prog}.${varname}` : varname;
const tag = plc.addTag(varname, prog || null);
tag.mapping = mapping;
tag.on('Initialized', onTagChanged);
tag.on('Changed', onTagChanged);
tag.on('Unknown', onTagUnknown);
tags.set(tagName, tag);
}
}
node.emit('#__NEW_TAGS__');
}
}
node.getStatus = () => status;
node.getTag = t => tags.get(t);
node.getTags = () => tags;
node.getAllTags = () => {
let res = [];
if (plc.PLC) {
plc.PLC.forEach(tag => {
res.push(tag);
});
}
return res;
};
/**
* Adds callback functions of write nodes
* @param {function} f
*/
node.setNeedsWrite = f => needsWrite.push(f);
function manageStatus(newStatus) {
if (status == newStatus) return;
status = newStatus;
node.emit('#__STATUS__', {
status: status
});
}
function onTagUnknown(tag) {
node.emit('Unknown', tag);
}
function onTagChanged(tag, lastValue = null) {
// Be sure to register changes to timestamp flag.
node.emit('#__CHANGED__', tag, lastValue);
tagChanged = true;
node.emit('#__ALL_CHANGED__');
tagChanged = false;
}
async function onConnect() {
createTags();
connected = true;
manageStatus('online');
}
function onControllerError(err) {
manageStatus('offline');
let errStr = err instanceof Error ? err.toString() : JSON.stringify(err);
node.error(RED._("ethip.error.onerror") + errStr, {});
}
// close the connection and remove tag listeners
function onNodeClose(done) {
manageStatus('offline');
connected = false;
closing = true;
for (let tag of tags.values()) {
tag.removeListener('Initialized', onTagChanged);
tag.removeListener('Changed', onTagChanged);
tag.removeListener('Unknown', onTagUnknown);
}
plc.removeListener("Error", onControllerError);
plc.removeListener("Connected", onConnect)
plc.disconnect().then(done);
}
function connect() {
connected = false;
plcManager = new ControllerManager()
plc = plcManager.addController(config.address, Number(config.slot) || 0, parseInt(config.cycletime) || 100, config.connectedMess, 5000, { unconnectedSendTimeout: 5064 })
plc.connect()
manageStatus('connecting');
if (isVerbose) {
node.log(RED._("ethip.info.connect") + `: ${config.address} / ${config.slot}`);
}
plc.on("Error", onControllerError);
plc.on("Connected", onConnect)
}
node.on('close', onNodeClose);
connect();
}
RED.nodes.registerType("eth-ip endpoint", EthIpEndpoint);
// ---------- Ethernet-IP In ----------
function EthIpIn(config) {
const node = this;
let statusVal, tag;
let unknownThrown = false;
RED.nodes.createNode(this, config);
node.endpoint = RED.nodes.getNode(config.endpoint);
if (!node.endpoint) {
return node.error(RED._("ethip.error.missingconfig"));
}
const tagName = config.program ? `Program:${config.program}.${config.variable}` : config.variable;
function gatherTag(tag) {
let object = {
value: tag.value,
topic: tag.mapping || tag.name || ''
}
if (config.includeTimestamp) {
object.timestamp = tag.timestamp_raw.getTime()/1000;
}
return object;
}
function onUnknown(tag) {
if (!config.addErrorOutput) {
if (!unknownThrown) {
node.error(new Error(`${tag.mapping || tag.name} Tag does not exist on PLC`));
unknownThrown = true;
}
} else {
let msg = {};
const key = tag.name || '';
const map = tag.mapping || key;
const data = "Tag does not exist on PLC";
if (config.gatherMetrics) {
msg.error = {
description: data,
tag: key,
topic: map
};
if (config.includeTimestamp) {
msg.error.timestamp = tag.timestamp_raw.getTime()/1000;
}
} else {
msg.error = data;
msg.tag = key;
msg.topic = map;
if (config.includeTimestamp) {
msg.timestamp = tag.timestamp_raw.getTime()/1000;
}
}
node.send([null, msg]);
}
node.status(generateStatus(node.endpoint.getStatus()));
}
function onChanged(tag, lastValue) {
let data = tag.value;
let key = tag.mapping || tag.name || '';
let msg = {};
if (config.gatherMetrics) {
msg.payload = gatherTag(tag);
msg.payload.lastValue = lastValue;
} else {
msg = {
payload: data,
topic: key,
}
if (config.includeTimestamp) {
msg.timestamp = tag.timestamp_raw.getTime()/1000;
}
msg.lastValue = lastValue;
}
config.addErrorOutput ? node.send([msg, null]) : node.send(msg);
node.status(generateStatus(node.endpoint.getStatus(), config.mode === 'single' ? data : null));
}
function onChangedAllValues() {
let payload = {};
let tags = node.endpoint.getAllTags();
let timestamps = {};
tags.forEach(tag => {
if (config.gatherMetrics) {
payload[tag.name] = gatherTag(tag);
} else {
payload[tag.mapping || tag.name] = tag.value;
timestamps[tag.mapping || tag.name] = tag.timestamp_raw.getTime()/1000.0;
}
});
let msg = { payload: payload };
if (!config.gatherMetrics && config.includeTimestamp) {
msg.timestamp = timestamps;
}
config.addErrorOutput ? node.send([msg, null]) : node.send(msg);
node.status(generateStatus(node.endpoint.getStatus()));
}
function onEndpointStatus(s) {
node.status(generateStatus(s.status, config.mode === 'single' ? statusVal : null));
}
function loadTag() {
unloadTag();
tag = node.endpoint.getTag(tagName);
if (!tag) {
//shouldn't reach here. But just in case..
console.log('Ethip In')
return node.error(RED._("ethip.error.invalidvar", { varname: tagName }));
}
tag.on('Initialized', onChanged);
tag.on('Changed', onChanged);
tag.on('Unknown', onUnknown);
}
function unloadTag() {
if (tag) {
tag.removeListener('Initialized', onChanged);
tag.removeListener('Changed', onChanged);
tag.removeListener('Unknown', onUnknown);
}
}
node.status(generateStatus(node.endpoint.getStatus(), ""));
node.endpoint.on('#__STATUS__', onEndpointStatus);
if (config.mode === 'single') {
node.endpoint.on('#__NEW_TAGS__', loadTag);
} else if (config.mode === 'all-split') {
node.endpoint.on('#__CHANGED__', onChanged);
} else {
node.endpoint.on('#__ALL_CHANGED__', onChangedAllValues);
}
if (config.mode !== 'single' && config.addErrorOutput) {
node.endpoint.on('Unknown', onUnknown);
}
node.on('close', function (done) {
node.endpoint.removeListener('Unknown', onUnknown);
node.endpoint.removeListener('#__ALL_CHANGED__', onChanged);
node.endpoint.removeListener('#__ALL_CHANGED__', onChangedAllValues);
node.endpoint.removeListener('#__STATUS__', onEndpointStatus);
node.endpoint.removeListener('#__NEW_TAGS__', loadTag);
unloadTag();
done();
});
}
RED.nodes.registerType("eth-ip in", EthIpIn);
// ---------- Ethernet-IP Out ----------
function EthIpOut(config) {
const node = this;
let statusVal, tag;
RED.nodes.createNode(this, config);
node.endpoint = RED.nodes.getNode(config.endpoint);
if (!node.endpoint) {
return node.error(RED._("ethip.in.error.missingconfig"));
}
const configTagName = config.variable ? (
config.program ? `Program:${config.program}.${config.variable}` : config.variable
) : null;
function onEndpointStatus(s) {
node.status(generateStatus(s.status, statusVal));
}
function onNewMsg(msg, send, done) {
const tagName = configTagName || msg.variable;
const tag = node.endpoint.getTag(tagName);
if (!tag) {
const err = RED._("ethip.error.invalidvar", { varname: tagName });
done(err);
} else {
//the actual write will be performed by the scan cycle
//of the Controller on the endpoint
tag.value = statusVal = msg.payload;
node.endpoint.setNeedsWrite(done);
}
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
}
node.status(generateStatus(node.endpoint.getStatus(), ""));
nrInputShim(node, onNewMsg);
node.endpoint.on('#__STATUS__', onEndpointStatus);
node.on('close', function (done) {
node.endpoint.removeListener('#__STATUS__', onEndpointStatus);
done();
});
}
RED.nodes.registerType("eth-ip out", EthIpOut);
// PLC, Tag Browser
RED.httpAdmin.get("/eth-ip", RED.auth.needsPermission("eth-ip.read"), function(req,res) {
res.json(browser.deviceList)
});
const browsedPLC = new Controller(false);
RED.httpAdmin.post("/eth-ip-tag", RED.auth.needsPermission("eth-ip.write"), function(req,res) {
browsedPLC.connect(req.body.plcAddress)
.then(() => {
res.json(browsedPLC.tagList);
browsedPLC.disconnect();
})
.catch(e => {
console.log(e);
browsedPLC.disconnect();
})
});
};