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