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,825 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { S7CommPlusClient } = require('../lib/s7plus/client');
5
+ const ItemAddress = require('../lib/s7plus/item-address');
6
+ const { decodeReadValue, encodeWriteValue } = require('../lib/s7plus/pvalue-codec');
7
+ const { buildReadPayload, buildWritePayload } = require('../lib/s7plus/read-result');
8
+ const { computeCrcFromMeta } = require('../lib/s7plus/crc');
9
+ const { scoped } = require('../lib/s7plus/debug');
10
+ const log = scoped('endpoint');
11
+
12
+ const BROWSE_SESSION_TTL_MS = 15 * 60 * 1000;
13
+ const RECONNECT_MAX_MS = 30000;
14
+ const WATCHDOG_MS = 10000;
15
+ // If the user lock is held but the transport produced NO inbound bytes
16
+ // for at least this long, the watchdog assumes a real hang and tears
17
+ // the transport down so the endpoint reconnects. With per-frame
18
+ // liveness in client._onDataReceived, even a slowly-arriving multi-MB
19
+ // browse response keeps this counter from advancing. 120 s is a
20
+ // conservative reserve for PLCs that take noticeably long to start
21
+ // answering big requests (e.g. manual browseFull via Explore node).
22
+ const WATCHDOG_HANG_NO_RESPONSE_MS = 120000;
23
+ const ephemeralBrowseSessions = new Map();
24
+
25
+ // DTL is the only datatype that needs the packed-struct interface
26
+ // timestamp echoed back on write. Accept both the canonical name and the
27
+ // numeric softdatatype id (67).
28
+ function isDtlDatatype(datatype) {
29
+ return datatype === 67 || (typeof datatype === 'string' && datatype.toLowerCase() === 'dtl');
30
+ }
31
+
32
+ function statusShape(state, text) {
33
+ switch (state) {
34
+ case 'online': return { fill: 'green', shape: 'dot', text: text || 'online' };
35
+ case 'connecting': return { fill: 'yellow', shape: 'dot', text: text || 'connecting' };
36
+ case 'offline': return { fill: 'red', shape: 'dot', text: text || 'offline' };
37
+ default: return { fill: 'grey', shape: 'ring', text: text || '' };
38
+ }
39
+ }
40
+
41
+ function epAddress(config) {
42
+ const a = (config.address || '').trim();
43
+ return a ? `${a}:102` : '';
44
+ }
45
+
46
+ function logConnectionEvent(node, RED, config, event, extra = {}) {
47
+ if (RED.settings.s7PlusEndpointLogConnection === false) return;
48
+ const address = epAddress(config);
49
+ if (!address) return;
50
+ switch (event) {
51
+ case 'connected':
52
+ node.log(`connected to ${address}`);
53
+ break;
54
+ case 'disconnected':
55
+ node.log(`disconnected from ${address} (${extra.reason || 'unknown'})`);
56
+ break;
57
+ case 'connection-lost':
58
+ node.log(`connection lost ${address} (${extra.reason || 'unknown'})`);
59
+ break;
60
+ case 'connect-failed':
61
+ node.warn(`connect failed ${address}: ${extra.message || ''}`);
62
+ break;
63
+ }
64
+ }
65
+
66
+ function browseBody(req) {
67
+ return req.body || {};
68
+ }
69
+
70
+ function browseCredentials(body) {
71
+ return {
72
+ address: (body.address || '').trim(),
73
+ password: '',
74
+ username: '',
75
+ timeoutMs: parseInt(body.timeout, 10) || 10000,
76
+ port: 102
77
+ };
78
+ }
79
+
80
+ function pruneEphemeralSessions() {
81
+ const now = Date.now();
82
+ for (const [id, sess] of ephemeralBrowseSessions) {
83
+ if (sess.expires < now || sess.dead) {
84
+ log('ephemeral prune', { sessionId: id, dead: !!sess.dead, expired: sess.expires < now });
85
+ try { sess.client.forceDisconnect('prune'); } catch { /* ignore */ }
86
+ ephemeralBrowseSessions.delete(id);
87
+ }
88
+ }
89
+ }
90
+
91
+ async function createEphemeralBrowseSession(creds) {
92
+ pruneEphemeralSessions();
93
+ log('ephemeral create', { address: creds.address, port: creds.port });
94
+ const client = new S7CommPlusClient();
95
+ await client.connect(creds.address, creds.password, creds.username, creds.timeoutMs, creds.port);
96
+ const sessionId = crypto.randomBytes(16).toString('hex');
97
+ const sess = {
98
+ client,
99
+ expires: Date.now() + BROWSE_SESSION_TTL_MS,
100
+ dead: false
101
+ };
102
+ client.on('disconnect', (info) => {
103
+ sess.dead = true;
104
+ log('ephemeral disconnect', { sessionId, reason: info && info.reason });
105
+ });
106
+ ephemeralBrowseSessions.set(sessionId, sess);
107
+ return sessionId;
108
+ }
109
+
110
+ function getEphemeralSession(sessionId) {
111
+ pruneEphemeralSessions();
112
+ const sess = ephemeralBrowseSessions.get(sessionId);
113
+ if (!sess) throw new Error('Browse session expired — open Browse PLC again');
114
+ if (sess.dead || !sess.client.connected) {
115
+ ephemeralBrowseSessions.delete(sessionId);
116
+ throw new Error('Browse session stale — open Browse PLC again');
117
+ }
118
+ sess.expires = Date.now() + BROWSE_SESSION_TTL_MS;
119
+ return sess.client;
120
+ }
121
+
122
+ function endpointNodeFromBody(RED, body) {
123
+ const id = body.id || body.endpointId;
124
+ return id ? RED.nodes.getNode(id) : null;
125
+ }
126
+
127
+ /**
128
+ * "Stale connection" errors are exactly the ones the new client raises
129
+ * when the transport is dead and the operation cannot complete. They are
130
+ * all safe to retry after a fresh reconnect; non-stale errors (protocol
131
+ * decode, access denied, invalid argument, lock-acquire timeout) must
132
+ * propagate as-is.
133
+ *
134
+ * Lock-acquire timeout is intentionally NOT treated as stale: the
135
+ * transport is fine; only the previous operation took too long. Treating
136
+ * it as stale would force an unnecessary reconnect that wipes a healthy
137
+ * session and aborts the still-running predecessor.
138
+ */
139
+ function isStaleConnectionError(err) {
140
+ if (!err || !err.message) return false;
141
+ const m = err.message;
142
+ return m.includes('Data receive Timeout') // _waitForResponse timeout
143
+ || m.includes('Client not connected') // _onTransportClosed reject
144
+ || m.includes('Send failed') // transport.send throw
145
+ || m.includes('socket-close')
146
+ || m.includes('socket-end')
147
+ || m.includes('socket-error');
148
+ }
149
+
150
+ /**
151
+ * True when a symbol-resolution error signals a STALE BROWSE TREE rather
152
+ * than a genuinely bad input. These messages are thrown by
153
+ * lib/s7plus/browse/resolve-symbolic.js while walking the cached tree:
154
+ * after a PLC program change (rename/delete/move of a DB member) the
155
+ * cached structure no longer matches the PLC, so a segment/root that
156
+ * used to exist is reported as missing. Treating these like the
157
+ * address-stale PLC codes lets the endpoint self-heal: clear the browse
158
+ * cache, re-resolve against the fresh PLC layout, and retry once.
159
+ *
160
+ * 'Invalid symbolic path:' is intentionally excluded — that is a real
161
+ * user input error (e.g. "DB1" without a member) and must not trigger
162
+ * a cache flush + lazy re-resolve retry.
163
+ */
164
+ function isStaleTreeError(err) {
165
+ if (!err || !err.message) return false;
166
+ const m = err.message;
167
+ return m.includes('not found in') // segment missing (renamed/deleted)
168
+ || m.includes('not found among PLC roots') // DB root renamed/removed
169
+ || m.includes('not reachable') // structure changed
170
+ || m.includes('does not resolve to a readable leaf'); // member type changed
171
+ }
172
+
173
+ async function withBrowseClient(RED, body, fn) {
174
+ const sessionId = body.browseSessionId;
175
+ if (sessionId) {
176
+ log('browse via ephemeral session', { sessionId });
177
+ try {
178
+ const client = getEphemeralSession(sessionId);
179
+ return await fn(client, sessionId);
180
+ } catch (e) {
181
+ if (!isStaleConnectionError(e) && !/session (expired|stale)/i.test(e.message || '')) throw e;
182
+ log('ephemeral retry: reconnecting', { sessionId, reason: e.message });
183
+ const stale = ephemeralBrowseSessions.get(sessionId);
184
+ if (stale) {
185
+ try { stale.client.forceDisconnect('retry'); } catch { /* ignore */ }
186
+ ephemeralBrowseSessions.delete(sessionId);
187
+ }
188
+ const creds = browseCredentials(body);
189
+ if (!creds.address) throw e;
190
+ const newSessionId = await createEphemeralBrowseSession(creds);
191
+ const client = getEphemeralSession(newSessionId);
192
+ return fn(client, newSessionId);
193
+ }
194
+ }
195
+ const ep = endpointNodeFromBody(RED, body);
196
+ if (ep) {
197
+ log('browse via deployed endpoint', { endpointId: body.id });
198
+ await ep.ensureConnected();
199
+ return fn(ep.client, null);
200
+ }
201
+ const creds = browseCredentials(body);
202
+ if (!creds.address) {
203
+ const id = body.id;
204
+ throw new Error(id
205
+ ? 'Endpoint not deployed yet — enter address and use Browse PLC (works before Deploy).'
206
+ : 'PLC address is required');
207
+ }
208
+ log('browse via fresh ephemeral session', { address: creds.address, port: creds.port });
209
+ const newSessionId = await createEphemeralBrowseSession(creds);
210
+ const client = getEphemeralSession(newSessionId);
211
+ return fn(client, newSessionId);
212
+ }
213
+
214
+ module.exports = function (RED) {
215
+ function S7ComPlusEndpoint(config) {
216
+ RED.nodes.createNode(this, config);
217
+ const node = this;
218
+ node.client = new S7CommPlusClient();
219
+ // Live DTL packed-struct interface timestamp captured from the PLC.
220
+ // Required to write DTL; null until the first DTL read/resolve sees it.
221
+ node._dtlInterfaceTs = null;
222
+ node._state = 'offline';
223
+ node._connectPromise = null;
224
+ node._closing = false;
225
+ node._reconnectTimer = null;
226
+ node._reconnectAttempt = 0;
227
+ node._watchdogTimer = null;
228
+
229
+ node.getStatus = () => node._state;
230
+
231
+ node._setStatus = (state, text) => {
232
+ node._state = state;
233
+ node.status(statusShape(state, text));
234
+ };
235
+
236
+ node.client.on('disconnect', (info) => {
237
+ const reason = info && info.reason ? info.reason : 'unknown';
238
+ log('endpoint disconnect', { id: node.id, reason, state: node._state });
239
+ if (node._closing) return;
240
+ if (node._state === 'online') {
241
+ logConnectionEvent(node, RED, config, 'connection-lost', { reason });
242
+ node._reconnectAttempt = 0;
243
+ node._setStatus('connecting', 'reconnecting\u2026');
244
+ scheduleReconnect();
245
+ }
246
+ });
247
+
248
+ const doConnect = async () => {
249
+ node._setStatus('connecting');
250
+ const timeout = parseInt(config.timeout, 10) || 10000;
251
+ log('endpoint connect', { id: node.id, address: config.address, port: 102, timeout });
252
+ try {
253
+ await node.client.connect(
254
+ config.address,
255
+ '',
256
+ '',
257
+ timeout,
258
+ 102
259
+ );
260
+ log('endpoint connect ok', { id: node.id });
261
+ logConnectionEvent(node, RED, config, 'connected');
262
+ node._setStatus('online');
263
+ return null;
264
+ } catch (e) {
265
+ node._setStatus('offline', e.message);
266
+ log('endpoint connect failed', { id: node.id, msg: e.message });
267
+ logConnectionEvent(node, RED, config, 'connect-failed', { message: e.message });
268
+ return e;
269
+ }
270
+ };
271
+
272
+ node.ensureConnected = async (forceReconnect = false) => {
273
+ // Only forceDisconnect when there is no in-flight connect.
274
+ // Otherwise two callers racing into ensureConnected(true)
275
+ // would tear down each other's reconnect mid-handshake and
276
+ // could ping-pong forever. With a connect in flight, joining
277
+ // it is correct: if it succeeds, we're fine; if it fails,
278
+ // each caller handles the error normally.
279
+ if (forceReconnect && !node._connectPromise) {
280
+ try { node.client.forceDisconnect('endpoint-force-reconnect'); } catch { /* ignore */ }
281
+ }
282
+ if (!forceReconnect && node.client.connected && !node.client.socketAlive) {
283
+ log('ensureConnected: socket dead but client flag stale — forcing reconnect', { id: node.id });
284
+ try { node.client.forceDisconnect('socket-dead'); } catch { /* ignore */ }
285
+ }
286
+ if (node.client.connected) return;
287
+ if (!node._connectPromise) {
288
+ node._connectPromise = doConnect().finally(() => {
289
+ node._connectPromise = null;
290
+ });
291
+ }
292
+ const err = await node._connectPromise;
293
+ if (err) throw err;
294
+ };
295
+
296
+ const scheduleReconnect = () => {
297
+ if (node._closing || node._reconnectTimer || node._connectPromise) return;
298
+ const delay = node._reconnectAttempt === 0
299
+ ? 500
300
+ : Math.min(1000 * Math.pow(2, node._reconnectAttempt - 1), RECONNECT_MAX_MS);
301
+ log('scheduleReconnect', { id: node.id, attempt: node._reconnectAttempt, delayMs: delay });
302
+ if (node._reconnectAttempt > 0) {
303
+ node._setStatus('connecting', `reconnect #${node._reconnectAttempt} in ${Math.round(delay / 1000)}s\u2026`);
304
+ }
305
+ node._reconnectTimer = setTimeout(async () => {
306
+ node._reconnectTimer = null;
307
+ if (node._closing) return;
308
+ try {
309
+ await node.ensureConnected();
310
+ node._reconnectAttempt = 0;
311
+ } catch {
312
+ node._reconnectAttempt++;
313
+ scheduleReconnect();
314
+ }
315
+ }, delay);
316
+ };
317
+
318
+ /**
319
+ * Run an operation, transparently recovering from a single stale-
320
+ * connection failure. The client itself guarantees the underlying
321
+ * call is bounded in time (lock-acquire + response timeouts), so
322
+ * the retry adds at most one reconnect + one operation duration.
323
+ * Used uniformly for browse, read, write — no operation-specific
324
+ * wrapper stacking.
325
+ */
326
+ const withReconnect = async (fn, tag) => {
327
+ log('endpoint op', { id: node.id, tag, connected: node.client.connected });
328
+ await node.ensureConnected();
329
+ try {
330
+ return await fn();
331
+ } catch (e) {
332
+ if (!isStaleConnectionError(e)) {
333
+ log('endpoint op failed (non-recoverable)', { id: node.id, tag, msg: e.message });
334
+ throw e;
335
+ }
336
+ log('endpoint op retry (stale connection)', { id: node.id, tag, reason: e.message });
337
+ await node.ensureConnected(true);
338
+ try {
339
+ return await fn();
340
+ } catch (e2) {
341
+ log('endpoint op retry failed', { id: node.id, tag, msg: e2.message });
342
+ throw e2;
343
+ }
344
+ }
345
+ };
346
+
347
+ node.browseRoots = () => withReconnect(() => {
348
+ node.client.clearBrowseState();
349
+ return node.client.browseRoots();
350
+ }, 'browseRoots');
351
+
352
+ node.browseChildren = (nodeId) => withReconnect(
353
+ () => node.client.browseChildren(nodeId),
354
+ `browseChildren:${nodeId}`
355
+ );
356
+
357
+ node.browseResolve = (nodeId) => withReconnect(
358
+ () => node.client.browseResolve(nodeId),
359
+ `browseResolve:${nodeId}`
360
+ );
361
+
362
+ node.explorePlcProgram = () => withReconnect(
363
+ () => node.client.explorePlcProgram(),
364
+ 'explorePlcProgram'
365
+ );
366
+
367
+ node.browseFull = (options) => withReconnect(
368
+ () => node.client.browseFull(options),
369
+ 'browseFull'
370
+ );
371
+
372
+ /**
373
+ * Read one or more tags. Each tag is `{ name?, address, datatype? }`.
374
+ * `address` must be a resolved hex access string (e.g. "8A0E0001.A").
375
+ * Symbol resolution is the caller's responsibility — the endpoint
376
+ * no longer keeps a shared symbol table.
377
+ */
378
+ node.readTags = (tags) => withReconnect(async () => {
379
+ const prepared = tags.map((t, i) => {
380
+ if (!t || !t.address) throw new Error(`Tag #${i} has no address`);
381
+ const addr = new ItemAddress(t.address);
382
+ if (t.symbolCrc) addr.symbolCrc = t.symbolCrc >>> 0;
383
+ return { tag: t, addr };
384
+ });
385
+ const { values, errors } = await node.client.readValues(prepared.map(a => a.addr));
386
+ captureDtlTimestamp(prepared.map(p => p.tag), values);
387
+ return buildReadPayload(prepared, values, errors, decodeReadValue);
388
+ }, 'readTags');
389
+
390
+ // Remember the packed-struct interface timestamp from any DTL value
391
+ // the PLC sent us, so a later DTL write can echo it back unchanged
392
+ // (a mismatch is rejected as InvalidTimestampInTypeSafeBlob).
393
+ function captureDtlTimestamp(tags, values) {
394
+ for (let i = 0; i < tags.length; i++) {
395
+ if (!isDtlDatatype(tags[i] && tags[i].datatype)) continue;
396
+ const v = values[i];
397
+ if (v && typeof v.packedInterfaceTimestamp === 'bigint' && v.packedInterfaceTimestamp !== 0n) {
398
+ node._dtlInterfaceTs = v.packedInterfaceTimestamp;
399
+ }
400
+ }
401
+ }
402
+
403
+ // --- CRC resolution cache ---
404
+ const CRC_CACHE_TTL_MS = 5 * 60 * 1000;
405
+ // Address/symbol-related PLC errors that mean the cached address is
406
+ // stale: the symbol was deleted or moved to a new address (PLC
407
+ // program changed). Both warrant the same self-heal: clear the
408
+ // browse-state + CRC cache, re-resolve against the fresh PLC tree,
409
+ // and retry once. 0x...12cbffef = CRC mismatch, 0x...0ebeffef =
410
+ // address/object no longer valid.
411
+ const ADDR_STALE_HEX = ['8009890012cbffef', '800989000ebeffef'];
412
+ const _crcCache = new Map();
413
+
414
+ function crcCacheGet(symbolPath) {
415
+ const entry = _crcCache.get(symbolPath);
416
+ if (!entry) return null;
417
+ if (Date.now() - entry.resolvedAt > CRC_CACHE_TTL_MS) {
418
+ _crcCache.delete(symbolPath);
419
+ return null;
420
+ }
421
+ return entry;
422
+ }
423
+
424
+ function crcCacheSet(symbolPath, address, symbolCrc, datatype) {
425
+ _crcCache.set(symbolPath, { address, symbolCrc, datatype, resolvedAt: Date.now() });
426
+ }
427
+
428
+ function crcCacheInvalidate(symbolPath) {
429
+ _crcCache.delete(symbolPath);
430
+ }
431
+
432
+ function crcCacheInvalidateAll() {
433
+ _crcCache.clear();
434
+ }
435
+
436
+ /**
437
+ * True when an error signals that a cached address is stale (symbol
438
+ * deleted or moved to a new address). Checks both the raw per-item
439
+ * PLC codes (err.writeErrorCodes, BigInt) and the error message so
440
+ * it works for read errors (message-only) and write errors (codes).
441
+ */
442
+ function isAddressStaleError(err) {
443
+ if (!err) return false;
444
+ const codes = err.writeErrorCodes;
445
+ if (Array.isArray(codes)) {
446
+ for (const c of codes) {
447
+ if (!c) continue;
448
+ const hex = c.toString(16);
449
+ if (ADDR_STALE_HEX.some(h => hex.includes(h))) return true;
450
+ }
451
+ }
452
+ const msg = err.message || '';
453
+ return ADDR_STALE_HEX.some(h => msg.includes(h));
454
+ }
455
+
456
+ /**
457
+ * Resolve a batch of symbols, using the CRC cache for hits and
458
+ * browseResolveSymbolicBatch for all misses in a single pass.
459
+ * Returns one entry per symbol (same order as input).
460
+ */
461
+ async function resolveSymbolsBatch(symbols) {
462
+ const entries = new Array(symbols.length);
463
+ const uncachedIndices = [];
464
+
465
+ for (let i = 0; i < symbols.length; i++) {
466
+ const cached = crcCacheGet(symbols[i]);
467
+ if (cached) {
468
+ entries[i] = cached;
469
+ } else {
470
+ uncachedIndices.push(i);
471
+ }
472
+ }
473
+
474
+ if (uncachedIndices.length > 0) {
475
+ const uncachedPaths = uncachedIndices.map(i => symbols[i]);
476
+ const resolved = await node.client.browseResolveSymbolicBatch(uncachedPaths);
477
+
478
+ for (let j = 0; j < uncachedIndices.length; j++) {
479
+ const idx = uncachedIndices[j];
480
+ const r = resolved[j];
481
+ if (r.error) {
482
+ entries[idx] = { address: null, symbolCrc: 0, error: r.error };
483
+ } else {
484
+ const symbolCrc = r.crcMeta ? computeCrcFromMeta(r.crcMeta) : 0;
485
+ const entry = { address: r.address, symbolCrc, datatype: r.datatype, resolvedAt: Date.now() };
486
+ crcCacheSet(symbols[idx], r.address, symbolCrc, r.datatype);
487
+ entries[idx] = entry;
488
+ }
489
+ }
490
+ }
491
+
492
+ return entries;
493
+ }
494
+
495
+ /**
496
+ * Resolve symbolic paths, compute CRC, perform a CRC-secured read.
497
+ * Uses batch resolution: one browseRootsCached + shared type-info
498
+ * cache for all uncached symbols.
499
+ * On CRC-Mismatch error: invalidate cache, re-resolve, retry once.
500
+ * @param {string[]} symbols - symbolic paths like "DB1.readings[0]"
501
+ * @returns {object} keyed by symbol path
502
+ */
503
+ node.resolveAndRead = async (symbols) => {
504
+ const doRead = async (entries) => {
505
+ const readable = [];
506
+ const readableSymbols = [];
507
+ const resolveErrors = {};
508
+
509
+ for (let i = 0; i < entries.length; i++) {
510
+ if (entries[i].error) {
511
+ resolveErrors[symbols[i]] = {
512
+ value: null,
513
+ status: 'error',
514
+ error: entries[i].error
515
+ };
516
+ } else {
517
+ readable.push({
518
+ name: symbols[i],
519
+ address: entries[i].address,
520
+ symbolCrc: entries[i].symbolCrc,
521
+ datatype: entries[i].datatype
522
+ });
523
+ readableSymbols.push(symbols[i]);
524
+ }
525
+ }
526
+
527
+ if (readable.length === 0) return resolveErrors;
528
+
529
+ const readResult = await node.readTags(readable);
530
+ return Object.assign(resolveErrors, readResult);
531
+ };
532
+
533
+ let entries = await resolveSymbolsBatch(symbols);
534
+ const result = await doRead(entries);
535
+
536
+ const mismatchIndices = [];
537
+ for (let i = 0; i < symbols.length; i++) {
538
+ const r = result[symbols[i]];
539
+ if (r && r.status === 'error' && r.error
540
+ && (isAddressStaleError({ message: r.error }) || isStaleTreeError({ message: r.error }))) {
541
+ mismatchIndices.push(i);
542
+ }
543
+ }
544
+
545
+ if (mismatchIndices.length === 0) return result;
546
+
547
+ const mismatchSymbols = mismatchIndices.map(i => symbols[i]);
548
+ node.warn(`Stale address on ${mismatchSymbols.length} symbol(s) — symbol may have moved or PLC program changed. Clearing cache and re-resolving. Affected: ${mismatchSymbols.slice(0, 5).join(', ')}${mismatchSymbols.length > 5 ? ' ...' : ''}`);
549
+ log('resolveAndRead stale address, retrying', { symbols: mismatchSymbols });
550
+
551
+ // Clear the cached browse tree so re-resolution walks the
552
+ // fresh PLC layout and picks up any moved symbol addresses.
553
+ node.client.clearBrowseState();
554
+ crcCacheInvalidateAll();
555
+
556
+ entries = await resolveSymbolsBatch(symbols);
557
+ return doRead(entries);
558
+ };
559
+
560
+
561
+ /**
562
+ * Write one or more tags. Each tag is
563
+ * `{ name?, address, value, datatype, symbolCrc? }`.
564
+ * `address` must be a resolved hex access string. When the tag
565
+ * carries a `symbolCrc` (browsed/resolved symbol) it is applied
566
+ * to the ItemAddress so the PLC verifies the symbol table has not
567
+ * changed — same CRC protection as readTags.
568
+ *
569
+ * Per-item PLC errors (bad address, CRC mismatch, access denied)
570
+ * are no longer swallowed: writeValues now returns real per-item
571
+ * codes, and any non-zero code is raised as an Error so the write
572
+ * node turns red and reports via done(err) instead of falsely
573
+ * showing success.
574
+ */
575
+ node.writeTags = (tags) => withReconnect(async () => {
576
+ const addresses = tags.map((t, i) => {
577
+ if (!t || !t.address) throw new Error(`Tag #${i} has no address`);
578
+ const addr = new ItemAddress(t.address);
579
+ if (t.symbolCrc) addr.symbolCrc = t.symbolCrc >>> 0;
580
+ return addr;
581
+ });
582
+
583
+ // DTL writes need the PLC's packed-struct interface timestamp.
584
+ // If no DTL has been read on this endpoint yet, read the DTL
585
+ // target(s) once to capture it before encoding the write.
586
+ const hasDtl = tags.some(t => isDtlDatatype(t && t.datatype));
587
+ if (hasDtl && node._dtlInterfaceTs == null) {
588
+ const dtlIdx = tags
589
+ .map((t, i) => (isDtlDatatype(t && t.datatype) ? i : -1))
590
+ .filter(i => i >= 0);
591
+ const { values } = await node.client.readValues(dtlIdx.map(i => addresses[i]));
592
+ captureDtlTimestamp(dtlIdx.map(i => tags[i]), values);
593
+ }
594
+
595
+ const opts = { dtlInterfaceTimestamp: node._dtlInterfaceTs };
596
+ const vals = tags.map(t => encodeWriteValue(t.value, t.datatype, opts));
597
+ const { errors } = await node.client.writeValues(addresses, vals);
598
+
599
+ // Per-item PLC errors are no longer thrown: writeTags now mirrors
600
+ // readTags and returns a per-tag result keyed by name with
601
+ // { value, status, error }. Stale-address self-heal in
602
+ // resolveAndWrite inspects these statuses instead of catching.
603
+ return buildWritePayload(tags, errors);
604
+ }, 'writeTags');
605
+
606
+ /**
607
+ * Resolve symbolic tags to hex address + CRC, then perform a
608
+ * CRC-secured write. Mirror of resolveAndRead so the write path
609
+ * gets the same symbol resolution and self-healing behaviour.
610
+ * On CRC mismatch: invalidate the cache, re-resolve, retry once.
611
+ * @param {Array} tags - `{ name?, address (symbolic), value, datatype? }`
612
+ */
613
+ node.resolveAndWrite = async (tags) => {
614
+ const symbols = tags.map(t => t.address);
615
+ const keyOf = (t) => t.name || t.address;
616
+
617
+ const doWrite = async (entries) => {
618
+ const writable = [];
619
+ const resolveErrors = {};
620
+
621
+ for (let i = 0; i < entries.length; i++) {
622
+ if (entries[i].error) {
623
+ resolveErrors[keyOf(tags[i])] = {
624
+ value: null,
625
+ status: 'error',
626
+ error: entries[i].error
627
+ };
628
+ } else {
629
+ writable.push({
630
+ name: keyOf(tags[i]),
631
+ address: entries[i].address,
632
+ symbolCrc: entries[i].symbolCrc,
633
+ datatype: tags[i].datatype || entries[i].datatype,
634
+ value: tags[i].value
635
+ });
636
+ }
637
+ }
638
+
639
+ if (writable.length === 0) return resolveErrors;
640
+
641
+ const writeResult = await node.writeTags(writable);
642
+ return Object.assign(resolveErrors, writeResult);
643
+ };
644
+
645
+ let entries = await resolveSymbolsBatch(symbols);
646
+ const result = await doWrite(entries);
647
+
648
+ const mismatchIndices = [];
649
+ for (let i = 0; i < tags.length; i++) {
650
+ const r = result[keyOf(tags[i])];
651
+ if (r && r.status === 'error' && r.error
652
+ && (isAddressStaleError({ message: r.error }) || isStaleTreeError({ message: r.error }))) {
653
+ mismatchIndices.push(i);
654
+ }
655
+ }
656
+
657
+ if (mismatchIndices.length === 0) return result;
658
+
659
+ const mismatchSymbols = mismatchIndices.map(i => symbols[i]);
660
+ node.warn(`Stale address on ${mismatchSymbols.length} write symbol(s) — symbol may have moved or PLC program changed. Clearing cache and re-resolving. Affected: ${mismatchSymbols.slice(0, 5).join(', ')}${mismatchSymbols.length > 5 ? ' ...' : ''}`);
661
+ log('resolveAndWrite stale address, retrying', { symbols: mismatchSymbols });
662
+
663
+ // A stale cached address may have been resolved through the
664
+ // cached browse tree; clear it so re-resolution walks the
665
+ // fresh PLC layout and picks up the symbol's new address.
666
+ node.client.clearBrowseState();
667
+ crcCacheInvalidateAll();
668
+
669
+ entries = await resolveSymbolsBatch(symbols);
670
+ return doWrite(entries);
671
+ };
672
+
673
+ node.on('close', (_removed, done) => {
674
+ node._closing = true;
675
+ if (node._reconnectTimer) {
676
+ clearTimeout(node._reconnectTimer);
677
+ node._reconnectTimer = null;
678
+ }
679
+ if (node._watchdogTimer) {
680
+ clearInterval(node._watchdogTimer);
681
+ node._watchdogTimer = null;
682
+ }
683
+ node.client.forceDisconnect('node-close');
684
+ done();
685
+ });
686
+
687
+ if (config.address) {
688
+ node._connectPromise = doConnect().finally(() => {
689
+ node._connectPromise = null;
690
+ });
691
+ node._connectPromise.then((err) => {
692
+ if (err) {
693
+ node._reconnectAttempt = 1;
694
+ scheduleReconnect();
695
+ }
696
+ });
697
+ let watchdogBusy = false;
698
+ node._watchdogTimer = setInterval(async () => {
699
+ if (node._closing || watchdogBusy) return;
700
+ watchdogBusy = true;
701
+ try {
702
+ if (!node.client.connected) {
703
+ if (!node._connectPromise && !node._reconnectTimer) {
704
+ log('watchdog: offline without pending reconnect', { id: node.id });
705
+ scheduleReconnect();
706
+ }
707
+ return;
708
+ }
709
+ if (!node.client.socketAlive) {
710
+ log('watchdog: socket dead', { id: node.id });
711
+ try { node.client.forceDisconnect('watchdog'); } catch { /* ignore */ }
712
+ return;
713
+ }
714
+ // Skip ping if a legitimate long-running user op
715
+ // (browseFull, big read/write) is in progress. A running
716
+ // op is itself proof of liveness. If the lock has been
717
+ // held without ANY inbound PDU for more than the hang
718
+ // threshold, treat it as a real hang and force reconnect.
719
+ if (node.client.userLockBusy) {
720
+ const sinceLastResponse = Date.now() - node.client.lastResponseAt;
721
+ if (sinceLastResponse > WATCHDOG_HANG_NO_RESPONSE_MS) {
722
+ const opLabel = node.client.userOpInFlight || 'unknown';
723
+ const seconds = Math.round(sinceLastResponse / 1000);
724
+ node.warn(`watchdog: forcing reconnect — operation '${opLabel}' produced no PDU for ${seconds}s`);
725
+ log('watchdog: lock busy and no response — forcing reconnect', {
726
+ id: node.id,
727
+ op: opLabel,
728
+ sinceLastResponseMs: sinceLastResponse,
729
+ thresholdMs: WATCHDOG_HANG_NO_RESPONSE_MS
730
+ });
731
+ try { node.client.forceDisconnect('watchdog-stuck'); } catch { /* ignore */ }
732
+ } else {
733
+ log('watchdog: skip (lock busy, traffic flowing)', () => [{
734
+ id: node.id,
735
+ op: node.client.userOpInFlight,
736
+ sinceLastResponseMs: sinceLastResponse
737
+ }]);
738
+ }
739
+ return;
740
+ }
741
+ try {
742
+ await node.client.ping();
743
+ } catch {
744
+ if (node._closing) return;
745
+ log('watchdog: ping failed, forcing reconnect', { id: node.id });
746
+ try { node.client.forceDisconnect('watchdog-ping'); } catch { /* ignore */ }
747
+ }
748
+ } finally {
749
+ watchdogBusy = false;
750
+ }
751
+ }, WATCHDOG_MS);
752
+ } else {
753
+ node._setStatus('offline', 'no address');
754
+ }
755
+ }
756
+
757
+ RED.nodes.registerType('s7-plus endpoint', S7ComPlusEndpoint, {
758
+ settings: {
759
+ s7PlusEndpointLogConnection: {
760
+ value: true,
761
+ exportable: false
762
+ }
763
+ }
764
+ });
765
+
766
+ // Wraps a browse HTTP handler so a transient stale-connection failure
767
+ // from either the deployed-endpoint path or the ephemeral-session
768
+ // path triggers a single reconnect+retry before bubbling up. The
769
+ // client and withBrowseClient each have their own recovery; this is
770
+ // the outermost net.
771
+ async function handleBrowseRequest(req, res, opName, runFn) {
772
+ const body = browseBody(req);
773
+ const t0 = Date.now();
774
+ log('http browse', { op: opName, id: body.id, sessionId: body.browseSessionId, nodeId: body.nodeId });
775
+ const attempt = async () => withBrowseClient(RED, body, runFn);
776
+ try {
777
+ let payload;
778
+ try {
779
+ payload = await attempt();
780
+ } catch (e1) {
781
+ if (!isStaleConnectionError(e1) && !/session (expired|stale)/i.test(e1.message || '')) throw e1;
782
+ log('http browse retry', { op: opName, reason: e1.message });
783
+ if (body.browseSessionId) {
784
+ const stale = ephemeralBrowseSessions.get(body.browseSessionId);
785
+ if (stale) {
786
+ try { stale.client.forceDisconnect('http-retry'); } catch { /* ignore */ }
787
+ ephemeralBrowseSessions.delete(body.browseSessionId);
788
+ }
789
+ body.browseSessionId = null;
790
+ }
791
+ payload = await attempt();
792
+ }
793
+ log('http browse ok', { op: opName, ms: Date.now() - t0 });
794
+ res.json(payload);
795
+ } catch (e) {
796
+ log('http browse fail', { op: opName, ms: Date.now() - t0, msg: e.message });
797
+ res.status(500).json({ error: e.message });
798
+ }
799
+ }
800
+
801
+ function handleBrowseRoots(req, res) {
802
+ return handleBrowseRequest(req, res, 'roots', async (client, sessionId) => {
803
+ client.clearBrowseState();
804
+ const result = await client.browseRoots();
805
+ return { nodes: result.nodes, browseSessionId: sessionId };
806
+ });
807
+ }
808
+
809
+ function handleBrowseChildren(req, res) {
810
+ const body = browseBody(req);
811
+ if (!body.nodeId) { res.status(400).json({ error: 'nodeId is required' }); return; }
812
+ return handleBrowseRequest(req, res, 'children', (client) => client.browseChildren(body.nodeId));
813
+ }
814
+
815
+ function handleBrowseResolve(req, res) {
816
+ const body = browseBody(req);
817
+ if (!body.nodeId) { res.status(400).json({ error: 'nodeId is required' }); return; }
818
+ return handleBrowseRequest(req, res, 'resolve', (client) => client.browseResolve(body.nodeId));
819
+ }
820
+
821
+ const browsePerm = RED.auth.needsPermission('flows.read');
822
+ RED.httpAdmin.post('/s7complus/browse/roots', browsePerm, handleBrowseRoots);
823
+ RED.httpAdmin.post('/s7complus/browse/children', browsePerm, handleBrowseChildren);
824
+ RED.httpAdmin.post('/s7complus/browse/resolve', browsePerm, handleBrowseResolve);
825
+ };