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.
@@ -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,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 (!Number.isNaN(asNum)) return { op: "*", val: asNum };
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
- const scaler = parseScaler(scaleRaw);
365
- if (scaler && typeof val === "number") {
366
- const fn = scalingOps[scaler.op] || scalingOps["*"];
367
- val = fn(val, scaler.val);
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
- setObjectProperty(decoded, name, val, "=>");
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 makePortKey(cfg) {
404
- return [cfg.serialPort || "", String(cfg.baudRate ?? ""), String(cfg.parity ?? ""), String(cfg.stopBits ?? "")].join(
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 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;
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
- 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);
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
- 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);
440
- }
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);
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
- 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);
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
- 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);
541
- }
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
- }
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 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
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
- 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));
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
- 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
- }
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
- } 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
- 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
- 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) {}
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
- } catch (_e) {}
849
-
850
- node.status({});
851
- done();
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
  };