smithtek-mako-rf 3.1.0 → 3.3.2
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 +79 -0
- package/bus-engine.js +281 -0
- package/package.json +8 -5
- package/range-scaling.js +26 -0
- package/smithtek-mako-rf.html +98 -17
- package/smithtek-mako-rf.js +177 -546
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
+
# Cloud data/alarms and local debug — 3.3.2
|
|
2
|
+
|
|
3
|
+
- **Top pin, output 1 — Data / alarm:** successful decoded readings and write responses, or the friendly named fault JSON when a request fails. Connect this to the cloud flow.
|
|
4
|
+
- **Bottom pin, output 2 — Debug:** technical error payloads containing `ok:false`, `error`, `code` and `req`. Connect this to a local Debug node, or leave it disconnected. Successful requests send nothing here.
|
|
5
|
+
|
|
6
|
+
A timeout alarm is emitted after **one poll exhausts its configured retries**. Retries 0 means one failed attempt; retries 2 means three failed attempts. There is no separate bad-poll counter. If a retry succeeds, only normal data is emitted. Permanent Modbus exceptions (such as an unsupported register), invalid configuration and queue rejection are reported immediately. Each failed poll emits one friendly alarm on output 1 and one technical message on output 2. Intentional shutdown/redeploy cancellation emits neither.
|
|
7
|
+
|
|
8
|
+
Existing first-output wires keep both data and friendly alarms. Node-RED logs, status and Catch handling remain enabled. Refresh the editor after updating to see the pin labels. This corrects the output routing introduced in 3.3.1.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Prefixes and operator fault messages — 3.3.0
|
|
13
|
+
|
|
14
|
+
## Prefix
|
|
15
|
+
|
|
16
|
+
Set the node's **Prefix** once, for example `b1`. A decoder row named `Pressure` then outputs `{"b1 Pressure":12}`. Copy the node and change its prefix to `b2` to reuse every table entry without renaming them. Leading/trailing prefix whitespace is trimmed, and exactly one space separates it from the row name. A blank prefix preserves existing output names. Existing nested names retain their structure: `tank=>level` becomes `{"b1 tank":{"level":12}}`.
|
|
17
|
+
|
|
18
|
+
## Operator fault JSON
|
|
19
|
+
|
|
20
|
+
Failed requests emit a flat numeric alarm on output 1 in `msg.payload`, suitable for forwarding as a named cloud value. For example:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{"b1 RF ID2 Not communicating — check power/RF; disable if out of service":1}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The key uses the prefix (or node Name when no prefix is set), actual channel (RF, RS485-1 or RS485-2), device ID and a short operator message. RS485 timeouts advise checking power and wiring. Disabling an out-of-service asset is operator advice; the node never disables a device automatically.
|
|
27
|
+
|
|
28
|
+
Only a final failed request emits this alarm: a timeout that recovers within its configured retries produces normal data, with no fault payload. Permanent device exceptions and configuration errors are reported immediately when retrying cannot help. Each failed poll emits one fault message. Queue overload, gateway port problems, invalid configuration and decoding failures have distinct messages; they are not described as RF failures. Intentional shutdown/redeploy cancellation emits no fault alarm. An optional RSSI diagnostic failure remains in `msg.rssi_error` and does not turn a successful register read into an asset communication fault.
|
|
29
|
+
|
|
30
|
+
Full technical details remain in `msg.modbus`: `ok:false`, `code`, `error`, `req`, and `fault` (key, reason, operator summary, channel, device ID, prefix, node name and timestamp). Node-RED Catch handling remains supported. Successful requests output normal prefixed readings. No synthetic zero/clear alarm is sent on recovery; cloud alarm reset/expiry must be handled by the receiving flow or service.
|
|
31
|
+
|
|
32
|
+
**Output change:** failure payloads previously contained `{ok:false,error,req}`; they now contain the flat named alarm above. Use `msg.modbus.ok`, `msg.modbus.error` and `msg.modbus.req` in flows that inspect errors. This package does not connect to or configure 3dm.space; compatibility with a particular cloud ingestion schema must be verified there.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
# Simple bus settings and range scaling — 3.2.4
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
For Range, enter Input low, Input high, Output low and Output high. For example:
|
|
45
|
+
|
|
46
|
+
| Input low | Input high | Output low | Output high | Result at input 12 |
|
|
47
|
+
|---|---|---|---|---|
|
|
48
|
+
| 4 | 20 | 0 | 100 | 50 |
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
# Reliability update 3.1.2
|
|
60
|
+
|
|
61
|
+
This release retains the pinned serial dependencies and supports Node.js 14 syntax.
|
|
62
|
+
|
|
63
|
+
- One worker owns each bus. Closing a runtime node cancels only its own work.
|
|
64
|
+
- Open, request, diagnostic and close operations have deadlines. A port whose close cannot be confirmed stays quarantined rather than acquiring a second owner.
|
|
65
|
+
- Serial errors are handled. Dead connections are retired before reopening.
|
|
66
|
+
- Reads and writes use FIFO order. Queue overflow returns an explicit error and completes once.
|
|
67
|
+
- Every accepted request is retained in FIFO order, including repeated reads from the same node. Requests are not coalesced or expired while waiting.
|
|
68
|
+
- Each queued request gets the configured timeout retry count before moving to the next. There is no offline cooldown or skipped polling.
|
|
69
|
+
- 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.
|
|
70
|
+
- RSSI queries verify their checksum, isolate diagnostic bytes, settle on cancellation and retain the serial connection after success.
|
|
71
|
+
- Invalid addresses, quantities and write values are rejected before transmission. Signed scale expressions add/subtract, and incomplete 32-bit values are rejected.
|
|
72
|
+
|
|
73
|
+
No additional bus settings are required. Recovery timing is automatic: the response timeout on RF and 100ms on RS485.
|
|
74
|
+
|
|
75
|
+
Failures emit msg.modbus.ok=false and the fault payload documented above. 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.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
---
|
|
1
80
|
**Installation**
|
|
2
81
|
Install using the `NodeRED palette manager`
|
|
3
82
|
|
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.
|
|
3
|
+
"version": "3.3.2",
|
|
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
|
-
|
|
18
|
-
|
|
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
|
}
|
package/range-scaling.js
ADDED
|
@@ -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 };
|
package/smithtek-mako-rf.html
CHANGED
|
@@ -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.
|
|
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
|
});
|
|
@@ -305,6 +369,7 @@
|
|
|
305
369
|
color: "#B9B6B8",
|
|
306
370
|
defaults: {
|
|
307
371
|
name: { value: "" },
|
|
372
|
+
prefix: { value: "" },
|
|
308
373
|
bus: { type: "smithtek-mako-rf-bus", required: true },
|
|
309
374
|
|
|
310
375
|
mode: { value: "read" },
|
|
@@ -318,10 +383,11 @@
|
|
|
318
383
|
valueText: { value: "" },
|
|
319
384
|
includeWriteQuantity: { value: false },
|
|
320
385
|
|
|
321
|
-
items: { value: [ defaultItemRow() ] }
|
|
386
|
+
items: { value: [ defaultItemRow() ], validate: validDecoderItems }
|
|
322
387
|
},
|
|
323
388
|
inputs: 1,
|
|
324
|
-
outputs:
|
|
389
|
+
outputs: 2,
|
|
390
|
+
outputLabels: ["Data / alarm", "Debug"],
|
|
325
391
|
icon: "bridge.svg",
|
|
326
392
|
label: function () { return this.name || "Smithtek Mako RF"; },
|
|
327
393
|
|
|
@@ -414,7 +480,7 @@
|
|
|
414
480
|
<label for="node-config-input-appendRssi"><i class="fa fa-signal"></i> RSSI</label>
|
|
415
481
|
<input type="checkbox" id="node-config-input-appendRssi" style="width:auto;">
|
|
416
482
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
417
|
-
(
|
|
483
|
+
(Append RSSI in dBm. Diagnostics add one radio query per successful read.)
|
|
418
484
|
</span>
|
|
419
485
|
</div>
|
|
420
486
|
|
|
@@ -468,7 +534,7 @@
|
|
|
468
534
|
<input type="number" id="node-config-input-maxQueue" min="1" style="width:140px;">
|
|
469
535
|
</div>
|
|
470
536
|
<div class="form-tips">
|
|
471
|
-
|
|
537
|
+
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
538
|
</div>
|
|
473
539
|
|
|
474
540
|
|
|
@@ -483,6 +549,12 @@
|
|
|
483
549
|
<input type="text" id="node-input-name" placeholder="">
|
|
484
550
|
</div>
|
|
485
551
|
|
|
552
|
+
<div class="form-row">
|
|
553
|
+
<label for="node-input-prefix"><i class="fa fa-tag"></i> Prefix</label>
|
|
554
|
+
<input type="text" id="node-input-prefix" placeholder="e.g. b1">
|
|
555
|
+
<div class="form-tips">Added before each decoder name with a space: b1 + Pressure becomes b1 Pressure. Leave blank to keep existing names.</div>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
486
558
|
<div class="form-row">
|
|
487
559
|
<label for="node-input-bus"><i class="fa fa-sitemap"></i> Bus</label>
|
|
488
560
|
<input type="text" id="node-input-bus" style="width: 25%;">
|
|
@@ -581,30 +653,39 @@
|
|
|
581
653
|
</div>
|
|
582
654
|
|
|
583
655
|
<div class="form-row">
|
|
584
|
-
<table class="table table-condensed" style="width:100%;">
|
|
656
|
+
<div style="overflow-x:auto;"><table class="table table-condensed" style="width:100%; min-width:1100px; table-layout:fixed;">
|
|
585
657
|
<thead>
|
|
586
658
|
<tr>
|
|
587
|
-
<th style="width:
|
|
588
|
-
<th style="width:
|
|
589
|
-
<th style="width:
|
|
590
|
-
<th style="width:
|
|
591
|
-
<th style="width:
|
|
592
|
-
<th style="width:
|
|
659
|
+
<th style="width:20%;">Name </th>
|
|
660
|
+
<th style="width:145px;">Register data Type</th>
|
|
661
|
+
<th style="width:65px;">Offset</th>
|
|
662
|
+
<th style="width:565px;">Scaling</th>
|
|
663
|
+
<th style="width:42px;">Bit</th>
|
|
664
|
+
<th style="width:55px;">Action</th>
|
|
593
665
|
</tr>
|
|
594
666
|
</thead>
|
|
595
667
|
<tbody id="st-items-tbody"></tbody>
|
|
596
|
-
</table>
|
|
668
|
+
</table></div>
|
|
597
669
|
</div>
|
|
598
670
|
|
|
599
671
|
<div style="font-size:12px; color:#666; margin-top:6px;">
|
|
600
672
|
(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
673
|
</div>
|
|
674
|
+
<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
675
|
</div>
|
|
603
676
|
</script>
|
|
604
677
|
|
|
605
678
|
<script type="text/markdown" data-help-name="smithtek-mako-rf">
|
|
606
|
-
Smithtek Mako RF
|
|
679
|
+
Smithtek Mako RF / RS485.
|
|
607
680
|
|
|
608
|
-
Read mode outputs decoded JSON in msg.payload using the Decoder map rows.
|
|
681
|
+
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
682
|
Raw modbus response is kept in msg.modbus.raw.
|
|
683
|
+
|
|
684
|
+
Prefix adds text and one space before each decoder name. Leave blank to preserve names.
|
|
685
|
+
|
|
686
|
+
Output 1 (Data / alarm) carries successful readings and write responses, or the friendly named fault JSON on failure. Output 2 (Debug) carries technical error details: ok, error, code and req. Connect it to a local Debug node or leave it disconnected. Node-RED error logs and Catch handling remain enabled. A timeout alarm is sent after one poll exhausts its retries (retries 2 means 3 attempts), not after multiple bad polls. Permanent Modbus exceptions report immediately. Each failed poll emits one alarm and one debug message.
|
|
687
|
+
|
|
688
|
+
Failed requests output a named numeric alarm on output 1, for example {"b1 RF ID2 Not communicating — check power/RF; disable if out of service":1}. Details remain in msg.modbus, including ok, code, error and fault. Only final failures are reported; a successful retry or intentional shutdown does not emit an alarm. Successful reads resume normal data; no automatic alarm-clear value is sent.
|
|
689
|
+
|
|
690
|
+
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
691
|
</script>
|