node-red-contrib-ta-cmi-coe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +201 -0
- package/README.md +277 -0
- package/__tests__/blockinfo.test.js +24 -0
- package/__tests__/conversion.test.js +22 -0
- package/__tests__/udp.test.js +46 -0
- package/coe/coe-input.html +61 -0
- package/coe/coe-input.js +144 -0
- package/coe/coe-monitor.html +60 -0
- package/coe/coe-monitor.js +138 -0
- package/coe/coe-output.html +99 -0
- package/coe/coe-output.js +68 -0
- package/coe/config.html +52 -0
- package/coe/config.js +101 -0
- package/coe/locales/de/coe-input.html +10 -0
- package/coe/locales/de/coe-input.json +19 -0
- package/coe/locales/de/coe-monitor.html +10 -0
- package/coe/locales/de/coe-monitor.json +20 -0
- package/coe/locales/de/coe-output.html +14 -0
- package/coe/locales/de/coe-output.json +18 -0
- package/coe/locales/de/config.html +13 -0
- package/coe/locales/de/config.json +10 -0
- package/coe/locales/en-US/coe-input.html +10 -0
- package/coe/locales/en-US/coe-input.json +19 -0
- package/coe/locales/en-US/coe-monitor.html +10 -0
- package/coe/locales/en-US/coe-monitor.json +20 -0
- package/coe/locales/en-US/coe-output.html +14 -0
- package/coe/locales/en-US/coe-output.json +18 -0
- package/coe/locales/en-US/config.html +13 -0
- package/coe/locales/en-US/config.json +10 -0
- package/coe/units-config.js +36 -0
- package/lib/coe-v2.js +189 -0
- package/lib/coe.js +109 -0
- package/lib/queueing.js +188 -0
- package/lib/units.js +78 -0
- package/lib/utils.js +163 -0
- package/package.json +39 -0
package/coe/coe-input.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoE Input Node
|
|
3
|
+
*
|
|
4
|
+
* Copyright 2025 Florian Mayrhofer
|
|
5
|
+
* Licensed under the Apache License, Version 2.0
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
'use strict';
|
|
11
|
+
const { getBlockInfo, getUnitInfo, mergeBlockData, createEmptyState } = require('../lib/utils');
|
|
12
|
+
|
|
13
|
+
// CoE Input Node (receiving values)
|
|
14
|
+
function CoEInputNode(config) {
|
|
15
|
+
RED.nodes.createNode(this, config);
|
|
16
|
+
const node = this;
|
|
17
|
+
|
|
18
|
+
node.cmiConfig = RED.nodes.getNode(config.cmiconfig);
|
|
19
|
+
|
|
20
|
+
if (!node.cmiConfig) {
|
|
21
|
+
node.error("CMI Configuration missing");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
node.cmiAddress = node.cmiConfig.address;
|
|
26
|
+
node.coeVersion = node.cmiConfig.coeVersion || 1;
|
|
27
|
+
node.lang = node.cmiConfig.lang;
|
|
28
|
+
node.nodeNumber = parseInt(config.nodeNumber) || 0;
|
|
29
|
+
node.outputNumber = parseInt(config.outputNumber) || 1;
|
|
30
|
+
node.dataType = config.dataType || 'analog';
|
|
31
|
+
|
|
32
|
+
// State management for LKGVs (Last Known Good Values) per block
|
|
33
|
+
node.blockState = {};
|
|
34
|
+
|
|
35
|
+
// Calculate block & position
|
|
36
|
+
const blockInfo = getBlockInfo(node.dataType, node.outputNumber);
|
|
37
|
+
|
|
38
|
+
// Set Timer for CoE timeout
|
|
39
|
+
const timeoutMs = (config.timeout || 20) * 60 * 1000; // Timeout in milliseconds
|
|
40
|
+
let timeoutTimer = null;
|
|
41
|
+
let currentNodeText = "";
|
|
42
|
+
|
|
43
|
+
// Listener for incoming data
|
|
44
|
+
const listener = (data) => {
|
|
45
|
+
// Data is now: { blocks, sourceIP, version, timestamp }
|
|
46
|
+
if (!data || !data.blocks || !Array.isArray(data.blocks)) {
|
|
47
|
+
node.warn('Received invalid data format');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let incomingBlock of data.blocks) {
|
|
52
|
+
if (!incomingBlock) continue;
|
|
53
|
+
|
|
54
|
+
const blockKey = `${incomingBlock.nodeNumber}-${incomingBlock.blockNumber}`;
|
|
55
|
+
|
|
56
|
+
// Filter Node number (if > 0)
|
|
57
|
+
if (node.nodeNumber > 0 && incomingBlock.nodeNumber !== node.nodeNumber) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Filter Block number
|
|
62
|
+
if (incomingBlock.blockNumber !== blockInfo.block) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Merge blocks
|
|
67
|
+
let currentState = node.blockState[blockKey];
|
|
68
|
+
if (!currentState) {
|
|
69
|
+
currentState = createEmptyState(incomingBlock);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mergedBlock = mergeBlockData(currentState, incomingBlock);
|
|
73
|
+
node.blockState[blockKey] = mergedBlock;
|
|
74
|
+
|
|
75
|
+
// Extract Values from merged block
|
|
76
|
+
let value, unit;
|
|
77
|
+
if (node.dataType === 'analog') {
|
|
78
|
+
value = mergedBlock.values[blockInfo.position];
|
|
79
|
+
unit = mergedBlock.units ? mergedBlock.units[blockInfo.position] : null;
|
|
80
|
+
} else {
|
|
81
|
+
value = mergedBlock.values[blockInfo.position] ? true : false;
|
|
82
|
+
unit = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build message
|
|
86
|
+
const unitInfo = getUnitInfo(unit, node.lang);
|
|
87
|
+
const msg = {
|
|
88
|
+
payload: value,
|
|
89
|
+
topic: `coe/${node.nodeNumber || mergedBlock.nodeNumber}/${node.dataType}/${node.outputNumber}`,
|
|
90
|
+
coe: {
|
|
91
|
+
nodeNumber: mergedBlock.nodeNumber,
|
|
92
|
+
blockNumber: mergedBlock.blockNumber,
|
|
93
|
+
outputNumber: node.outputNumber,
|
|
94
|
+
dataType: node.dataType,
|
|
95
|
+
version: data.version,
|
|
96
|
+
unit: unit,
|
|
97
|
+
unitName: unitInfo.name,
|
|
98
|
+
unitSymbol: unitInfo.symbol,
|
|
99
|
+
sourceIP: data.sourceIP,
|
|
100
|
+
timestamp: data.timestamp,
|
|
101
|
+
raw: mergedBlock
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (value !== undefined) { // Send only, if value is defined in block
|
|
106
|
+
node.send(msg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
currentNodeText = `${value} ${unitInfo.symbol || ''} [v${node.coeVersion}]` // Caching last Node text
|
|
110
|
+
|
|
111
|
+
node.status({
|
|
112
|
+
fill: "green",
|
|
113
|
+
shape: "dot",
|
|
114
|
+
text: currentNodeText
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
resetTimeout();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
node.cmiConfig.registerListener(listener);
|
|
122
|
+
|
|
123
|
+
// Reset CoE Timeout
|
|
124
|
+
function resetTimeout() {
|
|
125
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
126
|
+
timeoutTimer = setTimeout(() => {
|
|
127
|
+
node.status({ fill: "red", shape: "dot", text: `${currentNodeText} (Timeout)` });
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Status information, including if filtered
|
|
132
|
+
if (node.nodeNumber === 0) {
|
|
133
|
+
node.status({fill: "yellow", shape: "ring", text: "coe-input.status.waitingAny"});
|
|
134
|
+
} else {
|
|
135
|
+
node.status({fill: "grey", shape: "ring", text: "coe-input.status.waiting"});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
node.on('close', function() {
|
|
139
|
+
node.cmiConfig.unregisterListener(listener);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
RED.nodes.registerType("coe-input", CoEInputNode);
|
|
144
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
CoE Monitor Node
|
|
3
|
+
|
|
4
|
+
Copyright 2025 Florian Mayrhofer
|
|
5
|
+
Licensed under the Apache License, Version 2.0
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script type="text/javascript">
|
|
9
|
+
RED.nodes.registerType('coe-monitor', {
|
|
10
|
+
category: 'TA CMI',
|
|
11
|
+
color: '#FFCC66',
|
|
12
|
+
defaults: {
|
|
13
|
+
name: { value: "" },
|
|
14
|
+
cmiconfig: { value: "", type: "cmiconfig", required: true },
|
|
15
|
+
filterNodeNumber: { value: 0, validate: RED.validators.number() },
|
|
16
|
+
filterDataType: { value: "all" },
|
|
17
|
+
includeRaw: { value: false }
|
|
18
|
+
},
|
|
19
|
+
inputs: 0,
|
|
20
|
+
outputs: 1,
|
|
21
|
+
icon: "feed.svg",
|
|
22
|
+
label: function() {
|
|
23
|
+
if (this.filterNodeNumber && this.filterNodeNumber > 0) {
|
|
24
|
+
return this.name || `CoE Monitor (Node ${this.filterNodeNumber})`;
|
|
25
|
+
}
|
|
26
|
+
return this.name || "CoE Monitor (All)";
|
|
27
|
+
},
|
|
28
|
+
labelStyle: function() {
|
|
29
|
+
return this.name ? "node_label_italic" : "";
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<script type="text/html" data-template-name="coe-monitor">
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="coe-monitor.label.name"></span></label>
|
|
37
|
+
<input type="text" id="node-input-name">
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-input-cmiconfig"><i class="fa fa-cog"></i> <span data-i18n="coe-monitor.label.cmiconfig"></span></label>
|
|
41
|
+
<input type="text" id="node-input-cmiconfig">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-input-filterNodeNumber"><i class="fa fa-filter"></i> <span data-i18n="coe-monitor.label.filterNodeNumber"></span></label>
|
|
45
|
+
<input type="number" id="node-input-filterNodeNumber" placeholder="0" min="0" max="62">
|
|
46
|
+
<span id="monitor-filter-help" style="margin-left: 110px; font-size: 0.9em; color: #666;"></span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="form-row">
|
|
49
|
+
<label for="node-input-filterDataType"><i class="fa fa-filter"></i> <span data-i18n="coe-monitor.label.filterDataType"></span></label>
|
|
50
|
+
<select id="node-input-filterDataType">
|
|
51
|
+
<option value="" data-i18n="coe-monitor.label.all"></option>
|
|
52
|
+
<option value="analog">Analog</span></option>
|
|
53
|
+
<option value="digital">Digital</span></option>
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-input-includeRaw"><i class="fa fa-database"></i> <span data-i18n="coe-monitor.label.includeRaw"></span></label>
|
|
58
|
+
<input type="checkbox" id="node-input-includeRaw" style="display: inline-block; width: auto;">
|
|
59
|
+
</div>
|
|
60
|
+
</script>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoE Monitor Node (Receives all CoE packets)
|
|
3
|
+
*
|
|
4
|
+
* Copyright 2025 Florian Mayrhofer
|
|
5
|
+
* Licensed under the Apache License, Version 2.0
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
'use strict';
|
|
11
|
+
const { getUnitInfo } = require('../lib/utils')
|
|
12
|
+
|
|
13
|
+
function CoEMonitorNode(config) {
|
|
14
|
+
RED.nodes.createNode(this, config);
|
|
15
|
+
const node = this;
|
|
16
|
+
|
|
17
|
+
node.cmiConfig = RED.nodes.getNode(config.cmiconfig);
|
|
18
|
+
|
|
19
|
+
const lang = node.cmiConfig.lang;
|
|
20
|
+
const coeVersion = node.cmiConfig.coeVersion || 1;
|
|
21
|
+
|
|
22
|
+
if (!node.cmiConfig) {
|
|
23
|
+
node.error("CMI Configuration missing");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
node.filterNodeNumber = config.filterNodeNumber ? parseInt(config.filterNodeNumber) : null;
|
|
28
|
+
node.filterDataType = config.filterDataType || 'all';
|
|
29
|
+
node.includeRaw = config.includeRaw || false;
|
|
30
|
+
|
|
31
|
+
let packetCount = 0;
|
|
32
|
+
let lastUpdate = Date.now();
|
|
33
|
+
|
|
34
|
+
const listener = (data) => { // Listener for incoming data
|
|
35
|
+
if (!data || !data.blocks || !Array.isArray(data.blocks)) {
|
|
36
|
+
node.warn('Received invalid data format');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (let block of data.blocks) {
|
|
41
|
+
if (!block) continue;
|
|
42
|
+
|
|
43
|
+
if (node.filterNodeNumber !== null && // Filter by Node Number
|
|
44
|
+
node.filterNodeNumber !== 0 &&
|
|
45
|
+
block.nodeNumber !== node.filterNodeNumber) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isDigital = (block.blockNumber === 0 || block.blockNumber === 9); // Filter by Data Type
|
|
50
|
+
const isAnalog = !isDigital;
|
|
51
|
+
|
|
52
|
+
if (node.filterDataType === 'analog' && !isAnalog) continue;
|
|
53
|
+
if (node.filterDataType === 'digital' && !isDigital) continue;
|
|
54
|
+
|
|
55
|
+
packetCount++;
|
|
56
|
+
lastUpdate = Date.now();
|
|
57
|
+
|
|
58
|
+
// Build message
|
|
59
|
+
const msg = {
|
|
60
|
+
payload: {
|
|
61
|
+
nodeNumber: block.nodeNumber,
|
|
62
|
+
blockNumber: block.blockNumber,
|
|
63
|
+
dataType: isDigital ? 'digital' : 'analog',
|
|
64
|
+
values: block.values,
|
|
65
|
+
units: block.units,
|
|
66
|
+
sourceIP: data.sourceIP,
|
|
67
|
+
version: data.version,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
rawBuffer: data.rawBuffer ? data.rawBuffer.toString('hex').toUpperCase() : null
|
|
70
|
+
},
|
|
71
|
+
topic: `coe/monitor/${block.nodeNumber}/block/${block.blockNumber}`
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Additional Details for Analog Blocks
|
|
75
|
+
if (isAnalog && block.units) {
|
|
76
|
+
msg.payload.valuesDetailed = block.values.map((value, idx) => {
|
|
77
|
+
const unitInfo = getUnitInfo(block.units[idx], lang);
|
|
78
|
+
RED.log.debug(`Unit Info for unit ${block.units[idx]}: ${JSON.stringify(unitInfo)} + version: ${coeVersion} + key: ${unitInfo.key} + symbol: ${unitInfo.symbol}`);
|
|
79
|
+
const outputNumber = (block.blockNumber - 1) * 4 + idx + 1;
|
|
80
|
+
return {
|
|
81
|
+
outputNumber: outputNumber,
|
|
82
|
+
value: value,
|
|
83
|
+
unit: block.units[idx],
|
|
84
|
+
unitName: unitInfo.name,
|
|
85
|
+
unitSymbol: unitInfo.symbol
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Additional Details for Digital Blocks
|
|
91
|
+
if (isDigital) {
|
|
92
|
+
const baseOutput = block.blockNumber === 0 ? 1 : 17;
|
|
93
|
+
msg.payload.valuesDetailed = block.values.map((value, idx) => ({
|
|
94
|
+
outputNumber: baseOutput + idx,
|
|
95
|
+
value: value === 1,
|
|
96
|
+
state: value === 1 ? 'ON' : 'OFF'
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Raw Data
|
|
101
|
+
if (node.includeRaw) {
|
|
102
|
+
msg.payload.raw = block;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
node.send(msg);
|
|
106
|
+
|
|
107
|
+
// Status Update
|
|
108
|
+
const dataTypeLabel = isDigital ? 'D' : 'A';
|
|
109
|
+
node.status({
|
|
110
|
+
fill: "green",
|
|
111
|
+
shape: "dot",
|
|
112
|
+
text: RED._("coe-monitor.status.node") + ` ${block.nodeNumber} B${block.blockNumber}[${dataTypeLabel}] - ${packetCount} Pkts`
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
node.cmiConfig.registerListener(listener);
|
|
118
|
+
node.status({fill: "grey", shape: "ring", text: "coe-monitor.status.monitoring"});
|
|
119
|
+
|
|
120
|
+
// Status Update Timer (shows last activity)
|
|
121
|
+
const statusTimer = setInterval(() => {
|
|
122
|
+
const secsSinceUpdate = Math.floor((Date.now() - lastUpdate) / 1000);
|
|
123
|
+
if (secsSinceUpdate > 10) {
|
|
124
|
+
node.status({
|
|
125
|
+
fill: "yellow",
|
|
126
|
+
shape: "ring",
|
|
127
|
+
text: RED._("coe-monitor.status.idle") + ` ${secsSinceUpdate}s - ${packetCount} Pkts [v${coeVersion}]`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}, 5000);
|
|
131
|
+
|
|
132
|
+
node.on('close', function() {
|
|
133
|
+
clearInterval(statusTimer);
|
|
134
|
+
node.cmiConfig.unregisterListener(listener);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
RED.nodes.registerType("coe-monitor", CoEMonitorNode);
|
|
138
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
CoE Output Node
|
|
3
|
+
|
|
4
|
+
Copyright 2025 Florian Mayrhofer
|
|
5
|
+
Licensed under the Apache License, Version 2.0
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script type="text/javascript">
|
|
9
|
+
RED.nodes.registerType('coe-output', {
|
|
10
|
+
category: 'TA CMI',
|
|
11
|
+
color: '#C0DEED',
|
|
12
|
+
defaults: {
|
|
13
|
+
name: { value: "" },
|
|
14
|
+
cmiconfig: { value: "", type: "cmiconfig", required: true },
|
|
15
|
+
nodeNumber: { value: 1, validate: RED.validators.number() },
|
|
16
|
+
outputNumber: { value: 1, validate: RED.validators.number() },
|
|
17
|
+
dataType: { value: "analog" },
|
|
18
|
+
unit: { value: 0, validate: RED.validators.number() }
|
|
19
|
+
},
|
|
20
|
+
inputs: 1,
|
|
21
|
+
outputs: 2,
|
|
22
|
+
icon: "serial.svg",
|
|
23
|
+
outputLabels: ["message", "debug"],
|
|
24
|
+
label: function() {
|
|
25
|
+
const type = this.dataType === 'analog' ? 'A' : 'D';
|
|
26
|
+
return this.name || `CoE Out (${type}${this.outputNumber})`;
|
|
27
|
+
},
|
|
28
|
+
labelStyle: function() {
|
|
29
|
+
return this.name ? "node_label_italic" : "";
|
|
30
|
+
},
|
|
31
|
+
oneditprepare: function() {
|
|
32
|
+
const node = this;
|
|
33
|
+
const langString = RED.i18n.detectLanguage() || 'en';
|
|
34
|
+
const editorLanguage = (langString.toLowerCase().startsWith("de")) ? "de" : "en";
|
|
35
|
+
|
|
36
|
+
// Populate unit dropdown
|
|
37
|
+
$.getJSON('ta-cmi-coe/units/' + editorLanguage)
|
|
38
|
+
.done(function(unitsData) {
|
|
39
|
+
const selectElement = $('#node-input-unit');
|
|
40
|
+
selectElement.empty();
|
|
41
|
+
|
|
42
|
+
Object.keys(unitsData).forEach(function(key) {
|
|
43
|
+
const unit = unitsData[key];
|
|
44
|
+
selectElement.append(
|
|
45
|
+
$('<option></option>')
|
|
46
|
+
.attr('value', key)
|
|
47
|
+
.text(unit.name)
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Set existing value
|
|
52
|
+
selectElement.val(node.unit);
|
|
53
|
+
})
|
|
54
|
+
.fail(function(jqXHR, textStatus, errorThrown) {
|
|
55
|
+
console.error("Error loading unit list:", errorThrown);
|
|
56
|
+
RED.notify("Error loading unit list from unit-config.js.", "error");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
$("#node-input-dataType").change(function() {
|
|
60
|
+
if ($(this).val() === "analog") {
|
|
61
|
+
$("#unit-row").show();
|
|
62
|
+
} else {
|
|
63
|
+
$("#unit-row").hide();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
$("#node-input-dataType").change();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<script type="text/html" data-template-name="coe-output">
|
|
72
|
+
<div class="form-row">
|
|
73
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="coe-output.label.name"></span></label>
|
|
74
|
+
<input type="text" id="node-input-name">
|
|
75
|
+
</div>
|
|
76
|
+
<div class="form-row">
|
|
77
|
+
<label for="node-input-cmiconfig"><i class="fa fa-cog"></i> <span data-i18n="coe-output.label.cmiconfig"></span></label>
|
|
78
|
+
<input type="text" id="node-input-cmiconfig">
|
|
79
|
+
</div>
|
|
80
|
+
<div class="form-row">
|
|
81
|
+
<label for="node-input-nodeNumber"><i class="fa fa-sitemap"></i> <span data-i18n="coe-output.label.nodeNumber"></span></label>
|
|
82
|
+
<input type="number" id="node-input-nodeNumber" placeholder="1" min="1" max="62">
|
|
83
|
+
</div>
|
|
84
|
+
<div class="form-row">
|
|
85
|
+
<label for="node-input-outputNumber"><i class="fa fa-arrow-up"></i> <span data-i18n="coe-output.label.outputNumber"></span></label>
|
|
86
|
+
<input type="number" id="node-input-outputNumber" placeholder="1" min="1" max="32">
|
|
87
|
+
</div>
|
|
88
|
+
<div class="form-row">
|
|
89
|
+
<label for="node-input-dataType"><i class="fa fa-list"></i> <span data-i18n="coe-output.label.dataType"></span></label>
|
|
90
|
+
<select id="node-input-dataType">
|
|
91
|
+
<option value="analog">Analog</span></option>
|
|
92
|
+
<option value="digital">Digital</span></option>
|
|
93
|
+
</select>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="form-row" id="unit-row" style="display:none;">
|
|
96
|
+
<label for="node-input-unit"><i class="fa fa-ruler"></i> <span data-i18n="coe-output.label.unit"></span></label>
|
|
97
|
+
<select id="node-input-unit"></select>
|
|
98
|
+
</div>
|
|
99
|
+
</script>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoE Output Node (Sending of values)
|
|
3
|
+
*
|
|
4
|
+
* Copyright 2025 Florian Mayrhofer
|
|
5
|
+
* Licensed under the Apache License, Version 2.0
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
'use strict';
|
|
11
|
+
const { getBlockInfo } = require('../lib/utils')
|
|
12
|
+
const { queueAndSend } = require('../lib/queueing');
|
|
13
|
+
|
|
14
|
+
function CoEOutputNode(config) {
|
|
15
|
+
RED.nodes.createNode(this, config);
|
|
16
|
+
const node = this;
|
|
17
|
+
|
|
18
|
+
node.cmiConfig = RED.nodes.getNode(config.cmiconfig);
|
|
19
|
+
|
|
20
|
+
if (!node.cmiConfig) {
|
|
21
|
+
node.error("CoE Configuration missing or invalid.");
|
|
22
|
+
node.status({fill:"red", shape:"ring", text:"coe-output.status.noconfig"});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
node.cmiAddress = node.cmiConfig.address;
|
|
27
|
+
node.coeVersion = node.cmiConfig.coeVersion || 1;
|
|
28
|
+
node.nodeNumber = parseInt(config.nodeNumber) || 1;
|
|
29
|
+
node.outputNumber = parseInt(config.outputNumber) || 1;
|
|
30
|
+
node.dataType = config.dataType || 'analog';
|
|
31
|
+
node.unit = parseInt(config.unit) || 0;
|
|
32
|
+
|
|
33
|
+
node.on('input', function(msg) {
|
|
34
|
+
const blockInfo = getBlockInfo(node.dataType, node.outputNumber);
|
|
35
|
+
let values, units;
|
|
36
|
+
|
|
37
|
+
if (node.dataType === 'analog') {
|
|
38
|
+
values = [undefined, undefined, undefined, undefined];
|
|
39
|
+
units = [undefined, undefined, undefined, undefined];
|
|
40
|
+
|
|
41
|
+
const payloadValue = parseFloat(msg.payload);
|
|
42
|
+
values[blockInfo.position] = isNaN(payloadValue) ? 0 : payloadValue;
|
|
43
|
+
|
|
44
|
+
// Set unit for the specific output
|
|
45
|
+
units[blockInfo.position] =
|
|
46
|
+
(msg.coe && msg.coe.unit !== undefined)
|
|
47
|
+
? parseInt(msg.coe.unit)
|
|
48
|
+
: node.unit;
|
|
49
|
+
|
|
50
|
+
} else { // digital
|
|
51
|
+
values = new Array(16).fill(undefined);
|
|
52
|
+
values[blockInfo.position] = msg.payload ? 1 : 0;
|
|
53
|
+
units = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
node.status({
|
|
57
|
+
fill: "yellow",
|
|
58
|
+
shape: "dot",
|
|
59
|
+
text: RED._("coe-output.status.queued") + `[v${node.coeVersion}]`
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
queueAndSend(node, RED._, node.nodeNumber, blockInfo.block, values, units, node.dataType, node.coeVersion, node.cmiConfig, node.cmiAddress, msg);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
node.status({fill:"grey", shape:"ring", text:RED._("coe-output.status.ready") + ` [v${node.coeVersion}]`});
|
|
66
|
+
}
|
|
67
|
+
RED.nodes.registerType("coe-output", CoEOutputNode);
|
|
68
|
+
};
|
package/coe/config.html
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
CMI Configuration Node
|
|
3
|
+
|
|
4
|
+
Copyright 2025 Florian Mayrhofer
|
|
5
|
+
Licensed under the Apache License, Version 2.0
|
|
6
|
+
-->
|
|
7
|
+
|
|
8
|
+
<script type="text/javascript">
|
|
9
|
+
RED.nodes.registerType('cmiconfig', {
|
|
10
|
+
category: 'config',
|
|
11
|
+
defaults: {
|
|
12
|
+
name: { value: "" },
|
|
13
|
+
localip: { value: "0.0.0.0" },
|
|
14
|
+
address: { value: "192.168.0.100" },
|
|
15
|
+
coeVersion:{ value: 1 }
|
|
16
|
+
},
|
|
17
|
+
label: function() {
|
|
18
|
+
const version = this.coeVersion || 1;
|
|
19
|
+
return this.name || `Address (${this.address || "192.168.0.100"}), CMI Config (${version})`;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script type="text/html" data-template-name="cmiconfig">
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i>
|
|
27
|
+
<span data-i18n="config.label.name"></span>
|
|
28
|
+
</label>
|
|
29
|
+
<input type="text" id="node-config-input-name">
|
|
30
|
+
</div>
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="node-config-input-localip"><i class="fa fa-wifi"></i>
|
|
33
|
+
<span data-i18n="config.label.localip"></span>
|
|
34
|
+
</label>
|
|
35
|
+
<input type="text" id="node-config-input-localip" placeholder="0.0.0.0">
|
|
36
|
+
</div>
|
|
37
|
+
<div class="form-row">
|
|
38
|
+
<label for="node-config-input-address"><i class="fa fa-globe"></i>
|
|
39
|
+
<span data-i18n="config.label.address"></span>
|
|
40
|
+
</label>
|
|
41
|
+
<input type="text" id="node-config-input-address" placeholder="192.168.1.100">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-config-input-coeVersion"><i class="fa fa-cog"></i>
|
|
45
|
+
<span data-i18n="config.label.coeVersion"></span>
|
|
46
|
+
</label>
|
|
47
|
+
<select id="node-config-input-coeVersion">
|
|
48
|
+
<option value="1">V1</option>
|
|
49
|
+
<option value="2">V2</option>
|
|
50
|
+
</select>
|
|
51
|
+
</div>
|
|
52
|
+
</script>
|
package/coe/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMI Configuration Node & Shared UDP socket
|
|
3
|
+
*
|
|
4
|
+
* Copyright 2025 Florian Mayrhofer
|
|
5
|
+
* Licensed under the Apache License, Version 2.0
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = function(RED) {
|
|
10
|
+
"use strict";
|
|
11
|
+
const dgram = require('dgram');
|
|
12
|
+
const { parseCoEPacket } = require('../lib/coe');
|
|
13
|
+
|
|
14
|
+
// CoE Protocol Ports
|
|
15
|
+
const COE_PORT1 = 5441; // CoE v1
|
|
16
|
+
const COE_PORT2 = 5442; // CoE v2
|
|
17
|
+
|
|
18
|
+
function CMIConfigNode(config) {
|
|
19
|
+
RED.nodes.createNode(this, config);
|
|
20
|
+
const node = this;
|
|
21
|
+
|
|
22
|
+
node.lang = (RED.settings.lang.toLowerCase().startsWith("de")) ? "de" : "en";
|
|
23
|
+
|
|
24
|
+
node.address = config.address || '192.168.0.100';
|
|
25
|
+
node.coeVersion = parseInt(config.coeVersion) || 1;
|
|
26
|
+
node.port = (node.coeVersion === 2) ? COE_PORT2 : COE_PORT1;
|
|
27
|
+
node.localAddress = config.localip || '0.0.0.0';
|
|
28
|
+
node.socket = null;
|
|
29
|
+
node.listeners = [];
|
|
30
|
+
|
|
31
|
+
// Add UDP Socket
|
|
32
|
+
try {
|
|
33
|
+
node.socket = dgram.createSocket({
|
|
34
|
+
type: 'udp4',
|
|
35
|
+
reuseAddr: true // Enable Socket Reuse
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
node.socket.on('message', (msg, rinfo) => {
|
|
39
|
+
const blocks = parseCoEPacket(msg, node.coeVersion);
|
|
40
|
+
|
|
41
|
+
if (blocks && blocks.length > 0) {
|
|
42
|
+
const data = { // Wrapper object for meta info
|
|
43
|
+
blocks: blocks,
|
|
44
|
+
sourceIP: rinfo.address,
|
|
45
|
+
version: node.coeVersion,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
rawBuffer: msg
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
node.listeners.forEach(listener => { // Notify all listeners
|
|
51
|
+
try {
|
|
52
|
+
listener(data);
|
|
53
|
+
} catch(err) {
|
|
54
|
+
node.error(`Listener error: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
node.socket.on('error', (err) => {
|
|
61
|
+
node.error(`UDP Socket Error: ${err.message}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
node.socket.bind(node.port, node.localAddress, () => {
|
|
65
|
+
node.log(`CoE UDP Socket is listening on ${node.localAddress}:${node.port} (CoE V${node.coeVersion})`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
} catch(err) {
|
|
69
|
+
node.error(`Failed to create UDP socket: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
node.registerListener = function(callback) { // Add listener
|
|
73
|
+
node.listeners.push(callback);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
node.unregisterListener = function(callback) { // Remove listener
|
|
77
|
+
const index = node.listeners.indexOf(callback);
|
|
78
|
+
if (index > -1) {
|
|
79
|
+
node.listeners.splice(index, 1);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
node.send = function(host, packet) { // Send data
|
|
84
|
+
if (node.socket) {
|
|
85
|
+
node.socket.send(packet, 0, packet.length, node.port, host, (err) => {
|
|
86
|
+
if (err) {
|
|
87
|
+
node.error(`Failed to send: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
node.on('close', function() { // Cleanup on node shutdown
|
|
94
|
+
if (node.socket) {
|
|
95
|
+
node.socket.close();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
RED.nodes.registerType("cmiconfig", CMIConfigNode);
|
|
100
|
+
|
|
101
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script type="text/html" data-help-name="coe-input">
|
|
2
|
+
<p>Empfängt Werte von einer TA CMI via CoE (CAN over Ethernet).</p>
|
|
3
|
+
<h3>Konfiguration</h3>
|
|
4
|
+
<ul>
|
|
5
|
+
<li><b>Knoten:</b> CAN Knoten-Nummer (1-62, 0 = von beliebigem Knoten empfangen, nicht empfohlen in Produktivbetrieb)</li>
|
|
6
|
+
<li><b>Eingang:</b> Nummer des Netzwerkeingangs (1-32)</li>
|
|
7
|
+
<li><b>Datentyp:</b> Analog (1-32) oder Digital (1-32)</li>
|
|
8
|
+
<li><b>Timeout:</b> CAN-Bus Timeout-Wert in Minuten</li>
|
|
9
|
+
</ul>
|
|
10
|
+
</script>
|