UNPKG

@ali-pay/node-red-contrib-s7

Version:

A Node-RED node to interact with Siemens S7 PLCs

751 lines (635 loc) 26.4 kB
//@ts-check /* 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) */ 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 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 nodes7 = 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) { EventEmitter.call(this); 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'; RED.nodes.createNode(this, config); //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); 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 nodes7.S7Endpoint(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 nodes7.S7ItemGroup(node.endpoint); itemGroup.setTranslationCB(k => node._vars[k]); let varKeys = Object.keys(node._vars) if (!varKeys || !varKeys.length) { node.warn(RED._("s7.info.novars"), {}); return; } else { itemGroup.addItems(varKeys); } // 将字段加入s7 endpoint节点对象中 node.itemGroup = itemGroup; node.rewritetimes = parseInt(config.rewritetimes); node.rewriteinterval = parseInt(config.rewriteinterval); } RED.nodes.registerType("s7 endpoint", S7Endpoint); // ---------- 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(); var msg = { topic: key, payload: data, _s7: { plc: node.endpoint.name, ip: node.endpoint.endpoint._connOptsTcp.host, status: node.endpoint.getStatus() === 'online' ? '在线' : '离线', time: new Date(), } }; statusVal = status !== undefined ? status : data; 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) { 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)); // 只触发 ['online', 'offline'] 的事件 // if (!['online', 'offline'].includes(node.endpoint.getStatus())) return; var msg = { topic: '', payload: {}, _s7: { plc: node.endpoint.name, ip: node.endpoint.endpoint._connOptsTcp.host, status: node.endpoint.getStatus() === 'online' ? '在线' : '离线', time: new Date(), } }; node.send(msg); } node.status(generateStatus(node.endpoint.getStatus(), statusVal)); node.endpoint.on('__STATUS__', onEndpointStatus); if (config.diff) { switch (config.mode) { case 'all-split': node.endpoint.on('__CHANGED__', onChanged); break; case 'single': node.endpoint.on(config.variable, onData); break; case 'all': default: node.endpoint.on('__ALL_CHANGED__', onData); } } else { switch (config.mode) { case 'all-split': node.endpoint.on('__ALL__', onDataSplit); break; case 'single': node.endpoint.on('__ALL__', onDataSelect); break; case 'all': default: node.endpoint.on('__ALL__', onData); } } node.on('close', function (done) { node.endpoint.removeListener('__ALL__', onDataSelect); node.endpoint.removeListener('__ALL__', onDataSplit); node.endpoint.removeListener('__ALL__', onData); node.endpoint.removeListener('__ALL_CHANGED__', onData); node.endpoint.removeListener('__CHANGED__', onChanged); node.endpoint.removeListener('__STATUS__', onEndpointStatus); node.endpoint.removeListener(config.variable, onData); 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) => { /** * 第一次写入数据后,会进入本函数 */ // 写入的键 const variable = config.variable || msg.variable // 写入的值 const payload = msg.payload // 写入的键(数组) const variables = Array.isArray(variable) ? variable : [variable] // 写入的值(数组) const payloads = Array.isArray(payload) ? payload : [payload] // 写入的键值对 const values = {} variables.forEach((key, index) => { values[key] = payloads[index] }) // 调用 s7-out 后的输出消息 msg._s7 = { plc: node.endpoint.name, ip: node.endpoint.endpoint._connOptsTcp.host, status: node.endpoint.getStatus() === 'online' ? '在线' : '离线', time: new Date(), } msg.payload = { variable: variable, // 写入的键 payload: payload, // 写入的值 values: values, // 写入的键值对 newValues: {}, // plc的最新键值对 wrongValues: {}, // 跟写入值不一致的键值对 bingo: false, // plc的最新值跟写入值是否一致 error: error, // 错误 rewriteCount: 0, // 已重写次数 } // 处理错误 done(e) 不生效;需要使用 node.error(e) // https://nodered.org/docs/creating-nodes/node-js#handling-errors if (error) { node.error(error) node.send(msg) return } // 读取最新的值判断是否需要重写数据 async function rewrite() { // 延时读取最新的值 if (node.endpoint.rewritetimes && node.endpoint.rewriteinterval) await new Promise(resolve => setTimeout(resolve, node.endpoint.rewriteinterval)) try { // 清空上一次记录的数据 msg.payload.newValues = {} msg.payload.wrongValues = {} // 读取最新的值 const newValues = await node.endpoint.itemGroup.readAllItems() for (const key in newValues) { // 只匹配此次写入的变量 if (variables.includes(key)) { // plc的最新键值对 msg.payload.newValues[key] = newValues[key] // 跟写入值不一致的键值对 if (newValues[key] !== values[key]) msg.payload.wrongValues[key] = newValues[key] } } // 判断数据是否完全写入成功 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) } // 判断是否需要重写数据 if (!msg.payload.bingo && node.endpoint.rewritetimes > msg.payload.rewriteCount) { // 递增已重写次数 msg.payload.rewriteCount++ // 想查看重写记录时,可以注释这句话 // console.log(`[${new Date().toLocaleString()}]重写:`, 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) } // 读取最新的值判断是否需要重写数据 await rewrite() return } // 输出消息 node.send(msg) } // 读取最新的值判断是否需要重写数据 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 '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); };