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/bus-engine.js CHANGED
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
 
3
- // Node.js 14 compatible. Exactly one worker and one serial owner per bus.
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
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
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(), closePromise: null, ownerPath: null };
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
- clearTimeout(timer);
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
- try { Promise.resolve(start()).then(value => finish(null, value), finish); } catch (err) { finish(err); }
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
- // Do not release ownership until the old descriptor really closed. A timed-out
43
- // close blocks this port, but never blocks other physical buses or the worker.
44
- async function retire(state) {
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
- let settled = false;
50
- const finish = err => {
51
- if (settled) return;
52
- settled = true; clearTimeout(timer);
53
- err ? reject(err) : resolve();
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(state.ownerPath) === state) owners.delete(state.ownerPath);
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 timer = setTimeout(() => finish(error('Serial close deadline exceeded; port quarantined', 'ECLOSE')), 2000);
61
- if (typeof client._cancelPendingTransactions === 'function') client._cancelPendingTransactions();
62
- const close = () => {
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 (!err || !client.isOpen) release();
65
- finish(err && client.isOpen ? err : null);
66
- }); } catch (err) { finish(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
- // Opening cannot be cancelled by serialport. Wait for it to settle; close
69
- // a late successful open, retaining the lease throughout.
70
- if (client._makoOpening) client._makoOpening.then(close, () => {release(); finish();});
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
- if (state.closing) throw error('Bus closing', 'ECANCELLED');
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
- if (state.client && state.client._makoRetiring) await retire(state);
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
- const path = cfg.serialPort && await operation(state,'USB detection',5000,() => require('./usb-discovery').resolve(cfg.serialPort,state));
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
- owners.set(path, state); state.ownerPath = path;
88
- const client = new ModbusRTU(); state.client = client;
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
- client.on('error', onFault);
98
- client.on('close', () => onFault(error('Serial port closed', 'EDISCONNECT')));
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
- client._makoOpening = Promise.resolve(client.connectRTUBuffered(path, {
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
- client._makoOpening = null;
179
+ checkEpoch(state, epoch);
108
180
  state.transportFault = false;
109
- const port = portOf(client);
110
- if (port) port.on('data', () => { if (state.client === client) state.lastRx = Date.now(); });
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
- async function quiet(state, milliseconds) {
189
+ function quiet(state, milliseconds) {
120
190
  const start = Date.now();
121
- // New bytes restart the quiet interval, but continuous noise has a deadline.
122
- await operation(state, 'Serial quiet period', milliseconds * 3 + 1000, async () => {
123
- while (!state.closing) {
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) return;
126
- await delay(Math.min(remaining, 50));
127
- }
128
- throw error('Bus closing', 'ECANCELLED');
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
- await connect(state);
135
- // RTU has no transaction identity. Wait out late replies before reusing the
136
- // parser. This is bounded mitigation, not a guarantee for arbitrary RF delay.
137
- const guard = bounded(state.cfg.recoveryMs, state.cfg.serialPort === '/dev/ttyAMA2' ? timeoutMs(state.cfg) : 100, 50, 30000);
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(state.client);
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
- if (state.client._port && Buffer.isBuffer(state.client._port._buffer)) state.client._port._buffer = Buffer.alloc(0);
143
- state.dirty = false;
144
- } catch (err) { await retire(state).catch(() => {}); throw err; }
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
- () => client[methods[req.fc]](req.address, req.mode === 'read' ? req.quantity : req.value));
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.transportFault || ['EDEADLINE','EDISCONNECT','ESERIAL'].includes(err.code) || !client.isOpen) await retire(state).catch(() => {});
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 operation(state, 'RSSI guard', 1100, () => delay(bounded(state.cfg.rssiGuardMs, 10, 0, 1000)));
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
- const value = await operation(state, 'RSSI', bounded(state.cfg.rssiTimeoutMs, 100, 10, 5000), () => new Promise((resolve, reject) => {
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
- try { item.complete(err, result); } catch (e) { if (item.node.error) item.node.error(e); }
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
- async function runItem(state, item) {
199
- if (item.node._makoClosed) return finish(item, error('Node closed', 'ECANCELLED'));
200
- const maxAge = bounded(state.cfg.maxAge_s, 0, 0, 3600);
201
- if (maxAge > 0 && Date.now() - item.queuedAt > maxAge * 1000) return finish(item, error('Queued request expired', 'EQUEUEEXPIRED'));
202
- const offline = state.failures.get(item.req.unitid);
203
- if (item.req.mode === 'read' && offline && Date.now() < offline) return finish(item, error('Device offline; waiting for next probe', 'EOFFLINE'));
204
- const cooldown = bounded(state.cfg.offlineCooldown_s,0,0,300);
205
- // After the first failed retry group, one probe is enough to discover a
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'+item.req.unitid+' try '+attempt+'/'+tries});
212
- result = await request(state, item.req); lastErr = null; break;
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'+item.req.unitid+' try '+attempt+'/'+tries+': '+err.message);
216
- // Protocol exceptions and lifecycle/configuration errors cannot improve
217
- // by repeating the same request. Timeouts retain configured retry semantics.
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 (state.closing || item.node._makoClosed) return finish(item, error('Node or bus closed', 'ECANCELLED'));
223
- if (lastErr) {
224
- if (!lastErr.modbusCode && !['ECONFIG','EPORTBUSY','ECLOSE'].includes(lastErr.code)) state.failures.set(item.req.unitid, Date.now()+cooldown*1000);
225
- finish(item, lastErr);
226
- } else {
227
- state.failures.delete(item.req.unitid);
228
- if (item.after) await item.after(result);
229
- finish(item, null, result);
230
- }
231
- await operation(state, 'Request gap', 61000, () => delay(bounded(state.cfg.gap_s,0,0,60)*1000));
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.closing) return;
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.queue.length) {
238
- const item = state.queue.shift(); state.active = item;
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
- finally { state.active = null; }
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
- }).catch(err => { if (state.active) finish(state.active, err); }).finally(() => {
243
- state.worker = null;
244
- if (!state.closing && state.queue.length) kick(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
- if (state.closing || item.node._makoClosed) return finish(item, error('Bus or node closed', 'ECANCELLED'));
251
- // Retain only the newest pending read from this node. Never coalesce writes.
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 && state.cancel) {
264
- state.dirty = true; state.cancel(error('Node closed','ECANCELLED'));
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
- queued.forEach(item => finish(item,error('Bus closed','ECANCELLED')));
273
- if (state.cancel) state.cancel(error('Bus closed','ECANCELLED'));
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 { create, enqueue, detach, close, rssi };
281
- };
404
+ return {create, enqueue, detach, close, rssi, available};
405
+ };