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.
Files changed (67) hide show
  1. package/README.txt +69 -0
  2. package/node-red-contrib-mysql-extended-test/mysql-extended.html +443 -0
  3. package/node-red-contrib-mysql-extended-test/mysql-extended.js +355 -0
  4. package/node-red-contrib-rfid-helper/rfid-lazy.html +153 -0
  5. package/node-red-contrib-rfid-helper/rfid-lazy.js +361 -0
  6. package/node-red-contrib-s7-plus/LICENSE +235 -0
  7. package/node-red-contrib-s7-plus/README.md +141 -0
  8. package/node-red-contrib-s7-plus/examples/read-multiple-values-flow.json +346 -0
  9. package/node-red-contrib-s7-plus/examples/read-write-test-flow.json +172 -0
  10. package/node-red-contrib-s7-plus/examples/write-single-values-flow.json +5583 -0
  11. package/node-red-contrib-s7-plus/lib/s7plus/browse/README.md +72 -0
  12. package/node-red-contrib-s7-plus/lib/s7plus/browse/areas.js +36 -0
  13. package/node-red-contrib-s7-plus/lib/s7plus/browse/datatypes.js +91 -0
  14. package/node-red-contrib-s7-plus/lib/s7plus/browse/flat-browser.js +433 -0
  15. package/node-red-contrib-s7-plus/lib/s7plus/browse/index.js +22 -0
  16. package/node-red-contrib-s7-plus/lib/s7plus/browse/lazy.js +397 -0
  17. package/node-red-contrib-s7-plus/lib/s7plus/browse/node-id.js +22 -0
  18. package/node-red-contrib-s7-plus/lib/s7plus/browse/resolve-symbolic.js +181 -0
  19. package/node-red-contrib-s7-plus/lib/s7plus/browse/vte-helpers.js +49 -0
  20. package/node-red-contrib-s7-plus/lib/s7plus/buffer-stream.js +96 -0
  21. package/node-red-contrib-s7-plus/lib/s7plus/client.js +1238 -0
  22. package/node-red-contrib-s7-plus/lib/s7plus/constants.js +155 -0
  23. package/node-red-contrib-s7-plus/lib/s7plus/crc/index.js +3 -0
  24. package/node-red-contrib-s7-plus/lib/s7plus/crc/s7-crc32.js +70 -0
  25. package/node-red-contrib-s7-plus/lib/s7plus/crc/symbol-crc.js +271 -0
  26. package/node-red-contrib-s7-plus/lib/s7plus/debug.js +88 -0
  27. package/node-red-contrib-s7-plus/lib/s7plus/explore-result.js +41 -0
  28. package/node-red-contrib-s7-plus/lib/s7plus/item-address.js +55 -0
  29. package/node-red-contrib-s7-plus/lib/s7plus/pdu-explore.js +77 -0
  30. package/node-red-contrib-s7-plus/lib/s7plus/pdu-messages.js +359 -0
  31. package/node-red-contrib-s7-plus/lib/s7plus/pobject.js +197 -0
  32. package/node-red-contrib-s7-plus/lib/s7plus/pvalue-codec.js +435 -0
  33. package/node-red-contrib-s7-plus/lib/s7plus/pvalue.js +653 -0
  34. package/node-red-contrib-s7-plus/lib/s7plus/pvar-lists.js +336 -0
  35. package/node-red-contrib-s7-plus/lib/s7plus/read-result.js +91 -0
  36. package/node-red-contrib-s7-plus/lib/s7plus/s7p.js +266 -0
  37. package/node-red-contrib-s7-plus/lib/s7plus/tag-routing.js +30 -0
  38. package/node-red-contrib-s7-plus/lib/s7plus/transport/cotp-stream.js +335 -0
  39. package/node-red-contrib-s7-plus/lib/s7plus/transport/s7-transport.js +335 -0
  40. package/node-red-contrib-s7-plus/nodes/icons/s7complus.png +0 -0
  41. package/node-red-contrib-s7-plus/nodes/icons/s7complus.svg +17 -0
  42. package/node-red-contrib-s7-plus/nodes/s7complus-endpoint.js +825 -0
  43. package/node-red-contrib-s7-plus/nodes/s7complus-explore.js +91 -0
  44. package/node-red-contrib-s7-plus/nodes/s7complus-in.js +255 -0
  45. package/node-red-contrib-s7-plus/nodes/s7complus-out.js +182 -0
  46. package/node-red-contrib-s7-plus/nodes/s7complus.html +1541 -0
  47. package/node-red-contrib-s7-plus/nodes/s7complus.js +10 -0
  48. package/node-red-contrib-s7-plus/package.json +50 -0
  49. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Arrays.db +17 -0
  50. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Binary.db +16 -0
  51. package/node-red-contrib-s7-plus/plc/s7-1500/DB_BitStrings.db +36 -0
  52. package/node-red-contrib-s7-plus/plc/s7-1500/DB_CharacterStrings.db +42 -0
  53. package/node-red-contrib-s7-plus/plc/s7-1500/DB_DateAndTime.db +70 -0
  54. package/node-red-contrib-s7-plus/plc/s7-1500/DB_FloatingPoint.db +42 -0
  55. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Integers.db +72 -0
  56. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Timers.db +40 -0
  57. package/node-red-contrib-s7-plus/scripts/example-flow-shared.js +39 -0
  58. package/node-red-contrib-s7-plus/scripts/generate-read-multiple-flow.js +331 -0
  59. package/node-red-contrib-s7-plus/scripts/generate-rw-test-flow.js +690 -0
  60. package/node-red-contrib-s7-plus/scripts/generate-write-flow.js +476 -0
  61. package/node-red-contrib-s7-plus/scripts/layout-write-flow.js +156 -0
  62. package/node-red-contrib-s7-plus/scripts/run-tests.js +16 -0
  63. package/node-red-flowgobal-change/variable-change.html +425 -0
  64. package/node-red-flowgobal-change/variable-change.js +92 -0
  65. package/node-red-ui_media_viewer/ui_media_viewer.html +87 -0
  66. package/node-red-ui_media_viewer/ui_media_viewer.js +136 -0
  67. 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;