@oriolrius/node-red-contrib-s7
Version:
A Node-RED node to interact with Siemens S7 PLCs
836 lines (706 loc) • 30.9 kB
JavaScript
//@ts-check
function nrInputShim(node, fn) {
node.on('input', function (msg, send, done) {
send = send || node.send;
done = done || (err => err && node.error(err, msg));
fn(msg, send, done);
});
}
/**
* Compares two values for equality, includes special handling for arrays. Fixes #33
* @param {number|string|Array|Date} a
* @param {number|string|Array|Date} b
*/
function equals(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false;
}
return true;
}
return false;
}
var MIN_CYCLE_TIME = 50;
var tools = require('../src/tools.js');
module.exports = function (RED) {
"use strict";
var { S7Endpoint: S7EndpointClass, S7ItemGroup } = require('@st-one-io/nodes7');
var EventEmitter = require('events').EventEmitter;
// ---------- Discovery Endpoints ----------
RED.httpAdmin.get('/__node-red-contrib-s7/discover/available/iso-on-tcp', RED.auth.needsPermission('s7.discover'), function (req, res) {
tools.isPnToolsAvailable().then(function (available) {
res.json(available).end();
}).catch(() => {
res.status(500).end();
});
});
RED.httpAdmin.get('/__node-red-contrib-s7/discover/iso-on-tcp', RED.auth.needsPermission('s7.discover'), function (req, res) {
tools.listDevicesPN().then(function (devices) {
res.json(devices).end();
}).catch(() => {
res.status(500).end();
});
});
RED.httpAdmin.get('/__node-red-contrib-s7/flashled/iso-on-tcp/:mac', RED.auth.needsPermission('s7.discover'), function (req, res) {
let mac_addr = (req.params.mac || '').replace(/-/g, ':');
if (!/^([A-Fa-f0-9]{2}:){5}[A-Fa-f0-9]{2}$/.test(mac_addr)) {
res.status(400).end();
return;
}
tools.flashLedPN(mac_addr).then(function () {
res.status(204).end();
}).catch(() => {
res.status(500).end();
});
});
// ---------- S7 Endpoint ----------
function createTranslationTable(vars) {
var res = {};
vars.forEach(function (elm) {
if (!elm.name || !elm.addr) {
//skip incomplete entries
return;
}
res[elm.name] = elm.addr;
});
return res;
}
function generateStatus(status, val) {
var obj;
if (typeof val != 'string' && typeof val != 'number' && typeof val != 'boolean') {
val = RED._("s7.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._("s7.endpoint.status.badvalues")
};
break;
case 'offline':
obj = {
fill: 'red',
shape: 'dot',
text: RED._("s7.endpoint.status.offline")
};
break;
case 'connecting':
obj = {
fill: 'yellow',
shape: 'dot',
text: RED._("s7.endpoint.status.connecting")
};
break;
default:
obj = {
fill: 'grey',
shape: 'dot',
text: RED._("s7.endpoint.status.unknown")
};
}
return obj;
}
function validateTSAP(num) {
num = num.toString();
if (num.length != 2) return false;
if (!(/^[0-9a-fA-F]+$/.test(num))) return false;
var i = parseInt(num, 16);
if (isNaN(i) || i < 0 || i > 0xff) return false;
return true;
}
function S7Endpoint(config) {
RED.nodes.createNode(this, config);
var node = this;
var oldValues = {};
var status;
var readInProgress = false;
var readDeferred = 0;
var connected = false;
var currentCycleTime = config.cycletime;
var transport = config.transport || 'iso-on-tcp';
//avoids warnings when we have a lot of S7In nodes
this.setMaxListeners(0);
node.endpoint = null;
let connOpts;
let itemGroup;
let s7ConnOpts = { timeout: parseInt(config.timeout) }
if (transport === 'mpi-s7') {
node.adapter = RED.nodes.getNode(config.adapter);
if (!node.adapter) {
return node.error(RED._("s7.error.missingconfig"));
}
s7ConnOpts.maxJobs = 1;
connOpts = {
customTransport: async () => node.adapter.getStream(config.busaddr),
s7ConnOpts
}
} else if (transport === 'iso-on-tcp') {
switch (config.connmode) {
case "rack-slot":
connOpts = {
host: config.address,
port: Number(config.port),
rack: Number(config.rack),
slot: Number(config.slot),
s7ConnOpts: s7ConnOpts
}
break;
case "tsap":
if (!validateTSAP(config.localtsaphi) ||
!validateTSAP(config.localtsaplo) ||
!validateTSAP(config.remotetsaphi) ||
!validateTSAP(config.remotetsaplo)) {
node.error(RED._("s7.error.invalidtsap", config));
return;
}
let localTSAP = parseInt(config.localtsaphi, 16) << 8;
localTSAP += parseInt(config.localtsaplo, 16);
let remoteTSAP = parseInt(config.remotetsaphi, 16) << 8;
remoteTSAP += parseInt(config.remotetsaplo, 16);
connOpts = {
host: config.address,
port: config.port,
srcTSAP: localTSAP,
dstTSAP: remoteTSAP,
s7ConnOpts: s7ConnOpts
}
break;
default:
node.error(RED._("s7.error.invalidconntype", config));
return;
}
} else {
node.error(RED._("s7.error.invalidconntype", config));
return;
}
node._vars = createTranslationTable(config.vartable);
// ORC
node.setVars = function(newVarTable) {
itemGroup = new S7ItemGroup(node.endpoint);
node._vars = createTranslationTable(newVarTable);
// Use a simple function to prevent circular references
itemGroup.setTranslationCB(key => node._vars[key]);
let varKeys = Object.keys(node._vars)
if (varKeys && varKeys.length) {
itemGroup.addItems(varKeys);
}
node.itemGroup = itemGroup;
// Notify variable changes
node.emit('__VARS_CHANGED__', varKeys);
};
node.getStatus = function getStatus() {
return status;
};
node.writeVar = function writeVar(obj) {
itemGroup.writeItems(obj.name, obj.val)
.then(() => obj.done())
.catch(e => obj.done(e))
};
/**
* updates the current cycle time on the fly. A value of 0
* disables the cyclic reading of variables, and for positive values
* a minimum of 50 ms is enforced
*
* @param {number} interval the cycle time interval, in ms
* @returns {string|undefined} an string with the error if any, or undefined
*/
node.updateCycleTime = function updateCycleTime(interval) {
let time = parseInt(interval);
if (isNaN(time) || time < 0) {
return RED._("s7.error.invalidtimeinterval", { interval: interval });
}
clearInterval(node._td);
// don't set a new timer if value is zero
if (!time) return;
if (time < MIN_CYCLE_TIME) {
node.warn(RED._("s7.info.cycletimetooshort", { min: MIN_CYCLE_TIME }), {});
time = MIN_CYCLE_TIME;
}
currentCycleTime = time;
node._td = setInterval(doCycle, time);
}
function manageStatus(newStatus) {
if (status == newStatus) return;
status = newStatus;
node.emit('__STATUS__', {
status: status
});
}
function cycleCallback(values) {
readInProgress = false;
if (readDeferred && connected) {
doCycle();
readDeferred = 0;
}
manageStatus('online');
var changed = false;
node.emit('__ALL__', values);
Object.keys(values).forEach(function (key) {
if (!equals(oldValues[key], values[key])) {
changed = true;
node.emit(key, values[key]);
node.emit('__CHANGED__', {
key: key,
value: values[key]
});
oldValues[key] = values[key];
}
});
if (changed) node.emit('__ALL_CHANGED__', values);
}
function doCycle() {
if (!readInProgress && connected) {
itemGroup.readAllItems().then(cycleCallback).catch(e => {
node.error(e, {});
readInProgress = false;
});
readInProgress = true;
} else {
readDeferred++;
}
}
node.doCycle = doCycle;
function onConnect() {
readInProgress = false;
readDeferred = 0;
connected = true;
manageStatus('online');
node.updateCycleTime(currentCycleTime);
}
function onDisconnect() {
manageStatus('offline');
connected = false;
}
node.on('close', done => {
manageStatus('offline');
if (!node.endpoint) done();
node.endpoint.disconnect().then(done).catch(e => {
node.error(e);
});
});
manageStatus('offline');
node.endpoint = new S7EndpointClass(connOpts);
node.endpoint.on('connecting', () => manageStatus('connecting'));
node.endpoint.on('connect', onConnect);
node.endpoint.on('disconnect', onDisconnect);
node.endpoint.on('error', (e => {
manageStatus('offline');
node.error(e && e.toString(), {});
}));
itemGroup = new S7ItemGroup(node.endpoint);
// Safely handle variable translation using a simple function to prevent circular references
itemGroup.setTranslationCB(key => node._vars[key]);
let varKeys = Object.keys(node._vars)
if (!varKeys || !varKeys.length) {
node.warn(RED._("s7.info.novars"), {});
return;
} else {
itemGroup.addItems(varKeys);
}
// Add fields to s7 endpoint node object
node.itemGroup = itemGroup;
node.rewritetimes = parseInt(config.rewritetimes);
node.rewriteinterval = parseInt(config.rewriteinterval);
}
// Create a clean constructor function to avoid prototype issues
function S7EndpointClean(config) {
return S7Endpoint.call(this, config);
}
RED.nodes.registerType("s7 endpoint", S7EndpointClean);
// ---------- S7 In ----------
function S7In(config) {
var node = this;
var statusVal;
RED.nodes.createNode(this, config);
node.endpoint = RED.nodes.getNode(config.endpoint);
if (!node.endpoint) {
return node.error(RED._("s7.error.missingconfig"));
}
function sendMsg(data, key, status) {
if (key === undefined) key = '';
if (data instanceof Date) data = data.getTime();
// Safely handle data that might contain circular references
var safeData = tools.safeCloneData(data);
var msg = {
topic: key,
payload: safeData,
_s7: {
plc: node.endpoint.name,
ip: node.endpoint.endpoint && node.endpoint.endpoint._connOptsTcp ? node.endpoint.endpoint._connOptsTcp.host : 'unknown',
status: node.endpoint.getStatus() === 'online' ? 'online' : 'offline',
time: new Date(),
}
};
statusVal = status !== undefined ? status : safeData;
node.send(msg);
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
}
function onChanged(variable) {
sendMsg(variable.value, variable.key, null);
}
function onDataSplit(data) {
Object.keys(data).forEach(function (key) {
// Use the utility function to safely handle each data value
sendMsg(data[key], key, null);
});
}
function onData(data) {
sendMsg(data, config.mode == 'single' ? config.variable : '');
}
function onDataSelect(data) {
onData(data[config.variable]);
}
function onEndpointStatus(s) {
node.status(generateStatus(s.status, statusVal));
// Only trigger ['online', 'offline'] events
// if (!['online', 'offline'].includes(node.endpoint.getStatus())) return;
var msg = {
topic: '',
payload: {},
_s7: {
plc: node.endpoint.name,
ip: node.endpoint.endpoint && node.endpoint.endpoint._connOptsTcp ? node.endpoint.endpoint._connOptsTcp.host : 'unknown',
status: node.endpoint.getStatus() === 'online' ? 'online' : 'offline',
time: new Date(),
}
};
node.send(msg);
}
// 🟢 Guarda les funcions d’escolta per poder-les treure després
node._listeners = [];
function updateVariableListeners(varKeys) {
// Elimina escoltes anteriors
node._listeners.forEach(({event, fn}) => node.endpoint.removeListener(event, fn));
node._listeners = [];
// Torna a afegir escoltes per les noves variables segons el mode
if (config.diff) {
switch (config.mode) {
case 'all-split':
node.endpoint.on('__CHANGED__', onChanged);
node._listeners.push({event: '__CHANGED__', fn: onChanged});
break;
case 'single':
node.endpoint.on(config.variable, onData);
node._listeners.push({event: config.variable, fn: onData});
break;
case 'all':
default:
node.endpoint.on('__ALL_CHANGED__', onData);
node._listeners.push({event: '__ALL_CHANGED__', fn: onData});
}
} else {
switch (config.mode) {
case 'all-split':
node.endpoint.on('__ALL__', onDataSplit);
node._listeners.push({event: '__ALL__', fn: onDataSplit});
break;
case 'single':
node.endpoint.on('__ALL__', onDataSelect);
node._listeners.push({event: '__ALL__', fn: onDataSelect});
break;
case 'all':
default:
node.endpoint.on('__ALL__', onData);
node._listeners.push({event: '__ALL__', fn: onData});
}
}
}
// Listen for variable changes
function onVarsChanged(varKeys) {
updateVariableListeners(varKeys);
}
node.endpoint.on('__VARS_CHANGED__', onVarsChanged);
node._listeners.push({event: '__VARS_CHANGED__', fn: onVarsChanged});
// Initialize listeners with current variables
updateVariableListeners(Object.keys(node.endpoint._vars || {}));
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
node.endpoint.on('__STATUS__', onEndpointStatus);
node._listeners.push({event: '__STATUS__', fn: onEndpointStatus});
node.on('close', function (done) {
node._listeners.forEach(({event, fn}) => node.endpoint.removeListener(event, fn));
done();
});
}
RED.nodes.registerType("s7 in", S7In);
// ---------- S7 Out ----------
function S7Out(config) {
var node = this;
var statusVal;
RED.nodes.createNode(this, config);
node.endpoint = RED.nodes.getNode(config.endpoint);
if (!node.endpoint) {
return node.error(RED._("s7.error.missingconfig"));
}
function onEndpointStatus(s) {
node.status(generateStatus(s.status, statusVal));
}
function onNewMsg(msg, send, done) {
var writeObj = {
name: config.variable || msg.variable,
val: msg.payload,
done: (error) => {
/**
* This function is called after the first data write
*/
// Written key
const variable = config.variable || msg.variable
// Written value
const payload = msg.payload
// Written keys (array)
const variables = Array.isArray(variable) ? variable : [variable]
// Written values (array)
const payloads = Array.isArray(payload) ? payload : [payload]
// Written key-value pairs
const values = {}
variables.forEach((key, index) => {
values[key] = tools.safeCloneData(payloads[index])
})
// Output message after calling s7-out
msg._s7 = {
plc: node.endpoint.name,
ip: node.endpoint.endpoint && node.endpoint.endpoint._connOptsTcp ? node.endpoint.endpoint._connOptsTcp.host : 'unknown',
status: node.endpoint.getStatus() === 'online' ? 'online' : 'offline',
time: new Date(),
}
msg.payload = {
variable: variable, // Written key
payload: tools.safeCloneData(payload), // Written value (safely handled)
values: values, // Written key-value pairs
newValues: {}, // Latest PLC key-value pairs
wrongValues: {}, // Key-value pairs inconsistent with written values
bingo: false, // Whether PLC latest values match written values
error: error, // Error
rewriteCount: 0, // Number of rewrites performed
}
// Handle errors - done(e) doesn't work; need to use node.error(e)
// https://nodered.org/docs/creating-nodes/node-js#handling-errors
if (error) {
node.error(error)
node.send(msg)
return
}
// Read latest values to determine if data needs to be rewritten
async function rewrite() {
// Delayed reading of latest values
if (node.endpoint.rewritetimes && node.endpoint.rewriteinterval) await new Promise(resolve => setTimeout(resolve, node.endpoint.rewriteinterval))
try {
// Clear previously recorded data
msg.payload.newValues = {}
msg.payload.wrongValues = {}
// Read latest values
const newValues = await node.endpoint.itemGroup.readAllItems()
for (const key in newValues) {
// Only match variables written this time
if (variables.includes(key)) {
// Latest PLC key-value pairs
msg.payload.newValues[key] = newValues[key]
// Key-value pairs inconsistent with written values
if (newValues[key] !== values[key]) msg.payload.wrongValues[key] = newValues[key]
}
}
// Determine if data was completely written successfully
const v1 = Object.keys(msg.payload.values).length
const v2 = Object.keys(msg.payload.newValues).length
const v3 = Object.keys(msg.payload.wrongValues).length
msg.payload.bingo = v1 === v2 && v3 === 0
} catch (e) {
// node.error(e)
}
// Determine if data needs to be rewritten
if (!msg.payload.bingo && node.endpoint.rewritetimes > msg.payload.rewriteCount) {
// Increment rewrite count
msg.payload.rewriteCount++
// Uncomment this line to view rewrite records
// console.log(`[${new Date().toLocaleString()}] Rewrite:`, msg.payload.rewriteCount, msg.payload.wrongValues, msg.payload.newValues, msg.payload.values)
try {
await node.endpoint.itemGroup.writeItems(writeObj.name, writeObj.val)
}
catch (e) {
// node.error(e)
}
// Read latest values to determine if data needs to be rewritten
await rewrite()
return
}
// Output message
node.send(msg)
}
// Read latest values to determine if data needs to be rewritten
rewrite()
}
};
// Test for the case we're writing multiple vars
if (Array.isArray(writeObj.name)) {
if (!Array.isArray(writeObj.val) || writeObj.val.length !== writeObj.name.length) {
node.error(RED._("s7.error.valmismatch"));
node.status(generateStatus('badvalues', statusVal));
return;
}
for (const elm of writeObj.name) {
if (!node.endpoint._vars[elm]) {
node.error(RED._("s7.error.varunknown", { var: elm }));
node.status(generateStatus('badvalues', statusVal));
return;
}
}
} else if (!node.endpoint._vars[writeObj.name]) {
node.error(RED._("s7.error.varunknown", { var: writeObj.name }));
node.status(generateStatus('badvalues', statusVal));
return;
}
statusVal = writeObj.val;
node.endpoint.writeVar(writeObj);
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
}
nrInputShim(node, onNewMsg);
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
node.endpoint.on('__STATUS__', onEndpointStatus);
node.on('close', function (done) {
node.endpoint.removeListener('__STATUS__', onEndpointStatus);
done();
});
}
RED.nodes.registerType("s7 out", S7Out);
// ---------- S7 Control ----------
function S7Control(config) {
var node = this;
var statusVal;
RED.nodes.createNode(this, config);
node.endpoint = RED.nodes.getNode(config.endpoint);
if (!node.endpoint) {
return node.error(RED._("s7.error.missingconfig"));
}
function onEndpointStatus(s) {
node.status(generateStatus(s.status, statusVal));
}
function onMessage(msg, send, done) {
var res;
let func = config.function || msg.function;
switch (func) {
case 'setvartable':
var vartable = [];
// Check if we have a vartable array (legacy mode)
if (Array.isArray(msg.vartable) && msg.vartable.length) {
vartable = msg.vartable;
}
// Check for text parsing mode (new feature)
else if (typeof msg.payload === 'string' && msg.payload.trim()) {
// Parse text format: "address;name" per line
var lines = msg.payload.trim().split('\n');
lines.forEach(function(line) {
if (line.trim()) {
var parts = line.split(';');
if (parts.length === 2) {
var addr = parts[0].trim();
var name = parts[1].trim();
if (addr && name) {
vartable.push({
name: name,
addr: addr
});
}
}
}
});
}
// Validate we have variables to set
if (!Array.isArray(vartable) || !vartable.length) {
done('vartable missing or invalid. Expected either msg.vartable array or msg.payload text format "address;name" per line');
return;
}
node.endpoint.setVars(vartable);
// Return the new list for output
msg.payload = {vartable: vartable};
send(msg);
done();
break;
case 'cycletime':
res = node.endpoint.updateCycleTime(msg.payload);
if (res) {
done(res);
} else {
send(msg);
done();
}
break;
case 'trigger':
node.endpoint.doCycle();
send(msg);
done();
break;
case 'ssl':
node.endpoint.endpoint
.getSSL(Number(msg && msg.payload && msg.payload.id || 0), Number(msg && msg.payload && msg.payload.index || 0)).then(res => {
msg.payload = res;
send(msg);
done();
}).catch(e => {
done(e);
})
break;
case 'list-blocks':
node.endpoint.endpoint
.listAllBlocks().then(res => {
msg.payload = res;
send(msg);
done();
}).catch(e => {
done(e);
})
break;
case 'upload-block':
node.endpoint.endpoint
.uploadBlock(msg && msg.payload && msg.payload.type, Number(msg && msg.payload && msg.payload.number)).then(res => {
msg.payload = res;
send(msg);
done();
}).catch(e => {
done(e);
})
break;
case 'upload-all-blocks':
node.endpoint.endpoint
.uploadAllBlocks().then(res => {
msg.payload = res;
send(msg);
done();
}).catch(e => {
done(e);
})
break;
case 'all-block-info':
node.endpoint.endpoint
.getAllBlockInfo().then(res => {
msg.payload = res;
send(msg);
done();
}).catch(e => {
done(e);
})
break;
default:
node.error(RED._("s7.error.invalidcontrolfunction", { function: config.function }), msg);
}
}
node.status(generateStatus(node.endpoint.getStatus(), statusVal));
nrInputShim(node, onMessage);
node.endpoint.on('__STATUS__', onEndpointStatus);
node.on('close', function (done) {
node.endpoint.removeListener('__STATUS__', onEndpointStatus);
done();
});
}
RED.nodes.registerType("s7 control", S7Control);
};