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/smithtek-mako-rf.js
CHANGED
|
@@ -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
|
-
|
|
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,78 +77,8 @@ 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
|
-
function setObjectProperty(obj, path, value, delimiter) {
|
|
81
|
+
function setObjectProperty(obj, path, value, delimiter, prefix) {
|
|
154
82
|
if (!path || typeof path !== "string") {
|
|
155
83
|
obj[path] = value;
|
|
156
84
|
return;
|
|
@@ -162,6 +90,10 @@ 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");
|
|
94
|
+
// Prefix is literal text, even if it contains the nesting delimiter.
|
|
95
|
+
const labelPrefix = String(prefix == null ? '' : prefix).trim();
|
|
96
|
+
if (labelPrefix) parts[0] = labelPrefix + ' ' + parts[0];
|
|
165
97
|
let ref = obj;
|
|
166
98
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
167
99
|
const k = parts[i];
|
|
@@ -213,7 +145,7 @@ state._rssiCleanup = cleanup;
|
|
|
213
145
|
|
|
214
146
|
// Plain numeric means multiply (0 is valid!)
|
|
215
147
|
const asNum = Number(s);
|
|
216
|
-
if (
|
|
148
|
+
if (Number.isFinite(asNum) && !/^[+-]/.test(s)) return { op: "*", val: asNum };
|
|
217
149
|
|
|
218
150
|
// Operator form: "*2" "/10" "+5" etc
|
|
219
151
|
const m = s.match(scalerRegex);
|
|
@@ -297,7 +229,7 @@ state._rssiCleanup = cleanup;
|
|
|
297
229
|
}
|
|
298
230
|
|
|
299
231
|
case "digital to unsigned binary encoder": {
|
|
300
|
-
const word = w(regIndex) >>> 0;
|
|
232
|
+
const word = (isBoolArray ? coilWord(regIndex) : w(regIndex)) >>> 0;
|
|
301
233
|
|
|
302
234
|
// If the UI provided a bit (0–15), return that bit as TRUE/FALSE
|
|
303
235
|
const bit = clampInt(offsetBit, 0, 15, 0);
|
|
@@ -323,7 +255,7 @@ state._rssiCleanup = cleanup;
|
|
|
323
255
|
}
|
|
324
256
|
}
|
|
325
257
|
|
|
326
|
-
function decodeWithItems(regArray, items) {
|
|
258
|
+
function decodeWithItems(regArray, items, prefix) {
|
|
327
259
|
const decoded = {};
|
|
328
260
|
if (!Array.isArray(regArray) || !Array.isArray(items) || !items.length) return decoded;
|
|
329
261
|
|
|
@@ -342,6 +274,7 @@ state._rssiCleanup = cleanup;
|
|
|
342
274
|
const regIndex = Math.floor(byteOffset / 2);
|
|
343
275
|
if (regIndex < 0 || regIndex >= regArray.length) continue;
|
|
344
276
|
|
|
277
|
+
if (String(type).startsWith("32 bit") && regIndex + 1 >= regArray.length) throw new Error("Incomplete 32-bit register pair: " + name);
|
|
345
278
|
let val;
|
|
346
279
|
try {
|
|
347
280
|
val = readTyped(regArray, type, byteOffset, offsetBit);
|
|
@@ -361,498 +294,196 @@ state._rssiCleanup = cleanup;
|
|
|
361
294
|
val = (val & mask) >>> 0; // keep it sane as unsigned
|
|
362
295
|
}
|
|
363
296
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
297
|
+
if (it.scaleMode === 'range') {
|
|
298
|
+
try { val = rangeScaling.scale(val, it); }
|
|
299
|
+
catch (err) { throw new Error(name + ': ' + err.message); }
|
|
300
|
+
} else if (it.scaleMode !== 'none') {
|
|
301
|
+
const scaler = parseScaler(scaleRaw);
|
|
302
|
+
if (scaler && typeof val === "number") {
|
|
303
|
+
const fn = scalingOps[scaler.op] || scalingOps["*"];
|
|
304
|
+
val = fn(val, scaler.val);
|
|
305
|
+
}
|
|
368
306
|
}
|
|
369
307
|
|
|
370
|
-
|
|
308
|
+
// Digital points are numeric at the output boundary. Keep the internal
|
|
309
|
+
// boolean until here so saved math/masks cannot change a bit into another value.
|
|
310
|
+
if (typeof val === 'boolean') val = val ? 1 : 0;
|
|
311
|
+
setObjectProperty(decoded, name, val, "=>", prefix);
|
|
371
312
|
}
|
|
372
313
|
|
|
373
314
|
return decoded;
|
|
374
315
|
}
|
|
375
316
|
|
|
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
|
-
}
|
|
317
|
+
const engine = require('./bus-engine')(ModbusRTU);
|
|
402
318
|
|
|
403
|
-
function
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
319
|
+
function SmithtekMakoRfBusConfig(config) {
|
|
320
|
+
RED.nodes.createNode(this, config);
|
|
321
|
+
Object.assign(this, {
|
|
322
|
+
busName: config.busName || 'Mako', serialPort: config.serialPort || '',
|
|
323
|
+
baudRate: toNum(config.baudRate,9600), parity: config.parity || 'none', stopBits: toNum(config.stopBits,1),
|
|
324
|
+
timeout_s: toNum(config.timeout_s,8), retries: toNum(config.retries,2), gap_s: toNum(config.gap_s,0),
|
|
325
|
+
// Preserve FIFO semantics. Removed experimental settings from earlier
|
|
326
|
+
// releases are intentionally ignored, including values in saved flows.
|
|
327
|
+
maxQueue: toNum(config.maxQueue,50),
|
|
328
|
+
appendRssi: config.appendRssi === true, rssiGuardMs: toNum(config.rssiGuardMs,10), rssiTimeoutMs: toNum(config.rssiTimeoutMs,100)
|
|
329
|
+
});
|
|
330
|
+
this._makoState = engine.create(this);
|
|
331
|
+
this.on('close', (removed, done) => {
|
|
332
|
+
engine.close(this._makoState).then(() => done(), err => {this.error(err);done();});
|
|
333
|
+
});
|
|
407
334
|
}
|
|
335
|
+
RED.nodes.registerType('smithtek-mako-rf-bus', SmithtekMakoRfBusConfig);
|
|
408
336
|
|
|
409
|
-
function
|
|
410
|
-
|
|
411
|
-
|
|
337
|
+
function integer(value, min, max, label) {
|
|
338
|
+
const n = Number(value);
|
|
339
|
+
if (value === '' || value == null || !Number.isInteger(n) || n < min || n > max) throw new Error(label+' must be an integer from '+min+' to '+max);
|
|
340
|
+
return n;
|
|
412
341
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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);
|
|
342
|
+
function bool(value) {
|
|
343
|
+
if (value === true || value === 1 || value === 'true' || value === '1') return true;
|
|
344
|
+
if (value === false || value === 0 || value === 'false' || value === '0') return false;
|
|
345
|
+
throw new Error('Coil values must be true/false or 1/0');
|
|
428
346
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
347
|
+
function requestFor(config, msg) {
|
|
348
|
+
const mode = config.mode || 'read';
|
|
349
|
+
if (!['read','write'].includes(mode)) throw new Error('Invalid request mode');
|
|
350
|
+
const fc = integer(config.fc == null ? (mode === 'read' ? 3 : 5) : config.fc,1,16,'Function');
|
|
351
|
+
if (!(mode === 'read' ? [1,2,3,4] : [5,6,15,16]).includes(fc)) throw new Error('Function does not match request mode');
|
|
352
|
+
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')};
|
|
353
|
+
if(mode === 'read') req.quantity = integer(config.quantity == null ? 1 : config.quantity,1,fc <= 2 ? 2000 : 125,'Quantity');
|
|
354
|
+
else {
|
|
355
|
+
let value = msg.payload;
|
|
356
|
+
if(config.valueSource === 'fixed') {
|
|
357
|
+
const raw = config.valueText;
|
|
358
|
+
if(config.valueType === 'json') value = JSON.parse(raw);
|
|
359
|
+
else if(config.valueType === 'number') value = raw;
|
|
360
|
+
else if(config.valueType === 'bool') value = bool(raw);
|
|
361
|
+
else value = raw;
|
|
466
362
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
363
|
+
if(fc === 5) req.value = bool(value);
|
|
364
|
+
if(fc === 6) req.value = integer(value,0,65535,'Register value');
|
|
365
|
+
if(fc === 15 || fc === 16) {
|
|
366
|
+
if(!Array.isArray(value)) throw new Error('Multiple write requires an array');
|
|
367
|
+
integer(value.length,1,fc === 15 ? 1968 : 123,'Write length');
|
|
368
|
+
req.value = value.map(v => fc === 15 ? bool(v) : integer(v,0,65535,'Register value'));
|
|
485
369
|
}
|
|
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
370
|
}
|
|
371
|
+
const length = req.quantity || (Array.isArray(req.value) ? req.value.length : 1);
|
|
372
|
+
if(req.address + length > 65536) throw new Error('Request extends beyond register address 65535');
|
|
373
|
+
return req;
|
|
508
374
|
}
|
|
509
375
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
default:
|
|
550
|
-
throw new Error(`Unsupported function code: ${fc}`);
|
|
551
|
-
}
|
|
376
|
+
function faultInfo(err, config, cfg, req) {
|
|
377
|
+
const code = err.code || (err.modbusCode ? 'MODBUS_EXCEPTION_'+err.modbusCode : 'ECOMM');
|
|
378
|
+
const message = String(err.message || err);
|
|
379
|
+
const labels = {ECONFIG:'Configuration error',EDECODE:'Decode error',EQUEUEFULL:'Queue full',
|
|
380
|
+
EQUEUEEXPIRED:'Queue expired',EOFFLINE:'Offline',EPORTBUSY:'Port busy',ECLOSE:'Serial close failure',
|
|
381
|
+
EDISCONNECT:'Serial disconnected',EIO:'Serial error',ENOENT:'Serial port unavailable',EACCES:'Serial permission denied'};
|
|
382
|
+
let reason = labels[code];
|
|
383
|
+
if (!reason && err.modbusCode) reason = 'Modbus exception '+err.modbusCode;
|
|
384
|
+
if (!reason && (code === 'ETIMEDOUT' || /timed?\s*out|timeout/i.test(message))) reason = 'Timeout';
|
|
385
|
+
if (!reason && code === 'EDEADLINE') reason = /request/i.test(message) ? 'Timeout' : 'Operation deadline';
|
|
386
|
+
if (!reason && /crc/i.test(message)) reason = 'CRC error';
|
|
387
|
+
if (!reason) reason = 'Communication error';
|
|
388
|
+
const port = cfg && cfg.serialPort;
|
|
389
|
+
const channel = {'/dev/ttyAMA2':'RF','/dev/ttyAMA0':'RS485-1','/dev/ttyAMA1':'RS485-2'}[port] || (cfg && cfg.busName) || 'Bus';
|
|
390
|
+
const prefix = String(config.prefix == null ? '' : config.prefix).trim();
|
|
391
|
+
const label = prefix || String(config.name || '').trim();
|
|
392
|
+
const unitid = req ? req.unitid : (config.unitid == null ? 1 : config.unitid);
|
|
393
|
+
const link = channel === 'RF' ? 'RF' : /^RS485/.test(channel) ? 'RS485 wiring' : 'connection';
|
|
394
|
+
const advice = {
|
|
395
|
+
'Timeout':'Not communicating — check power/'+link+'; disable if out of service',
|
|
396
|
+
'Configuration error':'Setup error — check node settings',
|
|
397
|
+
'Decode error':'Data format error — check register map/scaling',
|
|
398
|
+
'Queue full':'Polling overloaded — reduce polling rate',
|
|
399
|
+
'Queue expired':'Polling delayed — reduce polling rate',
|
|
400
|
+
'Offline':'Polling paused — check device availability',
|
|
401
|
+
'Port busy':'Bus in use — check duplicate bus settings',
|
|
402
|
+
'Serial close failure':'Gateway port fault — check bus connection',
|
|
403
|
+
'Serial disconnected':'Gateway disconnected — check bus connection',
|
|
404
|
+
'Serial error':'Gateway port fault — check bus connection',
|
|
405
|
+
'Serial port unavailable':'Gateway port missing — check bus settings',
|
|
406
|
+
'Serial permission denied':'Gateway port blocked — check access permissions',
|
|
407
|
+
'Operation deadline':'Gateway operation stalled — check bus connection',
|
|
408
|
+
'CRC error':'Invalid reply — check '+link+' link'
|
|
409
|
+
};
|
|
410
|
+
const summary = advice[reason] || (err.modbusCode ? 'Request rejected — check function/register settings' : 'Communication fault — check power/'+link);
|
|
411
|
+
const key = [label,channel,'ID'+unitid,summary].filter(Boolean).join(' ');
|
|
412
|
+
return {key,code,message,reason,summary,channel,unitid,prefix,node:config.name || '',timestamp:new Date().toISOString()};
|
|
552
413
|
}
|
|
553
414
|
|
|
554
|
-
function
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
415
|
+
function SmithtekMakoRfNode(config) {
|
|
416
|
+
RED.nodes.createNode(this,config);
|
|
417
|
+
const node = this;
|
|
418
|
+
node._makoClosed = false;
|
|
419
|
+
const states = new Set();
|
|
420
|
+
node.on('input',(msg,send,done) => {
|
|
421
|
+
send = send || (m => node.send(m));
|
|
422
|
+
done = done || (err => {if(err)node.error(err,msg);});
|
|
423
|
+
let completed = false;
|
|
424
|
+
const completeOnce = err => {if(!completed){completed=true;done(err);}};
|
|
425
|
+
const cfg = RED.nodes.getNode(config.bus);
|
|
426
|
+
let req;
|
|
427
|
+
const complete = (err,res) => {
|
|
428
|
+
let failure = err;
|
|
429
|
+
try {
|
|
430
|
+
if(node._makoClosed || (failure && failure.code === 'ECANCELLED')) return;
|
|
431
|
+
let payload;
|
|
432
|
+
if (!failure) {
|
|
597
433
|
try {
|
|
598
|
-
|
|
599
|
-
} catch (
|
|
600
|
-
|
|
601
|
-
|
|
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));
|
|
434
|
+
payload = req.mode === 'read' && res && Array.isArray(res.data) ? decodeWithItems(res.data,config.items || [],config.prefix) : res;
|
|
435
|
+
} catch (decodeError) {
|
|
436
|
+
failure = decodeError;
|
|
437
|
+
failure.code = 'EDECODE';
|
|
611
438
|
}
|
|
612
439
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
}
|
|
440
|
+
msg.modbus = {ok:!failure,bus:cfg && cfg.busName,req};
|
|
441
|
+
if(res) msg.modbus.raw = res;
|
|
442
|
+
if(failure) {
|
|
443
|
+
const fault = faultInfo(failure,config,cfg,req);
|
|
444
|
+
msg.modbus.error = fault.message;
|
|
445
|
+
msg.modbus.code = fault.code;
|
|
446
|
+
msg.modbus.fault = fault;
|
|
447
|
+
msg.payload = {[fault.key]:1};
|
|
448
|
+
node.status({fill:failure.code === 'EOFFLINE' ? 'yellow' : 'red',shape:'ring',text:fault.message});
|
|
449
|
+
} else {
|
|
450
|
+
msg.payload = payload;
|
|
451
|
+
if(msg._makoRssi != null) {msg.payload[sanitizeKey(node.name || config.name || cfg.busName)+'_rssi_dbm']=msg._makoRssi;delete msg._makoRssi;}
|
|
452
|
+
node.status({fill:'green',shape:'dot',text:'ok uid'+req.unitid});
|
|
672
453
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
function SmithtekMakoRfNode(config) {
|
|
742
|
-
RED.nodes.createNode(this, config);
|
|
743
|
-
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;
|
|
815
|
-
}
|
|
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) {
|
|
454
|
+
// Cloud output always carries either decoded data or the friendly alarm.
|
|
455
|
+
// The second output contains a separate technical error payload.
|
|
456
|
+
const debugMsg = failure ? Object.assign({}, msg, {
|
|
457
|
+
payload: {ok:false,error:msg.modbus.error,code:msg.modbus.code,req},
|
|
458
|
+
modbus: Object.assign({}, msg.modbus)
|
|
459
|
+
}) : null;
|
|
460
|
+
send([msg, debugMsg]);
|
|
461
|
+
} catch(e) {completeOnce(e);return;}
|
|
462
|
+
finally {completeOnce(failure || undefined);}
|
|
463
|
+
};
|
|
832
464
|
try {
|
|
833
|
-
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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) {}
|
|
465
|
+
req=requestFor(config,msg);
|
|
466
|
+
if(req.mode === 'read') (config.items || []).forEach(item => {
|
|
467
|
+
if(item.scaleMode === 'range') {
|
|
468
|
+
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');
|
|
469
|
+
try { rangeScaling.endpoints(item); } catch(err) { throw new Error((item.name || 'Decoder item') + ': ' + err.message); }
|
|
846
470
|
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
471
|
+
});
|
|
472
|
+
if(!cfg || !cfg._makoState) throw new Error('No Mako bus configured');
|
|
473
|
+
const state=cfg._makoState;states.add(state);
|
|
474
|
+
engine.enqueue(state,{node,req,complete,after: async () => {
|
|
475
|
+
if(cfg.appendRssi && cfg.serialPort === '/dev/ttyAMA2' && req.mode === 'read') {
|
|
476
|
+
try {msg._makoRssi = await engine.rssi(state,buildRssiCommandBuffer,tryParseRssiAck);}
|
|
477
|
+
catch(e){if(e.code === 'ECANCELLED')throw e;msg.rssi_error=e.message;}
|
|
478
|
+
}
|
|
479
|
+
}});
|
|
480
|
+
}catch(err){if(!err.code) err.code='ECONFIG';complete(err);}
|
|
481
|
+
});
|
|
482
|
+
node.on('close',(_removed,done)=>{
|
|
483
|
+
node._makoClosed=true;
|
|
484
|
+
states.forEach(state=>engine.detach(state,node));
|
|
485
|
+
node.status({});done();
|
|
852
486
|
});
|
|
853
|
-
|
|
854
|
-
|
|
855
487
|
}
|
|
856
|
-
|
|
857
|
-
RED.nodes.registerType("smithtek-mako-rf", SmithtekMakoRfNode);
|
|
488
|
+
RED.nodes.registerType('smithtek-mako-rf', SmithtekMakoRfNode);
|
|
858
489
|
};
|