node-red-contrib-my-tools 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/README.txt +69 -0
- package/node-red-contrib-mysql-extended-test/mysql-extended.html +443 -0
- package/node-red-contrib-mysql-extended-test/mysql-extended.js +355 -0
- package/node-red-contrib-rfid-helper/rfid-lazy.html +153 -0
- package/node-red-contrib-rfid-helper/rfid-lazy.js +361 -0
- package/node-red-contrib-s7-plus/LICENSE +235 -0
- package/node-red-contrib-s7-plus/README.md +141 -0
- package/node-red-contrib-s7-plus/examples/read-multiple-values-flow.json +346 -0
- package/node-red-contrib-s7-plus/examples/read-write-test-flow.json +172 -0
- package/node-red-contrib-s7-plus/examples/write-single-values-flow.json +5583 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/README.md +72 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/areas.js +36 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/datatypes.js +91 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/flat-browser.js +433 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/index.js +22 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/lazy.js +397 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/node-id.js +22 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/resolve-symbolic.js +181 -0
- package/node-red-contrib-s7-plus/lib/s7plus/browse/vte-helpers.js +49 -0
- package/node-red-contrib-s7-plus/lib/s7plus/buffer-stream.js +96 -0
- package/node-red-contrib-s7-plus/lib/s7plus/client.js +1238 -0
- package/node-red-contrib-s7-plus/lib/s7plus/constants.js +155 -0
- package/node-red-contrib-s7-plus/lib/s7plus/crc/index.js +3 -0
- package/node-red-contrib-s7-plus/lib/s7plus/crc/s7-crc32.js +70 -0
- package/node-red-contrib-s7-plus/lib/s7plus/crc/symbol-crc.js +271 -0
- package/node-red-contrib-s7-plus/lib/s7plus/debug.js +88 -0
- package/node-red-contrib-s7-plus/lib/s7plus/explore-result.js +41 -0
- package/node-red-contrib-s7-plus/lib/s7plus/item-address.js +55 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pdu-explore.js +77 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pdu-messages.js +359 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pobject.js +197 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pvalue-codec.js +435 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pvalue.js +653 -0
- package/node-red-contrib-s7-plus/lib/s7plus/pvar-lists.js +336 -0
- package/node-red-contrib-s7-plus/lib/s7plus/read-result.js +91 -0
- package/node-red-contrib-s7-plus/lib/s7plus/s7p.js +266 -0
- package/node-red-contrib-s7-plus/lib/s7plus/tag-routing.js +30 -0
- package/node-red-contrib-s7-plus/lib/s7plus/transport/cotp-stream.js +335 -0
- package/node-red-contrib-s7-plus/lib/s7plus/transport/s7-transport.js +335 -0
- package/node-red-contrib-s7-plus/nodes/icons/s7complus.png +0 -0
- package/node-red-contrib-s7-plus/nodes/icons/s7complus.svg +17 -0
- package/node-red-contrib-s7-plus/nodes/s7complus-endpoint.js +825 -0
- package/node-red-contrib-s7-plus/nodes/s7complus-explore.js +91 -0
- package/node-red-contrib-s7-plus/nodes/s7complus-in.js +255 -0
- package/node-red-contrib-s7-plus/nodes/s7complus-out.js +182 -0
- package/node-red-contrib-s7-plus/nodes/s7complus.html +1541 -0
- package/node-red-contrib-s7-plus/nodes/s7complus.js +10 -0
- package/node-red-contrib-s7-plus/package.json +50 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_Arrays.db +17 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_Binary.db +16 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_BitStrings.db +36 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_CharacterStrings.db +42 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_DateAndTime.db +70 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_FloatingPoint.db +42 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_Integers.db +72 -0
- package/node-red-contrib-s7-plus/plc/s7-1500/DB_Timers.db +40 -0
- package/node-red-contrib-s7-plus/scripts/example-flow-shared.js +39 -0
- package/node-red-contrib-s7-plus/scripts/generate-read-multiple-flow.js +331 -0
- package/node-red-contrib-s7-plus/scripts/generate-rw-test-flow.js +690 -0
- package/node-red-contrib-s7-plus/scripts/generate-write-flow.js +476 -0
- package/node-red-contrib-s7-plus/scripts/layout-write-flow.js +156 -0
- package/node-red-contrib-s7-plus/scripts/run-tests.js +16 -0
- package/node-red-flowgobal-change/variable-change.html +425 -0
- package/node-red-flowgobal-change/variable-change.js +92 -0
- package/node-red-ui_media_viewer/ui_media_viewer.html +87 -0
- package/node-red-ui_media_viewer/ui_media_viewer.js +136 -0
- package/package.json +12 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HEX_ADDRESS_RE = /^[0-9A-Fa-f]+(\.[0-9A-Fa-f]+)*$/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Determine if a symbol string is a raw hex access address (as returned
|
|
7
|
+
* by browseResolve, e.g. "8A0E0001.A.0") rather than a symbolic path.
|
|
8
|
+
* Hex addresses consist solely of hex digits separated by dots.
|
|
9
|
+
*/
|
|
10
|
+
function isHexAddress(str) {
|
|
11
|
+
return HEX_ADDRESS_RE.test(str);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determine if a name string looks like a symbolic PLC path. Symbolic
|
|
16
|
+
* paths contain dots with at least one non-hex segment, or brackets
|
|
17
|
+
* (array indexing). Used to decide whether a tag must be resolved
|
|
18
|
+
* (CRC-secured) before reading/writing.
|
|
19
|
+
*/
|
|
20
|
+
function isSymbolicName(name) {
|
|
21
|
+
if (!name || typeof name !== 'string') return false;
|
|
22
|
+
if (isHexAddress(name)) return false;
|
|
23
|
+
return /[.\[]/.test(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
HEX_ADDRESS_RE,
|
|
28
|
+
isHexAddress,
|
|
29
|
+
isSymbolicName
|
|
30
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
const { Duplex } = require('stream');
|
|
5
|
+
|
|
6
|
+
const ISO_HEADER_SIZE = 7;
|
|
7
|
+
const TPKT_COTP_HEADER = Buffer.from([0x03, 0x00, 0x00, 0x1f, 0x02, 0xf0, 0x80]);
|
|
8
|
+
const MAX_PAYLOAD = 16 * 1024;
|
|
9
|
+
|
|
10
|
+
const READER_KEY = Symbol('s7plus.socketReader');
|
|
11
|
+
|
|
12
|
+
function getWord(b, pos) {
|
|
13
|
+
return (b[pos] << 8) | b[pos + 1];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setWord(b, pos, value) {
|
|
17
|
+
b[pos] = (value >> 8) & 0xff;
|
|
18
|
+
b[pos + 1] = value & 0xff;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Persistent buffered reader attached to a socket via a Symbol.
|
|
23
|
+
* Mirrors the synchronous Stream.Read accumulation pattern used in the C# driver
|
|
24
|
+
* and avoids Node's flowing-mode + unshift() data-loss pitfall.
|
|
25
|
+
*/
|
|
26
|
+
class SocketReader {
|
|
27
|
+
constructor(socket) {
|
|
28
|
+
this.socket = socket;
|
|
29
|
+
this.chunks = [];
|
|
30
|
+
this.totalLen = 0;
|
|
31
|
+
this.waiter = null;
|
|
32
|
+
this.error = null;
|
|
33
|
+
this._onData = (chunk) => {
|
|
34
|
+
this.chunks.push(chunk);
|
|
35
|
+
this.totalLen += chunk.length;
|
|
36
|
+
this._tryResolve();
|
|
37
|
+
};
|
|
38
|
+
this._onErr = (err) => this._fail(err);
|
|
39
|
+
this._onClose = () => this._fail(new Error('Socket closed'));
|
|
40
|
+
socket.on('data', this._onData);
|
|
41
|
+
socket.on('error', this._onErr);
|
|
42
|
+
socket.on('close', this._onClose);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_tryResolve() {
|
|
46
|
+
if (!this.waiter) return;
|
|
47
|
+
if (this.totalLen < this.waiter.count) return;
|
|
48
|
+
const w = this.waiter;
|
|
49
|
+
this.waiter = null;
|
|
50
|
+
clearTimeout(w.timer);
|
|
51
|
+
w.resolve(this._consume(w.count));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_consume(count) {
|
|
55
|
+
const out = Buffer.alloc(count);
|
|
56
|
+
let offset = 0;
|
|
57
|
+
while (offset < count) {
|
|
58
|
+
const head = this.chunks[0];
|
|
59
|
+
const take = Math.min(count - offset, head.length);
|
|
60
|
+
head.copy(out, offset, 0, take);
|
|
61
|
+
offset += take;
|
|
62
|
+
this.totalLen -= take;
|
|
63
|
+
if (take === head.length) {
|
|
64
|
+
this.chunks.shift();
|
|
65
|
+
} else {
|
|
66
|
+
this.chunks[0] = head.subarray(take);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_fail(err) {
|
|
73
|
+
if (!this.error) this.error = err;
|
|
74
|
+
const w = this.waiter;
|
|
75
|
+
if (w) {
|
|
76
|
+
this.waiter = null;
|
|
77
|
+
clearTimeout(w.timer);
|
|
78
|
+
w.reject(err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read exactly `count` bytes. If `timeoutMs > 0`, reject after that
|
|
84
|
+
* many ms with 'COTP read timeout'. If `timeoutMs <= 0` or not finite,
|
|
85
|
+
* wait indefinitely — used by the recv loop's "wait for next packet"
|
|
86
|
+
* read, where an idle connection MUST NOT be treated as a timeout
|
|
87
|
+
* (otherwise the loop dies silently every `timeoutMs` ms of idle).
|
|
88
|
+
*/
|
|
89
|
+
read(count, timeoutMs) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (this.error && this.totalLen < count) {
|
|
92
|
+
return reject(this.error);
|
|
93
|
+
}
|
|
94
|
+
if (this.totalLen >= count) {
|
|
95
|
+
return resolve(this._consume(count));
|
|
96
|
+
}
|
|
97
|
+
if (this.waiter) {
|
|
98
|
+
return reject(new Error('SocketReader: concurrent read not supported'));
|
|
99
|
+
}
|
|
100
|
+
let timer = null;
|
|
101
|
+
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
if (this.waiter && this.waiter.timer === timer) {
|
|
104
|
+
this.waiter = null;
|
|
105
|
+
reject(new Error('COTP read timeout'));
|
|
106
|
+
}
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
}
|
|
109
|
+
this.waiter = { count, timer, resolve, reject };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Aborts the currently pending read (if any) without dropping buffered bytes
|
|
115
|
+
* and without marking the reader as failed. The reader can immediately be
|
|
116
|
+
* reused by a new consumer (e.g. when switching from plain to TLS framing).
|
|
117
|
+
*/
|
|
118
|
+
interrupt(reason) {
|
|
119
|
+
const w = this.waiter;
|
|
120
|
+
if (w) {
|
|
121
|
+
this.waiter = null;
|
|
122
|
+
clearTimeout(w.timer);
|
|
123
|
+
const err = new Error(reason || 'SocketReader: read interrupted');
|
|
124
|
+
err.code = 'EREADINTERRUPTED';
|
|
125
|
+
w.reject(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
dispose() {
|
|
130
|
+
this.socket.removeListener('data', this._onData);
|
|
131
|
+
this.socket.removeListener('error', this._onErr);
|
|
132
|
+
this.socket.removeListener('close', this._onClose);
|
|
133
|
+
const w = this.waiter;
|
|
134
|
+
if (w) {
|
|
135
|
+
this.waiter = null;
|
|
136
|
+
clearTimeout(w.timer);
|
|
137
|
+
w.reject(new Error('SocketReader disposed'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getReader(socket) {
|
|
143
|
+
let r = socket[READER_KEY];
|
|
144
|
+
if (!r) {
|
|
145
|
+
r = new SocketReader(socket);
|
|
146
|
+
socket[READER_KEY] = r;
|
|
147
|
+
}
|
|
148
|
+
return r;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readExactly(socket, count, timeoutMs) {
|
|
152
|
+
return getReader(socket).read(count, timeoutMs);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function disposeReader(socket) {
|
|
156
|
+
const r = socket[READER_KEY];
|
|
157
|
+
if (r) {
|
|
158
|
+
r.dispose();
|
|
159
|
+
delete socket[READER_KEY];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function interruptReader(socket, reason) {
|
|
164
|
+
const r = socket[READER_KEY];
|
|
165
|
+
if (r) r.interrupt(reason);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Framed ISO-on-TCP stream for TLS layer (matches CotpStream.cs).
|
|
170
|
+
*/
|
|
171
|
+
class CotpStream extends EventEmitter {
|
|
172
|
+
constructor(innerSocket, timeoutMs = 60000) {
|
|
173
|
+
super();
|
|
174
|
+
this._socket = innerSocket;
|
|
175
|
+
this._timeoutMs = timeoutMs;
|
|
176
|
+
this._queue = [];
|
|
177
|
+
this._current = null;
|
|
178
|
+
this._offset = 0;
|
|
179
|
+
this._closed = false;
|
|
180
|
+
this._syncReaderActive = false;
|
|
181
|
+
this._recvLoop();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async _recvLoop() {
|
|
185
|
+
const lenBuf = Buffer.alloc(4);
|
|
186
|
+
try {
|
|
187
|
+
while (!this._closed && !this._socket.destroyed) {
|
|
188
|
+
// Wait indefinitely for the next packet header. Idle
|
|
189
|
+
// connections are normal — only an actual socket close
|
|
190
|
+
// or error should terminate this loop.
|
|
191
|
+
const h = await readExactly(this._socket, 4, 0);
|
|
192
|
+
h.copy(lenBuf, 0);
|
|
193
|
+
if (lenBuf[0] !== 0x03) continue;
|
|
194
|
+
const packetLen = getWord(lenBuf, 2);
|
|
195
|
+
if (packetLen < ISO_HEADER_SIZE) continue;
|
|
196
|
+
const toRead = packetLen - 4;
|
|
197
|
+
// Mid-packet: once we have the header, the body MUST
|
|
198
|
+
// arrive within timeoutMs. A stalled mid-packet read
|
|
199
|
+
// indicates real trouble.
|
|
200
|
+
const rest = await readExactly(this._socket, toRead, this._timeoutMs);
|
|
201
|
+
const payloadLen = toRead - 3;
|
|
202
|
+
if (payloadLen <= 0) continue;
|
|
203
|
+
const payload = rest.subarray(3, 3 + payloadLen);
|
|
204
|
+
// The 'packet' event is the only active consumer (CotpDuplex
|
|
205
|
+
// for the TLS pipeline). The _queue/_drain/readSync path is
|
|
206
|
+
// dead code in this driver — pushing here without a drainer
|
|
207
|
+
// grows _queue monotonically with every PLC frame and pins
|
|
208
|
+
// every received COTP buffer in the arrayBuffers pool, which
|
|
209
|
+
// eventually freezes Node-RED. Only buffer for readSync if a
|
|
210
|
+
// sync reader has actually subscribed.
|
|
211
|
+
if (this._syncReaderActive) this._queue.push(payload);
|
|
212
|
+
this.emit('packet', payload);
|
|
213
|
+
}
|
|
214
|
+
} catch (_) {
|
|
215
|
+
/* connection ended — _installSocketWatch on the wrapping
|
|
216
|
+
transport surfaces this via a 'close' event */
|
|
217
|
+
} finally {
|
|
218
|
+
this._closed = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_drain(buf, offset, count) {
|
|
223
|
+
let written = 0;
|
|
224
|
+
while (written < count) {
|
|
225
|
+
if (!this._current || this._offset >= this._current.length) {
|
|
226
|
+
if (this._queue.length === 0) return written;
|
|
227
|
+
this._current = this._queue.shift();
|
|
228
|
+
this._offset = 0;
|
|
229
|
+
}
|
|
230
|
+
const avail = this._current.length - this._offset;
|
|
231
|
+
const n = Math.min(avail, count - written);
|
|
232
|
+
this._current.copy(buf, offset + written, this._offset, this._offset + n);
|
|
233
|
+
this._offset += n;
|
|
234
|
+
written += n;
|
|
235
|
+
if (this._offset >= this._current.length) this._current = null;
|
|
236
|
+
}
|
|
237
|
+
return written;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
readSync(buffer, offset, count, waitMs = 30000) {
|
|
241
|
+
// Subscribe to the buffered queue so _recvLoop knows to retain
|
|
242
|
+
// payloads. Without an active sync reader the queue is intentionally
|
|
243
|
+
// not populated to avoid an unbounded retainer.
|
|
244
|
+
this._syncReaderActive = true;
|
|
245
|
+
const n = this._drain(buffer, offset, count);
|
|
246
|
+
if (n === count) {
|
|
247
|
+
if (this._queue.length === 0 && !this._current) this._syncReaderActive = false;
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const deadline = Date.now() + waitMs;
|
|
252
|
+
const tick = () => {
|
|
253
|
+
const r = this._drain(buffer, offset, count);
|
|
254
|
+
if (r === count) {
|
|
255
|
+
if (this._queue.length === 0 && !this._current) this._syncReaderActive = false;
|
|
256
|
+
return resolve(r);
|
|
257
|
+
}
|
|
258
|
+
if (this._closed) {
|
|
259
|
+
this._syncReaderActive = false;
|
|
260
|
+
return reject(new Error('COTP stream closed'));
|
|
261
|
+
}
|
|
262
|
+
if (Date.now() > deadline) {
|
|
263
|
+
this._syncReaderActive = false;
|
|
264
|
+
return reject(new Error('Timeout waiting for COTP packet'));
|
|
265
|
+
}
|
|
266
|
+
setImmediate(tick);
|
|
267
|
+
};
|
|
268
|
+
this.once('packet', tick);
|
|
269
|
+
tick();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
write(buffer) {
|
|
274
|
+
const packet = Buffer.alloc(ISO_HEADER_SIZE + buffer.length);
|
|
275
|
+
TPKT_COTP_HEADER.copy(packet, 0);
|
|
276
|
+
setWord(packet, 2, ISO_HEADER_SIZE + buffer.length);
|
|
277
|
+
buffer.copy(packet, ISO_HEADER_SIZE);
|
|
278
|
+
this._socket.write(packet);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
end() {
|
|
282
|
+
this._closed = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Duplex adapter so tls.TLSSocket can run over COTP framing. */
|
|
287
|
+
class CotpDuplex extends Duplex {
|
|
288
|
+
constructor(cotpStream) {
|
|
289
|
+
super();
|
|
290
|
+
this._cotp = cotpStream;
|
|
291
|
+
this._pending = [];
|
|
292
|
+
cotpStream.on('packet', (p) => {
|
|
293
|
+
this._pending.push(p);
|
|
294
|
+
this._readFromQueue();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_readFromQueue() {
|
|
299
|
+
while (this._pending.length) {
|
|
300
|
+
const p = this._pending.shift();
|
|
301
|
+
if (!this.push(p)) return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_read() {
|
|
306
|
+
this._readFromQueue();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_write(chunk, _enc, cb) {
|
|
310
|
+
try {
|
|
311
|
+
this._cotp.write(chunk);
|
|
312
|
+
cb();
|
|
313
|
+
} catch (e) {
|
|
314
|
+
cb(e);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_final(cb) {
|
|
319
|
+
this._cotp.end();
|
|
320
|
+
cb();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
module.exports = {
|
|
325
|
+
CotpStream,
|
|
326
|
+
CotpDuplex,
|
|
327
|
+
SocketReader,
|
|
328
|
+
readExactly,
|
|
329
|
+
disposeReader,
|
|
330
|
+
interruptReader,
|
|
331
|
+
getWord,
|
|
332
|
+
setWord,
|
|
333
|
+
ISO_HEADER_SIZE,
|
|
334
|
+
TPKT_COTP_HEADER
|
|
335
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const tls = require('tls');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
const {
|
|
7
|
+
CotpStream,
|
|
8
|
+
CotpDuplex,
|
|
9
|
+
readExactly,
|
|
10
|
+
disposeReader,
|
|
11
|
+
interruptReader,
|
|
12
|
+
getWord,
|
|
13
|
+
setWord,
|
|
14
|
+
ISO_HEADER_SIZE,
|
|
15
|
+
TPKT_COTP_HEADER
|
|
16
|
+
} = require('./cotp-stream');
|
|
17
|
+
const { S7Consts } = require('../constants');
|
|
18
|
+
const { scoped } = require('../debug');
|
|
19
|
+
const log = scoped('transport');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PORT_ISO_TCP = 102;
|
|
22
|
+
const ISO_CR_PREFIX = Buffer.from([
|
|
23
|
+
0x03, 0x00, 0x00, 0x24, 0x1f, 0xe0, 0x00, 0x00, 0x00, 0x01, 0x00, 0xc0, 0x01, 0x0a,
|
|
24
|
+
0xc1, 0x02, 0x01, 0x00, 0xc2, 0x10
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
class S7Transport extends EventEmitter {
|
|
28
|
+
constructor() {
|
|
29
|
+
super();
|
|
30
|
+
this._socket = null;
|
|
31
|
+
this._io = null;
|
|
32
|
+
this._cotp = null;
|
|
33
|
+
this._ssl = false;
|
|
34
|
+
this._recvBufs = [];
|
|
35
|
+
this._stopAfterNext = false;
|
|
36
|
+
this._recvRunning = false;
|
|
37
|
+
this._ip = '';
|
|
38
|
+
this._port = DEFAULT_PORT_ISO_TCP;
|
|
39
|
+
this._remoteTsap = null;
|
|
40
|
+
this._connTimeout = 10000;
|
|
41
|
+
this._recvTimeout = 10000;
|
|
42
|
+
this.lastError = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setConnectionParams(address, _localTsap, remoteTsap, port) {
|
|
46
|
+
this._ip = address;
|
|
47
|
+
this._remoteTsap = Buffer.from(remoteTsap);
|
|
48
|
+
if (port && Number.isFinite(port) && port > 0) this._port = port;
|
|
49
|
+
else this._port = DEFAULT_PORT_ISO_TCP;
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setStopAfterNextPacket() {
|
|
54
|
+
this._stopAfterNext = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setTimeouts(ms) {
|
|
58
|
+
this._connTimeout = ms;
|
|
59
|
+
this._recvTimeout = ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_onPayload(payload) {
|
|
63
|
+
try {
|
|
64
|
+
this.emit('data', payload);
|
|
65
|
+
} catch (_) { /* ignore */ }
|
|
66
|
+
if (this._stopAfterNext) {
|
|
67
|
+
this._stopAfterNext = false;
|
|
68
|
+
this._plainRecv = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_startPlainRecv() {
|
|
73
|
+
if (this._recvRunning) return;
|
|
74
|
+
this._recvRunning = true;
|
|
75
|
+
const loop = async () => {
|
|
76
|
+
let exitReason = null;
|
|
77
|
+
while (this._plainRecv && this._socket && !this._socket.destroyed && !this._ssl) {
|
|
78
|
+
try {
|
|
79
|
+
// Wait indefinitely for the next packet header.
|
|
80
|
+
// The recv loop only runs during the brief pre-TLS
|
|
81
|
+
// CC handshake phase, but even there an idle gap
|
|
82
|
+
// must not be misread as a failure.
|
|
83
|
+
const lenBuf = await readExactly(this._io, 4, 0);
|
|
84
|
+
if (lenBuf[0] !== 0x03) continue;
|
|
85
|
+
const total = getWord(lenBuf, 2);
|
|
86
|
+
if (total <= ISO_HEADER_SIZE) continue;
|
|
87
|
+
// Mid-packet: body must arrive within the configured
|
|
88
|
+
// recv timeout.
|
|
89
|
+
const rest = await readExactly(this._io, total - 4, this._recvTimeout);
|
|
90
|
+
const pdu = Buffer.concat([lenBuf, rest]);
|
|
91
|
+
const payloadLen = total - ISO_HEADER_SIZE;
|
|
92
|
+
const payload = pdu.subarray(ISO_HEADER_SIZE, ISO_HEADER_SIZE + payloadLen);
|
|
93
|
+
this._onPayload(payload);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err && err.code === 'EREADINTERRUPTED' && !this._plainRecv) {
|
|
96
|
+
// expected interrupt (e.g. switching to TLS)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
// Real failure — record reason and surface to client.
|
|
100
|
+
exitReason = `plain-recv: ${err && err.message ? err.message : err}`;
|
|
101
|
+
log('plain recv loop terminated', { reason: exitReason });
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this._recvRunning = false;
|
|
106
|
+
if (exitReason) {
|
|
107
|
+
try { this.emit('close', { kind: 'plain', reason: exitReason }); } catch { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
this._plainRecvDone = loop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
connect() {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
this.lastError = 0;
|
|
116
|
+
if (!this._ip || !this._remoteTsap) {
|
|
117
|
+
this.lastError = S7Consts.errCliInvalidParams;
|
|
118
|
+
return resolve(this.lastError);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let settled = false;
|
|
122
|
+
const finish = (code) => {
|
|
123
|
+
if (settled) return;
|
|
124
|
+
settled = true;
|
|
125
|
+
clearTimeout(connectTimer);
|
|
126
|
+
this.lastError = code;
|
|
127
|
+
resolve(code);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
log('connect tcp', { host: this._ip, port: this._port, connTimeout: this._connTimeout });
|
|
131
|
+
const sock = net.connect({ host: this._ip, port: this._port });
|
|
132
|
+
sock.setNoDelay(true);
|
|
133
|
+
sock.setKeepAlive(true, 15000);
|
|
134
|
+
|
|
135
|
+
const connectTimer = setTimeout(() => {
|
|
136
|
+
log('connect tcp timeout', { host: this._ip, port: this._port });
|
|
137
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
138
|
+
finish(S7Consts.errTCPConnectionTimeout);
|
|
139
|
+
}, this._connTimeout);
|
|
140
|
+
|
|
141
|
+
sock.once('error', (err) => {
|
|
142
|
+
log('connect tcp error', { msg: err && err.message });
|
|
143
|
+
finish(S7Consts.errTCPConnectionFailed);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
sock.once('connect', async () => {
|
|
147
|
+
clearTimeout(connectTimer);
|
|
148
|
+
log('tcp established', { host: this._ip, port: this._port });
|
|
149
|
+
this._socket = sock;
|
|
150
|
+
this._io = sock;
|
|
151
|
+
this._installSocketWatch(sock, 'plain');
|
|
152
|
+
try {
|
|
153
|
+
const cr = Buffer.alloc(20 + this._remoteTsap.length);
|
|
154
|
+
ISO_CR_PREFIX.copy(cr, 0, 0, 20);
|
|
155
|
+
this._remoteTsap.copy(cr, 20);
|
|
156
|
+
setWord(cr, 2, 20 + this._remoteTsap.length);
|
|
157
|
+
cr[4] = 15 + this._remoteTsap.length;
|
|
158
|
+
cr[19] = this._remoteTsap.length;
|
|
159
|
+
sock.write(cr);
|
|
160
|
+
const ccHdr = await readExactly(sock, 4, this._connTimeout);
|
|
161
|
+
if (!ccHdr || ccHdr[0] !== 0x03) {
|
|
162
|
+
return finish(S7Consts.errIsoConnect);
|
|
163
|
+
}
|
|
164
|
+
const ccTotal = getWord(ccHdr, 2);
|
|
165
|
+
if (ccTotal < ISO_HEADER_SIZE) {
|
|
166
|
+
return finish(S7Consts.errIsoConnect);
|
|
167
|
+
}
|
|
168
|
+
const ccBody = await readExactly(sock, ccTotal - 4, this._connTimeout);
|
|
169
|
+
if (!ccBody || ccBody[1] !== 0xd0) {
|
|
170
|
+
return finish(S7Consts.errIsoConnect);
|
|
171
|
+
}
|
|
172
|
+
this._plainRecv = true;
|
|
173
|
+
this._startPlainRecv();
|
|
174
|
+
if (!settled) {
|
|
175
|
+
settled = true;
|
|
176
|
+
resolve(0);
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
finish(S7Consts.errIsoConnect);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async sslActivate() {
|
|
186
|
+
if (!this._socket) {
|
|
187
|
+
this.lastError = S7Consts.errOpenSSL;
|
|
188
|
+
return this.lastError;
|
|
189
|
+
}
|
|
190
|
+
// Stop the plain receive loop cleanly: signal exit and interrupt the
|
|
191
|
+
// pending readExactly so the loop does not race with the upcoming
|
|
192
|
+
// CotpStream._recvLoop (both share the same SocketReader).
|
|
193
|
+
this._plainRecv = false;
|
|
194
|
+
interruptReader(this._socket, 'switching to TLS');
|
|
195
|
+
if (this._plainRecvDone) {
|
|
196
|
+
try { await this._plainRecvDone; } catch { /* ignore */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
try {
|
|
201
|
+
const cotp = new CotpStream(this._socket, this._recvTimeout);
|
|
202
|
+
this._cotp = cotp;
|
|
203
|
+
const duplex = new CotpDuplex(cotp);
|
|
204
|
+
// tls.connect (NOT new tls.TLSSocket) actually drives the
|
|
205
|
+
// handshake by sending ClientHello on the underlying duplex.
|
|
206
|
+
// S7-1500 only supports TLS 1.3, and the negotiated COTP TPDU
|
|
207
|
+
// size is 1024 bytes - default Node.js offers many key share
|
|
208
|
+
// groups (~1450 byte ClientHello) which the PLC silently drops.
|
|
209
|
+
// Restrict to X25519 so the ClientHello stays around 224 bytes
|
|
210
|
+
// and comfortably fits a single TPKT/COTP frame.
|
|
211
|
+
const secure = tls.connect({
|
|
212
|
+
socket: duplex,
|
|
213
|
+
minVersion: 'TLSv1.3',
|
|
214
|
+
maxVersion: 'TLSv1.3',
|
|
215
|
+
ecdhCurve: 'X25519',
|
|
216
|
+
rejectUnauthorized: false
|
|
217
|
+
});
|
|
218
|
+
let settled = false;
|
|
219
|
+
// Hard bound the handshake. Without this, a PLC that
|
|
220
|
+
// accepts the TCP connection but never sends ServerHello
|
|
221
|
+
// (firmware update, half-broken state) would block
|
|
222
|
+
// sslActivate forever.
|
|
223
|
+
const sslTimer = setTimeout(() => {
|
|
224
|
+
if (settled) return;
|
|
225
|
+
settled = true;
|
|
226
|
+
log('ssl handshake timeout', { timeoutMs: this._connTimeout });
|
|
227
|
+
this.lastError = S7Consts.errOpenSSL;
|
|
228
|
+
this._tlsError = new Error(`SSL handshake timeout (${this._connTimeout}ms)`);
|
|
229
|
+
try { secure.destroy(); } catch { /* ignore */ }
|
|
230
|
+
resolve(this.lastError);
|
|
231
|
+
}, this._connTimeout);
|
|
232
|
+
secure.once('secureConnect', () => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(sslTimer);
|
|
236
|
+
this._ssl = true;
|
|
237
|
+
this._tlsSocket = secure;
|
|
238
|
+
this._io = secure;
|
|
239
|
+
this._recvRunning = true;
|
|
240
|
+
secure.on('data', (chunk) => this._onPayload(chunk));
|
|
241
|
+
this._installSocketWatch(secure, 'tls');
|
|
242
|
+
resolve(0);
|
|
243
|
+
});
|
|
244
|
+
secure.once('error', (e) => {
|
|
245
|
+
if (settled) return;
|
|
246
|
+
settled = true;
|
|
247
|
+
clearTimeout(sslTimer);
|
|
248
|
+
this.lastError = S7Consts.errOpenSSL;
|
|
249
|
+
this._tlsError = e;
|
|
250
|
+
resolve(this.lastError);
|
|
251
|
+
});
|
|
252
|
+
} catch (e) {
|
|
253
|
+
this.lastError = S7Consts.errOpenSSL;
|
|
254
|
+
this._tlsError = e;
|
|
255
|
+
resolve(this.lastError);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Write a packet to the underlying socket. All failure modes throw
|
|
262
|
+
* so the caller learns immediately — a silent "write disappeared"
|
|
263
|
+
* would leave the request waiter sitting around until its read
|
|
264
|
+
* timeout fires, hiding the real cause.
|
|
265
|
+
*/
|
|
266
|
+
send(packet) {
|
|
267
|
+
if (!this._io) {
|
|
268
|
+
this.lastError = S7Consts.errTCPNotConnected;
|
|
269
|
+
throw new Error('Send failed: client not connected');
|
|
270
|
+
}
|
|
271
|
+
this.lastError = 0;
|
|
272
|
+
try {
|
|
273
|
+
if (this._ssl) {
|
|
274
|
+
this._tlsSocket.write(packet);
|
|
275
|
+
} else {
|
|
276
|
+
const packetLen = ISO_HEADER_SIZE + packet.length;
|
|
277
|
+
const out = Buffer.alloc(packetLen);
|
|
278
|
+
TPKT_COTP_HEADER.copy(out, 0);
|
|
279
|
+
setWord(out, 2, packetLen);
|
|
280
|
+
packet.copy(out, ISO_HEADER_SIZE);
|
|
281
|
+
this._io.write(out);
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.lastError = S7Consts.errTCPDataSend;
|
|
285
|
+
throw new Error(`Send failed: ${err && err.message ? err.message : err}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
disconnect() {
|
|
290
|
+
if (!this._socket && !this._io && !this._tlsSocket) {
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
log('disconnect', { hadSocket: !!this._socket, hadTls: !!this._tlsSocket });
|
|
294
|
+
this._recvRunning = false;
|
|
295
|
+
this._plainRecv = false;
|
|
296
|
+
try {
|
|
297
|
+
if (this._cotp) this._cotp.end();
|
|
298
|
+
if (this._socket) {
|
|
299
|
+
disposeReader(this._socket);
|
|
300
|
+
this._socket.destroy();
|
|
301
|
+
}
|
|
302
|
+
} catch { /* ignore */ }
|
|
303
|
+
this._socket = null;
|
|
304
|
+
this._io = null;
|
|
305
|
+
this._tlsSocket = null;
|
|
306
|
+
this._cotp = null;
|
|
307
|
+
this._ssl = false;
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Watch the raw or TLS socket for close/error/end and surface a single
|
|
313
|
+
* 'close' event so the client can flip its `_connected` flag and reject
|
|
314
|
+
* pending PDU waiters instead of hanging until the read timeout fires.
|
|
315
|
+
*/
|
|
316
|
+
_installSocketWatch(sock, kind) {
|
|
317
|
+
if (!sock || sock._s7pClosedWatch) return;
|
|
318
|
+
sock._s7pClosedWatch = true;
|
|
319
|
+
const fire = (reason) => {
|
|
320
|
+
if (sock._s7pCloseFired) return;
|
|
321
|
+
sock._s7pCloseFired = true;
|
|
322
|
+
log('socket close-event', { kind, reason });
|
|
323
|
+
try { this.emit('close', { kind, reason }); } catch { /* ignore */ }
|
|
324
|
+
};
|
|
325
|
+
sock.once('close', () => fire('socket-close'));
|
|
326
|
+
sock.once('end', () => fire('socket-end'));
|
|
327
|
+
sock.once('error', (err) => fire(`socket-error: ${err && err.message ? err.message : err}`));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get connected() {
|
|
331
|
+
return this._socket && !this._socket.destroyed;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = S7Transport;
|
|
Binary file
|