node-red-contrib-s5
Version:
A Node-RED Node to communicate with Siemens S5 PLCs over Serial Port
618 lines (522 loc) • 23.3 kB
HTML
<script type="text/html" data-template-name="s5 plc">
<div class="form-row">
<label for="node-config-input-serialport"><i class="fa fa-usb"></i> <span data-i18n="s5.plc.label.serialport"></span></label>
<input type="text" id="node-config-input-serialport" data-i18n="[placeholder]s5.plc.label.serialport" style="width: 60%; margin-right: 10px;">
<a id="node-config-lookup-serialport" class="btn red-ui-button" data-i18n="[title]s5.plc.label.search"><i class="fa fa-search"></i></a>
</div>
<div class="form-row">
<label for="node-config-input-cycletime"><i class="fa fa-refresh"></i> <span data-i18n="s5.plc.label.cycletime"></span></label>
<input type="text" id="node-config-input-cycletime" data-i18n="[placeholder]s5.plc.label.cycletime" style="width: 80px;"> <span>ms</span>
</div>
<div class="form-row" style="margin-bottom:0;">
<label><i class="fa fa-list"></i> <span data-i18n="s5.plc.label.variables.list"></span></label>
</div>
<div class="form-row node-input-variables-container-row" style="margin-bottom: 0px;">
<div id="node-config-input-variables-container-div" style="box-sizing: border-box; border-radius: 5px; height: 300px; padding: 5px; border: 1px solid #ccc; overflow-y:scroll;">
<ol id="node-config-input-variables-container" style=" list-style-type:none; margin: 0;"></ol>
</div>
</div>
<div class="form-row">
<a href="#" class="editor-button editor-button-small" id="node-config-s5-plc-var-export" style="margin: 4px; float: right"><i class="fa fa-download"></i> <span data-i18n="s5.plc.label.variables.export"></span></a>
<input type="file" id="node-config-s5-plc-var-import" style="display: none"/>
<a href="#" class="editor-button editor-button-small" id="node-config-s5-plc-var-import-btn" style="margin: 4px; float: right"><i class="fa fa-upload"></i> <span data-i18n="s5.plc.label.variables.import"></span></a>
<a href="#" class="editor-button editor-button-small" id="node-config-input-add-variable" style="margin: 4px;"><i class="fa fa-plus"></i> <span data-i18n="s5.plc.label.variables.add"></span></a>
<a href="#" class="editor-button editor-button-small" id="node-config-s5-plc-var-clean" style="margin: 4px;"><i class="fa fa-trash-o"></i> <span data-i18n="s5.plc.label.variables.clean"></span></a>
</div>
<br>
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> <span data-i18n="s5.label.name"></span></label>
<input type="text" id="node-config-input-name" data-i18n="[placeholder]s5.label.name">
</div>
</script>
<script type="text/javascript">
var saveAs = saveAs || function (e) { "use strict"; if (typeof e === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { return } var t = e.document, n = function () { return e.URL || e.webkitURL || e }, r = t.createElementNS("http://www.w3.org/1999/xhtml", "a"), o = "download" in r, a = function (e) { var t = new MouseEvent("click"); e.dispatchEvent(t) }, i = /constructor/i.test(e.HTMLElement) || e.safari, f = /CriOS\/[\d]+/.test(navigator.userAgent), u = function (t) { (e.setImmediate || e.setTimeout)(function () { throw t }, 0) }, s = "application/octet-stream", d = 1e3 * 40, c = function (e) { var t = function () { if (typeof e === "string") { n().revokeObjectURL(e) } else { e.remove() } }; setTimeout(t, d) }, l = function (e, t, n) { t = [].concat(t); var r = t.length; while (r--) { var o = e["on" + t[r]]; if (typeof o === "function") { try { o.call(e, n || e) } catch (a) { u(a) } } } }, p = function (e) { if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(e.type)) { return new Blob([String.fromCharCode(65279), e], { type: e.type }) } return e }, v = function (t, u, d) { if (!d) { t = p(t) } var v = this, w = t.type, m = w === s, y, h = function () { l(v, "writestart progress write writeend".split(" ")) }, S = function () { if ((f || m && i) && e.FileReader) { var r = new FileReader; r.onloadend = function () { var t = f ? r.result : r.result.replace(/^data:[^;]*;/, "data:attachment/file;"); var n = e.open(t, "_blank"); if (!n) e.location.href = t; t = undefined; v.readyState = v.DONE; h() }; r.readAsDataURL(t); v.readyState = v.INIT; return } if (!y) { y = n().createObjectURL(t) } if (m) { e.location.href = y } else { var o = e.open(y, "_blank"); if (!o) { e.location.href = y } } v.readyState = v.DONE; h(); c(y) }; v.readyState = v.INIT; if (o) { y = n().createObjectURL(t); setTimeout(function () { r.href = y; r.download = u; a(r); h(); c(y); v.readyState = v.DONE }); return } S() }, w = v.prototype, m = function (e, t, n) { return new v(e, t || e.name || "download", n) }; if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { return function (e, t, n) { t = t || e.name || "download"; if (!n) { e = p(e) } return navigator.msSaveOrOpenBlob(e, t) } } w.abort = function () { }; w.readyState = w.INIT = 0; w.WRITING = 1; w.DONE = 2; w.error = w.onwritestart = w.onprogress = w.onwrite = w.onabort = w.onerror = w.onwriteend = null; return m }(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content); if (typeof module !== "undefined" && module.exports) { module.exports.saveAs = saveAs } else if (typeof define !== "undefined" && define !== null && define.amd !== null) { define("FileSaver.js", function () { return saveAs }) }
</script>
<script type="text/javascript">
function validateSerialport(serialport) {
if (!serialport || serialport === "") return false;
return true;
}
function validateS5Address(address) {
if (!address) return 'ERR_PARSE_EMPTY';
var NODES5_REGEX_ADDR = /^(?:(DB|DX)(\d+),)?([A-Z]+)(\d+)(?:\.(\d+))?(?:\.(\d+))?$/;
var NODES5_ADDRTYPE = ["E", "I", "A", "Q", "P", "F", "T", "C"];
var NODES5_DATATYPE = ["X", "B", "C", "I", "DI", "W", "D", "DW", "R", "RI", "RDI", "RW", "RD", "RDW", "RR"];
var NODES5_DATATYPE_DB = ["X", "BYTE", "CHAR", "STRING", "INT", "DINT", "WORD", "DWORD", "REAL", "RINT", "RDINT", "RWORD", "RDWORD", "RREAL", "DT", "DTZ", "DTL", "DTLZ", "B", "C", "S", "I", "DI", "W", "D", "DW", "R", "RI", "RDI", "RW", "RD", "RDW", "RR"];
var match = address.match(NODES5_REGEX_ADDR);
if (!match) return 'ERR_PARSE_UNKNOWN_FORMAT';
let addrType = match[1];
let match_db = match[2];
let match_area = match[3];
let match_addr = match[4];
let match_bitAddr = parseInt(match[5]);
let match_arrLen = parseInt(match[6]);
var dataType, addressOffset, dbNumber;
addressOffset = parseInt(match_addr)
if (isNaN(addressOffset)) return 'ERR_PARSE_ADDR_OFFSET';
if (match_db) {
dbNumber = parseInt(match_db);
if (dbNumber < 1 || isNaN(dbNumber)) return 'ERR_PARSE_DB_NUMBER';
}
if (dbNumber) {
if (!NODES5_DATATYPE_DB.includes(match_area)) return 'ERR_PARSE_DB_DATATYPE';
dataType = match_area;
} else {
// the first char indicates the PLC area (I, Q, M, P, ...)
addrType = match_area.charAt(0);
// the data type (if area is P, skip the first char (I,E,Q,A))
dataType = match_area.substr(addrType === "P" ? 2 : 1);
// validate address type
if (!NODES5_ADDRTYPE.includes(addrType)) return 'ERR_PARSE_AREA';
if (dataType === "" && addrType !== "T" && addrType !== "C") {
dataType = "X";
}
// validate data type
if (!["T", "C"].includes(match_area) && !NODES5_DATATYPE.includes(dataType)) return "ERR_PARSE_DATATYPE";
}
if (dataType === "X") {
// handle array lengths and bit address
if (isNaN(match_bitAddr)) return 'ERR_PARSE_BIT_OFFSET';
if (match_bitAddr > 7) return 'ERR_PARSE_BIT_OFFSET';
} else if (dataType === "STRING" || dataType === "S") {
// match_bitAddr is the string length for string types
if (isNaN(match_bitAddr) || match_bitAddr < 1) return 'ERR_PARSE_STRING_LEN';
} else {
// the array length should be at the bitAddr field, this is a syntax error
if (!isNaN(match_arrLen)) return 'ERR_PARSE_INVALID_BIT_OFFSET';
// the bitAddr field is actually the array length
if (!isNaN(match_bitAddr) && match_bitAddr < 1) return 'ERR_PARSE_INVALID_ARR_LEN';
}
return null;
}
function validateAddressList(list) {
for (var i = 0; i < list.length; i++){
var elm = list[i];
if (!elm.name) return false;
if (validateS5Address(elm.addr)) return false;
}
return true;
}
RED.nodes.registerType('s5 plc', {
category: 'config',
color: '#ff00c8',
defaults: {
name: {
value: ""
},
serialport: {
value: "",
validate: validateSerialport
},
cycletime: {
value: 1000
},
vartable: {
value: [{
name: "",
addr: ""
}],
validate: validateAddressList
}
},
label: function () {
var self = this;
if (this.name) return this.name;
return "s5 plc";
},
oneditprepare: function () {
var self = this;
var tt = this._.bind(this);
var labelName = this._("s5.plc.label.variables.name");
var labelAddr = this._("s5.plc.label.variables.addr");
var labelDel = this._("s5.plc.label.variables.del");
var fieldSerialport = $('#node-config-input-serialport');
var lookupSerialportBtn = $('#node-config-lookup-serialport');
$("#node-config-input-cycletime").spinner({
min: 0
});
function generateVariable(variable) {
var curTooltip;
var previousValue = variable.addr;
var container = $('<li/>', {
style: "background: #fff; margin:0; padding:8px 0px; border-bottom: 1px solid #ccc;"
});
var row1 = $('<div/>').appendTo(container);
var variableAddr = $('<input/>', {
style: "width: 110px; margin-right: 10px;",
class: "node-config-input-variable-addr",
type: "text",
placeholder: labelAddr
}).appendTo(row1);
var variableName = $('<input/>', {
style: "width: 250px",
class: "node-config-input-variable-name",
type: "text",
placeholder: labelName
}).appendTo(row1);
var finalspan = $('<span/>', {
style: "float: right; margin-right: 10px;"
}).appendTo(row1);
var deleteButton = $('<a/>', {
href: "#",
class: "editor-button editor-button-small",
style: "margin-top: 7px; margin-left: 5px;",
title: labelDel
}).appendTo(finalspan);
$('<i/>', {
class: "fa fa-remove"
}).appendTo(deleteButton);
deleteButton.click(function () {
container.css({
"background": "#fee"
});
container.fadeOut(150, function () {
$(this).remove();
});
if (curTooltip) curTooltip.close();
});
variableAddr.change(function () {
//validate address
var curVal = variableAddr.val();
var valError = validateS5Address(curVal);
if (valError) {
variableAddr.addClass('input-error')
var errorText = tt("s5.plc.validation." + valError);
if (curTooltip) {
curTooltip.setContent(errorText);
curTooltip.open();
} else if (RED.popover && RED.popover.tooltip){
curTooltip = RED.popover.tooltip(variableAddr, errorText);
curTooltip.open();
}
} else {
variableAddr.removeClass('input-error');
if(curTooltip) {
curTooltip.close();
curTooltip.setContent('');
//hack to remove the popup, as Node-RED don't offer
// and "unbind" function. May break in the future
variableAddr.off('mouseenter mouseleave disabled');
curTooltip = null;
}
}
//update name if matching old one
if (previousValue && variableName.val() == previousValue) {
variableName.val(curVal);
}
previousValue = curVal;
});
//populate data
variableAddr.val(variable.addr);
variableName.val(variable.name);
variableAddr.change();
$("#node-config-input-variables-container").append(container);
}
function cleanVarTable() {
$("#node-config-input-variables-container").children().remove();
}
function populateVarTable() {
if (self.vartable) {
if (typeof self.vartable == 'string') {
self.vartable = JSON.parse(self.vartable);
}
for (var i = 0; i < self.vartable.length; i++) {
generateVariable(self.vartable[i]);
}
}
}
$("#node-config-input-add-variable").click(function () {
generateVariable({
name: "",
addr: ""
});
});
$("#node-config-s5-plc-var-clean").click(cleanVarTable);
populateVarTable();
var lookupSerialportValue = null;
fieldSerialport.change(function() {
lookupSerialportValue = null;
})
lookupSerialportBtn.click(function () {
if (lookupSerialportBtn.hasClass('disabled')) return;
lookupSerialportBtn.addClass('disabled');
var iconLookup = lookupSerialportBtn.children('i');
iconLookup.removeClass('fa-search').addClass('fa-spinner fa-spin fa-fw');
$.getJSON('__node-red-contrib-s5/discover/serialports', function (data) {
console.log("node-red-contrib-s5 serial ports", data)
var listData = $.map(data, function(val){
return { label: val['manufacturer'], path: val['path']}
})
fieldSerialport.autocomplete({
source: listData,
minLength: 0,
create: function (event, ui) {
$(this).data('ui-autocomplete')._renderItem = function (ul, elm) {
let label = elm.label ? elm.label : 'Undefined';
elmHtml = '<a>'
+ '<b>' + label + '</b>'
+ '<br/>'+ elm.path + '</a>'
return $('<li style="padding: 2px">').attr("data-value", elm.path).append(elmHtml).appendTo(ul);
};
},
select: function (event, ui) {
let elm = ui.item
event.preventDefault();
event.stopPropagation();
fieldSerialport.val(elm.path);
fieldSerialport.change();
lookupSerialportValue = elm;
}
});
fieldSerialport.focus(function() {
fieldSerialport.autocomplete("search", "")
});
fieldSerialport.focus();
}).always(function() {
iconLookup.removeClass('fa-spinner fa-spin fa-fw').addClass('fa-search');
lookupSerialportBtn.removeClass('disabled');
});
});
// export
function exportCSV() {
var vars = $("#node-config-input-variables-container").children();
var lines = [];
vars.each(function (i) {
var elm = $(this);
lines.push([
elm.find(".node-config-input-variable-addr").val(), //addr
elm.find(".node-config-input-variable-name").val() //name
].join(';'));
});
saveAs(new Blob([lines.join('\r\n')]), 's5plc' + (self.name ? '_' + self.name : '') + '.csv');
}
$('#node-config-s5-plc-var-export').click(exportCSV);
// import
function importCSV(e) {
var file = e.target.files[0];
if (!file) {
return;
}
var reader = new FileReader();
reader.onload = function (e) {
var res = [], i, fields;
var contents = e.target.result || '';
var lines = contents.split(/[\r\n]+/);
if (!lines.length) {
alert('file is empty!');
return;
}
for (i = 0; i < lines.length; i++) {
lines[i] = lines[i].trim();
if (lines[i] == '') continue;
fields = lines[i].split(/[\t;]/);
if (fields.length < 2) {
alert('line must have at least two parameters, address and name');
return;
}
res.push({
addr: fields[0],
name: fields[1]
});
}
if (res.length) {
cleanVarTable();
self.vartable = res;
populateVarTable();
}
};
reader.readAsText(file);
}
$('#node-config-s5-plc-var-import').on('change', importCSV);
$('#node-config-s5-plc-var-import-btn').click(function () {
$('#node-config-s5-plc-var-import').click();
})
},
oneditsave: function () {
var node = this;
var vars = $("#node-config-input-variables-container").children();
node.vartable = [];
vars.each(function (i) {
var elm = $(this);
var addr = elm.find(".node-config-input-variable-addr").val();
var name = elm.find(".node-config-input-variable-name").val();
var v = {
addr: addr,
name: name || addr
}
node.vartable.push(v);
});
}
});
</script>
<!-- ######################################################################################## -->
<script type="text/html" data-template-name="s5 in">
<div class="form-row">
<label for="node-input-plc">
<i class="fa fa-cog"></i>
<span data-i18n="s5.in.label.endpoint"></span>
</label>
<input type="text" id="node-input-plc" data-i18n="[placeholder]s5.in.label.endpoint">
</div>
<div class="form-row">
<label for="node-input-mode"><i class="fa fa-sliders"></i> <span data-i18n="s5.in.label.mode"></span></label>
<select type="text" id="node-input-mode">
<option value="single" data-i18n="s5.in.mode.single"></option>
<option value="all-split" data-i18n="s5.in.mode.all-split"></option>
<option value="all" data-i18n="s5.in.mode.all"></option>
</select>
</div>
<div class="form-row s5-input-var-row">
<label for="node-input-variable"><i class="fa fa-random"></i> <span data-i18n="s5.in.label.variable"></span></label>
<select type="text" id="node-input-variable">
</select>
<span id="s5-custom-var-addr" style="margin-left:5px"></span>
</div>
<div class="form-row">
<label> </label>
<input type="checkbox" id="node-input-diff" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-diff" style="width:70%;"><span data-i18n="s5.in.label.diff"></span></label>
</div>
<div class="form-row">
<label for="node-input-name">
<i class="fa fa-tag"></i>
<span data-i18n="s5.label.name"></span>
</label>
<input type="text" id="node-input-name" data-i18n="[placeholder]s5.label.name">
</div>
</script>
<script type="text/javascript">
(function () {
RED.nodes.registerType('s5 in', {
category: 'plc',
color: '#ff00c8',
defaults: {
plc: {
value: "",
type: "s5 plc"
},
mode: {
value: "single"
},
variable: {
value: ""
},
diff: {
value: true
},
name: {
value: ""
}
},
inputs: 0,
outputs: 1,
icon: "serial.png",
paletteLabel: "s5 in",
label: function () {
if (this.name) return this.name;
return this._("s5.in.label.name");
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
},
oneditprepare: function () {
var self = this;
var varList = $('#node-input-variable');
var varAddr = $('#s5-custom-var-addr');
var modeList = $('#node-input-mode');
var plcList = $("#node-input-plc");
var vars = [];
function updateVarList(plcId) {
$('#node-input-variable option').remove();
var plcNode = RED.nodes.node(plcId);
if (!plcNode) return;
vars = plcNode.vartable || [];
if (typeof vars === 'string') vars = JSON.parse(vars);
varList.append($('<option/>', {
disabled: "disabled",
selected: "selected",
style: "display:none;",
text: vars.length ? self._("s5.in.label.variable-select") : self._("s5.in.label.variable-novar")
}));
$.each(vars, function (i, val) {
varList.append($('<option/>', {
value: val.name || val.addr,
text: val.name || val.addr
}));
if (val.name == self.variable) {
varList.val(self.variable);
}
});
}
varList.change(function () {
$.each(vars, function (i, val) {
if (varList.val() == val.name) {
varAddr[0].innerText = val.addr;
return true;
}
});
});
plcList.change(function () {
updateVarList(plcList.val());
});
updateVarList(self.plc);
modeList.change(function () {
if (modeList.val() == "single") {
varList.parent().show();
} else {
varList.parent().hide();
}
});
modeList.change();
}
});
})();
</script>
<!-- ######################################################################################## -->
<script type="text/html" data-template-name="s5 control">
<div class="form-row">
<label for="node-input-plc"><i class="fa fa-bolt"></i> <span data-i18n="s5.in.label.endpoint"></span></label>
<input type="text" id="node-input-plc" data-i18n="[placeholder]s5.in.label.endpoint">
</div>
<div class="form-row">
<label for="node-input-function"><i class="fa fa-sliders"></i> <span data-i18n="s5.control.label.function"></span></label>
<select type="text" id="node-input-function">
<option value="cycletime" data-i18n="s5.control.function.cycletime"></option>
<option value="trigger" data-i18n="s5.control.function.trigger"></option>
</select>
</div>
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="s5.label.name"></span></label>
<input type="text" id="node-input-name" data-i18n="[placeholder]s5.label.name">
</div>
</script>
<script type="text/javascript">
RED.nodes.registerType('s5 control', {
category: 'plc',
defaults: {
plc: {
value: "",
type: "s5 plc",
required: true
},
function: {
value: "cycletime"
},
name: {
value: ""
}
},
color: "#ff00c8",
inputs: 1,
outputs: 1,
icon: "serial.png",
paletteLabel: "s5 control",
label: function () {
if (this.name) return this.name;
return this._("s5.control.label.name");
},
labelStyle: function () {
return this.name ? "node_label_italic" : "";
}
});
</script>