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,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 };
|