node-red-contrib-omron-eip 0.2.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/LICENSE +339 -0
- package/MANUAL.md +457 -0
- package/README.md +472 -0
- package/nodes/common.js +237 -0
- package/nodes/omron-plc.html +152 -0
- package/nodes/omron-plc.js +131 -0
- package/nodes/omron-read.html +299 -0
- package/nodes/omron-read.js +135 -0
- package/nodes/omron-write.html +416 -0
- package/nodes/omron-write.js +163 -0
- package/package.json +52 -0
package/nodes/common.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for the omron-eip Node-RED nodes.
|
|
4
|
+
*
|
|
5
|
+
* This module deliberately contains NO Node-RED or omron-eip dependencies, so it can be
|
|
6
|
+
* unit-tested offline. It holds the message-shape parsing/normalization and error
|
|
7
|
+
* formatting that turn loose Node-RED messages into well-formed library calls and turn
|
|
8
|
+
* library errors into clear, actionable messages.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the list of variable names a read/write operation should act on.
|
|
13
|
+
*
|
|
14
|
+
* Precedence: an explicit list on the incoming message overrides the node's configured
|
|
15
|
+
* list. This lets a flow drive the variable set dynamically while still supporting a
|
|
16
|
+
* static configuration.
|
|
17
|
+
*
|
|
18
|
+
* Accepted message overrides (on msg[varProp], default msg.variables):
|
|
19
|
+
* - an array of strings: ['A', 'B', 'C']
|
|
20
|
+
* - a single string: 'A'
|
|
21
|
+
* Anything else is ignored and the configured list is used.
|
|
22
|
+
*
|
|
23
|
+
* @param {string[]} configuredNames names from the node's edit panel
|
|
24
|
+
* @param {*} msgOverride msg[varProp], may be undefined
|
|
25
|
+
* @returns {string[]} the resolved, non-empty list of names
|
|
26
|
+
*/
|
|
27
|
+
function resolveReadVariables(configuredNames, msgOverride) {
|
|
28
|
+
if (Array.isArray(msgOverride) && msgOverride.length > 0 &&
|
|
29
|
+
msgOverride.every(n => typeof n === 'string' && n.length > 0)) {
|
|
30
|
+
return msgOverride.slice();
|
|
31
|
+
}
|
|
32
|
+
if (typeof msgOverride === 'string' && msgOverride.length > 0) {
|
|
33
|
+
return [msgOverride];
|
|
34
|
+
}
|
|
35
|
+
return (configuredNames || []).filter(n => typeof n === 'string' && n.length > 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the {name: value} map for a write operation.
|
|
40
|
+
*
|
|
41
|
+
* Two value sources, chosen at the node level:
|
|
42
|
+
*
|
|
43
|
+
* source === 'fixed':
|
|
44
|
+
* Values come entirely from the node's configured rows. `fixedPairs` is an array of
|
|
45
|
+
* { name, value } already type-coerced by the caller. The incoming message is only a
|
|
46
|
+
* trigger and its payload is ignored.
|
|
47
|
+
*
|
|
48
|
+
* source === 'msg':
|
|
49
|
+
* Values come from the incoming message payload. Two accepted shapes:
|
|
50
|
+
* - object keyed by variable name: { Setpoint: 1500, Mode: 3 }
|
|
51
|
+
* - a bare value (number/boolean/string/object-for-struct), ONLY valid when exactly
|
|
52
|
+
* one variable is configured: 1500 -> { <the one configured name>: 1500 }
|
|
53
|
+
*
|
|
54
|
+
* Throws an Error with a precise, user-facing message when the shape can't be resolved.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} opts
|
|
57
|
+
* @param {string} opts.source 'fixed' | 'msg'
|
|
58
|
+
* @param {Array} opts.fixedPairs [{name, value}] (for source 'fixed')
|
|
59
|
+
* @param {string[]} opts.configuredNames configured variable names (for source 'msg')
|
|
60
|
+
* @param {*} opts.payload msg payload (for source 'msg')
|
|
61
|
+
* @returns {Object} map of { variableName: value }
|
|
62
|
+
*/
|
|
63
|
+
function buildWriteMap(opts) {
|
|
64
|
+
const { source, fixedPairs, configuredNames, payload } = opts;
|
|
65
|
+
|
|
66
|
+
if (source === 'fixed') {
|
|
67
|
+
const pairs = (fixedPairs || []).filter(p => p && typeof p.name === 'string' && p.name.length > 0);
|
|
68
|
+
if (pairs.length === 0) {
|
|
69
|
+
throw new Error('Write node has no variables configured. Add at least one variable and value.');
|
|
70
|
+
}
|
|
71
|
+
const map = {};
|
|
72
|
+
for (const p of pairs) map[p.name] = p.value;
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// source === 'msg'
|
|
77
|
+
const names = (configuredNames || []).filter(n => typeof n === 'string' && n.length > 0);
|
|
78
|
+
|
|
79
|
+
// Object payload keyed by variable name.
|
|
80
|
+
if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
81
|
+
const keys = Object.keys(payload);
|
|
82
|
+
if (keys.length === 0) {
|
|
83
|
+
throw new Error('Write expected msg.payload to be an object like { VariableName: value } but it was empty.');
|
|
84
|
+
}
|
|
85
|
+
return Object.assign({}, payload);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Bare value payload — only valid for a single configured variable.
|
|
89
|
+
if (names.length === 1) {
|
|
90
|
+
if (payload === undefined) {
|
|
91
|
+
throw new Error(`Write expected msg.payload to hold the value for "${names[0]}" but it was undefined.`);
|
|
92
|
+
}
|
|
93
|
+
return { [names[0]]: payload };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Bare value but multiple/zero configured variables: ambiguous.
|
|
97
|
+
const got = payload === null ? 'null' : Array.isArray(payload) ? 'array' : typeof payload;
|
|
98
|
+
if (names.length === 0) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Write received a ${got} payload but no variables are configured. ` +
|
|
101
|
+
`Either configure variable names, or send msg.payload as an object like { VariableName: value }.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Write expected msg.payload to be an object like { VariableName: value } because ${names.length} ` +
|
|
106
|
+
`variables are configured (${names.join(', ')}), but got a ${got}.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Coerce a typed-input style value into a JS value for writing.
|
|
112
|
+
* Node-RED's typed input gives us a string plus a type tag; for the value types we type
|
|
113
|
+
* inline (num/bool/str/json) we convert here. (msg/flow/global are resolved by Node-RED
|
|
114
|
+
* before reaching us, so they arrive already as native values and pass through.)
|
|
115
|
+
*
|
|
116
|
+
* @param {*} value the raw value (already native for msg/flow/global)
|
|
117
|
+
* @param {string} type 'num' | 'bool' | 'str' | 'json' | other
|
|
118
|
+
* @returns {*}
|
|
119
|
+
*/
|
|
120
|
+
function coerceTypedValue(value, type) {
|
|
121
|
+
switch (type) {
|
|
122
|
+
case 'num': {
|
|
123
|
+
const n = Number(value);
|
|
124
|
+
if (Number.isNaN(n)) throw new Error(`"${value}" is not a valid number.`);
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
case 'bool':
|
|
128
|
+
return value === true || value === 'true';
|
|
129
|
+
case 'json':
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
try { return JSON.parse(value); }
|
|
132
|
+
catch (e) { throw new Error(`Invalid JSON value: ${e.message}`); }
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
case 'str':
|
|
136
|
+
return value == null ? '' : String(value);
|
|
137
|
+
default:
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Shape the result of a multi-tag read into the message payload form the user chose.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} resultMap { name: value | {__error: Error} } from readVariables
|
|
146
|
+
* @param {string} outputShape 'object' (default) | 'perTag'
|
|
147
|
+
* @returns {{ kind: 'single', items: [{topic, payload, error}] }}
|
|
148
|
+
* A normalized description the node turns into one or many messages.
|
|
149
|
+
* For 'object', items has length 1 with payload = the whole map (errors stripped
|
|
150
|
+
* into a separate .errors field). For 'perTag', one item per variable.
|
|
151
|
+
*/
|
|
152
|
+
function shapeReadResult(resultMap, outputShape) {
|
|
153
|
+
const names = Object.keys(resultMap);
|
|
154
|
+
|
|
155
|
+
if (outputShape === 'perTag') {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'perTag',
|
|
158
|
+
items: names.map(name => {
|
|
159
|
+
const v = resultMap[name];
|
|
160
|
+
const isErr = v && typeof v === 'object' && v.__error instanceof Error;
|
|
161
|
+
return {
|
|
162
|
+
topic: name,
|
|
163
|
+
payload: isErr ? null : v,
|
|
164
|
+
error: isErr ? v.__error : null,
|
|
165
|
+
};
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 'object' (default): one message, payload is { name: value }, errors collected aside.
|
|
171
|
+
const payload = {};
|
|
172
|
+
const errors = {};
|
|
173
|
+
let hasError = false;
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
const v = resultMap[name];
|
|
176
|
+
if (v && typeof v === 'object' && v.__error instanceof Error) {
|
|
177
|
+
errors[name] = v.__error.message;
|
|
178
|
+
hasError = true;
|
|
179
|
+
} else {
|
|
180
|
+
payload[name] = v;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
kind: 'object',
|
|
185
|
+
items: [{
|
|
186
|
+
topic: names.length === 1 ? names[0] : undefined,
|
|
187
|
+
payload,
|
|
188
|
+
errors: hasError ? errors : null,
|
|
189
|
+
}],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Turn an error from the library into a concise, user-facing string, decoding the common
|
|
195
|
+
* CIP status codes into plain language where we can.
|
|
196
|
+
*
|
|
197
|
+
* @param {Error} err may be a CIPException (has .statusCode / .extendedStatusCode) or generic
|
|
198
|
+
* @param {string} [variableName] the tag involved, if known
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function describeError(err, variableName) {
|
|
202
|
+
if (!err) return 'Unknown error';
|
|
203
|
+
const tag = variableName ? ` (variable "${variableName}")` : '';
|
|
204
|
+
const code = (err.statusCode !== undefined && err.statusCode !== null)
|
|
205
|
+
? Number(err.statusCode) : null;
|
|
206
|
+
|
|
207
|
+
// Decode the CIP statuses users hit most often.
|
|
208
|
+
const HINTS = {
|
|
209
|
+
0x04: 'path segment error — check the variable name/syntax',
|
|
210
|
+
0x05: 'variable not found on the controller — check the name and that it is published to the network',
|
|
211
|
+
0x08: 'service not supported by the controller',
|
|
212
|
+
0x11: 'reply data too large — the value exceeds the connection limit (use UCMM for large arrays)',
|
|
213
|
+
0x13: 'not enough data — the value is shorter than the variable expects',
|
|
214
|
+
0x15: 'too much data — the value is larger than the variable, or the type does not match',
|
|
215
|
+
};
|
|
216
|
+
if (code !== null && HINTS[code]) {
|
|
217
|
+
return `CIP error 0x${code.toString(16).padStart(2, '0')}: ${HINTS[code]}${tag}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Clean up the library's type-validation messages (thrown before anything hits the wire),
|
|
221
|
+
// e.g. "CIPDoubleInteger.fromValue: expected an integer Number, got string \"104\"".
|
|
222
|
+
let msg = err.message || String(err);
|
|
223
|
+
const m = msg.match(/^CIP\w+\.fromValue:\s*(.*)$/);
|
|
224
|
+
if (m) {
|
|
225
|
+
// Strip the internal class/method prefix; keep the human-readable reason.
|
|
226
|
+
return `type mismatch — ${m[1]}${tag}`;
|
|
227
|
+
}
|
|
228
|
+
return `${msg}${tag}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
resolveReadVariables,
|
|
233
|
+
buildWriteMap,
|
|
234
|
+
coerceTypedValue,
|
|
235
|
+
shapeReadResult,
|
|
236
|
+
describeError,
|
|
237
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('omron-plc', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
address: { value: '', required: true },
|
|
7
|
+
messaging: { value: 'ucmm' },
|
|
8
|
+
},
|
|
9
|
+
label: function () {
|
|
10
|
+
return this.name || this.address || 'omron-plc';
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script type="text/html" data-template-name="omron-plc">
|
|
16
|
+
<div class="omron-banner">
|
|
17
|
+
<i class="fa fa-info-circle"></i>
|
|
18
|
+
<b>Deploy after editing.</b> The connection to the PLC is created when you
|
|
19
|
+
<b>Deploy</b> the flow. A newly added or changed PLC config is not live until then.
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="omron-section-title">Connection</div>
|
|
23
|
+
|
|
24
|
+
<div class="form-row">
|
|
25
|
+
<label for="node-config-input-name">
|
|
26
|
+
<i class="fa fa-tag"></i> Name
|
|
27
|
+
<i class="fa fa-info-circle omron-info" title="A friendly name for this controller so you can recognize it later, for example Line 3 NX102 or Packaging PLC. It appears in the PLC dropdown on every read and write node. Optional, but recommended if you have more than one controller."></i>
|
|
28
|
+
</label>
|
|
29
|
+
<input type="text" id="node-config-input-name" placeholder="e.g. Line 3 NX102">
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="form-row">
|
|
33
|
+
<label for="node-config-input-address">
|
|
34
|
+
<i class="fa fa-globe"></i> Address
|
|
35
|
+
<i class="fa fa-info-circle omron-info" title="The network address (IP) of the controller, for example 192.168.250.1. This is the address of the controller's EtherNet/IP port. Important: the computer (or container) running Node-RED must be able to reach this address on the network — if you cannot ping it from there, the node cannot connect either."></i>
|
|
36
|
+
</label>
|
|
37
|
+
<input type="text" id="node-config-input-address" placeholder="192.168.250.1">
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="form-row">
|
|
41
|
+
<label>
|
|
42
|
+
<i class="fa fa-exchange"></i> Messaging
|
|
43
|
+
<i class="fa fa-info-circle omron-info" title="Two ways of talking to the controller. UCMM is the normal, recommended choice for almost everyone, especially when this computer connects straight to the PLC — it is faster and simpler. Class 3 only helps in special network setups (going through a routing gateway or across a backplane). When in doubt, leave it on UCMM. The box below explains both in more detail."></i>
|
|
44
|
+
</label>
|
|
45
|
+
<div style="display:inline-block;">
|
|
46
|
+
<label style="width:auto; margin-right:15px;">
|
|
47
|
+
<input type="radio" name="node-config-input-messaging" value="ucmm" style="width:auto; vertical-align:middle;">
|
|
48
|
+
UCMM (recommended)
|
|
49
|
+
</label>
|
|
50
|
+
<label style="width:auto;">
|
|
51
|
+
<input type="radio" name="node-config-input-messaging" value="class3" style="width:auto; vertical-align:middle;">
|
|
52
|
+
Class 3 connected
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="form-tips omron-tips">
|
|
58
|
+
<b>Which messaging mode?</b><br>
|
|
59
|
+
<b>UCMM</b> (unconnected) is the default and the right choice for almost every setup,
|
|
60
|
+
especially a direct PC-to-PLC connection. It is faster for high-rate and parallel reads,
|
|
61
|
+
has no large-array limitation, and needs no connection setup.<br><br>
|
|
62
|
+
<b>Class 3</b> (connected) opens a persistent CIP connection. It only helps when requests
|
|
63
|
+
travel through a routing gateway, a comms module, or across a backplane, where it avoids
|
|
64
|
+
re-resolving the path on every request. On a direct connection it is <i>slower</i> than UCMM
|
|
65
|
+
(a single connection serializes requests) and it cannot read very large arrays. Use it only
|
|
66
|
+
if your topology specifically benefits. If the controller rejects Class 3, the library
|
|
67
|
+
automatically falls back to UCMM.
|
|
68
|
+
</div>
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<style>
|
|
72
|
+
/* Shared styling for the Omron node edit panels. */
|
|
73
|
+
.omron-banner {
|
|
74
|
+
background: #eaf4fb;
|
|
75
|
+
border: 1px solid #b6dcf5;
|
|
76
|
+
border-left: 4px solid #4a9fd8;
|
|
77
|
+
border-radius: 3px;
|
|
78
|
+
padding: 8px 10px;
|
|
79
|
+
margin-bottom: 14px;
|
|
80
|
+
font-size: 12px;
|
|
81
|
+
line-height: 1.5;
|
|
82
|
+
color: #2c5d7c;
|
|
83
|
+
}
|
|
84
|
+
.omron-banner .fa-info-circle { margin-right: 5px; }
|
|
85
|
+
.omron-section-title {
|
|
86
|
+
font-size: 11px;
|
|
87
|
+
text-transform: uppercase;
|
|
88
|
+
letter-spacing: 0.06em;
|
|
89
|
+
color: #888;
|
|
90
|
+
border-bottom: 1px solid #ddd;
|
|
91
|
+
padding-bottom: 4px;
|
|
92
|
+
margin: 14px 0 10px;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
}
|
|
95
|
+
.omron-info {
|
|
96
|
+
color: #b0b0b0;
|
|
97
|
+
margin-left: 4px;
|
|
98
|
+
cursor: help;
|
|
99
|
+
font-size: 12px;
|
|
100
|
+
}
|
|
101
|
+
.omron-info:hover { color: #4a9fd8; }
|
|
102
|
+
.omron-tips {
|
|
103
|
+
max-width: none;
|
|
104
|
+
background: #fbfbf6;
|
|
105
|
+
line-height: 1.5;
|
|
106
|
+
}
|
|
107
|
+
.omron-typeref-table {
|
|
108
|
+
width: 100%;
|
|
109
|
+
border-collapse: collapse;
|
|
110
|
+
font-size: 12px;
|
|
111
|
+
margin-bottom: 8px;
|
|
112
|
+
}
|
|
113
|
+
.omron-typeref-table th,
|
|
114
|
+
.omron-typeref-table td {
|
|
115
|
+
border: 1px solid #e0e0e0;
|
|
116
|
+
padding: 4px 7px;
|
|
117
|
+
text-align: left;
|
|
118
|
+
vertical-align: top;
|
|
119
|
+
}
|
|
120
|
+
.omron-typeref-table th {
|
|
121
|
+
background: #f3f3f3;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
}
|
|
124
|
+
.omron-typeref-table code {
|
|
125
|
+
font-size: 11px;
|
|
126
|
+
}
|
|
127
|
+
.omron-typeref-note {
|
|
128
|
+
background: #fbfbf6;
|
|
129
|
+
border: 1px solid #eee;
|
|
130
|
+
border-radius: 3px;
|
|
131
|
+
padding: 8px 10px;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
line-height: 1.5;
|
|
134
|
+
color: #555;
|
|
135
|
+
}
|
|
136
|
+
.omron-json {
|
|
137
|
+
background: #f4f4f4 !important;
|
|
138
|
+
color: #1a1a1a !important;
|
|
139
|
+
border: 1px solid #d0d0d0;
|
|
140
|
+
border-radius: 3px;
|
|
141
|
+
padding: 8px 10px;
|
|
142
|
+
font-size: 12px;
|
|
143
|
+
line-height: 1.45;
|
|
144
|
+
overflow-x: auto;
|
|
145
|
+
white-space: pre;
|
|
146
|
+
margin: 6px 0;
|
|
147
|
+
font-family: monospace;
|
|
148
|
+
}
|
|
149
|
+
.omron-json * {
|
|
150
|
+
color: #1a1a1a !important;
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* omron-plc — config node. Owns one shared NSeriesController for a controller, referenced
|
|
4
|
+
* by any number of omron-read / omron-write nodes. Holds the IP address and the UCMM/Class 3
|
|
5
|
+
* messaging choice. Manages the connection lifecycle and exposes a small helper API the
|
|
6
|
+
* operational nodes use (getController, whenReady, listTags).
|
|
7
|
+
*/
|
|
8
|
+
const { NSeriesController } = require('omron-eip');
|
|
9
|
+
|
|
10
|
+
module.exports = function (RED) {
|
|
11
|
+
function OmronPlcNode(config) {
|
|
12
|
+
RED.nodes.createNode(this, config);
|
|
13
|
+
const node = this;
|
|
14
|
+
|
|
15
|
+
node.address = config.address;
|
|
16
|
+
node.messaging = config.messaging || 'ucmm'; // 'ucmm' | 'class3'
|
|
17
|
+
node.label = config.name || config.address;
|
|
18
|
+
|
|
19
|
+
// Build the resilient controller. autoConnect is false; we connect explicitly below so
|
|
20
|
+
// we can surface state to the operational nodes via events.
|
|
21
|
+
node.controller = new NSeriesController({
|
|
22
|
+
host: node.address,
|
|
23
|
+
useConnectedMessaging: node.messaging === 'class3',
|
|
24
|
+
keepAlive: true,
|
|
25
|
+
autoConnect: false,
|
|
26
|
+
// A light logger that forwards to Node-RED's log at debug level.
|
|
27
|
+
logger: {
|
|
28
|
+
debug: (m) => node.debug(m),
|
|
29
|
+
info: (m) => node.debug(m),
|
|
30
|
+
warn: (m) => node.warn(m),
|
|
31
|
+
error: (m) => node.debug(m), // connection errors are surfaced via events instead
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
node.connected = false;
|
|
36
|
+
node.lastError = null;
|
|
37
|
+
|
|
38
|
+
node.controller.on('connect', () => { node.connected = true; node.lastError = null; node.emit('plc-state', 'connected'); });
|
|
39
|
+
node.controller.on('reconnect', () => { node.connected = true; node.lastError = null; node.emit('plc-state', 'connected'); });
|
|
40
|
+
node.controller.on('disconnect', () => { node.connected = false; node.emit('plc-state', 'disconnected'); });
|
|
41
|
+
node.controller.on('error', (err) => { node.lastError = err; node.emit('plc-state', 'error', err); });
|
|
42
|
+
node.controller.on('dispatcherError', (err) => { node.lastError = err; });
|
|
43
|
+
|
|
44
|
+
// Kick off the initial connection. Failures here are normal (PLC may be offline at
|
|
45
|
+
// deploy time); the controller keeps retrying with backoff.
|
|
46
|
+
node.controller.connect().catch((err) => { node.lastError = err; });
|
|
47
|
+
|
|
48
|
+
/** Resolve when the controller is connected, or reject after timeoutMs. */
|
|
49
|
+
node.whenReady = function (timeoutMs = 8000) {
|
|
50
|
+
if (node.connected) return Promise.resolve();
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
node.removeListener('plc-state', onState);
|
|
54
|
+
reject(node.lastError || new Error(`Not connected to ${node.address} (timed out)`));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
function onState(state) {
|
|
57
|
+
if (state === 'connected') {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
node.removeListener('plc-state', onState);
|
|
60
|
+
resolve();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
node.on('plc-state', onState);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Return the shared controller (operational nodes call this). */
|
|
68
|
+
node.getController = function () { return node.controller; };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pull the live tag dictionary from the controller (used by the editor's
|
|
72
|
+
* "Connect & validate" button). Returns a sorted list of published variable names.
|
|
73
|
+
*/
|
|
74
|
+
node.listTags = async function () {
|
|
75
|
+
await node.whenReady(8000);
|
|
76
|
+
await node.controller.updateVariableDictionary();
|
|
77
|
+
const names = node.controller.userVariableList
|
|
78
|
+
? node.controller.userVariableList()
|
|
79
|
+
: node.controller.variableList();
|
|
80
|
+
|
|
81
|
+
// Also expose each tag's type category so the editor can generate example JSON with
|
|
82
|
+
// correct placeholder values. We reach into the underlying NSeries dispatcher's
|
|
83
|
+
// variable map (name -> type instance) and classify by the instance class name.
|
|
84
|
+
const types = {};
|
|
85
|
+
try {
|
|
86
|
+
const plc = node.controller.plc;
|
|
87
|
+
const map = plc && plc.dispatcher &&
|
|
88
|
+
(plc.dispatcher.userVariables || plc.dispatcher.variables);
|
|
89
|
+
if (map) {
|
|
90
|
+
for (const [name, inst] of map.entries()) {
|
|
91
|
+
types[name] = classifyType(inst);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (_) { /* types are best-effort; names still work */ }
|
|
95
|
+
|
|
96
|
+
return { tags: (names || []).slice().sort(), types };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Map a CIP type instance to a coarse category the editor uses to pick a JSON example value.
|
|
100
|
+
function classifyType(inst) {
|
|
101
|
+
const n = inst && inst.constructor && inst.constructor.name || '';
|
|
102
|
+
if (n === 'CIPBoolean') return 'bool';
|
|
103
|
+
if (n === 'CIPReal' || n === 'CIPLongReal') return 'float';
|
|
104
|
+
if (n === 'CIPString') return 'string';
|
|
105
|
+
if (n === 'CIPAbbreviatedStructure' || n === 'CIPStructure') return 'struct';
|
|
106
|
+
if (n === 'CIPArray') return 'array';
|
|
107
|
+
if (/^Omron(Date|Time)/.test(n)) return 'datetime';
|
|
108
|
+
// All integer-ish types (SINT..LWORD, ENUM, BCD) -> number
|
|
109
|
+
return 'number';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
node.on('close', async function (done) {
|
|
113
|
+
try { await node.controller.close(); } catch (_) {}
|
|
114
|
+
done();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
RED.nodes.registerType('omron-plc', OmronPlcNode);
|
|
119
|
+
|
|
120
|
+
// ----- Editor-facing HTTP admin endpoint: list tags for a given config node -----
|
|
121
|
+
// Called by the edit panels' "Connect & validate" button. The :id is the config node's id.
|
|
122
|
+
RED.httpAdmin.get('/omron-eip/:id/tags', RED.auth.needsPermission('omron-plc.read'), function (req, res) {
|
|
123
|
+
const cfg = RED.nodes.getNode(req.params.id);
|
|
124
|
+
if (!cfg || cfg.type !== 'omron-plc') {
|
|
125
|
+
return res.status(404).json({ error: 'config node not found or not deployed yet' });
|
|
126
|
+
}
|
|
127
|
+
cfg.listTags()
|
|
128
|
+
.then((result) => res.json(result)) // { tags: [...], types: { name: category } }
|
|
129
|
+
.catch((err) => res.status(502).json({ error: err.message }));
|
|
130
|
+
});
|
|
131
|
+
};
|