smithtek-mako-rf 3.4.2 → 3.5.1
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/LICENSE.txt +80 -0
- package/LICENSING.md +90 -0
- package/MAKO-RF.md +22 -12
- package/PASSPORT-CONNECT.md +24 -2
- package/README.md +32 -4
- package/THIRD-PARTY-NOTICES.md +659 -0
- package/UPSTREAM.md +21 -1
- package/bus-engine.js +266 -142
- package/channel-map.mjs +56 -54
- package/package.json +7 -4
- package/passport-link-node.js +39 -18
- package/smithtek-mako-rf.html +782 -765
- package/smithtek-mako-rf.js +7 -2
package/bus-engine.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Node.js 14 compatible.
|
|
3
|
+
// Smithtek Software Licence: LICENSE.txt. Node.js 14 compatible.
|
|
4
|
+
// One FIFO worker and one confirmed serial owner per bus, including PPC batches.
|
|
4
5
|
module.exports = function createEngine(ModbusRTU) {
|
|
5
6
|
const owners = new Map();
|
|
6
|
-
|
|
7
|
+
// A retired serial stream can still emit a delayed error. An inert sink keeps
|
|
8
|
+
// EventEmitter from throwing without retaining any bus, node or worker state.
|
|
9
|
+
const ignoreRetiredError = () => {};
|
|
7
10
|
const bounded = (v, fallback, min, max) => v != null && v !== '' && Number.isFinite(Number(v)) ? Math.min(max, Math.max(min, Number(v))) : fallback;
|
|
8
11
|
const error = (message, code) => Object.assign(new Error(message), { code });
|
|
9
12
|
const timeoutMs = cfg => bounded(cfg.timeout_s, 8, 1, 120) * 1000;
|
|
@@ -11,271 +14,392 @@ module.exports = function createEngine(ModbusRTU) {
|
|
|
11
14
|
|
|
12
15
|
function create(cfg) {
|
|
13
16
|
return { cfg, queue: [], active: null, worker: null, closing: false, client: null,
|
|
14
|
-
cancel: null, dirty: false, transportFault: false, lastRx: 0, failures: new Map(),
|
|
17
|
+
cancel: null, dirty: false, transportFault: false, lastRx: 0, failures: new Map(),
|
|
18
|
+
closePromise: null, ownerPath: null, epoch: 0, recovering: false, recoveryPromise: null, recoveryError: null };
|
|
19
|
+
}
|
|
20
|
+
function available(state) {
|
|
21
|
+
if (state.closing) return error('Bus closing', 'ECANCELLED');
|
|
22
|
+
if (state.recovering) return error('Bus recovery in progress; request not queued', 'EBUSRECOVERING');
|
|
23
|
+
if (state.recoveryError) return error(state.recoveryError.message, 'EBUSRECOVERYFAILED');
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function checkEpoch(state, epoch) {
|
|
27
|
+
if (state.closing) throw error('Bus closing', 'ECANCELLED');
|
|
28
|
+
if (state.epoch !== epoch) throw error('Bus queue reset; operation cancelled', 'EBUSRESET');
|
|
29
|
+
}
|
|
30
|
+
function checkItem(state, item) {
|
|
31
|
+
if (item.finished) throw item.failure || error('Request already completed', 'ECANCELLED');
|
|
32
|
+
if (item.node._makoClosed) throw error('Node closed', 'ECANCELLED');
|
|
33
|
+
checkEpoch(state, item.epoch);
|
|
15
34
|
}
|
|
16
35
|
|
|
36
|
+
// Every deadline, pause and quiet-period timer is owned by this operation.
|
|
37
|
+
// Cancellation clears its timers; a late callback can only settle a dead promise.
|
|
17
38
|
function operation(state, label, ms, start) {
|
|
18
39
|
return new Promise((resolve, reject) => {
|
|
40
|
+
const epoch = state.epoch, cleanups = [];
|
|
19
41
|
let finished = false;
|
|
20
42
|
const finish = (err, value) => {
|
|
21
43
|
if (finished) return;
|
|
22
|
-
finished = true;
|
|
23
|
-
|
|
44
|
+
finished = true; clearTimeout(timer);
|
|
45
|
+
cleanups.splice(0).forEach(fn => { try { fn(); } catch (_) {} });
|
|
24
46
|
if (state.cancel === cancel) state.cancel = null;
|
|
47
|
+
if (!err) { try { checkEpoch(state, epoch); } catch (e) { err = e; } }
|
|
25
48
|
err ? reject(err) : resolve(value);
|
|
26
49
|
};
|
|
27
50
|
const cancel = err => finish(err || error('Operation cancelled', 'ECANCELLED'));
|
|
28
51
|
const timer = setTimeout(() => finish(error(label + ' deadline exceeded', 'EDEADLINE')), ms);
|
|
29
52
|
state.cancel = cancel;
|
|
30
|
-
if (state.closing) return cancel();
|
|
31
|
-
|
|
53
|
+
if (state.closing) return cancel(error('Bus closing', 'ECANCELLED'));
|
|
54
|
+
const cleanup = fn => { if (finished) fn(); else cleanups.push(fn); };
|
|
55
|
+
try { Promise.resolve(start(cleanup)).then(value => finish(null, value), finish); } catch (err) { finish(err); }
|
|
32
56
|
});
|
|
33
57
|
}
|
|
34
|
-
|
|
58
|
+
function pause(state, label, ms) {
|
|
59
|
+
return operation(state, label, ms + 1000, cleanup => new Promise(resolve => {
|
|
60
|
+
const timer = setTimeout(resolve, ms); cleanup(() => clearTimeout(timer));
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
35
63
|
function callbackCall(target, method, args) {
|
|
36
64
|
return new Promise((resolve, reject) => {
|
|
37
65
|
try { target[method].apply(target, (args || []).concat(err => err ? reject(err) : resolve())); }
|
|
38
66
|
catch (err) { reject(err); }
|
|
39
67
|
});
|
|
40
68
|
}
|
|
69
|
+
function clearTransactions(client) {
|
|
70
|
+
if (typeof client._cancelPendingTransactions === 'function') client._cancelPendingTransactions();
|
|
71
|
+
if (client._transactions) client._transactions = {};
|
|
72
|
+
}
|
|
73
|
+
function cleanupClient(client) {
|
|
74
|
+
if (client._makoCleanup) { client._makoCleanup(); client._makoCleanup = null; }
|
|
75
|
+
if (!client.listeners('error').includes(ignoreRetiredError)) client.on('error', ignoreRetiredError);
|
|
76
|
+
}
|
|
41
77
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
78
|
+
// A close callback alone is not proof of closure. Require closed state on the
|
|
79
|
+
// actual serial descriptor too, and never release a still-opening descriptor.
|
|
80
|
+
// A timed-out open retains a cleanup continuation ONLY to close that old port.
|
|
81
|
+
// It can never start a new worker, reopen, or declare recovery successful.
|
|
82
|
+
function retire(state) {
|
|
45
83
|
const client = state.client;
|
|
46
|
-
if (!client) return;
|
|
84
|
+
if (!client) return Promise.resolve();
|
|
47
85
|
if (client._makoRetiring) return client._makoRetiring;
|
|
48
|
-
client._makoRetiring = new Promise((resolve, reject) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
86
|
+
client._makoRetiring = Promise.resolve().then(() => new Promise((resolve, reject) => {
|
|
87
|
+
const leasePath = state.ownerPath;
|
|
88
|
+
let settled = false, closeStarted = false;
|
|
89
|
+
const closed = () => {
|
|
90
|
+
const port = portOf(client);
|
|
91
|
+
return !client._makoOpening && client.isOpen === false &&
|
|
92
|
+
(!port || (port.isOpen === false && !port.opening && !port.closing));
|
|
54
93
|
};
|
|
55
94
|
const release = () => {
|
|
95
|
+
if (!closed()) return false;
|
|
96
|
+
clearTransactions(client); cleanupClient(client);
|
|
56
97
|
if (state.client === client) state.client = null;
|
|
57
|
-
if (owners.get(
|
|
98
|
+
if (owners.get(leasePath) === state) owners.delete(leasePath);
|
|
58
99
|
client._makoClosed = true;
|
|
100
|
+
return true;
|
|
101
|
+
};
|
|
102
|
+
const finish = err => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true; clearTimeout(timer);
|
|
105
|
+
client.removeListener('close', onClose);
|
|
106
|
+
err ? reject(err) : resolve();
|
|
59
107
|
};
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
108
|
+
const onClose = () => { if (release()) finish(); };
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
if (release()) finish();
|
|
111
|
+
else finish(error('Serial closure could not be confirmed; port quarantined', 'ECLOSE'));
|
|
112
|
+
}, 2000);
|
|
113
|
+
client.on('close', onClose);
|
|
114
|
+
try { clearTransactions(client); } catch (err) { finish(err); return; }
|
|
115
|
+
const closeOld = () => {
|
|
116
|
+
if (release()) { finish(); return; }
|
|
117
|
+
if (closeStarted) return;
|
|
118
|
+
closeStarted = true;
|
|
63
119
|
try { client.close(err => {
|
|
64
|
-
if (
|
|
65
|
-
finish(
|
|
66
|
-
}); } catch (err) {
|
|
120
|
+
if (release()) finish();
|
|
121
|
+
else finish(error('Serial closure could not be confirmed' + (err ? ': ' + err.message : '') + '; port quarantined', 'ECLOSE'));
|
|
122
|
+
}); } catch (err) {
|
|
123
|
+
if (release()) finish();
|
|
124
|
+
else finish(error('Serial close failed: ' + err.message + '; port quarantined', 'ECLOSE'));
|
|
125
|
+
}
|
|
67
126
|
};
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
else close();
|
|
72
|
-
});
|
|
127
|
+
if (client._makoOpening) client._makoOpening.then(closeOld, closeOld);
|
|
128
|
+
else closeOld();
|
|
129
|
+
}));
|
|
73
130
|
return client._makoRetiring;
|
|
74
131
|
}
|
|
75
132
|
|
|
76
133
|
async function connect(state) {
|
|
77
|
-
|
|
134
|
+
const epoch = state.epoch;
|
|
135
|
+
checkEpoch(state, epoch);
|
|
78
136
|
const cfg = state.cfg;
|
|
79
|
-
if (state.client && state.transportFault) await retire(state);
|
|
80
|
-
|
|
137
|
+
if (state.client && (state.transportFault || state.client._makoRetiring)) await retire(state);
|
|
138
|
+
checkEpoch(state, epoch);
|
|
81
139
|
if (state.client && state.client.isOpen) return;
|
|
82
140
|
if (state.client) await retire(state);
|
|
83
|
-
|
|
141
|
+
checkEpoch(state, epoch);
|
|
142
|
+
// USB enumeration itself cannot be cancelled. Use scratch identity state so
|
|
143
|
+
// a late enumeration cannot mutate a stopped/recovering bus.
|
|
144
|
+
const usb = {usbIdentity: state.usbIdentity};
|
|
145
|
+
const path = cfg.serialPort && await operation(state, 'USB detection', 5000,
|
|
146
|
+
() => require('./usb-discovery').resolve(cfg.serialPort, usb));
|
|
147
|
+
checkEpoch(state, epoch);
|
|
84
148
|
if (!path) throw error('No serial port configured', 'ECONFIG');
|
|
149
|
+
if (usb.usbIdentity) state.usbIdentity = usb.usbIdentity;
|
|
85
150
|
const owner = owners.get(path);
|
|
86
151
|
if (owner && owner !== state) throw error('Serial port already owned by another Mako bus: ' + path, 'EPORTBUSY');
|
|
87
|
-
|
|
88
|
-
|
|
152
|
+
const client = new ModbusRTU();
|
|
153
|
+
owners.set(path, state); state.ownerPath = path; state.client = client;
|
|
154
|
+
let port;
|
|
155
|
+
const onData = () => { if (state.client === client && !client._makoRetiring) state.lastRx = Date.now(); };
|
|
89
156
|
const onFault = err => {
|
|
90
|
-
if (state.client !== client || client._makoRetiring) return;
|
|
91
|
-
state.dirty = true;
|
|
92
|
-
state.transportFault = true;
|
|
157
|
+
if (state.client !== client || client._makoRetiring || client._makoClosed) return;
|
|
158
|
+
state.dirty = true; state.transportFault = true;
|
|
93
159
|
const failure = err instanceof Error ? err : error('Serial port disconnected', 'EDISCONNECT');
|
|
94
160
|
if (!failure.code) failure.code = 'ESERIAL';
|
|
95
161
|
if (state.cancel) state.cancel(failure);
|
|
96
162
|
};
|
|
97
|
-
|
|
98
|
-
client.on('
|
|
163
|
+
const onClose = () => onFault(error('Serial port closed', 'EDISCONNECT'));
|
|
164
|
+
client.on('error', onFault); client.on('close', onClose);
|
|
165
|
+
client._makoCleanup = () => {
|
|
166
|
+
client.removeListener('error', onFault); client.removeListener('close', onClose);
|
|
167
|
+
if (port) port.removeListener('data', onData);
|
|
168
|
+
};
|
|
99
169
|
try {
|
|
100
170
|
await operation(state, 'Serial open', 5000, () => {
|
|
101
|
-
|
|
171
|
+
const opening = Promise.resolve().then(() => { checkEpoch(state, epoch); return client.connectRTUBuffered(path, {
|
|
102
172
|
baudRate: bounded(cfg.baudRate, 9600, 1200, 921600), dataBits: 8,
|
|
103
173
|
stopBits: Number(cfg.stopBits) === 2 ? 2 : 1, parity: cfg.parity || 'none'
|
|
104
|
-
}));
|
|
174
|
+
}); });
|
|
175
|
+
client._makoOpening = opening.then(value => { client._makoOpening = null; return value; },
|
|
176
|
+
err => { client._makoOpening = null; throw err; });
|
|
105
177
|
return client._makoOpening;
|
|
106
178
|
});
|
|
107
|
-
|
|
179
|
+
checkEpoch(state, epoch);
|
|
108
180
|
state.transportFault = false;
|
|
109
|
-
|
|
110
|
-
if (port) port.on('data',
|
|
111
|
-
// Preserve a pending recovery interval across reconnects: bytes already
|
|
112
|
-
// travelling over RF can still arrive on the new descriptor.
|
|
181
|
+
port = portOf(client);
|
|
182
|
+
if (port) port.on('data', onData);
|
|
113
183
|
} catch (err) {
|
|
114
|
-
await retire(state).catch(() => {});
|
|
184
|
+
if (!state.closing && state.epoch === epoch) await retire(state).catch(() => {});
|
|
115
185
|
throw err;
|
|
116
186
|
}
|
|
117
187
|
}
|
|
118
188
|
|
|
119
|
-
|
|
189
|
+
function quiet(state, milliseconds) {
|
|
120
190
|
const start = Date.now();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
191
|
+
return operation(state, 'Serial quiet period', milliseconds * 3 + 1000, cleanup => new Promise(resolve => {
|
|
192
|
+
let timer, stopped = false;
|
|
193
|
+
cleanup(() => { stopped = true; clearTimeout(timer); });
|
|
194
|
+
const check = () => {
|
|
195
|
+
if (stopped) return;
|
|
124
196
|
const remaining = milliseconds - (Date.now() - Math.max(start, state.lastRx));
|
|
125
|
-
if (remaining <= 0)
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
});
|
|
197
|
+
if (remaining <= 0) resolve();
|
|
198
|
+
else timer = setTimeout(check, Math.min(remaining, 50));
|
|
199
|
+
};
|
|
200
|
+
check();
|
|
201
|
+
}));
|
|
130
202
|
}
|
|
131
|
-
|
|
132
203
|
async function recover(state) {
|
|
133
204
|
if (!state.dirty) return;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
205
|
+
const epoch = state.epoch;
|
|
206
|
+
await connect(state); checkEpoch(state, epoch);
|
|
207
|
+
// RTU has no reply transaction identity. This guards bounded late RF traffic;
|
|
208
|
+
// it cannot identify a response delayed beyond the configured quiet window.
|
|
209
|
+
const guard = bounded(state.cfg.recoveryMs, state.cfg.serialPort === '/dev/ttyAMA2' ? timeoutMs(state.cfg) : 100, 50, 120000);
|
|
138
210
|
try {
|
|
139
|
-
await quiet(state, guard);
|
|
140
|
-
const port = portOf(
|
|
211
|
+
await quiet(state, guard); checkEpoch(state, epoch);
|
|
212
|
+
const client = state.client, port = portOf(client);
|
|
141
213
|
if (port && typeof port.flush === 'function') await operation(state, 'Serial flush', 1000, () => callbackCall(port, 'flush'));
|
|
142
|
-
|
|
143
|
-
state.
|
|
144
|
-
|
|
214
|
+
checkEpoch(state, epoch);
|
|
215
|
+
if (!client.isOpen || state.transportFault) throw error('Serial connection failed during recovery', 'ESERIAL');
|
|
216
|
+
if (client._port && Buffer.isBuffer(client._port._buffer)) client._port._buffer = Buffer.alloc(0);
|
|
217
|
+
clearTransactions(client); state.dirty = false;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (!state.closing && state.epoch === epoch) await retire(state).catch(() => {});
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
145
222
|
}
|
|
146
223
|
|
|
147
|
-
async function request(state, req) {
|
|
148
|
-
await connect(state);
|
|
149
|
-
await recover(state);
|
|
224
|
+
async function request(state, item, req) {
|
|
225
|
+
checkItem(state, item); await connect(state);
|
|
226
|
+
checkItem(state, item); await recover(state);
|
|
227
|
+
checkItem(state, item);
|
|
150
228
|
const client = state.client;
|
|
151
229
|
client.setID(req.unitid); client.setTimeout(timeoutMs(state.cfg));
|
|
152
230
|
const methods = {1:'readCoils',2:'readDiscreteInputs',3:'readHoldingRegisters',4:'readInputRegisters',5:'writeCoil',6:'writeRegister',15:'writeCoils',16:'writeRegisters'};
|
|
153
231
|
try {
|
|
154
|
-
return await operation(state, 'Modbus request', timeoutMs(state.cfg) + 1000,
|
|
155
|
-
(
|
|
232
|
+
return await operation(state, 'Modbus request', timeoutMs(state.cfg) + 1000, () => {
|
|
233
|
+
checkItem(state, item);
|
|
234
|
+
if (req.mode === 'write') item.writeMayHaveApplied = true;
|
|
235
|
+
return client[methods[req.fc]](req.address, req.mode === 'read' ? req.quantity : req.value);
|
|
236
|
+
});
|
|
156
237
|
} catch (err) {
|
|
157
238
|
if (!err.modbusCode) state.dirty = true;
|
|
158
|
-
if (state.
|
|
239
|
+
if (!state.closing && state.epoch === item.epoch &&
|
|
240
|
+
(state.transportFault || ['EDEADLINE','EDISCONNECT','ESERIAL'].includes(err.code) || !client.isOpen)) await retire(state).catch(() => {});
|
|
159
241
|
throw err;
|
|
160
242
|
}
|
|
161
243
|
}
|
|
162
244
|
|
|
163
245
|
async function rssi(state, command, parse) {
|
|
164
|
-
const client = state.client;
|
|
165
|
-
const wrapper = client && client._port;
|
|
166
|
-
const port = portOf(client);
|
|
246
|
+
const client = state.client, wrapper = client && client._port, port = portOf(client);
|
|
167
247
|
if (!port || !wrapper) throw error('RSSI serial port unavailable', 'ERSSI');
|
|
168
|
-
await
|
|
248
|
+
await pause(state, 'RSSI guard', bounded(state.cfg.rssiGuardMs, 10, 0, 1000));
|
|
169
249
|
await operation(state, 'RSSI flush', 1000, () => callbackCall(port, 'flush'));
|
|
170
|
-
// Suspend Modbus delivery while the radio's proprietary frame is read.
|
|
171
250
|
wrapper.removeListener('data', client._onReceive);
|
|
172
251
|
let listener;
|
|
173
252
|
try {
|
|
174
|
-
|
|
175
|
-
let rx = Buffer.alloc(0);
|
|
253
|
+
return await operation(state, 'RSSI', bounded(state.cfg.rssiTimeoutMs, 100, 10, 5000), cleanup => new Promise((resolve, reject) => {
|
|
254
|
+
let rx = Buffer.alloc(0), stopped = false;
|
|
255
|
+
cleanup(() => { stopped = true; if (listener) port.removeListener('data', listener); });
|
|
176
256
|
listener = chunk => {
|
|
177
257
|
rx = Buffer.concat([rx, chunk]).slice(-512);
|
|
178
258
|
const result = parse(rx); if (result) resolve(result.rssiDbm);
|
|
179
259
|
};
|
|
180
260
|
port.on('data', listener);
|
|
181
|
-
port.write(command(), err => { if (err) reject(err); else port.drain(err => {if(err)reject(err);}); });
|
|
261
|
+
port.write(command(), err => { if (stopped) return; if (err) reject(err); else port.drain(err => { if (!stopped && err) reject(err); }); });
|
|
182
262
|
}));
|
|
183
|
-
return value;
|
|
184
263
|
} catch (err) { state.dirty = true; throw err; }
|
|
185
264
|
finally {
|
|
186
265
|
if (listener) port.removeListener('data', listener);
|
|
187
266
|
if (Buffer.isBuffer(wrapper._buffer)) wrapper._buffer = Buffer.alloc(0);
|
|
188
|
-
if (!state.closing && state.client === client && !client._makoRetiring) wrapper.on('data', client._onReceive);
|
|
267
|
+
if (!state.closing && !state.recovering && state.client === client && !client._makoRetiring) wrapper.on('data', client._onReceive);
|
|
189
268
|
}
|
|
190
269
|
}
|
|
191
270
|
|
|
192
271
|
function finish(item, err, result) {
|
|
193
272
|
if (item.finished) return;
|
|
194
273
|
item.finished = true;
|
|
195
|
-
|
|
274
|
+
if (err) item.failure = Object.assign(new Error(err.message || String(err)), err,
|
|
275
|
+
{writeMayHaveApplied: !!item.writeMayHaveApplied});
|
|
276
|
+
try { item.complete(item.failure || null, result); } catch (e) { if (item.node.error) item.node.error(e); }
|
|
196
277
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// returning device. Do not monopolize the bus with another retry group.
|
|
207
|
-
const tries = offline && cooldown > 0 && item.req.mode === 'read' ? 1 : Math.floor(bounded(item.retries == null ? state.cfg.retries : item.retries, 2, 0, 10)) + 1;
|
|
208
|
-
let result, lastErr;
|
|
209
|
-
for (let attempt = 1; attempt <= tries && !state.closing && !item.node._makoClosed; attempt++) {
|
|
278
|
+
async function execute(state, item, req) {
|
|
279
|
+
checkItem(state, item);
|
|
280
|
+
const offline = state.failures.get(req.unitid);
|
|
281
|
+
if (req.mode === 'read' && offline && Date.now() < offline) throw error('Device offline; waiting for next probe', 'EOFFLINE');
|
|
282
|
+
const cooldown = bounded(state.cfg.offlineCooldown_s, 0, 0, 300);
|
|
283
|
+
const tries = offline && cooldown > 0 && req.mode === 'read' ? 1 : Math.floor(bounded(item.retries == null ? state.cfg.retries : item.retries, 2, 0, 10)) + 1;
|
|
284
|
+
let lastErr;
|
|
285
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
286
|
+
checkItem(state, item);
|
|
210
287
|
try {
|
|
211
|
-
item.node.status({fill:'blue',shape:'dot',text:'uid'+
|
|
212
|
-
result = await request(state, item
|
|
288
|
+
item.node.status({fill:'blue',shape:'dot',text:'uid'+req.unitid+' try '+attempt+'/'+tries});
|
|
289
|
+
const result = await request(state, item, req);
|
|
290
|
+
checkItem(state, item); state.failures.delete(req.unitid); return result;
|
|
213
291
|
} catch (err) {
|
|
214
|
-
lastErr = err;
|
|
215
|
-
if (item.node.warn) item.node.warn('uid'+
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (err.modbusCode || ['ECANCELLED','ECONFIG','EPORTBUSY','ECLOSE'].includes(err.code)) break;
|
|
219
|
-
if (attempt < tries) await operation(state, 'Retry gap', 61000, () => delay(bounded(state.cfg.gap_s, 0, 0, 60)*1000));
|
|
292
|
+
checkItem(state, item); lastErr = err;
|
|
293
|
+
if (item.node.warn) item.node.warn('uid'+req.unitid+' try '+attempt+'/'+tries+': '+err.message);
|
|
294
|
+
if (err.modbusCode || ['ECANCELLED','ECONFIG','EPORTBUSY','ECLOSE','EBUSRESET','EBUSRECOVERING','EBUSRECOVERYFAILED'].includes(err.code)) break;
|
|
295
|
+
if (attempt < tries) await pause(state, 'Retry gap', bounded(state.cfg.gap_s, 0, 0, 60)*1000);
|
|
220
296
|
}
|
|
221
297
|
}
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
298
|
+
if (lastErr && !lastErr.modbusCode && !['ECONFIG','EPORTBUSY','ECLOSE'].includes(lastErr.code)) state.failures.set(req.unitid, Date.now()+cooldown*1000);
|
|
299
|
+
throw lastErr;
|
|
300
|
+
}
|
|
301
|
+
async function runItem(state, item) {
|
|
302
|
+
checkItem(state, item);
|
|
303
|
+
const maxAge = bounded(state.cfg.maxAge_s, 0, 0, 3600);
|
|
304
|
+
if (maxAge > 0 && Date.now() - item.queuedAt > maxAge*1000) throw error('Queued request expired', 'EQUEUEEXPIRED');
|
|
305
|
+
let result;
|
|
306
|
+
if (item.run) {
|
|
307
|
+
let groups = 0;
|
|
308
|
+
result = await item.run(async req => {
|
|
309
|
+
checkItem(state, item);
|
|
310
|
+
if (groups++) await pause(state, 'Request gap', bounded(state.cfg.gap_s, 0, 0, 60)*1000);
|
|
311
|
+
checkItem(state, item); return execute(state, item, req);
|
|
312
|
+
}, () => checkItem(state, item));
|
|
313
|
+
} else result = await execute(state, item, item.req);
|
|
314
|
+
checkItem(state, item);
|
|
315
|
+
if (item.after) await item.after(result);
|
|
316
|
+
checkItem(state, item); finish(item, null, result);
|
|
232
317
|
}
|
|
233
|
-
|
|
234
318
|
function kick(state) {
|
|
235
|
-
if (state.worker || state.
|
|
319
|
+
if (state.worker || available(state) || !state.queue.length) return;
|
|
320
|
+
// Reserve the active item synchronously: Max Queue=1 still permits one
|
|
321
|
+
// active request. Only the next waiting request reaches that threshold.
|
|
322
|
+
state.active = state.queue.shift();
|
|
236
323
|
state.worker = Promise.resolve().then(async () => {
|
|
237
|
-
while (!state.closing && state.
|
|
238
|
-
const item = state.
|
|
324
|
+
while (state.active && !state.closing && !state.recovering) {
|
|
325
|
+
const item = state.active;
|
|
239
326
|
try { await runItem(state, item); } catch (err) { finish(item, err); }
|
|
240
|
-
|
|
327
|
+
if (!state.closing && !state.recovering) {
|
|
328
|
+
try { await pause(state, 'Request gap', bounded(state.cfg.gap_s, 0, 0, 60)*1000); } catch (_) {}
|
|
329
|
+
}
|
|
330
|
+
state.active = null;
|
|
331
|
+
if (!available(state)) state.active = state.queue.shift() || null;
|
|
241
332
|
}
|
|
242
|
-
}).
|
|
243
|
-
state.worker = null;
|
|
244
|
-
if (!state
|
|
333
|
+
}).finally(() => {
|
|
334
|
+
state.active = null; state.worker = null;
|
|
335
|
+
if (!available(state) && state.queue.length) kick(state);
|
|
245
336
|
});
|
|
246
337
|
}
|
|
247
|
-
|
|
338
|
+
function status(state, fill, text) {
|
|
339
|
+
if (typeof state.cfg.status === 'function') state.cfg.status({fill,shape:'ring',text});
|
|
340
|
+
}
|
|
341
|
+
function reset(state) {
|
|
342
|
+
if (state.closing || state.recovering) return;
|
|
343
|
+
state.recovering = true; state.epoch++;
|
|
344
|
+
const reason = error('Max Queue reached; bus reset cancelled this request. A transmitted write may already have executed.', 'EBUSRESET');
|
|
345
|
+
const queued = state.queue.splice(0);
|
|
346
|
+
if (state.active) finish(state.active, reason);
|
|
347
|
+
queued.forEach(item => finish(item, reason));
|
|
348
|
+
if (state.cancel) state.cancel(reason);
|
|
349
|
+
status(state, 'yellow', 'Recovering bus');
|
|
350
|
+
const worker = state.worker;
|
|
351
|
+
state.recoveryPromise = Promise.resolve().then(async () => {
|
|
352
|
+
if (worker) await worker;
|
|
353
|
+
if (state.closing) return;
|
|
354
|
+
await retire(state);
|
|
355
|
+
if (state.closing) return;
|
|
356
|
+
state.failures.clear(); state.transportFault = false; state.lastRx = 0; state.dirty = true;
|
|
357
|
+
await connect(state); await recover(state);
|
|
358
|
+
if (state.closing) return;
|
|
359
|
+
state.recoveryError = null;
|
|
360
|
+
status(state, 'green', 'Bus recovered; ready for new requests');
|
|
361
|
+
}).catch(err => {
|
|
362
|
+
if (state.closing) return;
|
|
363
|
+
state.recoveryError = error('Bus recovery failed: ' + err.message + '. Check the connection and redeploy before sending new requests.', 'EBUSRECOVERYFAILED');
|
|
364
|
+
state.recoveryError.causeCode = err.code;
|
|
365
|
+
status(state, 'red', 'Bus recovery failed');
|
|
366
|
+
if (typeof state.cfg.error === 'function') state.cfg.error(state.recoveryError);
|
|
367
|
+
}).finally(() => { state.recovering = false; state.recoveryPromise = null; });
|
|
368
|
+
}
|
|
248
369
|
function enqueue(state, item) {
|
|
249
|
-
item.queuedAt = Date.now();
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (state.cfg.coalesceReads === true && item.req.mode === 'read') {
|
|
253
|
-
const idx = state.queue.findIndex(q => q.node === item.node && q.req.mode === 'read');
|
|
254
|
-
if (idx >= 0) finish(state.queue.splice(idx,1)[0],error('Superseded by newer poll','ESUPERSEDED'));
|
|
370
|
+
item.queuedAt = Date.now(); item.epoch = state.epoch;
|
|
371
|
+
const err = available(state);
|
|
372
|
+
if (err || item.node._makoClosed) return finish(item, err || error('Node closed', 'ECANCELLED'));
|
|
373
|
+
if (state.cfg.coalesceReads === true && item.req && item.req.mode === 'read') {
|
|
374
|
+
const idx = state.queue.findIndex(q => q.node === item.node && q.req && q.req.mode === 'read');
|
|
375
|
+
if (idx >= 0) finish(state.queue.splice(idx,1)[0], error('Superseded by newer poll','ESUPERSEDED'));
|
|
255
376
|
}
|
|
256
|
-
if (state.queue.length >= bounded(state.cfg.maxQueue,50,1,1000)) return finish(item,error('Bus queue full','EQUEUEFULL'));
|
|
257
377
|
state.queue.push(item); kick(state);
|
|
378
|
+
if (state.queue.length >= Math.floor(bounded(state.cfg.maxQueue, 50, 1, 1000))) reset(state);
|
|
258
379
|
}
|
|
259
|
-
|
|
260
380
|
function detach(state, node) {
|
|
261
381
|
node._makoClosed = true;
|
|
262
|
-
state.queue = state.queue.filter(item => {if(item.node!==node)return true;finish(item,error('Node closed','ECANCELLED'));return false;});
|
|
263
|
-
if (state.active && state.active.node === node
|
|
264
|
-
state.
|
|
382
|
+
state.queue = state.queue.filter(item => { if (item.node !== node) return true; finish(item, error('Node closed','ECANCELLED')); return false; });
|
|
383
|
+
if (state.active && state.active.node === node) {
|
|
384
|
+
finish(state.active, error('Node closed','ECANCELLED'));
|
|
385
|
+
state.dirty = true; state.transportFault = true;
|
|
386
|
+
if (state.cancel) state.cancel(error('Node closed','ECANCELLED'));
|
|
265
387
|
}
|
|
266
388
|
}
|
|
267
|
-
|
|
268
389
|
function close(state) {
|
|
269
390
|
if (state.closePromise) return state.closePromise;
|
|
270
|
-
state.closing = true;
|
|
391
|
+
state.closing = true; state.epoch++;
|
|
392
|
+
const reason = error('Bus closed','ECANCELLED');
|
|
271
393
|
const queued = state.queue.splice(0);
|
|
272
|
-
|
|
273
|
-
|
|
394
|
+
if (state.active) finish(state.active, reason);
|
|
395
|
+
queued.forEach(item => finish(item, reason));
|
|
396
|
+
if (state.cancel) state.cancel(reason);
|
|
274
397
|
state.closePromise = (async () => {
|
|
275
398
|
if (state.worker) await state.worker;
|
|
399
|
+
if (state.recoveryPromise) await state.recoveryPromise;
|
|
276
400
|
await retire(state);
|
|
277
401
|
})();
|
|
278
402
|
return state.closePromise;
|
|
279
403
|
}
|
|
280
|
-
return {
|
|
281
|
-
};
|
|
404
|
+
return {create, enqueue, detach, close, rssi, available};
|
|
405
|
+
};
|