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,1238 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { EventEmitter } = require('events');
5
+ const BufferStream = require('./buffer-stream');
6
+ const S7Transport = require('./transport/s7-transport');
7
+ const ItemAddress = require('./item-address');
8
+ const {
9
+ ProtocolVersion,
10
+ Opcode,
11
+ Functioncode,
12
+ Ids,
13
+ AccessLevel,
14
+ S7Consts,
15
+ errorText
16
+ } = require('./constants');
17
+ const { ExploreRequest, ExploreResponse } = require('./pdu-explore');
18
+ const { decodeNodeId } = require('./browse/node-id');
19
+ const { listBlockRoots, listChildren, resolveLeaf } = require('./browse/lazy');
20
+ const { buildFlatSymbolList, collectReferencedRelIds } = require('./browse/flat-browser');
21
+ const { normalizeExploreScope, MEMORY_AREAS } = require('./browse/areas');
22
+ const { scoped } = require('./debug');
23
+ const log = scoped('client');
24
+ const {
25
+ InitSslRequest,
26
+ InitSslResponse,
27
+ CreateObjectRequest,
28
+ CreateObjectResponse,
29
+ SetMultiVariablesRequest,
30
+ SetMultiVariablesResponse,
31
+ GetMultiVariablesRequest,
32
+ GetMultiVariablesResponse,
33
+ DeleteObjectRequest,
34
+ GetVarSubstreamedRequest,
35
+ GetVarSubstreamedResponse,
36
+ SetVariableRequest,
37
+ SetVariableResponse
38
+ } = require('./pdu-messages');
39
+ const pvalue = require('./pvalue');
40
+
41
+ const REMOTE_TSAP = Buffer.from('SIMATIC-ROOT-HMI', 'ascii');
42
+
43
+ // Hard upper bound for how long the queue may make a caller wait. If the
44
+ // previous user-operation has not released the lock in this time, the
45
+ // next caller fails fast. Lock-acquire failure is a per-operation error
46
+ // only — the transport is NOT torn down (that would unfairly punish
47
+ // legitimate long ops like browseFull on a large PLC).
48
+ const DEFAULT_LOCK_ACQUIRE_TIMEOUT_MS = 30000;
49
+
50
+ // Cap on the type-info cache. Each entry mirrors a TIA struct/UDT
51
+ // definition. A full browseFull on a large PLC seeds at most a few
52
+ // hundred entries; lazy browse may add more over time but should never
53
+ // approach this bound under normal use. If it does, we drop the cache
54
+ // (next browseChildren re-fetches), so the cache is bounded memory-wise.
55
+ const TYPE_INFO_CACHE_MAX = 5000;
56
+
57
+ // Hard limit on user operations in flight (running + queued). Protects
58
+ // the endpoint from self-DoS when a fast trigger (e.g. inject every
59
+ // 2 s) feeds a slow operation (e.g. resolveAndRead on thousands of
60
+ // symbols). Without this, _userLockTail grows unbounded and each
61
+ // waiter pins a Node-RED msg in memory until it eventually runs.
62
+ const MAX_USER_LOCK_INFLIGHT = 16;
63
+
64
+ /**
65
+ * S7CommPlus client.
66
+ *
67
+ * Concurrency model
68
+ * -----------------
69
+ *
70
+ * Two independent serialization slots:
71
+ * _userLockTail — serializes user-facing operations (read, write,
72
+ * browse*, ping). Acquire is bounded so a single
73
+ * stuck operation cannot freeze the entire client.
74
+ * _lifecycleLockTail — serializes connect/disconnect against each
75
+ * other. Independent of the user lock so a
76
+ * reconnect can always run.
77
+ *
78
+ * Request/response dispatch is sequence-number-based via a Map. Each
79
+ * outgoing request gets a unique seq; the matching response wakes its
80
+ * waiter directly. Unmatched PDUs (late responses, notifications, stale
81
+ * data after reconnect) are logged and dropped — they never pollute the
82
+ * next read.
83
+ *
84
+ * This design has three guarantees:
85
+ * - No hang can outlive `DEFAULT_LOCK_ACQUIRE_TIMEOUT_MS` per user op.
86
+ * - No response ever ends up at the wrong waiter.
87
+ * - Reconnect starts with provably empty dispatcher state.
88
+ */
89
+ class S7CommPlusClient extends EventEmitter {
90
+ constructor() {
91
+ super();
92
+ this._transport = new S7Transport();
93
+
94
+ // Locks
95
+ this._userLockTail = Promise.resolve();
96
+ this._lifecycleLockTail = Promise.resolve();
97
+
98
+ // Sequence-number-based dispatcher.
99
+ // Map<seq, { resolve, reject, timer, label }>
100
+ this._pendingResponses = new Map();
101
+
102
+ // Multi-fragment receive aggregation (one logical S7+ PDU may
103
+ // arrive across several TPKT frames).
104
+ this._tempPdu = null;
105
+ this._needMoreData = false;
106
+
107
+ // Session / protocol counters
108
+ this._sessionId = 0;
109
+ this._sessionId2 = 0;
110
+ this._sequenceNumber = 0;
111
+ this._integrityId = 0;
112
+ this._integrityIdSet = 0;
113
+
114
+ // Configuration
115
+ this._readTimeout = 10000;
116
+ this._tagsPerReadMax = 20;
117
+ this._tagsPerWriteMax = 20;
118
+
119
+ // Connection state
120
+ this._connected = false;
121
+ this.lastError = 0;
122
+
123
+ // Liveness tracking: lets the endpoint watchdog decide between
124
+ // "lock is busy with a legitimate long op" and "transport is
125
+ // actually stuck" without having to take the user lock itself.
126
+ this._userOpInFlight = null;
127
+ this._userOpStartedAt = 0;
128
+ this._lastResponseAt = Date.now();
129
+ // Counts user ops currently running OR waiting on the user lock.
130
+ this._userLockInflight = 0;
131
+
132
+ /** @type {{ dbList: object[], typeInfoCache: Map<number, object> } | null} */
133
+ this._browseState = null;
134
+ }
135
+
136
+ // ---------- BROWSE CACHE ----------
137
+
138
+ clearBrowseState() {
139
+ this._browseState = null;
140
+ }
141
+
142
+ _getBrowseState() {
143
+ if (!this._browseState) {
144
+ this._browseState = { dbList: [], typeInfoCache: new Map() };
145
+ }
146
+ return this._browseState;
147
+ }
148
+
149
+ // ---------- LOCKS ----------
150
+
151
+ /**
152
+ * Acquire a serialization slot for the lock identified by `lockProp`.
153
+ * Returns the `release` function the caller MUST call exactly once
154
+ * (in finally). If the previous slot does not become free within
155
+ * `acquireTimeoutMs`, the acquire promise rejects AND the slot is
156
+ * auto-released so the next acquire can proceed.
157
+ *
158
+ * The lock-acquire timeout fails ONLY the waiting operation. It does
159
+ * NOT tear down the transport — that is the response timeout's job.
160
+ * Killing the transport here would also kill any legitimate long-
161
+ * running predecessor (large browseFull, big multi-tag read, etc.)
162
+ * just because a watchdog or a sibling op got impatient.
163
+ */
164
+ _acquireLock(lockProp, opLabel, acquireTimeoutMs) {
165
+ return new Promise((resolve, reject) => {
166
+ const prev = this[lockProp];
167
+ let release;
168
+ const myTurn = new Promise(r => { release = r; });
169
+ this[lockProp] = myTurn;
170
+
171
+ let settled = false;
172
+ const timer = setTimeout(() => {
173
+ if (settled) return;
174
+ settled = true;
175
+ log('lock-acquire timeout', { lock: lockProp, op: opLabel, timeoutMs: acquireTimeoutMs });
176
+ // Release our slot so the queue does not jam permanently.
177
+ release();
178
+ reject(new Error(
179
+ `Lock-acquire timeout (${opLabel}, ${acquireTimeoutMs}ms) — ` +
180
+ 'previous operation still holds the lock.'
181
+ ));
182
+ }, acquireTimeoutMs);
183
+
184
+ const onPrev = () => {
185
+ if (settled) return;
186
+ settled = true;
187
+ clearTimeout(timer);
188
+ resolve(release);
189
+ };
190
+ prev.then(onPrev, onPrev);
191
+ });
192
+ }
193
+
194
+ async _withUserLock(opLabel, fn, acquireTimeoutMs = DEFAULT_LOCK_ACQUIRE_TIMEOUT_MS) {
195
+ // Hard cap to keep callers from piling up faster than ops can
196
+ // drain. Reject before queuing so the rejected caller can shed
197
+ // its msg/closure immediately instead of pinning memory.
198
+ if (this._userLockInflight >= MAX_USER_LOCK_INFLIGHT) {
199
+ throw new Error(
200
+ `Endpoint queue overloaded (${this._userLockInflight} ops in flight) — ` +
201
+ `'${opLabel}' rejected. Reduce request rate.`
202
+ );
203
+ }
204
+ this._userLockInflight++;
205
+ try {
206
+ const release = await this._acquireLock('_userLockTail', opLabel, acquireTimeoutMs);
207
+ this._userOpInFlight = opLabel;
208
+ this._userOpStartedAt = Date.now();
209
+ try {
210
+ return await fn();
211
+ } finally {
212
+ this._userOpInFlight = null;
213
+ this._userOpStartedAt = 0;
214
+ release();
215
+ }
216
+ } finally {
217
+ this._userLockInflight--;
218
+ }
219
+ }
220
+
221
+ async _withLifecycleLock(opLabel, fn, acquireTimeoutMs = DEFAULT_LOCK_ACQUIRE_TIMEOUT_MS) {
222
+ const release = await this._acquireLock('_lifecycleLockTail', opLabel, acquireTimeoutMs);
223
+ try {
224
+ return await fn();
225
+ } finally {
226
+ release();
227
+ }
228
+ }
229
+
230
+ // ---------- SEQUENCE / INTEGRITY COUNTERS ----------
231
+
232
+ _getNextSequenceNumber() {
233
+ if (this._sequenceNumber === 0xffff) this._sequenceNumber = 1;
234
+ else this._sequenceNumber++;
235
+ return this._sequenceNumber;
236
+ }
237
+
238
+ _getNextIntegrityId(functionCode) {
239
+ const setFc = [
240
+ Functioncode.SetMultiVariables,
241
+ Functioncode.SetVariable,
242
+ Functioncode.SetVarSubStreamed,
243
+ Functioncode.DeleteObject,
244
+ Functioncode.CreateObject
245
+ ];
246
+ if (setFc.includes(functionCode)) {
247
+ if (this._integrityIdSet === 0xffffffff) this._integrityIdSet = 0;
248
+ else this._integrityIdSet++;
249
+ return this._integrityIdSet;
250
+ }
251
+ if (this._integrityId === 0xffffffff) this._integrityId = 0;
252
+ else this._integrityId++;
253
+ return this._integrityId;
254
+ }
255
+
256
+ // ---------- RECEIVE PIPELINE ----------
257
+
258
+ /**
259
+ * Reset all transient receive state: drop fragmented-PDU buffer and
260
+ * reject every pending response with `reason`. Safe to call repeatedly.
261
+ */
262
+ _resetReceiveState(reason) {
263
+ this._tempPdu = null;
264
+ this._needMoreData = false;
265
+ if (this._pendingResponses.size) {
266
+ log('rejecting pending responses', { count: this._pendingResponses.size, reason });
267
+ const err = new Error(`${errorText(S7Consts.errTCPNotConnected)} (${reason})`);
268
+ for (const [, w] of this._pendingResponses) {
269
+ clearTimeout(w.timer);
270
+ try { w.reject(err); } catch { /* ignore */ }
271
+ }
272
+ this._pendingResponses.clear();
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Reassemble one logical S7+ PDU from the (possibly fragmented)
278
+ * TPKT/COTP frames delivered by the transport. Each call sees one
279
+ * payload buffer. The S7+ header carries its own length, which is
280
+ * used to decide whether the next call continues this PDU or starts
281
+ * a new one (via `_needMoreData`).
282
+ *
283
+ * Once a PDU is complete, it is handed to `_dispatchPdu`.
284
+ */
285
+ _onDataReceived(pdu) {
286
+ // Any inbound frame is proof the transport is alive — record it
287
+ // BEFORE inspecting the contents, so that a slowly-arriving multi-
288
+ // fragment PDU does not look like a dead connection to the
289
+ // watchdog. _dispatchPdu also updates this, but only for the
290
+ // last fragment, which can be tens of seconds after the first.
291
+ this._lastResponseAt = Date.now();
292
+ // _tempPdu collects Buffer chunks (the 1-byte protoVersion plus one
293
+ // payload slice per fragment) and is finalized with Buffer.concat.
294
+ // Spreading bytes through Array.push(...) was O(N) call-stack args
295
+ // per fragment and could overflow the stack on multi-KB browse PDUs.
296
+ if (!this._needMoreData) this._tempPdu = [];
297
+ let pos = 0;
298
+ if (pdu[pos] !== 0x72) {
299
+ log('framing error', { firstByte: pdu[pos] });
300
+ this.lastError = S7Consts.errIsoInvalidPDU;
301
+ this._resetReceiveState('framing-error');
302
+ return;
303
+ }
304
+ pos++;
305
+ const protoVersion = pdu[pos];
306
+ if (!this._needMoreData) this._tempPdu.push(Buffer.from([protoVersion]));
307
+ pos++;
308
+ const s7HeaderDataLen = (pdu[pos] << 8) | pdu[pos + 1];
309
+ pos += 2;
310
+ if (s7HeaderDataLen > 0) {
311
+ if (protoVersion === ProtocolVersion.SystemEvent) {
312
+ this._needMoreData = false;
313
+ this._tempPdu = null;
314
+ return;
315
+ }
316
+ this._tempPdu.push(pdu.subarray(pos, pos + s7HeaderDataLen));
317
+ pos += s7HeaderDataLen;
318
+ if ((pdu.length - 4 - 4) === s7HeaderDataLen) {
319
+ this._needMoreData = false;
320
+ const complete = Buffer.concat(this._tempPdu);
321
+ this._tempPdu = null;
322
+ this._dispatchPdu(complete);
323
+ } else {
324
+ this._needMoreData = true;
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Route a fully assembled response PDU to its waiter, matched by
331
+ * sequence number. Unmatched PDUs (notifications, late responses,
332
+ * stale data after reconnect) are logged and dropped instead of
333
+ * being given to the next innocent reader.
334
+ *
335
+ * S7+ response header layout (in `_tempPdu` coordinates):
336
+ * [0] protocolVersion
337
+ * [1] opcode (Response = 0x32)
338
+ * [2-3] reserved
339
+ * [4-5] functionCode (UInt16, big-endian)
340
+ * [6-7] reserved
341
+ * [8-9] sequenceNumber (UInt16, big-endian)
342
+ */
343
+ _dispatchPdu(pdu) {
344
+ // Any inbound PDU (matched, late or notification) is proof that
345
+ // the transport is still alive — record it so the watchdog can
346
+ // distinguish a legitimately busy user lock from a real hang.
347
+ this._lastResponseAt = Date.now();
348
+ const opcode = pdu[1];
349
+ if (opcode !== Opcode.Response) {
350
+ log('non-response PDU dropped', { opcode, bytes: pdu.length });
351
+ return;
352
+ }
353
+ const seq = (pdu[8] << 8) | pdu[9];
354
+ const waiter = this._pendingResponses.get(seq);
355
+ if (!waiter) {
356
+ log('unmatched response dropped (stale/late/notification)', { seq, bytes: pdu.length });
357
+ return;
358
+ }
359
+ this._pendingResponses.delete(seq);
360
+ clearTimeout(waiter.timer);
361
+ log('<- pdu dispatched', () => [{ seq, bytes: pdu.length, label: waiter.label }]);
362
+ waiter.resolve(pdu);
363
+ }
364
+
365
+ // ---------- REQUEST / RESPONSE ----------
366
+
367
+ _sendS7plusPdu(data, protoVersion) {
368
+ const maxSize = 1024 - 4 - 3 - 5 - 17 - 4 - 4;
369
+ let sourcePos = 0;
370
+ let bytesToSend = data.length;
371
+ while (bytesToSend > 0) {
372
+ const curSize = Math.min(bytesToSend, maxSize);
373
+ bytesToSend -= curSize;
374
+ const packet = Buffer.alloc(4 + curSize + (bytesToSend === 0 ? 4 : 0));
375
+ packet[0] = 0x72;
376
+ packet[1] = protoVersion;
377
+ packet[2] = (curSize >> 8) & 0xff;
378
+ packet[3] = curSize & 0xff;
379
+ data.copy(packet, 4, sourcePos, sourcePos + curSize);
380
+ sourcePos += curSize;
381
+ let sendLen = 4 + curSize;
382
+ if (bytesToSend === 0) {
383
+ packet[sendLen++] = 0x72;
384
+ packet[sendLen++] = protoVersion;
385
+ packet[sendLen++] = 0;
386
+ packet[sendLen++] = 0;
387
+ }
388
+ this._transport.send(packet.subarray(0, sendLen));
389
+ }
390
+ return 0;
391
+ }
392
+
393
+ _sendRequest(req) {
394
+ if (this._sessionId === 0) req.sessionId = Ids.ObjectNullServerSession;
395
+ else req.sessionId = this._sessionId;
396
+ req.sequenceNumber = this._getNextSequenceNumber();
397
+ if (req.withIntegrityId) {
398
+ req.integrityId = this._getNextIntegrityId(req.functionCode);
399
+ }
400
+ const body = req.serialize();
401
+ log('-> request', () => [{
402
+ fc: req.functionCode,
403
+ seq: req.sequenceNumber,
404
+ session: req.sessionId,
405
+ integrity: req.integrityId,
406
+ bytes: body.length
407
+ }]);
408
+ this._sendS7plusPdu(body, req.protocolVersion);
409
+ return req.sequenceNumber;
410
+ }
411
+
412
+ /**
413
+ * Wait for the response to a specific request, identified by its
414
+ * sequence number. On timeout, tears down the transport — a silent
415
+ * PLC is almost always a dead socket, and waiting longer just hides
416
+ * the real failure.
417
+ */
418
+ _waitForResponse(seq, label, timeoutMs) {
419
+ return new Promise((resolve, reject) => {
420
+ const timer = setTimeout(() => {
421
+ if (!this._pendingResponses.has(seq)) return;
422
+ this._pendingResponses.delete(seq);
423
+ log('response timeout', { seq, label, timeoutMs });
424
+ this.lastError = S7Consts.errTCPDataReceive;
425
+ reject(new Error(`${errorText(S7Consts.errTCPDataReceive)} (${label}, seq ${seq})`));
426
+ if (this._connected) {
427
+ this._onTransportClosed({ reason: `response-timeout:${label}` });
428
+ }
429
+ }, timeoutMs);
430
+ this._pendingResponses.set(seq, { resolve, reject, timer, label });
431
+ });
432
+ }
433
+
434
+ async _requestResponse(req, deserializeFn, label) {
435
+ const opLabel = label || `fc=0x${req.functionCode.toString(16)}`;
436
+ const seq = this._sendRequest(req);
437
+ const pdu = await this._waitForResponse(seq, opLabel, this._readTimeout);
438
+ const stream = new BufferStream(pdu);
439
+ return deserializeFn(stream);
440
+ }
441
+
442
+ // ---------- CONNECT / DISCONNECT (lifecycle lock) ----------
443
+
444
+ async connect(address, password = '', username = '', timeoutMs = 10000, port = 102) {
445
+ return this._withLifecycleLock('connect', async () => {
446
+ log('connect start', { address, port, timeoutMs, hadConnection: this._connected });
447
+ if (this._connected) {
448
+ await this._teardownGraceful('reconnect');
449
+ }
450
+
451
+ // Hard reset of all transient state so nothing from the
452
+ // previous (possibly broken) session survives.
453
+ this._resetReceiveState('connect');
454
+ this._sessionId = 0;
455
+ this._sessionId2 = 0;
456
+ this._sequenceNumber = 0;
457
+ this._integrityId = 0;
458
+ this._integrityIdSet = 0;
459
+ this._readTimeout = timeoutMs > 0 ? timeoutMs : 10000;
460
+ this.lastError = 0;
461
+ this._browseState = null;
462
+ this._lastResponseAt = Date.now();
463
+
464
+ this._transport.removeAllListeners('data');
465
+ this._transport.removeAllListeners('close');
466
+ this._transport.on('data', (p) => this._onDataReceived(p));
467
+ this._transport.on('close', (info) => this._onTransportClosed(info));
468
+ this._transport.setTimeouts(this._readTimeout);
469
+ this._transport.setConnectionParams(address, 0x0600, REMOTE_TSAP, port);
470
+
471
+ let res = await this._transport.connect();
472
+ if (res !== 0) {
473
+ log('connect transport failed', { code: res, text: errorText(res) });
474
+ throw new Error(errorText(res));
475
+ }
476
+
477
+ this._transport.setStopAfterNextPacket();
478
+
479
+ // SSL init exchange uses sequence-based dispatch like any
480
+ // other request — _dispatchPdu doesn't care that _connected
481
+ // is still false; it routes by seq either way.
482
+ const sslReq = new InitSslRequest(ProtocolVersion.V1, 0, 0);
483
+ const sslSeq = this._sendRequest(sslReq);
484
+ const sslPdu = await this._waitForResponse(sslSeq, 'InitSsl', this._readTimeout);
485
+ const sslRes = InitSslResponse.deserializeFromPdu(new BufferStream(sslPdu));
486
+ if (!sslRes) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
487
+
488
+ res = await this._transport.sslActivate();
489
+ if (res !== 0) throw new Error(errorText(res));
490
+
491
+ const createReq = new CreateObjectRequest(ProtocolVersion.V1, 0, false);
492
+ createReq.setNullServerSessionData();
493
+ const createRes = await this._requestResponse(createReq, CreateObjectResponse.deserializeFromPdu, 'CreateObject');
494
+ if (!createRes || !createRes.objectIds.length) {
495
+ throw new Error(errorText(S7Consts.errIsoInvalidPDU));
496
+ }
497
+ this._sessionId = createRes.objectIds[0];
498
+ this._sessionId2 = createRes.objectIds[1];
499
+ const serverSession = createRes.responseObject.getAttribute(Ids.ServerSessionVersion);
500
+
501
+ const setMulti = new SetMultiVariablesRequest(ProtocolVersion.V2);
502
+ setMulti.setSessionSetupData(this._sessionId, serverSession);
503
+ const setMultiRes = await this._requestResponse(setMulti, SetMultiVariablesResponse.deserializeFromPdu, 'SessionSetup');
504
+ if (!setMultiRes) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
505
+
506
+ // Mark connected BEFORE _readSystemLimits/_legitimate so the
507
+ // raw read helpers (which guard on _connected) accept calls.
508
+ this._connected = true;
509
+ try {
510
+ await this._readSystemLimits();
511
+ await this._legitimate(serverSession, password, username);
512
+ } catch (e) {
513
+ // Post-setup failure: revert to clean disconnected state.
514
+ this._connected = false;
515
+ this._resetReceiveState('connect-setup-failed');
516
+ try { this._transport.disconnect(); } catch { /* ignore */ }
517
+ throw e;
518
+ }
519
+
520
+ log('connect ok', { address, port, sessionId: this._sessionId });
521
+ this.emit('connect');
522
+ return 0;
523
+ });
524
+ }
525
+
526
+ async disconnect() {
527
+ return this._withLifecycleLock('disconnect', () => this._teardownGraceful('graceful'));
528
+ }
529
+
530
+ /**
531
+ * Graceful teardown: send DeleteObject (if session alive), then close
532
+ * the transport. Used by connect() (for reconnect) and disconnect().
533
+ * Must run inside the lifecycle lock.
534
+ */
535
+ async _teardownGraceful(reason) {
536
+ log('teardown graceful', { reason, connected: this._connected, sessionId: this._sessionId });
537
+ const sock = this._transport && this._transport._socket;
538
+ const socketAlive = !!(sock && !sock.destroyed);
539
+ if (this._sessionId && this._connected && reason === 'graceful' && socketAlive) {
540
+ try {
541
+ const del = new DeleteObjectRequest(ProtocolVersion.V2);
542
+ del.deleteObjectId = this._sessionId;
543
+ del.withIntegrityId = false;
544
+ await this._requestResponse(del, () => ({}), 'DeleteObject');
545
+ } catch (e) {
546
+ log('teardown DeleteObject failed (ignored)', { msg: e.message });
547
+ }
548
+ }
549
+ this._connected = false;
550
+ this._sessionId = 0;
551
+ this._sessionId2 = 0;
552
+ this._browseState = null;
553
+ this._resetReceiveState(reason);
554
+ try { this._transport.disconnect(); } catch { /* ignore */ }
555
+ this.emit('disconnect', { reason });
556
+ }
557
+
558
+ /**
559
+ * Forceful teardown: skip DeleteObject (socket is presumed dead).
560
+ * Safe to call from anywhere — does NOT take the lifecycle lock,
561
+ * because recovery paths may run while connect() holds it.
562
+ */
563
+ forceDisconnect(reason = 'force') {
564
+ log('forceDisconnect', { reason, connected: this._connected, sessionId: this._sessionId });
565
+ const wasConnected = this._connected;
566
+ this._connected = false;
567
+ this._sessionId = 0;
568
+ this._sessionId2 = 0;
569
+ this._browseState = null;
570
+ this.lastError = S7Consts.errTCPNotConnected;
571
+ this._resetReceiveState(reason);
572
+ try { this._transport.disconnect(); } catch { /* ignore */ }
573
+ if (wasConnected) {
574
+ try { this.emit('disconnect', { reason }); } catch { /* ignore */ }
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Called when the transport's underlying socket emits close/end/error
580
+ * or when a response timeout fires (almost certainly a dead socket).
581
+ * Marks the client as disconnected, rejects every pending response
582
+ * and emits 'disconnect' so the endpoint can schedule a reconnect.
583
+ */
584
+ _onTransportClosed(info) {
585
+ const reason = info && info.reason ? info.reason : 'socket-close';
586
+ if (!this._connected) {
587
+ // Still purge stale state — a response that arrives after
588
+ // we've already disconnected must not pollute the next read.
589
+ this._resetReceiveState(reason);
590
+ return;
591
+ }
592
+ log('transport closed', { reason, sessionId: this._sessionId, pendingCount: this._pendingResponses.size });
593
+ this._connected = false;
594
+ this._sessionId = 0;
595
+ this._sessionId2 = 0;
596
+ this._browseState = null;
597
+ this.lastError = S7Consts.errTCPNotConnected;
598
+ this._resetReceiveState(reason);
599
+ try { this._transport.disconnect(); } catch { /* ignore */ }
600
+ try { this.emit('disconnect', { reason }); } catch { /* ignore */ }
601
+ }
602
+
603
+ // ---------- USER OPERATIONS (user lock) ----------
604
+
605
+ async _readSystemLimits() {
606
+ // Runs inside connect()'s lifecycle lock, after _connected = true.
607
+ const readlist = [
608
+ new ItemAddress(Ids.ObjectRoot, Ids.SystemLimits),
609
+ new ItemAddress(Ids.ObjectRoot, Ids.SystemLimits),
610
+ new ItemAddress(Ids.ObjectRoot, Ids.SystemLimits),
611
+ new ItemAddress(Ids.ObjectRoot, Ids.SystemLimits)
612
+ ];
613
+ readlist[0].lid.push(1000);
614
+ readlist[1].lid.push(1001);
615
+ readlist[2].lid.push(0);
616
+ readlist[3].lid.push(1);
617
+ const { values } = await this._readValuesRaw(readlist);
618
+ if (values[0] != null) this._tagsPerReadMax = pvalueToNumber(values[0]) || 20;
619
+ if (values[1] != null) this._tagsPerWriteMax = pvalueToNumber(values[1]) || 20;
620
+ }
621
+
622
+ async _legitimate(serverSession, password, username) {
623
+ const paom = serverSession.getStructElement(Ids.LID_SessionVersionSystemPAOMString);
624
+ const paomStr = paom ? paom.toJs() : '';
625
+ const re = /^.*;.*[17]\s?([52]\d\d).+;[VS](\d\.\d)$/;
626
+ const m = paomStr.match ? String(paomStr).match(re) : null;
627
+ if (!m) {
628
+ if (password) throw new Error(errorText(S7Consts.errCliFirmwareNotSupported));
629
+ return;
630
+ }
631
+
632
+ const getProt = new GetVarSubstreamedRequest(ProtocolVersion.V2);
633
+ getProt.inObjectId = this._sessionId;
634
+ getProt.address = Ids.EffectiveProtectionLevel;
635
+ const protRes = await this._requestResponse(getProt, GetVarSubstreamedResponse.deserializeFromPdu, 'GetProtection');
636
+ if (!protRes || !protRes.value) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
637
+ const accessLevel = pvalueToNumber(protRes.value);
638
+ if (accessLevel > AccessLevel.FullAccess && password) {
639
+ await this._legitimateLegacy(password);
640
+ } else if (accessLevel > AccessLevel.FullAccess && !password) {
641
+ throw new Error(errorText(S7Consts.errCliNeedPassword));
642
+ }
643
+ }
644
+
645
+ async _legitimateLegacy(password) {
646
+ const getCh = new GetVarSubstreamedRequest(ProtocolVersion.V2);
647
+ getCh.inObjectId = this._sessionId;
648
+ getCh.address = Ids.ServerSessionRequest;
649
+ const chRes = await this._requestResponse(getCh, GetVarSubstreamedResponse.deserializeFromPdu, 'GetChallenge');
650
+ const challenge = chRes.value.toJs();
651
+ if (!Array.isArray(challenge) || challenge.length === 0) {
652
+ throw new Error(errorText(S7Consts.errIsoInvalidPDU));
653
+ }
654
+ let response = crypto.createHash('sha1').update(password, 'utf8').digest();
655
+ if (response.length !== challenge.length) {
656
+ throw new Error(errorText(S7Consts.errIsoInvalidPDU));
657
+ }
658
+ response = Buffer.from(response.map((b, i) => b ^ challenge[i]));
659
+ const setReq = new SetVariableRequest(ProtocolVersion.V2);
660
+ setReq.inObjectId = this._sessionId;
661
+ setReq.address = Ids.ServerSessionResponse;
662
+ setReq.value = new pvalue.ValueUSIntArray([...response]);
663
+ const setRes = await this._requestResponse(setReq, SetVariableResponse.deserializeFromPdu, 'SetSessionResponse');
664
+ if (!setRes || Number(setRes.returnValue) < 0) {
665
+ throw new Error(errorText(S7Consts.errCliAccessDenied));
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Low-level read — does NOT take the user lock. Used by callers that
671
+ * are already inside a lock (connect, browseRoots, ping, public
672
+ * readValues).
673
+ */
674
+ async _readValuesRaw(addressList) {
675
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
676
+ const values = addressList.map(() => null);
677
+ const errors = addressList.map(() => BigInt('18446744073709551615'));
678
+ let chunkStart = 0;
679
+ while (chunkStart < addressList.length) {
680
+ const req = new GetMultiVariablesRequest(ProtocolVersion.V2);
681
+ let count = 0;
682
+ while (count < this._tagsPerReadMax && chunkStart + count < addressList.length) {
683
+ req.addressList.push(addressList[chunkStart + count]);
684
+ count++;
685
+ }
686
+ const res = await this._requestResponse(req, GetMultiVariablesResponse.deserializeFromPdu, 'GetMultiVars');
687
+ if (!res) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
688
+ for (const [key, val] of res.values) {
689
+ const idx = chunkStart + Number(key) - 1;
690
+ values[idx] = val;
691
+ errors[idx] = 0n;
692
+ }
693
+ for (const [key, err] of res.errorValues) {
694
+ errors[chunkStart + Number(key) - 1] = err;
695
+ }
696
+ chunkStart += count;
697
+ // Yield to the event loop after every chunk. setImmediate is not
698
+ // a delay — it just lets pending I/O, TLS encryption, watchdog
699
+ // pings and other timers run between back-to-back GetMultiVariables
700
+ // requests. Without this yield, a chain of awaits resolves on the
701
+ // microtask queue without ever returning to the event loop, so
702
+ // TLS write buffers and PLC inbox both see a tight ~250 frames/s
703
+ // burst — which some S7+ PLCs answer with a TCP RST after 14-30s.
704
+ if (chunkStart < addressList.length) {
705
+ await new Promise(resolve => setImmediate(resolve));
706
+ }
707
+ }
708
+ return { values, errors };
709
+ }
710
+
711
+ async readValues(addressList) {
712
+ // Pacing for very large reads: split into batches of MAX_TAGS_PER_LOCK
713
+ // tags so that the user lock is released between batches. This lets
714
+ // the watchdog ping run, gives other nodes a chance, and breaks up
715
+ // sustained PDU bursts that some PLCs answer with TCP RST.
716
+ const MAX_TAGS_PER_LOCK = 500;
717
+ if (addressList.length <= MAX_TAGS_PER_LOCK) {
718
+ return this._withUserLock('readValues', () => this._readValuesRaw(addressList));
719
+ }
720
+ const allValues = new Array(addressList.length);
721
+ const allErrors = new Array(addressList.length);
722
+ for (let offset = 0; offset < addressList.length; offset += MAX_TAGS_PER_LOCK) {
723
+ const slice = addressList.slice(offset, offset + MAX_TAGS_PER_LOCK);
724
+ const { values, errors } = await this._withUserLock(
725
+ 'readValues',
726
+ () => this._readValuesRaw(slice)
727
+ );
728
+ for (let i = 0; i < slice.length; i++) {
729
+ allValues[offset + i] = values[i];
730
+ allErrors[offset + i] = errors[i];
731
+ }
732
+ }
733
+ return { values: allValues, errors: allErrors };
734
+ }
735
+
736
+ async writeValues(addressList, writeValues) {
737
+ return this._withUserLock('writeValues', async () => {
738
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
739
+ // Per-item write status. Initialised to 0n (success); the PLC
740
+ // only reports the items it REJECTED, via a 1-based item
741
+ // number -> error code map in res.errorValues (bad address,
742
+ // CRC mismatch 0x8009890012cbffef, access denied, ...). Items
743
+ // absent from that map were written successfully. This is the
744
+ // exact same per-item contract as _readValuesRaw.
745
+ const errors = addressList.map(() => 0n);
746
+ let chunkStart = 0;
747
+ while (chunkStart < addressList.length) {
748
+ const req = new SetMultiVariablesRequest(ProtocolVersion.V2);
749
+ req.inObjectId = 0;
750
+ let count = 0;
751
+ while (count < this._tagsPerWriteMax && chunkStart + count < addressList.length) {
752
+ req.addressListVar.push(addressList[chunkStart + count]);
753
+ req.valueList.push(writeValues[chunkStart + count]);
754
+ count++;
755
+ }
756
+ const res = await this._requestResponse(req, SetMultiVariablesResponse.deserializeFromPdu, 'SetMultiVars');
757
+ if (!res) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
758
+ // The global res.returnValue is intentionally NOT used as
759
+ // an error gate: a successful SetMultiVariables response
760
+ // also carries a non-zero returnValue (same as a
761
+ // successful GetMultiVariables), so treating it as an
762
+ // error would fail every healthy write. errorValues is
763
+ // the authoritative per-item source.
764
+ if (res.errorValues) {
765
+ for (const [key, err] of res.errorValues) {
766
+ errors[chunkStart + Number(key) - 1] = err;
767
+ }
768
+ }
769
+ chunkStart += count;
770
+ // Yield to the event loop between chunks for the same
771
+ // reasons as _readValuesRaw (let TLS, watchdog and other
772
+ // timers run; avoid a tight PDU burst).
773
+ if (chunkStart < addressList.length) {
774
+ await new Promise(resolve => setImmediate(resolve));
775
+ }
776
+ }
777
+ return { errors };
778
+ });
779
+ }
780
+
781
+ async browseRoots() {
782
+ return this._withUserLock('browseRoots', async () => {
783
+ log('browseRoots', { connected: this._connected });
784
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
785
+ const state = this._getBrowseState();
786
+ state.dbList = await this._fetchDbListFromPlc();
787
+ log('browseRoots done', { dbCount: state.dbList.length });
788
+ return { nodes: listBlockRoots(state.dbList) };
789
+ });
790
+ }
791
+
792
+ /**
793
+ * Return cached roots without clearing browse state. Only fetches
794
+ * from the PLC when the cache is empty or older than maxAgeMs.
795
+ * Used by batch symbolic resolution to avoid redundant PLC requests.
796
+ */
797
+ async browseRootsCached(maxAgeMs = 300000) {
798
+ return this._withUserLock('browseRootsCached', async () => {
799
+ log('browseRootsCached', { connected: this._connected });
800
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
801
+ const state = this._getBrowseState();
802
+ if (state.dbList.length > 0 && state._rootsFetchedAt
803
+ && (Date.now() - state._rootsFetchedAt) < maxAgeMs) {
804
+ log('browseRootsCached hit', { dbCount: state.dbList.length });
805
+ return { nodes: listBlockRoots(state.dbList) };
806
+ }
807
+ state.dbList = await this._fetchDbListFromPlc();
808
+ state._rootsFetchedAt = Date.now();
809
+ log('browseRootsCached fetched', { dbCount: state.dbList.length });
810
+ return { nodes: listBlockRoots(state.dbList) };
811
+ });
812
+ }
813
+
814
+ async browseChildren(nodeId) {
815
+ return this._withUserLock('browseChildren', async () => {
816
+ log('browseChildren', { nodeId, connected: this._connected });
817
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
818
+ const desc = decodeNodeId(nodeId);
819
+ if (desc.t === 'block' || desc.t === 'struct') {
820
+ await this._ensureTypeInfoLoaded(desc.tiRelId);
821
+ }
822
+ const state = this._getBrowseState();
823
+ const result = { nodes: listChildren(desc, state.typeInfoCache) };
824
+ log('browseChildren done', { nodeId, type: desc.t, count: result.nodes.length });
825
+ return result;
826
+ });
827
+ }
828
+
829
+ async browseResolve(nodeId) {
830
+ return this._withUserLock('browseResolve', async () => {
831
+ log('browseResolve', { nodeId, connected: this._connected });
832
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
833
+ const desc = decodeNodeId(nodeId);
834
+ return resolveLeaf(desc);
835
+ });
836
+ }
837
+
838
+ /**
839
+ * Resolve a symbolic PLC path (e.g. "DB1.readings[0]") to
840
+ * { name, address, datatype, crcMeta } by walking the browse tree.
841
+ * Handles arrays with index notation and quoted DB names.
842
+ */
843
+ async browseResolveSymbolic(symbolPath) {
844
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
845
+ const { resolveSymbolicPath } = require('./browse/resolve-symbolic');
846
+ return resolveSymbolicPath(this, symbolPath);
847
+ }
848
+
849
+ /**
850
+ * Batch-resolve multiple symbolic PLC paths in one pass, sharing a
851
+ * single browseRootsCached() call and the accumulated type-info
852
+ * cache. Each symbol is resolved independently; failures produce
853
+ * { error } entries instead of throwing.
854
+ * @param {string[]} symbolPaths
855
+ * @returns {Promise<Array<{name, address, datatype, crcMeta} | {error: string}>>}
856
+ */
857
+ async browseResolveSymbolicBatch(symbolPaths) {
858
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
859
+ const { resolveSymbolicBatch } = require('./browse/resolve-symbolic');
860
+ return resolveSymbolicBatch(this, symbolPaths);
861
+ }
862
+
863
+ /**
864
+ * Full symbol browse (C# Browse): PLC program Explore, LID=1 reads, type-info
865
+ * container Explore, then flat symbol list. Seeds browse cache for lazy browse.
866
+ * @param {object} [options]
867
+ * @param {number} [options.maxSymbols] - cap flat symbol count; partial result when exceeded
868
+ * @returns {Promise<{ symbols: object[], meta: object }>}
869
+ */
870
+ async browseFull(options = {}) {
871
+ return this._withUserLock('browseFull', async () => {
872
+ log('browseFull', { connected: this._connected });
873
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
874
+ const scope = normalizeExploreScope(options.scope);
875
+ const maxSymbols = Number(options.maxSymbols);
876
+ const flatOptions = Number.isFinite(maxSymbols) && maxSymbols > 0
877
+ ? { maxSymbols: Math.floor(maxSymbols), scope }
878
+ : { scope };
879
+ const t0 = Date.now();
880
+ const exploreRes1 = await this._explorePlcProgramRequest();
881
+ let exploreData = this._parseDbListFromPlcExplore(exploreRes1);
882
+ if (!scope.everything) {
883
+ const dbSet = new Set(scope.dbs);
884
+ exploreData = exploreData.filter(d => dbSet.has(d.db_name));
885
+ }
886
+ exploreData = await this._readTiRelIdsForDbs(exploreData);
887
+
888
+ // Type-info acquisition. A full browse downloads the entire PLC
889
+ // type catalog (OMSTypeInfoContainer) once. A scoped browse must
890
+ // NOT pay that cost: it fetches only the type subtrees referenced
891
+ // by the scoped DBs (+ selected memory areas) via transitive relId
892
+ // resolution — orders of magnitude less data on large PLCs.
893
+ let typeInfoObjects;
894
+ if (scope.everything) {
895
+ const { containerChildren, allObjects } = await this._fetchTypeInfoContainerChildren();
896
+ this._seedBrowseStateFromFullBrowse(exploreData, containerChildren, allObjects);
897
+ typeInfoObjects = containerChildren;
898
+ } else {
899
+ const rootTiRelIds = [];
900
+ for (const d of exploreData) {
901
+ if (d.db_block_ti_relid) rootTiRelIds.push(d.db_block_ti_relid);
902
+ }
903
+ for (const area of MEMORY_AREAS) {
904
+ if (scope.areas.includes(area.name)) rootTiRelIds.push(area.tiRelId);
905
+ }
906
+ typeInfoObjects = await this._fetchTypeInfoForRoots(rootTiRelIds);
907
+ this._seedBrowseStateFromScopedBrowse(exploreData, typeInfoObjects);
908
+ }
909
+ const flat = buildFlatSymbolList(exploreData, typeInfoObjects, flatOptions);
910
+ const meta = {
911
+ dbCount: exploreData.length,
912
+ symbolCount: flat.symbols.length,
913
+ durationMs: Date.now() - t0,
914
+ limitExceeded: !!flat.limitExceeded,
915
+ maxSymbols: flat.maxSymbols ?? null,
916
+ scope
917
+ };
918
+ if (flat.limitExceeded && flat.maxSymbols != null) {
919
+ meta.limitMessage = `Symbol limit exceeded (${flat.symbols.length} exported, max ${flat.maxSymbols})`;
920
+ }
921
+ log('browseFull done', meta);
922
+ return { symbols: flat.symbols, meta };
923
+ });
924
+ }
925
+
926
+ /**
927
+ * Single Explore on NativeObjects_thePLCProgram_Rid (browse step 1 only).
928
+ * Does not read LID=1 or mutate browse state.
929
+ * @returns {Promise<{protocolVersion: number, sequenceNumber: number, objects: object[]}>}
930
+ */
931
+ async explorePlcProgram() {
932
+ return this._withUserLock('explorePlcProgram', async () => {
933
+ log('explorePlcProgram', { connected: this._connected });
934
+ if (!this._connected) throw new Error(errorText(S7Consts.errTCPNotConnected));
935
+ const res = await this._explorePlcProgramRequest();
936
+ log('explorePlcProgram done', { objectCount: (res.objects || []).length });
937
+ return res;
938
+ });
939
+ }
940
+
941
+ async _explorePlcProgramRequest() {
942
+ const exploreReq = new ExploreRequest(ProtocolVersion.V2);
943
+ exploreReq.exploreId = Ids.NativeObjects_thePLCProgram_Rid;
944
+ exploreReq.exploreRequestId = Ids.None;
945
+ exploreReq.exploreChildsRecursive = 1;
946
+ exploreReq.exploreParents = 0;
947
+ // DB number comes from relationId; comment is unused in browse — name only.
948
+ exploreReq.addressList.push(Ids.ObjectVariableTypeName);
949
+
950
+ const exploreRes = await this._requestResponse(
951
+ exploreReq,
952
+ (pdu) => ExploreResponse.deserializeFromPdu(pdu, true),
953
+ 'Explore-PLCProgram'
954
+ );
955
+ if (!exploreRes) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
956
+ return exploreRes;
957
+ }
958
+
959
+ _parseDbListFromPlcExplore(exploreRes1) {
960
+ const plcProg = exploreRes1.objects.find(o => o.classId === Ids.PLCProgram_Class_Rid);
961
+ if (!plcProg) throw new Error(errorText(S7Consts.errCliAccessDenied));
962
+
963
+ const exploreData = [];
964
+ for (const ob of plcProg.getObjects()) {
965
+ if (ob.classId !== Ids.DB_Class_Rid) continue;
966
+ const relid = ob.relationId;
967
+ const area = relid >>> 16;
968
+ const num = relid & 0xffff;
969
+ if (area !== 0x8a0e) continue;
970
+ const nameAttr = ob.getAttribute(Ids.ObjectVariableTypeName);
971
+ const dbName = nameAttr ? String(nameAttr.toJs()) : `DB${num}`;
972
+ exploreData.push({
973
+ db_block_relid: relid,
974
+ db_name: dbName,
975
+ db_number: num,
976
+ db_block_ti_relid: 0
977
+ });
978
+ }
979
+ return exploreData;
980
+ }
981
+
982
+ async _readTiRelIdsForDbs(exploreData) {
983
+ const readlist = [];
984
+ const indices = [];
985
+ for (let i = 0; i < exploreData.length; i++) {
986
+ const data = exploreData[i];
987
+ if (data.db_number > 0) {
988
+ const adr = new ItemAddress();
989
+ adr.accessArea = data.db_block_relid;
990
+ adr.accessSubArea = Ids.DB_ValueActual;
991
+ adr.lid.push(1);
992
+ readlist.push(adr);
993
+ indices.push(i);
994
+ }
995
+ }
996
+
997
+ if (readlist.length) {
998
+ const { values, errors } = await this._readValuesRaw(readlist);
999
+ for (let j = 0; j < indices.length; j++) {
1000
+ const i = indices[j];
1001
+ if (errors[j] === 0n && values[j]) {
1002
+ exploreData[i].db_block_ti_relid = Number(values[j].toJs());
1003
+ } else {
1004
+ exploreData[i].db_block_ti_relid = 0;
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ return exploreData.filter(d => d.db_block_ti_relid !== 0);
1010
+ }
1011
+
1012
+ async _fetchTypeInfoContainerChildren() {
1013
+ const objects = await this._exploreTypeInfoRequest(Ids.ObjectOMSTypeInfoContainer, 1);
1014
+ const tiContainer = objects.find(o => o.classId === Ids.ClassOMSTypeInfoContainer);
1015
+ const containerChildren = (tiContainer && typeof tiContainer.getObjects === 'function')
1016
+ ? tiContainer.getObjects()
1017
+ : objects.filter(o => o.vartypeList);
1018
+ return { containerChildren, allObjects: objects };
1019
+ }
1020
+
1021
+ /**
1022
+ * Scoped counterpart to _fetchTypeInfoContainerChildren: instead of
1023
+ * downloading the whole OMSTypeInfoContainer, fetch only the type
1024
+ * objects reachable from the given root type relIds (scoped DB type
1025
+ * relIds + selected memory-area type relIds).
1026
+ *
1027
+ * A single Explore on a type relId returns only that type's own
1028
+ * vartypeList, not the types it references. We therefore walk the
1029
+ * reference graph breadth-first: fetch a type, enqueue the relIds it
1030
+ * references (collectReferencedRelIds), repeat until closure. The
1031
+ * returned flat object list has the same shape buildFlatSymbolList
1032
+ * expects for its typeInfoObjects argument (findObjectByRelId lookups).
1033
+ *
1034
+ * @param {number[]} rootTiRelIds
1035
+ * @returns {Promise<object[]>} all reachable type objects (deduped by relationId)
1036
+ */
1037
+ async _fetchTypeInfoForRoots(rootTiRelIds) {
1038
+ const accumulator = [];
1039
+ const collected = new Set(); // relationIds already in accumulator
1040
+ const requested = new Set(); // relIds already explored (cycle guard)
1041
+ const queue = [];
1042
+ for (const r of rootTiRelIds) {
1043
+ const id = r >>> 0;
1044
+ if (id && !requested.has(id)) { requested.add(id); queue.push(id); }
1045
+ }
1046
+
1047
+ let roundTrips = 0;
1048
+ while (queue.length) {
1049
+ const relId = queue.shift();
1050
+ const objects = await this._exploreTypeInfoRequest(relId, 1);
1051
+ roundTrips++;
1052
+ // Walk the returned object tree: collect every type object and
1053
+ // enqueue any newly referenced type relIds.
1054
+ const stack = [...objects];
1055
+ while (stack.length) {
1056
+ const ob = stack.pop();
1057
+ if (!ob) continue;
1058
+ if (ob.relationId && !collected.has(ob.relationId)) {
1059
+ collected.add(ob.relationId);
1060
+ accumulator.push(ob);
1061
+ }
1062
+ if (ob.vartypeList) {
1063
+ for (const refId of collectReferencedRelIds(ob)) {
1064
+ const id = refId >>> 0;
1065
+ if (id && !requested.has(id)) { requested.add(id); queue.push(id); }
1066
+ }
1067
+ }
1068
+ if (typeof ob.getObjects === 'function') {
1069
+ for (const child of ob.getObjects()) stack.push(child);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ log('typeInfoForRoots done', { roots: rootTiRelIds.length, roundTrips, objects: accumulator.length });
1075
+ return accumulator;
1076
+ }
1077
+
1078
+ /**
1079
+ * Seed browse cache after a scoped browse. Mirrors
1080
+ * _seedBrowseStateFromFullBrowse but uses the scoped type-object list
1081
+ * (already the transitive closure) instead of the full container.
1082
+ */
1083
+ _seedBrowseStateFromScopedBrowse(dbList, typeInfoObjects) {
1084
+ const state = this._getBrowseState();
1085
+ state.dbList = dbList;
1086
+ state._rootsFetchedAt = Date.now();
1087
+ state.typeInfoCache.clear();
1088
+ for (const ob of typeInfoObjects) {
1089
+ if (ob.relationId) state.typeInfoCache.set(ob.relationId, ob);
1090
+ }
1091
+ }
1092
+
1093
+ _seedBrowseStateFromFullBrowse(dbList, containerChildren, allObjects) {
1094
+ const state = this._getBrowseState();
1095
+ state.dbList = dbList;
1096
+ state._rootsFetchedAt = Date.now();
1097
+ // Each browseFull replaces the type-info cache wholesale —
1098
+ // otherwise it would only ever grow as PLC programs change.
1099
+ state.typeInfoCache.clear();
1100
+ this._cacheTypeInfoObjects(allObjects);
1101
+ for (const child of containerChildren) {
1102
+ if (child.relationId) state.typeInfoCache.set(child.relationId, child);
1103
+ }
1104
+ }
1105
+
1106
+ async _fetchDbListFromPlc() {
1107
+ const exploreRes1 = await this._explorePlcProgramRequest();
1108
+ let exploreData = this._parseDbListFromPlcExplore(exploreRes1);
1109
+ return this._readTiRelIdsForDbs(exploreData);
1110
+ }
1111
+
1112
+ async _ensureTypeInfoLoaded(tiRelId, forceFullContainer = false) {
1113
+ const state = this._getBrowseState();
1114
+ if (!forceFullContainer && tiRelId && state.typeInfoCache.has(tiRelId)) {
1115
+ const cached = state.typeInfoCache.get(tiRelId);
1116
+ if (cached && cached.vartypeList) return cached;
1117
+ }
1118
+
1119
+ if (forceFullContainer) {
1120
+ const objects = await this._exploreTypeInfoRequest(Ids.ObjectOMSTypeInfoContainer, 1);
1121
+ this._cacheTypeInfoObjects(objects);
1122
+ const tiContainer = objects.find(o => o.classId === Ids.ClassOMSTypeInfoContainer)
1123
+ || [...state.typeInfoCache.values()].find(o => o.classId === Ids.ClassOMSTypeInfoContainer);
1124
+ if (tiContainer && typeof tiContainer.getObjects === 'function') {
1125
+ for (const child of tiContainer.getObjects()) {
1126
+ if (child.relationId) state.typeInfoCache.set(child.relationId, child);
1127
+ }
1128
+ }
1129
+ return null;
1130
+ }
1131
+
1132
+ const exploreId = tiRelId || Ids.ObjectOMSTypeInfoContainer;
1133
+ const objects = await this._exploreTypeInfoRequest(exploreId, 1);
1134
+ this._cacheTypeInfoObjects(objects);
1135
+
1136
+ let typeOb = state.typeInfoCache.get(tiRelId) || null;
1137
+ if (!typeOb || !typeOb.vartypeList) {
1138
+ typeOb = objects.find(o => o.relationId === tiRelId)
1139
+ || objects.find(o => o.vartypeList)
1140
+ || null;
1141
+ if (typeOb && typeOb.relationId) state.typeInfoCache.set(typeOb.relationId, typeOb);
1142
+ if (typeOb && tiRelId && !state.typeInfoCache.has(tiRelId)) {
1143
+ state.typeInfoCache.set(tiRelId, typeOb);
1144
+ }
1145
+ }
1146
+ return typeOb;
1147
+ }
1148
+
1149
+ async _exploreTypeInfoRequest(exploreId, recursive) {
1150
+ const exploreReq = new ExploreRequest(ProtocolVersion.V2);
1151
+ exploreReq.exploreId = exploreId >>> 0;
1152
+ exploreReq.exploreRequestId = Ids.None;
1153
+ exploreReq.exploreChildsRecursive = recursive ? 1 : 0;
1154
+ exploreReq.exploreParents = 0;
1155
+
1156
+ const exploreRes = await this._requestResponse(
1157
+ exploreReq,
1158
+ (pdu) => ExploreResponse.deserializeFromPdu(pdu, true),
1159
+ 'Explore-TypeInfo'
1160
+ );
1161
+ if (!exploreRes) throw new Error(errorText(S7Consts.errIsoInvalidPDU));
1162
+ return exploreRes.objects || [];
1163
+ }
1164
+
1165
+ _cacheTypeInfoObjects(objects) {
1166
+ const state = this._getBrowseState();
1167
+ const walk = (list) => {
1168
+ if (!list) return;
1169
+ for (const ob of list) {
1170
+ if (ob.relationId) state.typeInfoCache.set(ob.relationId, ob);
1171
+ if (typeof ob.getObjects === 'function') walk(ob.getObjects());
1172
+ }
1173
+ };
1174
+ walk(objects);
1175
+ // If lazy-browse over a long-lived endpoint inflates the cache
1176
+ // beyond the bound, drop everything; lazy-browse will re-seed
1177
+ // on demand. This is preferable to per-entry LRU because the
1178
+ // cache is rarely the hot path and keeping it small wins.
1179
+ if (state.typeInfoCache.size > TYPE_INFO_CACHE_MAX) {
1180
+ log('typeInfoCache exceeded cap, clearing', { size: state.typeInfoCache.size, cap: TYPE_INFO_CACHE_MAX });
1181
+ state.typeInfoCache.clear();
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Watchdog ping. Short acquire timeout (2s) so the watchdog can
1187
+ * detect a stuck user op instead of queueing behind it. If we
1188
+ * cannot grab the lock in 2s, the lock-acquire-timeout path tears
1189
+ * the transport down and the endpoint reconnects.
1190
+ */
1191
+ async ping(timeoutMs = 2000) {
1192
+ return this._withUserLock('ping', async () => {
1193
+ if (!this._connected) throw new Error('not connected');
1194
+ const saved = this._readTimeout;
1195
+ this._readTimeout = timeoutMs;
1196
+ try {
1197
+ const addr = new ItemAddress(Ids.ObjectRoot, Ids.SystemLimits);
1198
+ addr.lid.push(0);
1199
+ await this._readValuesRaw([addr]);
1200
+ } finally {
1201
+ this._readTimeout = saved;
1202
+ }
1203
+ }, /* acquireTimeoutMs */ 2000);
1204
+ }
1205
+
1206
+ get connected() {
1207
+ return this._connected;
1208
+ }
1209
+
1210
+ get socketAlive() {
1211
+ return this._connected && this._transport.connected;
1212
+ }
1213
+
1214
+ /** True iff a user operation currently holds the user lock. */
1215
+ get userLockBusy() {
1216
+ return this._userOpInFlight !== null;
1217
+ }
1218
+
1219
+ /** Timestamp (ms since epoch) of the last inbound PDU. */
1220
+ get lastResponseAt() {
1221
+ return this._lastResponseAt;
1222
+ }
1223
+
1224
+ /** Label of the in-flight user op (or null). For diagnostics. */
1225
+ get userOpInFlight() {
1226
+ return this._userOpInFlight;
1227
+ }
1228
+ }
1229
+
1230
+ function pvalueToNumber(v) {
1231
+ if (!v) return null;
1232
+ const j = v.toJs();
1233
+ if (typeof j === 'number') return j;
1234
+ if (typeof j === 'bigint') return Number(j);
1235
+ return null;
1236
+ }
1237
+
1238
+ module.exports = { S7CommPlusClient, ItemAddress };