smithtek-mako-rf 3.1.0 → 3.2.4

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.md CHANGED
@@ -1,3 +1,47 @@
1
+ Digital read outputs in msg.payload use numeric 1 (on) and 0 (off), including coils (FC1), discrete inputs (FC2), and individual register bits. The raw Modbus response retains its original representation. Coil writes still accept true/false and 1/0.
2
+
3
+ # Simple bus settings and range scaling — 3.2.4
4
+
5
+ The bus editor retains the original timeout, retries, gap, maximum queue and RSSI controls. Queue age, coalescing, offline probe and recovery-quiet controls have been removed. Any saved values for those removed controls are ignored. All accepted requests wait in FIFO order; recovery timing is managed internally. The Range option in the decoder table remains available.
6
+
7
+ Each numeric decoder row has a compact Scaling dropdown: None (unchanged), Math (+ − × ÷), or Range (input → output). New rows start at None, with all calculation fields hidden. Math and Range settings appear beside the dropdown on the same line. Range shows In low, In high, Out low, Out high and Clamp side by side. Every decoder entry stays on one line; narrow dialogs scroll horizontally. Selecting None hides the fields and disables both calculations, retaining the entered settings for reuse. Existing configured Math and Range rows keep their active mode.
8
+
9
+ For Range, enter Input low, Input high, Output low and Output high. For example:
10
+
11
+ | Input low | Input high | Output low | Output high | Result at input 12 |
12
+ |---|---|---|---|---|
13
+ | 4 | 20 | 0 | 100 | 50 |
14
+
15
+ The input endpoints must match the values reported in Modbus registers. If a sensor reports 4000–20000 instead of 4–20, enter 4000 and 20000. No PLC calculation or extra Function node is needed.
16
+
17
+ Range scaling uses outputLow + (value - inputLow) * (outputHigh - outputLow) / (inputHigh - inputLow). It operates on the decoded numeric value after any existing integer mask. Range replaces the Math expression, retaining that expression for switching back. Negative, decimal and reversed ranges are supported. Equal input endpoints and invalid/nonfinite values are rejected. Digital/boolean rows cannot use Range mode.
18
+
19
+ Values outside the input span continue along the same line unless Clamp to output range is checked. For example, 0 mA maps to -25 for a 4–20 to 0–100 range; with Clamp it maps to 0. Floating-point results are not automatically rounded.
20
+
21
+ Existing rows without a scaling mode continue using Math. Raw Modbus data remains available in msg.modbus.raw. All FIFO queue and recovery behaviour from 3.1.2 is retained, and dependencies are unchanged.
22
+
23
+ ---
24
+ # Reliability update 3.1.2
25
+
26
+ This release retains the pinned serial dependencies and supports Node.js 14 syntax.
27
+
28
+ - One worker owns each bus. Closing a runtime node cancels only its own work.
29
+ - Open, request, diagnostic and close operations have deadlines. A port whose close cannot be confirmed stays quarantined rather than acquiring a second owner.
30
+ - Serial errors are handled. Dead connections are retired before reopening.
31
+ - Writes use FIFO order. Queue overflow, expiry and superseded polls return explicit errors and complete once.
32
+ - Every accepted request is retained in FIFO order, including repeated reads from the same node. Requests are not coalesced or expired while waiting.
33
+ - Each queued request gets the configured timeout retry count before moving to the next. There is no offline cooldown or skipped polling.
34
+ - After a communication error, the bus waits for a quiet period and flushes stale buffered data. Automatic quiet time is the response timeout on RF and 100ms on RS485. Late RF frames outside that interval remain a protocol limitation: Modbus RTU read replies carry no register address or transaction ID.
35
+ - RSSI queries verify their checksum, isolate diagnostic bytes, settle on cancellation and retain the serial connection after success.
36
+ - Invalid addresses, quantities and write values are rejected before transmission. Signed scale expressions add/subtract, and incomplete 32-bit values are rejected.
37
+
38
+ No additional bus settings are required. Recovery timing is automatic: the response timeout on RF and 100ms on RS485.
39
+
40
+ Failures still emit msg.modbus.ok=false and a failure payload. msg.modbus.code identifies queue, offline and transport conditions; done(error) also enables Node-RED Catch handling. Intentional node/bus shutdown settles callbacks without emitting from closed nodes. No retry can guarantee a timed-out write was not applied at the device: retain an appropriate retry setting for the PLC command semantics.
41
+
42
+ Validated on a Passport with Raspbian Buster, Node.js 14.21.3, native serial dependencies and two physical RF Makos. Node.js 16 has not been separately executed. No dependency upgrade is required.
43
+
44
+ ---
1
45
  **Installation**
2
46
  Install using the `NodeRED palette manager`
3
47
 
package/bus-engine.js ADDED
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ // Node.js 14 compatible. Exactly one worker and one serial owner per bus.
4
+ module.exports = function createEngine(ModbusRTU) {
5
+ const owners = new Map();
6
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
7
+ const bounded = (v, fallback, min, max) => v != null && v !== '' && Number.isFinite(Number(v)) ? Math.min(max, Math.max(min, Number(v))) : fallback;
8
+ const error = (message, code) => Object.assign(new Error(message), { code });
9
+ const timeoutMs = cfg => bounded(cfg.timeout_s, 8, 1, 120) * 1000;
10
+ const portOf = client => client && client._port && client._port._client;
11
+
12
+ function create(cfg) {
13
+ 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 };
15
+ }
16
+
17
+ function operation(state, label, ms, start) {
18
+ return new Promise((resolve, reject) => {
19
+ let finished = false;
20
+ const finish = (err, value) => {
21
+ if (finished) return;
22
+ finished = true;
23
+ clearTimeout(timer);
24
+ if (state.cancel === cancel) state.cancel = null;
25
+ err ? reject(err) : resolve(value);
26
+ };
27
+ const cancel = err => finish(err || error('Operation cancelled', 'ECANCELLED'));
28
+ const timer = setTimeout(() => finish(error(label + ' deadline exceeded', 'EDEADLINE')), ms);
29
+ state.cancel = cancel;
30
+ if (state.closing) return cancel();
31
+ try { Promise.resolve(start()).then(value => finish(null, value), finish); } catch (err) { finish(err); }
32
+ });
33
+ }
34
+
35
+ function callbackCall(target, method, args) {
36
+ return new Promise((resolve, reject) => {
37
+ try { target[method].apply(target, (args || []).concat(err => err ? reject(err) : resolve())); }
38
+ catch (err) { reject(err); }
39
+ });
40
+ }
41
+
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) {
45
+ const client = state.client;
46
+ if (!client) return;
47
+ 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();
54
+ };
55
+ const release = () => {
56
+ if (state.client === client) state.client = null;
57
+ if (owners.get(state.ownerPath) === state) owners.delete(state.ownerPath);
58
+ client._makoClosed = true;
59
+ };
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 = () => {
63
+ try { client.close(err => {
64
+ if (!err || !client.isOpen) release();
65
+ finish(err && client.isOpen ? err : null);
66
+ }); } catch (err) { finish(err); }
67
+ };
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
+ });
73
+ return client._makoRetiring;
74
+ }
75
+
76
+ async function connect(state) {
77
+ if (state.closing) throw error('Bus closing', 'ECANCELLED');
78
+ const cfg = state.cfg;
79
+ if (state.client && state.transportFault) await retire(state);
80
+ if (state.client && state.client._makoRetiring) await retire(state);
81
+ if (state.client && state.client.isOpen) return;
82
+ if (state.client) await retire(state);
83
+ const path = cfg.serialPort;
84
+ if (!path) throw error('No serial port configured', 'ECONFIG');
85
+ const owner = owners.get(path);
86
+ 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;
89
+ const onFault = err => {
90
+ if (state.client !== client || client._makoRetiring) return;
91
+ state.dirty = true;
92
+ state.transportFault = true;
93
+ const failure = err instanceof Error ? err : error('Serial port disconnected', 'EDISCONNECT');
94
+ if (!failure.code) failure.code = 'ESERIAL';
95
+ if (state.cancel) state.cancel(failure);
96
+ };
97
+ client.on('error', onFault);
98
+ client.on('close', () => onFault(error('Serial port closed', 'EDISCONNECT')));
99
+ try {
100
+ await operation(state, 'Serial open', 5000, () => {
101
+ client._makoOpening = Promise.resolve(client.connectRTUBuffered(path, {
102
+ baudRate: bounded(cfg.baudRate, 9600, 1200, 921600), dataBits: 8,
103
+ stopBits: Number(cfg.stopBits) === 2 ? 2 : 1, parity: cfg.parity || 'none'
104
+ }));
105
+ return client._makoOpening;
106
+ });
107
+ client._makoOpening = null;
108
+ 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.
113
+ } catch (err) {
114
+ await retire(state).catch(() => {});
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ async function quiet(state, milliseconds) {
120
+ 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) {
124
+ 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
+ });
130
+ }
131
+
132
+ async function recover(state) {
133
+ 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);
138
+ try {
139
+ await quiet(state, guard);
140
+ const port = portOf(state.client);
141
+ 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; }
145
+ }
146
+
147
+ async function request(state, req) {
148
+ await connect(state);
149
+ await recover(state);
150
+ const client = state.client;
151
+ client.setID(req.unitid); client.setTimeout(timeoutMs(state.cfg));
152
+ const methods = {1:'readCoils',2:'readDiscreteInputs',3:'readHoldingRegisters',4:'readInputRegisters',5:'writeCoil',6:'writeRegister',15:'writeCoils',16:'writeRegisters'};
153
+ 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));
156
+ } catch (err) {
157
+ if (!err.modbusCode) state.dirty = true;
158
+ if (state.transportFault || ['EDEADLINE','EDISCONNECT','ESERIAL'].includes(err.code) || !client.isOpen) await retire(state).catch(() => {});
159
+ throw err;
160
+ }
161
+ }
162
+
163
+ async function rssi(state, command, parse) {
164
+ const client = state.client;
165
+ const wrapper = client && client._port;
166
+ const port = portOf(client);
167
+ 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)));
169
+ await operation(state, 'RSSI flush', 1000, () => callbackCall(port, 'flush'));
170
+ // Suspend Modbus delivery while the radio's proprietary frame is read.
171
+ wrapper.removeListener('data', client._onReceive);
172
+ let listener;
173
+ 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);
176
+ listener = chunk => {
177
+ rx = Buffer.concat([rx, chunk]).slice(-512);
178
+ const result = parse(rx); if (result) resolve(result.rssiDbm);
179
+ };
180
+ port.on('data', listener);
181
+ port.write(command(), err => { if (err) reject(err); else port.drain(err => {if(err)reject(err);}); });
182
+ }));
183
+ return value;
184
+ } catch (err) { state.dirty = true; throw err; }
185
+ finally {
186
+ if (listener) port.removeListener('data', listener);
187
+ if (Buffer.isBuffer(wrapper._buffer)) wrapper._buffer = Buffer.alloc(0);
188
+ if (!state.closing && state.client === client && !client._makoRetiring) wrapper.on('data', client._onReceive);
189
+ }
190
+ }
191
+
192
+ function finish(item, err, result) {
193
+ if (item.finished) return;
194
+ item.finished = true;
195
+ try { item.complete(err, result); } catch (e) { if (item.node.error) item.node.error(e); }
196
+ }
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(state.cfg.retries, 2, 0, 10)) + 1;
208
+ let result, lastErr;
209
+ for (let attempt = 1; attempt <= tries && !state.closing && !item.node._makoClosed; attempt++) {
210
+ 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;
213
+ } 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));
220
+ }
221
+ }
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));
232
+ }
233
+
234
+ function kick(state) {
235
+ if (state.worker || state.closing) return;
236
+ state.worker = Promise.resolve().then(async () => {
237
+ while (!state.closing && state.queue.length) {
238
+ const item = state.queue.shift(); state.active = item;
239
+ try { await runItem(state, item); } catch (err) { finish(item, err); }
240
+ finally { state.active = null; }
241
+ }
242
+ }).catch(err => { if (state.active) finish(state.active, err); }).finally(() => {
243
+ state.worker = null;
244
+ if (!state.closing && state.queue.length) kick(state);
245
+ });
246
+ }
247
+
248
+ 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'));
255
+ }
256
+ if (state.queue.length >= bounded(state.cfg.maxQueue,50,1,1000)) return finish(item,error('Bus queue full','EQUEUEFULL'));
257
+ state.queue.push(item); kick(state);
258
+ }
259
+
260
+ function detach(state, node) {
261
+ 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'));
265
+ }
266
+ }
267
+
268
+ function close(state) {
269
+ if (state.closePromise) return state.closePromise;
270
+ state.closing = true;
271
+ 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'));
274
+ state.closePromise = (async () => {
275
+ if (state.worker) await state.worker;
276
+ await retire(state);
277
+ })();
278
+ return state.closePromise;
279
+ }
280
+ return { create, enqueue, detach, close, rssi };
281
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smithtek-mako-rf",
3
- "version": "3.1.0",
3
+ "version": "3.2.4",
4
4
  "description": "Smithtek dedicated node for communicating with the Mako PLC over RS485 or RF",
5
5
  "keywords": [
6
6
  "node-red",
@@ -13,13 +13,16 @@
13
13
  "license": "GPL-3.0-only",
14
14
  "author": "Smithtek",
15
15
  "homepage": "https://www.smithtek.com.au",
16
- "dependencies": {
17
- "modbus-serial": "8.0.23-no-serial-port",
18
- "serialport": "10.4.0"
19
- },
16
+ "dependencies": {
17
+ "modbus-serial": "8.0.23-no-serial-port",
18
+ "serialport": "10.4.0"
19
+ },
20
20
  "node-red": {
21
21
  "nodes": {
22
22
  "smithtek-mako-rf": "smithtek-mako-rf.js"
23
23
  }
24
+ },
25
+ "engines": {
26
+ "node": ">=14"
24
27
  }
25
28
  }
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ function endpoints(item) {
4
+ const values = ['inputLow', 'inputHigh', 'outputLow', 'outputHigh'].map(key => {
5
+ const raw = item[key];
6
+ if ((typeof raw !== 'number' && typeof raw !== 'string') || String(raw).trim() === '' || !Number.isFinite(Number(raw))) {
7
+ throw new Error('Range scaling: ' + key + ' must be a finite number');
8
+ }
9
+ return Number(raw);
10
+ });
11
+ if (values[0] === values[1]) throw new Error('Range scaling: input low and input high must differ');
12
+ if (!Number.isFinite(values[1] - values[0]) || !Number.isFinite(values[3] - values[2])) throw new Error('Range scaling: endpoints are too far apart');
13
+ return values;
14
+ }
15
+
16
+ function scale(value, item) {
17
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Range scaling requires a finite numeric value');
18
+ const [inputLow, inputHigh, outputLow, outputHigh] = endpoints(item);
19
+ let fraction = (value - inputLow) / (inputHigh - inputLow);
20
+ if (item.clampRange === true) fraction = Math.max(0, Math.min(1, fraction));
21
+ const result = outputLow + fraction * (outputHigh - outputLow);
22
+ if (!Number.isFinite(result)) throw new Error('Range scaling result is not finite');
23
+ return result;
24
+ }
25
+
26
+ module.exports = { endpoints, scale };
@@ -26,6 +26,12 @@
26
26
  offset: 0, // BYTES stored in config (legacy decoder compatibility)
27
27
  length: 1, // stored but hidden (1=16-bit, 2=32-bit)
28
28
  scale: "",
29
+ scaleMode: "none",
30
+ inputLow: 4,
31
+ inputHigh: 20,
32
+ outputLow: 0,
33
+ outputHigh: 100,
34
+ clampRange: false,
29
35
  offsetbit: 0
30
36
  };
31
37
  }
@@ -58,10 +64,42 @@
58
64
  } else {
59
65
  $bit.val(0);
60
66
  $bit.prop("disabled", true).hide();
61
- $bitCol.hide();
67
+ $bitCol.show();
68
+ }
69
+ updateScaleUi($tr);
70
+ }
71
+
72
+ function updateScaleUi($tr) {
73
+ const mode = $tr.find('.st-scale-mode').val();
74
+ const range = mode === 'range';
75
+ const digital = isDigitalUnsigned($tr.find('.st-item-type').val());
76
+ $tr.find('.st-item-scale').toggle(mode === 'math');
77
+ $tr.find('.st-range-fields').toggle(range);
78
+ $tr.find('.st-range-note').toggle(range && digital);
79
+ const inputs = $tr.find('.st-range-number');
80
+ inputs.each(function () {
81
+ const raw = String($(this).val()).trim();
82
+ const bad = range && (raw === '' || !Number.isFinite(Number(raw)));
83
+ this.setCustomValidity(bad ? 'Enter a finite number.' : '');
84
+ $(this).attr('aria-invalid', bad ? 'true' : 'false');
85
+ });
86
+ const low = $tr.find('.st-inputLow'), high = $tr.find('.st-inputHigh');
87
+ if (range && String(low.val()).trim() !== '' && String(high.val()).trim() !== '' && Number(low.val()) === Number(high.val())) {
88
+ high[0].setCustomValidity('Input low and input high must differ.');
89
+ high.attr('aria-invalid', 'true');
62
90
  }
63
91
  }
64
92
 
93
+ function validDecoderItems(items) {
94
+ return Array.isArray(items) && items.every(item => {
95
+ if (item.scaleMode !== 'range') return true;
96
+ if (isDigitalUnsigned(item.type) || item.type === 'bool') return false;
97
+ const values = ['inputLow','inputHigh','outputLow','outputHigh'].map(key => item[key]);
98
+ return values.every(value => (typeof value === 'string' || typeof value === 'number') && String(value).trim() !== '' && Number.isFinite(Number(value))) &&
99
+ Number(item.inputLow) !== Number(item.inputHigh) && Number.isFinite(Number(item.inputHigh)-Number(item.inputLow)) && Number.isFinite(Number(item.outputHigh)-Number(item.outputLow));
100
+ });
101
+ }
102
+
65
103
  // Determine next row behaviour based on the last row (REGS visible)
66
104
  function nextRowFromLast($tbody) {
67
105
  const $last = $tbody.find("tr").last();
@@ -135,7 +173,27 @@
135
173
  .attr("type", "text")
136
174
  .addClass("st-item-scale")
137
175
  .css("width", "100%")
138
- .val(it.scale ?? "");
176
+ .val(it.scale ?? it.scaler ?? it.scaleFactor ?? "");
177
+
178
+ const $scaleMode = $('<select></select>').addClass('st-scale-mode').attr('aria-label','Scaling mode').css({width:'82px',flex:'0 0 82px'})
179
+ .attr('title','None: unchanged; Math: expression such as /100; Range: input low/high to output low/high.')
180
+ .append($('<option value="none">None</option>'))
181
+ .append($('<option value="math">Math</option>'))
182
+ .append($('<option value="range">Range</option>'))
183
+ .val(it.scaleMode === 'none' ? 'none' : it.scaleMode === 'range' ? 'range' : String($scale.val()).trim() ? 'math' : 'none');
184
+ $scale.css({width:'100px',flex:'0 0 100px'}).attr('placeholder','/100 or +5').attr('aria-label','Math expression');
185
+ const $rangeFields = $('<div></div>').addClass('st-range-fields').css({display:'flex','align-items':'center',gap:'5px','flex-wrap':'nowrap'});
186
+ const $grid = $('<div></div>').css({display:'flex','align-items':'center',gap:'5px','flex-wrap':'nowrap'});
187
+ [['inputLow','In low',4],['inputHigh','In high',20],['outputLow','Out low',0],['outputHigh','Out high',100]].forEach(([key,label,fallback]) => {
188
+ const $input = $('<input/>').attr({type:'number',step:'any','aria-label':label,title:label.replace('In ', 'Input ').replace('Out ', 'Output ')}).addClass('st-range-number st-'+key).css({width:'62px','min-width':'62px',padding:'4px'}).val(it[key] ?? fallback);
189
+ const $label = $('<label></label>').css({display:'flex','align-items':'center',gap:'3px',width:'auto','white-space':'nowrap','font-size':'10px',margin:0}).append($('<span></span>').text(label)).append($input);
190
+ $input.on('input',() => updateScaleUi($tr));
191
+ $grid.append($label);
192
+ });
193
+ const $clamp = $('<input/>').attr('type','checkbox').addClass('st-clamp-range').prop('checked',it.clampRange === true).css({width:'auto',margin:'0 5px 0 0'});
194
+ $rangeFields.append($grid).append($('<label></label>').css({display:'flex','align-items':'center',width:'auto','white-space':'nowrap','font-size':'11px',margin:0}).append($clamp).append(document.createTextNode('Clamp')));
195
+ $rangeFields.append($('<div></div>').addClass('st-range-note').attr('title','Choose a numeric register type for range scaling.').css({color:'#b33','font-size':'11px'}).text('!'));
196
+ $scaleMode.on('change',() => updateScaleUi($tr));
139
197
 
140
198
  const $offsetbit = $("<input/>")
141
199
  .attr("type", "number")
@@ -166,7 +224,7 @@
166
224
  $tr.append($("<td></td>").append($name));
167
225
  $tr.append($("<td></td>").append($type));
168
226
  $tr.append($("<td></td>").append($offsetRegs).append($offsetBytes));
169
- $tr.append($("<td></td>").append($scale));
227
+ $tr.append($("<td></td>").append($("<div></div>").addClass("st-scaling-inline").css({display:"flex","align-items":"center",gap:"5px","flex-wrap":"nowrap"}).append($scaleMode).append($scale).append($rangeFields)));
170
228
 
171
229
  const $bitTd = $("<td></td>").addClass("st-bit-col").append($offsetbit);
172
230
  $tr.append($bitTd);
@@ -194,6 +252,12 @@
194
252
  offset: Number($tr.find(".st-item-offset").val()) || 0, // BYTES saved
195
253
  length: Number($tr.find(".st-item-length").val()) || 1, // hidden but saved
196
254
  scale: String($tr.find(".st-item-scale").val() ?? "").trim(),
255
+ scaleMode: $tr.find('.st-scale-mode').val() || 'none',
256
+ inputLow: String($tr.find('.st-inputLow').val() ?? '').trim(),
257
+ inputHigh: String($tr.find('.st-inputHigh').val() ?? '').trim(),
258
+ outputLow: String($tr.find('.st-outputLow').val() ?? '').trim(),
259
+ outputHigh: String($tr.find('.st-outputHigh').val() ?? '').trim(),
260
+ clampRange: $tr.find('.st-clamp-range').prop('checked') === true,
197
261
  offsetbit: Number($tr.find(".st-item-offsetbit").val()) || 0
198
262
  });
199
263
  });
@@ -318,7 +382,7 @@
318
382
  valueText: { value: "" },
319
383
  includeWriteQuantity: { value: false },
320
384
 
321
- items: { value: [ defaultItemRow() ] }
385
+ items: { value: [ defaultItemRow() ], validate: validDecoderItems }
322
386
  },
323
387
  inputs: 1,
324
388
  outputs: 1,
@@ -414,7 +478,7 @@
414
478
  <label for="node-config-input-appendRssi"><i class="fa fa-signal"></i> RSSI</label>
415
479
  <input type="checkbox" id="node-config-input-appendRssi" style="width:auto;">
416
480
  <span style="margin-left:8px; font-size:12px; color:#888;">
417
- (Tick on RSSI diagnostic in dBi, using this feeature slows down RF polling by 30%.)
481
+ (Append RSSI in dBm. Diagnostics add one radio query per successful read.)
418
482
  </span>
419
483
  </div>
420
484
 
@@ -468,7 +532,7 @@
468
532
  <input type="number" id="node-config-input-maxQueue" min="1" style="width:140px;">
469
533
  </div>
470
534
  <div class="form-tips">
471
- The max queue will limmit how many polls can be queued from the connected devices. It will drop the oldest from the queue.
535
+ Requests wait in order. Each request finishes or exhausts its timeout retries before the next starts. New requests are reported as failed if the queue is full.
472
536
  </div>
473
537
 
474
538
 
@@ -581,30 +645,33 @@
581
645
  </div>
582
646
 
583
647
  <div class="form-row">
584
- <table class="table table-condensed" style="width:100%;">
648
+ <div style="overflow-x:auto;"><table class="table table-condensed" style="width:100%; min-width:1100px; table-layout:fixed;">
585
649
  <thead>
586
650
  <tr>
587
- <th style="width:28%;">Name </th>
588
- <th style="width:26%;">Register data Type</th>
589
- <th style="width:14%;">Offset (regs)</th>
590
- <th style="width:22%;">Scale</th>
591
- <th style="width:6%;">Bit</th>
592
- <th style="width:4%;">Action</th>
651
+ <th style="width:20%;">Name </th>
652
+ <th style="width:145px;">Register data Type</th>
653
+ <th style="width:65px;">Offset</th>
654
+ <th style="width:565px;">Scaling</th>
655
+ <th style="width:42px;">Bit</th>
656
+ <th style="width:55px;">Action</th>
593
657
  </tr>
594
658
  </thead>
595
659
  <tbody id="st-items-tbody"></tbody>
596
- </table>
660
+ </table></div>
597
661
  </div>
598
662
 
599
663
  <div style="font-size:12px; color:#666; margin-top:6px;">
600
664
  (Register Offsets numbers should match the modbus table you have built in V-NET!, if you add a digital it will automatically increase the bit number. Bit 1 = digital 1 and so on.
601
665
  </div>
666
+ <div class="form-tips">None keeps the row compact and applies no scaling. Choose Math for an inline expression such as /100, or Range to map input low/high to output low/high. Use the values actually reported by the Mako: for example 4–20 to 0–100, or 4000–20000 to 0–100 if the register reports microamps. Range replaces the Math expression. Values outside the input range continue scaling unless Clamp is checked.</div>
602
667
  </div>
603
668
  </script>
604
669
 
605
670
  <script type="text/markdown" data-help-name="smithtek-mako-rf">
606
- Smithtek Mako RF (RS-485 only).
671
+ Smithtek Mako RF / RS485.
607
672
 
608
- Read mode outputs decoded JSON in msg.payload using the Decoder map rows.
673
+ Read mode outputs decoded JSON in msg.payload using the Decoder map rows. Digital points (coils, discrete inputs and register bits) output numeric 1 for on and 0 for off.
609
674
  Raw modbus response is kept in msg.modbus.raw.
675
+
676
+ Each numeric decoder row starts compact with None (unchanged). Choose Math or Range to show the inline settings and enable scaling. Selecting None hides both controls and disables both calculations, while retaining the entered settings. Range maps input low/high to output low/high using a straight line; for example 4–20 maps to 0–100, with 12 mapping to 50. Use raw register endpoints, not assumed electrical units. Input endpoints must differ. Reversed ranges and negative/decimal endpoints are supported. Clamp optionally limits output to the configured range. Existing rows without a scaling mode retain their Math expression.
610
677
  </script>
@@ -3,11 +3,9 @@ module.exports = function (RED) {
3
3
  "use strict";
4
4
 
5
5
  const ModbusRTU = require("modbus-serial");
6
+ const rangeScaling = require('./range-scaling');
6
7
 
7
- // Shared bus state across all node instances (keyed by config node id)
8
- const BUS = new Map();
9
-
10
- function toNum(v, fallback) {
8
+ function toNum(v, fallback) {
11
9
  const n = Number(v);
12
10
  return Number.isFinite(n) ? n : fallback;
13
11
  }
@@ -69,7 +67,7 @@ module.exports = function (RED) {
69
67
  f[7] === 0x01 &&
70
68
  f[10] === 0x0D && f[11] === 0x0A;
71
69
 
72
- if (!ok) continue;
70
+ if (!ok || ((f.slice(0,9).reduce((a,b) => a+b,0) & 0xff) !== f[9])) continue;
73
71
 
74
72
  const xx = f[8];
75
73
  const rssiDbm = -xx; // Manufacturer rule
@@ -79,76 +77,6 @@ module.exports = function (RED) {
79
77
  return null;
80
78
  }
81
79
 
82
- async function queryRssiOnSamePort(state, guardMs, timeoutMs) {
83
- // modbus-serial keeps the underlying serialport here in most builds:
84
- // state.client._port or state.client._client (varies by version)
85
- const p0 = state?.client?._port;
86
- const port =
87
- (p0 && (p0._client || p0.port || p0._port)) || // unwrap common wrappers
88
- state?.client?._client ||
89
- p0;
90
-
91
- if (!port || typeof port.write !== "function" || typeof port.on !== "function") {
92
- throw new Error("No underlying serial port handle found (modbus-serial port not accessible)");
93
- }
94
-
95
- if (guardMs > 0) await sleep(guardMs);
96
-
97
- const cmd = buildRssiCommandBuffer();
98
-
99
- // Optional: flush any buffered junk (some serialport builds support this)
100
- try { if (typeof port.flush === "function") port.flush(() => {}); } catch (_e) {}
101
-
102
- let rx = Buffer.alloc(0);
103
-
104
- return await new Promise((resolve, reject) => {
105
- let finished = false;
106
-
107
- const timer = setTimeout(() => {
108
- cleanup();
109
- reject(new Error("RSSI timeout"));
110
- }, Math.max(10, timeoutMs | 0));
111
-
112
- function cleanup() {
113
- if (finished) return;
114
- finished = true;
115
- clearTimeout(timer);
116
- try { port.off("data", onData); } catch (_e) {
117
- try { port.removeListener("data", onData); } catch (_e2) {}
118
- }
119
- try { if (typeof port.flush === "function") port.flush(() => {}); } catch (_e) {}
120
- if (state._rssiCleanup === cleanup) delete state._rssiCleanup;
121
- }
122
- state._rssiCleanup = cleanup;
123
-
124
- function onData(chunk) {
125
- rx = Buffer.concat([rx, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
126
- const parsed = tryParseRssiAck(rx);
127
- if (parsed) {
128
- cleanup();
129
- resolve(parsed.rssiDbm);
130
- } else {
131
- // keep rx from growing forever
132
- if (rx.length > 512) rx = rx.slice(-128);
133
- }
134
- }
135
-
136
- port.on("data", onData);
137
-
138
- port.write(cmd, (err) => {
139
- if (err) {
140
- cleanup();
141
- reject(err);
142
- return;
143
- }
144
- // ensure bytes actually leave the building
145
- try {
146
- if (typeof port.drain === "function") port.drain(() => {});
147
- } catch (_e) {}
148
- });
149
- });
150
- }
151
-
152
80
  // Nested property setter using delimiter "=>"
153
81
  function setObjectProperty(obj, path, value, delimiter) {
154
82
  if (!path || typeof path !== "string") {
@@ -162,6 +90,7 @@ state._rssiCleanup = cleanup;
162
90
 
163
91
  if (!parts.length) return;
164
92
 
93
+ if (parts.some(k => ["__proto__","prototype","constructor"].includes(k))) throw new Error("Invalid decoder property path");
165
94
  let ref = obj;
166
95
  for (let i = 0; i < parts.length - 1; i++) {
167
96
  const k = parts[i];
@@ -213,7 +142,7 @@ state._rssiCleanup = cleanup;
213
142
 
214
143
  // Plain numeric means multiply (0 is valid!)
215
144
  const asNum = Number(s);
216
- if (!Number.isNaN(asNum)) return { op: "*", val: asNum };
145
+ if (Number.isFinite(asNum) && !/^[+-]/.test(s)) return { op: "*", val: asNum };
217
146
 
218
147
  // Operator form: "*2" "/10" "+5" etc
219
148
  const m = s.match(scalerRegex);
@@ -297,7 +226,7 @@ state._rssiCleanup = cleanup;
297
226
  }
298
227
 
299
228
  case "digital to unsigned binary encoder": {
300
- const word = w(regIndex) >>> 0;
229
+ const word = (isBoolArray ? coilWord(regIndex) : w(regIndex)) >>> 0;
301
230
 
302
231
  // If the UI provided a bit (0–15), return that bit as TRUE/FALSE
303
232
  const bit = clampInt(offsetBit, 0, 15, 0);
@@ -342,6 +271,7 @@ state._rssiCleanup = cleanup;
342
271
  const regIndex = Math.floor(byteOffset / 2);
343
272
  if (regIndex < 0 || regIndex >= regArray.length) continue;
344
273
 
274
+ if (String(type).startsWith("32 bit") && regIndex + 1 >= regArray.length) throw new Error("Incomplete 32-bit register pair: " + name);
345
275
  let val;
346
276
  try {
347
277
  val = readTyped(regArray, type, byteOffset, offsetBit);
@@ -361,498 +291,139 @@ state._rssiCleanup = cleanup;
361
291
  val = (val & mask) >>> 0; // keep it sane as unsigned
362
292
  }
363
293
 
364
- const scaler = parseScaler(scaleRaw);
365
- if (scaler && typeof val === "number") {
366
- const fn = scalingOps[scaler.op] || scalingOps["*"];
367
- val = fn(val, scaler.val);
294
+ if (it.scaleMode === 'range') {
295
+ try { val = rangeScaling.scale(val, it); }
296
+ catch (err) { throw new Error(name + ': ' + err.message); }
297
+ } else if (it.scaleMode !== 'none') {
298
+ const scaler = parseScaler(scaleRaw);
299
+ if (scaler && typeof val === "number") {
300
+ const fn = scalingOps[scaler.op] || scalingOps["*"];
301
+ val = fn(val, scaler.val);
302
+ }
368
303
  }
369
304
 
305
+ // Digital points are numeric at the output boundary. Keep the internal
306
+ // boolean until here so saved math/masks cannot change a bit into another value.
307
+ if (typeof val === 'boolean') val = val ? 1 : 0;
370
308
  setObjectProperty(decoded, name, val, "=>");
371
309
  }
372
310
 
373
311
  return decoded;
374
312
  }
375
313
 
376
- // ==========================
377
- // Modbus bus + queue engine
378
- // ==========================
379
-
380
- function ensureBusState(busConfig) {
381
- const key = busConfig.id;
382
- let s = BUS.get(key);
383
- if (!s) {
384
- s = {
385
- id: key,
386
- client: new ModbusRTU(),
387
- connecting: false,
388
- connected: false,
389
- queue: [],
390
- busy: false,
391
- lastPortKey: "",
392
- maxQueue: 50
393
- };
394
- BUS.set(key, s);
395
- }
396
- return s;
397
- }
398
-
399
- function getFreshBusConfig(busId) {
400
- return RED.nodes.getNode(busId) || null;
401
- }
314
+ const engine = require('./bus-engine')(ModbusRTU);
402
315
 
403
- function makePortKey(cfg) {
404
- return [cfg.serialPort || "", String(cfg.baudRate ?? ""), String(cfg.parity ?? ""), String(cfg.stopBits ?? "")].join(
405
- "|"
406
- );
316
+ function SmithtekMakoRfBusConfig(config) {
317
+ RED.nodes.createNode(this, config);
318
+ Object.assign(this, {
319
+ busName: config.busName || 'Mako', serialPort: config.serialPort || '',
320
+ baudRate: toNum(config.baudRate,9600), parity: config.parity || 'none', stopBits: toNum(config.stopBits,1),
321
+ timeout_s: toNum(config.timeout_s,8), retries: toNum(config.retries,2), gap_s: toNum(config.gap_s,0),
322
+ // Preserve FIFO semantics. Removed experimental settings from earlier
323
+ // releases are intentionally ignored, including values in saved flows.
324
+ maxQueue: toNum(config.maxQueue,50),
325
+ appendRssi: config.appendRssi === true, rssiGuardMs: toNum(config.rssiGuardMs,10), rssiTimeoutMs: toNum(config.rssiTimeoutMs,100)
326
+ });
327
+ this._makoState = engine.create(this);
328
+ this.on('close', (removed, done) => {
329
+ engine.close(this._makoState).then(() => done(), err => {this.error(err);done();});
330
+ });
407
331
  }
332
+ RED.nodes.registerType('smithtek-mako-rf-bus', SmithtekMakoRfBusConfig);
408
333
 
409
- function enforceFcForMode(mode, fc) {
410
- if (mode === "read") return [1, 2, 3, 4].includes(fc) ? fc : 3;
411
- return [5, 6, 15, 16].includes(fc) ? fc : 5;
334
+ function integer(value, min, max, label) {
335
+ const n = Number(value);
336
+ if (value === '' || value == null || !Number.isInteger(n) || n < min || n > max) throw new Error(label+' must be an integer from '+min+' to '+max);
337
+ return n;
412
338
  }
413
-
414
- async function closeClientSafe(state) {
415
- // Best-effort: close and wait a moment for the fd to actually drop
416
- try {
417
- await new Promise((resolve) => {
418
- try {
419
- state.client.close(() => resolve());
420
- } catch (_e) {
421
- resolve();
422
- }
423
- });
424
- } catch (_e) {}
425
-
426
- // tiny settle time helps on Pi serial
427
- await sleep(50);
339
+ function bool(value) {
340
+ if (value === true || value === 1 || value === 'true' || value === '1') return true;
341
+ if (value === false || value === 0 || value === 'false' || value === '0') return false;
342
+ throw new Error('Coil values must be true/false or 1/0');
428
343
  }
429
- async function connectIfNeeded(cfg, state) {
430
- if (!cfg) throw new Error("Bus config missing");
431
-
432
- const portKey = makePortKey(cfg);
433
-
434
- // If serial settings have actually changed, reconnect.
435
- if (state.lastPortKey && state.lastPortKey !== portKey) {
436
- state.connected = false;
437
-
438
- if (state.client.isOpen) {
439
- await closeClientSafe(state);
344
+ function requestFor(config, msg) {
345
+ const mode = config.mode || 'read';
346
+ if (!['read','write'].includes(mode)) throw new Error('Invalid request mode');
347
+ const fc = integer(config.fc == null ? (mode === 'read' ? 3 : 5) : config.fc,1,16,'Function');
348
+ if (!(mode === 'read' ? [1,2,3,4] : [5,6,15,16]).includes(fc)) throw new Error('Function does not match request mode');
349
+ const req = {mode,fc,unitid:integer(config.unitid == null ? 1 : config.unitid,1,247,'Device ID'),address:integer(config.address == null ? 0 : config.address,0,65535,'Address')};
350
+ if(mode === 'read') req.quantity = integer(config.quantity == null ? 1 : config.quantity,1,fc <= 2 ? 2000 : 125,'Quantity');
351
+ else {
352
+ let value = msg.payload;
353
+ if(config.valueSource === 'fixed') {
354
+ const raw = config.valueText;
355
+ if(config.valueType === 'json') value = JSON.parse(raw);
356
+ else if(config.valueType === 'number') value = raw;
357
+ else if(config.valueType === 'bool') value = bool(raw);
358
+ else value = raw;
440
359
  }
441
- }
442
-
443
- // Trust the actual modbus-serial port state.
444
- // If the serial port is already open, reuse it.
445
- if (state.client.isOpen) {
446
- state.connected = true;
447
- state.lastPortKey = portKey;
448
- return;
449
- }
450
-
451
- if (state.connected) {
452
- state.connected = false;
453
- }
454
-
455
- // If another request is already opening the port, wait.
456
- if (state.connecting) {
457
- for (let i = 0; i < 200; i++) {
458
- if (state.client.isOpen) {
459
- state.connected = true;
460
- state.lastPortKey = portKey;
461
- return;
462
- }
463
-
464
- if (!state.connecting) break;
465
- await sleep(50);
466
- }
467
- }
468
-
469
- state.connecting = true;
470
-
471
- try {
472
- const port = cfg.serialPort;
473
- if (!port) throw new Error("No serial port configured");
474
-
475
- const options = {
476
- baudRate: clampInt(cfg.baudRate, 1200, 921600, 9600),
477
- dataBits: 8,
478
- stopBits: clampInt(cfg.stopBits, 1, 2, 1),
479
- parity: cfg.parity || "none",
480
- };
481
-
482
- // Only close if the library says the port is actually open.
483
- if (state.client.isOpen) {
484
- await closeClientSafe(state);
485
- }
486
-
487
- // Allow Linux/serialport time to release any previous lock.
488
- await sleep(200);
489
-
490
- await state.client.connectRTUBuffered(port, options);
491
-
492
- // UI uses seconds; library uses milliseconds.
493
- const timeout_s = Math.max(1, toNum(cfg.timeout_s, 8));
494
- state.client.setTimeout(
495
- Math.max(1000, Math.floor(timeout_s * 1000))
496
- );
497
-
498
- state.connected = true;
499
- state.lastPortKey = portKey;
500
-
501
- } catch (err) {
502
- state.connected = false;
503
- throw err;
504
-
505
- } finally {
506
- state.connecting = false;
507
- }
508
- }
509
-
510
-
511
- async function doRequest(cfg, state, req) {
512
- await connectIfNeeded(cfg, state);
513
-
514
- state.client.setID(req.unitid);
515
-
516
- const fc = req.fc;
517
- const addr = req.address;
518
-
519
- switch (fc) {
520
- // READS
521
- case 1:
522
- return state.client.readCoils(addr, req.quantity);
523
- case 2:
524
- return state.client.readDiscreteInputs(addr, req.quantity);
525
- case 3:
526
- return state.client.readHoldingRegisters(addr, req.quantity);
527
- case 4:
528
- return state.client.readInputRegisters(addr, req.quantity);
529
-
530
- // WRITES
531
- case 5:
532
- return state.client.writeCoil(addr, !!req.value);
533
-
534
- case 6:
535
- return state.client.writeRegister(addr, clampInt(req.value, 0, 65535, 0));
536
-
537
- case 15: {
538
- if (!Array.isArray(req.value)) throw new Error("FC15 requires an array value");
539
- const arr = req.value.map((v) => !!v);
540
- return state.client.writeCoils(addr, arr);
360
+ if(fc === 5) req.value = bool(value);
361
+ if(fc === 6) req.value = integer(value,0,65535,'Register value');
362
+ if(fc === 15 || fc === 16) {
363
+ if(!Array.isArray(value)) throw new Error('Multiple write requires an array');
364
+ integer(value.length,1,fc === 15 ? 1968 : 123,'Write length');
365
+ req.value = value.map(v => fc === 15 ? bool(v) : integer(v,0,65535,'Register value'));
541
366
  }
542
-
543
- case 16: {
544
- if (!Array.isArray(req.value)) throw new Error("FC16 requires an array value");
545
- const arr = req.value.map((v) => clampInt(v, 0, 65535, 0));
546
- return state.client.writeRegisters(addr, arr);
547
- }
548
-
549
- default:
550
- throw new Error(`Unsupported function code: ${fc}`);
551
367
  }
368
+ const length = req.quantity || (Array.isArray(req.value) ? req.value.length : 1);
369
+ if(req.address + length > 65536) throw new Error('Request extends beyond register address 65535');
370
+ return req;
552
371
  }
553
372
 
554
- function scheduleProcess(state, busId) {
555
- if (state.busy) return;
556
- state.busy = true;
557
-
558
- (async () => {
559
- await sleep(20);
560
- while (state.queue.length > 0) {
561
- const item = state.queue.shift();
562
- const { node, msg, send, done, req, items } = item;
563
-
564
- const cfg = getFreshBusConfig(busId);
565
- if (!cfg) {
566
- node.status({ fill: "red", shape: "ring", text: "bus config missing" });
567
- msg.payload = { ok: false, error: "Bus config missing", req };
568
- msg.modbus = { ok: false, error: "Bus config missing", req };
569
- send(msg);
570
- done();
571
- continue;
572
- }
573
-
574
- const busName = (cfg.busName || "Mako rf").trim();
575
-
576
- // retries=0 means exactly 1 attempt
577
- const retries = clampInt(cfg.retries, 0, 10, 2);
578
- const totalTries = retries + 1;
579
-
580
- const timeout_s = Math.max(1, toNum(cfg.timeout_s, 8));
581
- const gap_s = Math.max(0, toNum(cfg.gap_s, 0));
582
-
583
- let attempt = 0;
584
- let lastErr = null;
585
- let res = null;
586
-
587
- while (attempt < totalTries) {
588
- attempt += 1;
589
- try {
590
- node.status({
591
- fill: "blue",
592
- shape: "dot",
593
- text: `${busName}: fc${req.fc} uid${req.unitid} @${req.address} (try ${attempt}/${totalTries})`,
594
- });
595
-
596
- // apply timeout every time
597
- try {
598
- state.client.setTimeout(Math.max(1000, Math.floor(timeout_s * 1000)));
599
- } catch (_e) {}
600
-
601
- res = await doRequest(cfg, state, req);
602
- lastErr = null;
603
- break;
604
- } catch (err) {
605
- lastErr = err;
606
-
607
- node.warn(`${busName}: RF error fc${req.fc} uid${req.unitid} try ${attempt}/${totalTries}: ${err.message || err}`);
608
-
609
- if (attempt < totalTries && gap_s > 0) {
610
- await sleep(Math.floor(gap_s * 1000));
611
- }
612
- }
613
- }
614
-
615
- if (gap_s > 0) await sleep(Math.floor(gap_s * 1000));
616
-
617
- if (lastErr) {
618
- node.status({ fill: "red", shape: "ring", text: `${busName}: fail fc${req.fc} uid${req.unitid}` });
619
-
620
- msg.modbus = msg.modbus || {};
621
- msg.modbus.ok = false;
622
- msg.modbus.bus = busName;
623
- msg.modbus.req = req;
624
- msg.modbus.error = lastErr && lastErr.message ? lastErr.message : String(lastErr);
625
-
626
- msg.payload = { ok: false, error: msg.modbus.error, req };
627
- send(msg);
628
- done();
629
- continue;
630
- }
631
-
632
- node.status({ fill: "green", shape: "dot", text: `${busName}: ok fc${req.fc} uid${req.unitid}` });
633
-
634
- msg.modbus = msg.modbus || {};
635
- msg.modbus.ok = true;
636
- msg.modbus.bus = busName;
637
- msg.modbus.req = req;
638
- msg.modbus.raw = res;
639
-
640
- if (req.mode === "read" && res && Array.isArray(res.data)) {
641
- msg.payload = decodeWithItems(res.data, items);
642
- } else {
643
- msg.payload = res;
644
- }
645
-
646
- // ---- OPTIONAL RSSI APPEND (after successful Modbus) ----
647
-
648
- try {
649
- const appendRssi = !!cfg.appendRssi;
650
- if (appendRssi && req.mode === "read" && cfg.serialPort === "/dev/ttyAMA2") {
651
- const guardMs = toNum(cfg.rssiGuardMs, 10);
652
- const timeoutMs = toNum(cfg.rssiTimeoutMs, 100);
653
-
654
-
655
- const rssiDbm = await queryRssiOnSamePort(state, guardMs, timeoutMs);
656
- await sleep(30);
657
- state.connected = false;
658
- try {
659
- await new Promise((resolve) => state.client.close(() => resolve()));
660
- } catch (_e) {}
661
-
662
-
663
-
664
- const nodeKeyBase = sanitizeKey(node.name || busName || "mako_rf");
665
- const key = `${nodeKeyBase}_rssi_dbm`;
666
-
667
- if (msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload)) {
668
- msg.payload[key] = rssiDbm;
669
- } else {
670
- msg.rssi_dbm = rssiDbm;
671
- }
672
- }
673
- } catch (e) {
674
- msg.rssi_error = (e && e.message) ? e.message : String(e);
675
- }
676
- // -------------------------------------------------------
677
-
678
- send(msg);
679
- done();
680
-
681
- }
682
- })().finally(() => {
683
- state.busy = false;
684
- // If something got queued right as we finished, kick the processor again
685
- if (state.queue.length > 0) {
686
- scheduleProcess(state, busId);
687
- }
688
- });
689
-
690
- }
691
-
692
- // =========================
693
- // Config Node: smithtek-mako-rf-bus
694
- // =========================
695
- function SmithtekMakoRfBusConfig(n) {
696
- RED.nodes.createNode(this, n);
697
-
698
- this.busName = (n.busName || "mako rf").trim();
699
- this.serialPort = n.serialPort || "";
700
- // ADD THESE HERE:
701
- this.appendRssi = !!n.appendRssi;
702
- this.rssiGuardMs = toNum(n.rssiGuardMs, 10);
703
- this.rssiTimeoutMs = toNum(n.rssiTimeoutMs, 100);
704
-
705
- this.baudRate = toNum(n.baudRate, 9600);
706
- this.stopBits = toNum(n.stopBits, 1);
707
- this.parity = n.parity || "none";
708
-
709
- this.timeout_s = toNum(n.timeout_s, 8);
710
- this.retries = toNum(n.retries, 2);
711
- this.gap_s = toNum(n.gap_s, 0);
712
- this.maxQueue = toNum(n.maxQueue, 50);
713
-
714
- BUS.delete(this.id);
715
- ensureBusState(this);
716
-
717
- this.on("close", (removed, done) => {
718
- const s = BUS.get(this.id);
719
- if (s) {
720
- // Clear queue and reset flags
721
- s.queue = [];
722
- s.busy = false;
723
-
724
- try {
725
- s.client.close(() => {});
726
- } catch (_e) {}
727
-
728
- if (removed) {
729
- BUS.delete(this.id);
730
- }
731
- }
732
- done();
733
- });
734
-
735
- }
736
- RED.nodes.registerType("smithtek-mako-rf-bus", SmithtekMakoRfBusConfig);
737
-
738
- // =========================
739
- // Runtime Node: smithtek-mako-rf
740
- // =========================
741
373
  function SmithtekMakoRfNode(config) {
742
- RED.nodes.createNode(this, config);
374
+ RED.nodes.createNode(this,config);
743
375
  const node = this;
744
- // TEMP: until we add editor UI in the .html
745
- // node._config_appendRssi = !!config.appendRssi; // set true in flow JSON to enable
746
- // node._config_rssiGuardMs = toNum(config.rssiGuardMs, 3);
747
- // node._config_rssiTimeoutMs = toNum(config.rssiTimeoutMs, 50);
748
-
749
- node.on("input", function (msg, send, done) {
750
- send = send || function () { node.send.apply(node, arguments); };
751
-
752
- const busCfg = RED.nodes.getNode(config.bus);
753
- if (!busCfg) {
754
- node.status({ fill: "red", shape: "ring", text: "no bus config" });
755
- done(new Error("No Smithtek Mako RF bus configured"));
756
- return;
757
- }
758
-
759
- const state = ensureBusState(busCfg);
760
-
761
- const mode = (config.mode || "read").toLowerCase();
762
-
763
- let fc = clampInt(config.fc, 1, 16, 3);
764
- fc = enforceFcForMode(mode, fc);
765
-
766
- const unitid = clampInt(config.unitid, 1, 247, 1);
767
- const address = clampInt(config.address, 0, 100000, 0);
768
- const quantity = clampInt(config.quantity, 1, 2000, 1);
769
-
770
- // Decoder items from UI
771
- const items = Array.isArray(config.items) ? config.items : [];
772
-
773
- const req = { mode, fc, unitid, address };
774
-
775
- if (mode === "read") {
776
- req.quantity = quantity;
777
- } else {
778
- const valueSource = (config.valueSource || "msg.payload").toLowerCase();
779
- let value;
780
-
781
- if (valueSource === "fixed") {
782
- const vt = (config.valueType || "bool").toLowerCase();
783
- const raw = config.valueText ?? "";
784
-
785
- if (vt === "bool") value = String(raw).trim().toLowerCase() === "true";
786
- else if (vt === "number") value = toNum(raw, 0);
787
- else if (vt === "json") {
788
- try { value = JSON.parse(String(raw)); } catch (_e) { value = String(raw); }
789
- } else value = String(raw);
790
- } else {
791
- value = msg.payload;
792
- }
793
-
794
- req.value = value;
795
-
796
- if (config.includeWriteQuantity) req.quantity = quantity;
797
- }
798
-
799
- const maxQ = clampInt(busCfg.maxQueue, 1, 1000, 50);
800
-
801
- // If queue is full, drop NEW requests (keeps fairness for the ones already queued)
802
- const qItem = { node, msg, send, done, req, items };
803
- const isWrite = (req.mode === "write");
804
-
805
- // If queue is full:
806
- // - NEVER drop writes
807
- // - Drop one pending READ to make room
808
- if (state.queue.length >= maxQ) {
809
- if (isWrite) {
810
- const idx = state.queue.findIndex(it => it && it.req && it.req.mode === "read");
811
- if (idx >= 0) state.queue.splice(idx, 1);
812
- else {
813
- try { done(); } catch (_e) {}
814
- return;
376
+ node._makoClosed = false;
377
+ const states = new Set();
378
+ node.on('input',(msg,send,done) => {
379
+ send = send || (m => node.send(m));
380
+ done = done || (err => {if(err)node.error(err,msg);});
381
+ let completed = false;
382
+ const completeOnce = err => {if(!completed){completed=true;done(err);}};
383
+ const cfg = RED.nodes.getNode(config.bus);
384
+ let req;
385
+ const complete = (err,res) => {
386
+ try {
387
+ if(node._makoClosed || (err && err.code === 'ECANCELLED')) return;
388
+ msg.modbus = {ok:!err,bus:cfg && cfg.busName,req};
389
+ if(err) {
390
+ msg.modbus.error = err.message || String(err);
391
+ msg.modbus.code = err.code || (err.modbusCode ? 'MODBUS_EXCEPTION_'+err.modbusCode : 'ECOMM');
392
+ msg.payload = {ok:false,error:msg.modbus.error,req};
393
+ node.status({fill:err.code === 'EOFFLINE' ? 'yellow' : 'red',shape:'ring',text:msg.modbus.error});
394
+ } else {
395
+ msg.modbus.raw = res;
396
+ msg.payload = req.mode === 'read' && res && Array.isArray(res.data) ? decodeWithItems(res.data,config.items || []) : res;
397
+ if(msg._makoRssi != null) {msg.payload[sanitizeKey(node.name || config.name || cfg.busName)+'_rssi_dbm']=msg._makoRssi;delete msg._makoRssi;}
398
+ node.status({fill:'green',shape:'dot',text:'ok uid'+req.unitid});
815
399
  }
816
- } else {
817
- try { done(); } catch (_e) {}
818
- return;
819
- }
820
- }
821
-
822
- // PRIORITY: writes go to the FRONT
823
- if (isWrite) state.queue.unshift(qItem);
824
- else state.queue.push(qItem);
825
-
826
- scheduleProcess(state, busCfg.id);
827
-
828
-
829
- });
830
-
831
- node.on("close", function (_removed, done) {
400
+ send(msg);
401
+ } catch(e) {completeOnce(e);return;}
402
+ finally {completeOnce(err || undefined);}
403
+ };
832
404
  try {
833
- const busCfg = RED.nodes.getNode(config.bus);
834
- if (busCfg) {
835
- const s = BUS.get(busCfg.id);
836
- if (s) {
837
- s.queue = [];
838
- s.busy = false;
839
-
840
- if (typeof s._rssiCleanup === "function") {
841
- try { s._rssiCleanup(); } catch (_e) {}
842
- }
843
-
844
- s.connected = false;
845
- try { s.client.close(() => {}); } catch (_e) {}
405
+ req=requestFor(config,msg);
406
+ if(req.mode === 'read') (config.items || []).forEach(item => {
407
+ if(item.scaleMode === 'range') {
408
+ if(['bool','digital to unsigned binary encoder'].includes(String(item.type).toLowerCase())) throw new Error((item.name || 'Decoder item') + ': range scaling requires a numeric register type');
409
+ try { rangeScaling.endpoints(item); } catch(err) { throw new Error((item.name || 'Decoder item') + ': ' + err.message); }
846
410
  }
847
- }
848
- } catch (_e) {}
849
-
850
- node.status({});
851
- done();
411
+ });
412
+ if(!cfg || !cfg._makoState) throw new Error('No Mako bus configured');
413
+ const state=cfg._makoState;states.add(state);
414
+ engine.enqueue(state,{node,req,complete,after: async () => {
415
+ if(cfg.appendRssi && cfg.serialPort === '/dev/ttyAMA2' && req.mode === 'read') {
416
+ try {msg._makoRssi = await engine.rssi(state,buildRssiCommandBuffer,tryParseRssiAck);}
417
+ catch(e){if(e.code === 'ECANCELLED')throw e;msg.rssi_error=e.message;}
418
+ }
419
+ }});
420
+ }catch(err){complete(err);}
421
+ });
422
+ node.on('close',(_removed,done)=>{
423
+ node._makoClosed=true;
424
+ states.forEach(state=>engine.detach(state,node));
425
+ node.status({});done();
852
426
  });
853
-
854
-
855
427
  }
856
-
857
- RED.nodes.registerType("smithtek-mako-rf", SmithtekMakoRfNode);
428
+ RED.nodes.registerType('smithtek-mako-rf', SmithtekMakoRfNode);
858
429
  };