smithtek-mako-rf 3.3.3 → 3.4.0
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/MAKO-RF.md +607 -0
- package/PASSPORT-CONNECT.md +207 -0
- package/README.md +226 -600
- package/UPSTREAM.md +3 -0
- package/bus-engine.js +2 -2
- package/channel-map.mjs +54 -0
- package/channel-transform.js +12 -0
- package/icons/1.png +0 -0
- package/icons/2.png +0 -0
- package/icons/controlpng.png +0 -0
- package/icons/passport-connect-cropped.png +0 -0
- package/icons/passport-connect.png +0 -0
- package/package.json +23 -4
- package/passport-link-node.js +33 -0
- package/smithtek-mako-rf.html +167 -10
- package/smithtek-mako-rf.js +9 -0
- package/usb-discovery.js +20 -0
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Package history
|
|
2
|
+
|
|
3
|
+
This package continues smithtek-mako-rf by Smithtek, licensed GPL-3.0-only. The original Mako RF nodes remain included. Version 3.4.0 adds PassPort Connect for V-net projects alongside those nodes. Existing node identifiers are preserved so saved flows remain compatible.
|
package/bus-engine.js
CHANGED
|
@@ -80,7 +80,7 @@ module.exports = function createEngine(ModbusRTU) {
|
|
|
80
80
|
if (state.client && state.client._makoRetiring) await retire(state);
|
|
81
81
|
if (state.client && state.client.isOpen) return;
|
|
82
82
|
if (state.client) await retire(state);
|
|
83
|
-
const path = cfg.serialPort;
|
|
83
|
+
const path = cfg.serialPort && await operation(state,'USB detection',5000,() => require('./usb-discovery').resolve(cfg.serialPort,state));
|
|
84
84
|
if (!path) throw error('No serial port configured', 'ECONFIG');
|
|
85
85
|
const owner = owners.get(path);
|
|
86
86
|
if (owner && owner !== state) throw error('Serial port already owned by another Mako bus: ' + path, 'EPORTBUSY');
|
|
@@ -204,7 +204,7 @@ module.exports = function createEngine(ModbusRTU) {
|
|
|
204
204
|
const cooldown = bounded(state.cfg.offlineCooldown_s,0,0,300);
|
|
205
205
|
// After the first failed retry group, one probe is enough to discover a
|
|
206
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;
|
|
207
|
+
const tries = offline && cooldown > 0 && item.req.mode === 'read' ? 1 : Math.floor(bounded(item.retries == null ? state.cfg.retries : item.retries, 2, 0, 10)) + 1;
|
|
208
208
|
let result, lastErr;
|
|
209
209
|
for (let attempt = 1; attempt <= tries && !state.closing && !item.node._makoClosed; attempt++) {
|
|
210
210
|
try {
|
package/channel-map.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const hasOwn=(o,k)=>Object.prototype.hasOwnProperty.call(o,k);
|
|
2
|
+
// Shared by the V-net exporter and the local Node-RED node. No transport dependencies.
|
|
3
|
+
export const widths={float32:2,int32:2,uint32:2,int16:1,uint16:1,boolean:1};
|
|
4
|
+
export function identity(map){return 'passport-link-v1:'+JSON.stringify([map.unitId,map.channels.map(c=>[c.kind,c.index,c.name,c.dataType,c.address,c.slot,!!c.pulseOnWrite,c.pulseMs||250])]);}
|
|
5
|
+
export function validateMap(map){
|
|
6
|
+
if(!map||map.schema!=='smithtek.passport-channels'||map.version!==1||!Number.isInteger(map.unitId)||map.unitId<1||map.unitId>247||!Array.isArray(map.channels)||map.channels.length<1||map.channels.length>64)throw Error('Invalid PassPort channel mapping');
|
|
7
|
+
const names=new Set(),slots=new Set(),used=new Set(),indices={analog:0,switches:0};
|
|
8
|
+
for(const c of map.channels){
|
|
9
|
+
if(!['analog','switches'].includes(c.kind)||c.index!==indices[c.kind]++||typeof c.name!=='string'||!c.name.trim()||c.name.length>40||names.has(c.name)||['__proto__','constructor','prototype'].includes(c.name)||!Number.isInteger(c.slot)||c.slot<0||c.slot>63||slots.has(c.slot)||!hasOwn(widths,c.dataType)||(c.kind==='switches')!==(c.dataType==='boolean')||!Number.isInteger(c.address)||c.address<0||c.address+widths[c.dataType]>65536)throw Error('Invalid channel name, order, type or address');
|
|
10
|
+
if(c.pulseOnWrite&&(c.kind!=='switches'||!Number.isInteger(c.pulseMs)||c.pulseMs<1||c.pulseMs>1000))throw Error('Invalid Digital pulse settings');
|
|
11
|
+
names.add(c.name);slots.add(c.slot);for(let i=0;i<widths[c.dataType];i++){const key=c.kind+':'+(c.address+i);if(used.has(key))throw Error('Overlapping channel addresses');used.add(key);}
|
|
12
|
+
}
|
|
13
|
+
if(map.mappingId!==identity(map))throw Error('Mapping identity does not match its channels');return map;
|
|
14
|
+
}
|
|
15
|
+
export function createMap(unitId,rows){const indices={analog:0,switches:0};const map={schema:'smithtek.passport-channels',version:1,unitId,channels:rows.map(r=>{const kind=r.area==='coil'?'switches':'analog';return {kind,index:indices[kind]++,name:r.name.trim(),slot:r.slot,address:r.address,dataType:kind==='switches'?'boolean':r.dataType||'float32',...(r.pulseOnWrite?{pulseOnWrite:true,pulseMs:r.pulseMs??250}:{})};})};map.mappingId=identity(map);return validateMap(map);}
|
|
16
|
+
export function encode(c,value){
|
|
17
|
+
if(c.dataType==='boolean'){if(value===true||value===1)return [true];if(value===false||value===0)return [false];throw Error(c.name+': use TRUE/FALSE or 1/0');}
|
|
18
|
+
const limits={int16:[-32768,32767],uint16:[0,65535],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-3.4028234663852886e38,3.4028234663852886e38]};
|
|
19
|
+
if(typeof value!=='number'||!Number.isFinite(value)||value<limits[c.dataType][0]||value>limits[c.dataType][1]||(c.dataType!=='float32'&&!Number.isInteger(value)))throw Error(c.name+': value does not fit '+c.dataType);
|
|
20
|
+
if(widths[c.dataType]===1)return [value&65535];const b=new DataView(new ArrayBuffer(4));if(c.dataType==='float32')b.setFloat32(0,value);else if(c.dataType==='int32')b.setInt32(0,value);else b.setUint32(0,value);return [b.getUint16(2),b.getUint16(0)];
|
|
21
|
+
}
|
|
22
|
+
export function decode(c,values){if(c.dataType==='boolean'){if(![true,false,0,1].includes(values[0]))throw Error('Invalid Digital reply');return !!values[0];}if(values.length!==widths[c.dataType]||values.some(v=>!Number.isInteger(v)||v<0||v>65535))throw Error('Invalid Analog reply');if(widths[c.dataType]===1)return c.dataType==='int16'&&values[0]>32767?values[0]-65536:values[0];const b=new DataView(new ArrayBuffer(4));b.setUint16(0,values[1]);b.setUint16(2,values[0]);const v=c.dataType==='float32'?b.getFloat32(0):c.dataType==='int32'?b.getInt32(0):b.getUint32(0);if(!Number.isFinite(v))throw Error('Non-finite Analog reply');return v;}
|
|
23
|
+
export function groups(map){validateMap(map);const result=[];for(const kind of ['analog','switches']){let g;for(const c of map.channels.filter(c=>c.kind===kind).sort((a,b)=>a.address-b.address)){if(!g||c.address!==g.end||c.address+widths[c.dataType]-g.address>32){g={kind,address:c.address,end:c.address,channels:[]};result.push(g);}g.channels.push(c);g.end+=widths[c.dataType];}}return result;}
|
|
24
|
+
export function plan(map,mode,payload){const all=groups(map);if(!['read','write'].includes(mode))throw Error('Choose Read or Write');let encoded;
|
|
25
|
+
if(mode==='write'){
|
|
26
|
+
if(!payload||typeof payload!=='object'||Array.isArray(payload)||payload.mappingId!==map.mappingId)throw Error('Write needs the matching mappingId and analog and/or switches arrays');
|
|
27
|
+
if(Object.keys(payload).some(k=>!['mappingId','analog','switches'].includes(k)))throw Error('Unknown channel array');
|
|
28
|
+
encoded=new Map();let provided=false;
|
|
29
|
+
for(const kind of ['analog','switches'])if(hasOwn(payload,kind)){provided=true;const channels=map.channels.filter(c=>c.kind===kind);if(!Array.isArray(payload[kind])||payload[kind].length!==channels.length)throw Error(kind+' array must contain exactly '+channels.length+' values');for(const c of channels)encoded.set(c.slot,encode(c,payload[kind][c.index]));}
|
|
30
|
+
if(!provided||!encoded.size)throw Error('Provide at least one non-empty channel array');
|
|
31
|
+
}
|
|
32
|
+
return all.filter(g=>mode==='read'||hasOwn(payload,g.kind)).map(g=>({group:g,req:{mode,unitid:map.unitId,fc:mode==='read'?(g.kind==='switches'?1:3):(g.kind==='switches'?15:16),address:g.address,...(mode==='read'?{quantity:g.end-g.address}:{value:g.channels.flatMap(c=>encoded.get(c.slot))})}}));
|
|
33
|
+
}
|
|
34
|
+
export function emptyResult(map){return {mappingId:map.mappingId,analog:[],switches:[]};}
|
|
35
|
+
export function applyRead(result,group,data){
|
|
36
|
+
const expected=group.end-group.address;
|
|
37
|
+
// Coil replies are packed into whole bytes. modbus-serial expands the last
|
|
38
|
+
// byte too, so unused trailing bits are valid but are not mapped channels.
|
|
39
|
+
const padded=group.kind==='switches'?Math.ceil(expected/8)*8:expected;
|
|
40
|
+
if(!Array.isArray(data)||(data.length!==expected&&data.length!==padded))throw Error('Incomplete channel reply: expected '+expected+' '+(group.kind==='switches'?'digital values':'registers')+', received '+(Array.isArray(data)?data.length:'no data'));
|
|
41
|
+
for(const c of group.channels)result[c.kind][c.index]=decode(c,data.slice(c.address-group.address,c.address-group.address+widths[c.dataType]));return result;}
|
|
42
|
+
|
|
43
|
+
export function namedValues(map,result){return Object.fromEntries(map.channels.map(c=>[c.name,result[c.kind][c.index]]));}
|
|
44
|
+
// Resolve only supplied names. Never fill omitted channels with stale/default values.
|
|
45
|
+
export function planNamed(map,values){
|
|
46
|
+
validateMap(map);if(!values||typeof values!=='object'||Array.isArray(values)||!Object.keys(values).length)throw Error('Provide a named channel object');
|
|
47
|
+
const selected=new Map();for(const name of Object.keys(values)){const c=map.channels.find(c=>c.name===name);if(!c)throw Error('Unknown channel: '+name);selected.set(c.slot,encode(c,values[name]));}
|
|
48
|
+
const ops=[];for(const kind of ['analog','switches']){let op;for(const c of map.channels.filter(c=>c.kind===kind&&selected.has(c.slot)).sort((a,b)=>a.address-b.address)){
|
|
49
|
+
const v=selected.get(c.slot);if(!op||c.address!==op.req.address+op.req.value.length||op.req.value.length+v.length>32){op={req:{mode:'write',unitid:map.unitId,fc:kind==='switches'?15:16,address:c.address,value:[]}};ops.push(op);}op.req.value.push(...v);
|
|
50
|
+
}}return ops;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function planSingle(map,name,value){validateMap(map);if(typeof name!=='string'||!map.channels.some(c=>c.name===name))throw Error('Choose a channel from this node table');return planNamed(map,{[name]:value});}
|
|
54
|
+
export function prefixValues(values,prefix){const label=String(prefix==null?'':prefix).trim();return Object.fromEntries(Object.entries(values).map(([name,value])=>[label?label+' '+name:name,value]));}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const range=require('./range-scaling');
|
|
3
|
+
const ops={'*':(v,n)=>v*n,'/':(v,n)=>v/n,'+':(v,n)=>v+n,'-':(v,n)=>v-n,'>':(v,n)=>v>n,'<':(v,n)=>v<n,'==':(v,n)=>v===n,'!=':(v,n)=>v!==n,'%':(v,n)=>v%n,'<<':(v,n)=>v<<n,'>>':(v,n)=>v>>n,'>>>':(v,n)=>v>>>n};
|
|
4
|
+
function prepare(c){const mode=c.scaleMode||'none';if(mode==='none')return v=>v;if(c.kind==='switches')throw Error(c.name+': scaling needs an Analog channel');
|
|
5
|
+
if(mode==='range'){range.endpoints(c);return v=>range.scale(v,c);}
|
|
6
|
+
if(mode!=='math')throw Error('Unknown scaling mode');const text=String(c.scale??'').trim();let op,n;if(text!==''&&Number.isFinite(Number(text))&&!/^[+-]/.test(text)){op='*';n=Number(text);}else{const m=text.match(/^([*\/+\-]|>>>|>>|<<|==|!=|>|<|%)\s*(-?\d+(?:\.\d+)?)$/);if(m){op=m[1];n=Number(m[2]);}}
|
|
7
|
+
if(!ops[op]||!Number.isFinite(n)||((op==='/'||op==='%')&&n===0))throw Error(c.name+': invalid math expression');
|
|
8
|
+
return v=>{const result=ops[op](v,n);if(typeof result==='number'&&!Number.isFinite(result))throw Error(c.name+': math result is not finite');return result;};
|
|
9
|
+
}
|
|
10
|
+
function converters(map){return map.channels.map(c=>({name:c.name,apply:prepare(c)}));}
|
|
11
|
+
function apply(values,converters){return Object.fromEntries(converters.map(c=>[c.name,c.apply(values[c.name])]));}
|
|
12
|
+
module.exports={converters,apply};
|
package/icons/1.png
ADDED
|
Binary file
|
package/icons/2.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smithtek-mako-rf",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Smithtek
|
|
3
|
+
"version": "3.4.0",
|
|
4
|
+
"description": "Smithtek Mako RF and PassPort Connect nodes for Mako and Solaris readings and controls",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
7
7
|
"smithtek",
|
|
8
8
|
"modbus",
|
|
9
9
|
"rtu",
|
|
10
10
|
"rs485",
|
|
11
|
-
"lora"
|
|
11
|
+
"lora",
|
|
12
|
+
"passport-connect",
|
|
13
|
+
"mako",
|
|
14
|
+
"solaris"
|
|
12
15
|
],
|
|
13
16
|
"license": "GPL-3.0-only",
|
|
14
17
|
"author": "Smithtek",
|
|
@@ -24,5 +27,21 @@
|
|
|
24
27
|
},
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=14"
|
|
27
|
-
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"smithtek-mako-rf.js",
|
|
33
|
+
"smithtek-mako-rf.html",
|
|
34
|
+
"bus-engine.js",
|
|
35
|
+
"usb-discovery.js",
|
|
36
|
+
"range-scaling.js",
|
|
37
|
+
"passport-link-node.js",
|
|
38
|
+
"channel-map.mjs",
|
|
39
|
+
"icon.png",
|
|
40
|
+
"icons",
|
|
41
|
+
"README.md",
|
|
42
|
+
"UPSTREAM.md",
|
|
43
|
+
"channel-transform.js",
|
|
44
|
+
"PASSPORT-CONNECT.md",
|
|
45
|
+
"MAKO-RF.md"
|
|
46
|
+
]
|
|
28
47
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
module.exports=function(RED,engine){
|
|
3
|
+
const mapping=import('./channel-map.mjs'),transform=require('./channel-transform');
|
|
4
|
+
function PassPortLink(config){
|
|
5
|
+
RED.nodes.createNode(this,config);const node=this,states=new Set();node._makoClosed=false;let tail=Promise.resolve(),pending=0;
|
|
6
|
+
node.on('input',(msg,send,done)=>{
|
|
7
|
+
send=send||node.send.bind(node);done=done||function(err){if(err)node.error(err,msg);};
|
|
8
|
+
if(pending>=16){done(Error('PassPort Connect queue full'));return;}pending++;
|
|
9
|
+
const input=RED.util.cloneMessage(msg);
|
|
10
|
+
const run=async()=>{let completed=0,total=0,writing=false;
|
|
11
|
+
try{
|
|
12
|
+
const m=await mapping;if(node._makoClosed)throw Object.assign(Error('Node closed'),{code:'ECANCELLED'});
|
|
13
|
+
const map=m.validateMap(typeof config.mapping==='string'?JSON.parse(config.mapping):config.mapping);
|
|
14
|
+
const converters=config.mode==='write'?null:transform.converters(map);
|
|
15
|
+
const operations=config.mode==='write'&&config.inputMode==='single'?m.planSingle(map,config.writeChannel,input.payload):config.mode==='write'&&config.inputMode==='named'?m.planNamed(map,input.payload):m.plan(map,config.mode||'read',input.payload);total=operations.length;writing=config.mode==='write';
|
|
16
|
+
const bus=RED.nodes.getNode(config.bus);if(!bus||!bus._makoState)throw Error('Choose a PassPort connection');states.add(bus._makoState);
|
|
17
|
+
const payload=m.emptyResult(map);node.status({fill:'blue',shape:'dot',text:writing?'Writing channels':'Reading channels'});
|
|
18
|
+
for(const operation of operations){
|
|
19
|
+
if(node._makoClosed)throw Object.assign(Error('Node closed'),{code:'ECANCELLED'});
|
|
20
|
+
const res=await new Promise((resolve,reject)=>engine.enqueue(bus._makoState,{node,req:operation.req,...(writing?{retries:0}:{}),complete:(err,value)=>err?reject(err):resolve(value)}));
|
|
21
|
+
if(!writing)m.applyRead(payload,operation.group,res&&res.data);completed++;
|
|
22
|
+
}
|
|
23
|
+
if(node._makoClosed)throw Object.assign(Error('Node closed'),{code:'ECANCELLED'});
|
|
24
|
+
const output={...input,payload:writing?input.payload:m.prefixValues(transform.apply(m.namedValues(map,payload),converters),config.prefix),...(!writing?{channels:payload,ts:Date.now()}:{}),passport:{ok:true,source:config.bus+':'+map.unitId,unitId:map.unitId,mode:config.mode||'read',completed,total}};
|
|
25
|
+
send([output,null]);node.status({fill:'green',shape:'dot',text:'ID '+map.unitId+' complete'});done();
|
|
26
|
+
}catch(err){if(!node._makoClosed){node.status({fill:'red',shape:'ring',text:err.message});send([null,{...input,payload:{ok:false,error:err.message,completed,total,writeMayHaveApplied:writing&&total>0},passport:{ok:false}}]);}done(err);}
|
|
27
|
+
};
|
|
28
|
+
tail=tail.then(run,run).finally(()=>{pending--;});
|
|
29
|
+
});
|
|
30
|
+
node.on('close',(_removed,done)=>{node._makoClosed=true;states.forEach(state=>engine.detach(state,node));tail.finally(()=>{node.status({});done();});});
|
|
31
|
+
}
|
|
32
|
+
RED.nodes.registerType('smithtek-passport-link',PassPortLink);
|
|
33
|
+
};
|
package/smithtek-mako-rf.html
CHANGED
|
@@ -339,7 +339,7 @@
|
|
|
339
339
|
rssiGuardMs: { value: 10, validate: RED.validators.number() },
|
|
340
340
|
rssiTimeoutMs: { value: 100, validate: RED.validators.number() },
|
|
341
341
|
|
|
342
|
-
//
|
|
342
|
+
// Keep imported USB/by-id paths editable as well as the built-in ports.
|
|
343
343
|
serialPort: { value: "/dev/ttyAMA2", required: true },
|
|
344
344
|
|
|
345
345
|
baudRate: { value: 9600, required: true, validate: RED.validators.number() },
|
|
@@ -356,6 +356,25 @@
|
|
|
356
356
|
return this.busName || "smithtek mako rf";
|
|
357
357
|
},
|
|
358
358
|
oneditprepare: function () {
|
|
359
|
+
const select = $("#node-config-input-serialPort"), saved = this.serialPort || "/dev/ttyAMA2";
|
|
360
|
+
const builtins = ['/dev/ttyAMA2','/dev/ttyAMA0','/dev/ttyAMA1','usb:auto'];
|
|
361
|
+
if (!builtins.includes(saved)) $('<option>').val(saved).text('USB · saved device').appendTo(select);
|
|
362
|
+
select.val(saved);
|
|
363
|
+
const refresh = function () {
|
|
364
|
+
const current = select.val();
|
|
365
|
+
$('#st-usb-status').text('Checking USB connections…');
|
|
366
|
+
$.getJSON('smithtek-passport/usb').done(function(devices) {
|
|
367
|
+
select.find('option[data-detected]').remove();
|
|
368
|
+
devices.forEach(function(device) {
|
|
369
|
+
if (select.find('option').toArray().some(function(o){return o.value===device.value;})) return;
|
|
370
|
+
$('<option>').val(device.value).text(device.label).attr('data-detected','true').appendTo(select);
|
|
371
|
+
});
|
|
372
|
+
select.val(current);
|
|
373
|
+
$('#st-usb-status').text(devices.length ? 'USB devices found. Choose your device if more than one is connected.' : 'No USB devices found. Check power and the data cable.');
|
|
374
|
+
}).fail(function() { $('#st-usb-status').text('Unable to check USB connections. Try Refresh USB again.'); });
|
|
375
|
+
};
|
|
376
|
+
$('#st-usb-refresh').on('click',function(e){e.preventDefault();refresh();});
|
|
377
|
+
refresh();
|
|
359
378
|
updateSerialUiForChannel();
|
|
360
379
|
$("#node-config-input-serialPort").on("change", updateSerialUiForChannel);
|
|
361
380
|
}
|
|
@@ -445,10 +464,14 @@
|
|
|
445
464
|
<div class="form-row">
|
|
446
465
|
<label for="node-config-input-serialPort"><i class="fa fa-plug"></i> Channel</label>
|
|
447
466
|
<select id="node-config-input-serialPort" style="width:260px;">
|
|
467
|
+
<option value="/dev/ttyAMA2">RF</option>
|
|
448
468
|
<option value="/dev/ttyAMA0">RS485-1</option>
|
|
449
469
|
<option value="/dev/ttyAMA1">RS485-2</option>
|
|
450
|
-
<option value="
|
|
470
|
+
<option value="usb:auto">USB · Auto detect</option>
|
|
451
471
|
</select>
|
|
472
|
+
<button type="button" id="st-usb-refresh" class="red-ui-button">Refresh USB</button>
|
|
473
|
+
<p id="st-usb-status" class="form-tips"></p>
|
|
474
|
+
<p class="form-tips">Connect your Mako before using USB. Auto detect requires exactly one USB serial device. If several are connected, select yours. Match the PLC communication speed and format.</p>
|
|
452
475
|
</div>
|
|
453
476
|
|
|
454
477
|
<div class="form-row" id="st-row-baudRate">
|
|
@@ -488,7 +511,7 @@
|
|
|
488
511
|
<label for="node-config-input-rssiGuardMs"><i class="fa fa-clock-o"></i> RSSI guard (ms)</label>
|
|
489
512
|
<input type="number" id="node-config-input-rssiGuardMs" min="0" style="width:140px;">
|
|
490
513
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
491
|
-
(
|
|
514
|
+
(Older RF nodes only: pause before checking signal strength. Keep the default unless advised otherwise.)
|
|
492
515
|
</span>
|
|
493
516
|
</div>
|
|
494
517
|
|
|
@@ -496,7 +519,7 @@
|
|
|
496
519
|
<label for="node-config-input-rssiTimeoutMs"><i class="fa fa-hourglass-end"></i> RSSI timeout (ms)</label>
|
|
497
520
|
<input type="number" id="node-config-input-rssiTimeoutMs" min="10" style="width:140px;">
|
|
498
521
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
499
|
-
(
|
|
522
|
+
(Older RF nodes only: how long to wait for signal strength. PassPort Connect does not use this reading.)
|
|
500
523
|
</span>
|
|
501
524
|
</div>
|
|
502
525
|
|
|
@@ -507,7 +530,7 @@
|
|
|
507
530
|
<label for="node-config-input-timeout_s"><i class="fa fa-clock-o"></i> Timeout (seconds)</label>
|
|
508
531
|
<input type="number" id="node-config-input-timeout_s" style="width:140px;">
|
|
509
532
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
510
|
-
(
|
|
533
|
+
(How long to wait for a reply. A longer wait also delays the next message if a PLC is offline.)
|
|
511
534
|
</span>
|
|
512
535
|
</div>
|
|
513
536
|
|
|
@@ -515,7 +538,7 @@
|
|
|
515
538
|
<label for="node-config-input-retries"><i class="fa fa-repeat"></i> Retries</label>
|
|
516
539
|
<input type="number" id="node-config-input-retries" style="width:140px;">
|
|
517
540
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
518
|
-
(
|
|
541
|
+
(Extra attempts after a failed read. PassPort Connect commands are not automatically repeated.)
|
|
519
542
|
</span>
|
|
520
543
|
</div>
|
|
521
544
|
|
|
@@ -523,18 +546,18 @@
|
|
|
523
546
|
<label for="node-config-input-gap_s"><i class="fa fa-arrows-h"></i> Gap (seconds)</label>
|
|
524
547
|
<input type="number" id="node-config-input-gap_s" step="0.1" min="0" style="width:140px;">
|
|
525
548
|
<span style="margin-left:8px; font-size:12px; color:#888;">
|
|
526
|
-
(
|
|
549
|
+
(A short pause between messages. Keep the default to start.)
|
|
527
550
|
</span>
|
|
528
551
|
</div>
|
|
529
552
|
<div class="form-tips">
|
|
530
|
-
|
|
553
|
+
Choose RF for wireless, RS485 for a wired connection, or USB for a connected Mako.
|
|
531
554
|
</div>
|
|
532
555
|
<div class="form-row">
|
|
533
556
|
<label for="node-config-input-maxQueue"><i class="fa fa-list"></i> Max queue</label>
|
|
534
557
|
<input type="number" id="node-config-input-maxQueue" min="1" style="width:140px;">
|
|
535
558
|
</div>
|
|
536
559
|
<div class="form-tips">
|
|
537
|
-
|
|
560
|
+
Messages take turns. Max queue is the waiting-list limit. If it fills up, slow down your repeating reads.
|
|
538
561
|
</div>
|
|
539
562
|
|
|
540
563
|
|
|
@@ -678,7 +701,7 @@
|
|
|
678
701
|
<script type="text/markdown" data-help-name="smithtek-mako-rf">
|
|
679
702
|
Smithtek Mako RF / RS485.
|
|
680
703
|
|
|
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.
|
|
704
|
+
Read mode outputs decoded JSON in msg.payload using the Decoder map rows. Numeric readings are rounded to at most six decimal places after Math or Range scaling, and remain numbers. Digital points (coils, discrete inputs and register bits) output numeric 1 for on and 0 for off.
|
|
682
705
|
Raw modbus response is kept in msg.modbus.raw.
|
|
683
706
|
|
|
684
707
|
Prefix adds text and one space before each decoder name. Leave blank to preserve names.
|
|
@@ -689,3 +712,137 @@ Failed requests output a named numeric alarm on output 1, for example {"b1 RF ID
|
|
|
689
712
|
|
|
690
713
|
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.
|
|
691
714
|
</script>
|
|
715
|
+
|
|
716
|
+
<script type="text/javascript">
|
|
717
|
+
(function(){
|
|
718
|
+
const typeLabels={float32:'Float',uint32:'32-bit unsigned',int32:'32-bit signed',int16:'16-bit signed',uint16:'16-bit unsigned',boolean:'TRUE / FALSE'};
|
|
719
|
+
const width={float32:2,int32:2,uint32:2,int16:1,uint16:1,boolean:1};
|
|
720
|
+
function collect(){const channels=[],indices={analog:0,switches:0};$('#pl-rows tr').each(function(i){const tr=$(this),get=k=>tr.find('[data-field="'+k+'"]').val(),kind=get('kind');channels.push({...(tr.data('channel')||{}),slot:i,index:indices[kind]++,kind,name:get('name').trim(),dataType:kind==='switches'?'boolean':get('dataType'),address:Number(get('address')),pulseOnWrite:kind==='switches'&&!!(tr.data('channel')||{}).pulseOnWrite,scaleMode:kind==='switches'?'none':get('scaleMode'),scale:get('scale'),inputLow:get('inputLow'),inputHigh:get('inputHigh'),outputLow:get('outputLow'),outputHigh:get('outputHigh'),rangeCollapsed:!!tr.data('rangeCollapsed'),clampRange:tr.find('[data-field="clampRange"]').prop('checked')});});const map={schema:'smithtek.passport-channels',version:1,unitId:Number($('#pl-unit').val()),channels};map.mappingId='passport-link-v1:'+JSON.stringify([map.unitId,channels.map(c=>[c.kind,c.index,c.name,c.dataType,c.address,c.slot,!!c.pulseOnWrite,c.pulseMs||250])]);return map;}
|
|
721
|
+
function modeUi(){const write=$('#node-input-mode').val()==='write';$('.pl-write-only').toggle(write);$('.pl-read-only').toggle(!write);$('.pl-single-only').toggle(write&&$('#node-input-inputMode').val()==='single');$('.pl-table tr').each(function(){$(this).children().eq(4).toggle(!write);});}
|
|
722
|
+
function sync(){const map=collect();const choose=$('#node-input-writeChannel'),selected=choose.val()||choose.data('selected')||'';choose.empty().append($('<option>').val('').text('Choose channel…'));map.channels.forEach(c=>choose.append($('<option>').val(c.name).text(c.name+' · '+(typeLabels[c.dataType]||c.dataType))));if(selected&&!map.channels.some(c=>c.name===selected))choose.append($('<option>').val(selected).text(selected+' (not in table)'));choose.val(selected);modeUi();$('#node-input-mapping').val(JSON.stringify(map)).trigger('change');$('#pl-count').text(map.channels.length+' / 64 channels');$('#pl-add').prop('disabled',map.channels.length>=64);}
|
|
723
|
+
function row(c){const tr=$('<tr>').data('rangeCollapsed',!!c.rangeCollapsed).data('channel',c).appendTo('#pl-rows');function field(key,type,value){return $('<input>').attr({type:type||'text','data-field':key,'aria-label':key}).val(value??'');}function select(key,options,value){const el=$('<select>').attr({'data-field':key,'aria-label':key});options.forEach(o=>$('<option>').val(o[0]).text(o[1]).appendTo(el));return el.val(value);}
|
|
724
|
+
const name=field('name','text',c.name).attr('maxlength',40),kind=select('kind',[['analog','Analog'],['switches','Digital']],c.kind),type=select('dataType',Object.entries(typeLabels),c.dataType),address=field('address','number',c.address).attr({min:0,max:65535,step:1}),mode=select('scaleMode',[['none','None'],['math','Math'],['range','Range']],c.scaleMode||'none'),math=field('scale','text',c.scale).attr('placeholder','/100 or +5'),range=$('<div class="pl-range">');
|
|
725
|
+
for(const [key,label] of [['inputLow','Input low'],['inputHigh','Input high'],['outputLow','Output low'],['outputHigh','Output high']])$('<label>').text(label).append(field(key,'number',c[key]).attr('step','any')).appendTo(range);
|
|
726
|
+
$('<label>').text('Clamp ').append(field('clampRange','checkbox','').prop('checked',!!c.clampRange)).appendTo(range);
|
|
727
|
+
const toggle=$('<button type="button" class="red-ui-button">').on('click',()=>{tr.data('rangeCollapsed',!tr.data('rangeCollapsed'));visibility();sync();});
|
|
728
|
+
for(const control of [name,kind,type,address])$('<td>').append(control).appendTo(tr);$('<td>').append($('<div style="display:flex;gap:5px;align-items:center">').append(mode,toggle),math,range).appendTo(tr);
|
|
729
|
+
const buttons=$('<td>');for(const [label,action] of [['↑',()=>{tr.prev().before(tr);sync();}],['↓',()=>{tr.next().after(tr);sync();}],['×',()=>{tr.remove();sync();}]])$('<button type="button" class="red-ui-button">').text(label).attr('aria-label',label==='×'?'Remove channel':label==='↑'?'Move channel up':'Move channel down').on('click',action).appendTo(buttons);buttons.appendTo(tr);
|
|
730
|
+
function visibility(){const sw=kind.val()==='switches';if(sw){type.val('boolean');mode.val('none');}else if(type.val()==='boolean')type.val('float32');type.prop('disabled',sw);mode.prop('disabled',sw);math.toggle(mode.val()==='math');const isRange=mode.val()==='range',collapsed=!!tr.data('rangeCollapsed');range.toggle(isRange&&!collapsed);toggle.toggle(isRange).text(collapsed?'Show range':'Hide range').attr('aria-expanded',String(!collapsed)).css({whiteSpace:'nowrap',flexShrink:0});}
|
|
731
|
+
mode.on('change',()=>{if(mode.val()==='range')tr.data('rangeCollapsed',false);});
|
|
732
|
+
kind.on('change',()=>{const others=collect().channels.filter((_,i)=>i!==tr.index()).filter(c=>c.kind===kind.val());address.val(others.reduce((end,c)=>Math.max(end,c.address+(width[c.dataType]||1)),0));});
|
|
733
|
+
tr.on('input change','input,select',()=>{visibility();sync();});visibility();
|
|
734
|
+
}
|
|
735
|
+
function valid(value){try{const m=typeof value==='string'?JSON.parse(value):value;if(!m||!Number.isInteger(m.unitId)||m.unitId<1||m.unitId>247||!m.channels.length||m.channels.length>64)return false;const names=new Set(),used=new Set();return m.channels.every(c=>{if(!c.name||names.has(c.name)||['__proto__','constructor','prototype'].includes(c.name)||!width[c.dataType]||!Number.isInteger(c.address)||c.address<0||c.address+width[c.dataType]>65536)return false;names.add(c.name);for(let i=0;i<width[c.dataType];i++){const k=c.kind+':'+(c.address+i);if(used.has(k))return false;used.add(k);}if(c.scaleMode==='range'){const vals=['inputLow','inputHigh','outputLow','outputHigh'].map(k=>c[k]);if(vals.some(v=>v===''||!Number.isFinite(Number(v)))||Number(vals[0])===Number(vals[1]))return false;}if(c.scaleMode==='math'){const s=String(c.scale||'').trim();if(!s||(!Number.isFinite(Number(s))&&!/^([*\/+\-]|>>>|>>|<<|==|!=|>|<|%)\s*(-?\d+(?:\.\d+)?)$/.test(s))||/^[/%%]\s*-?0(?:\.0+)?$/.test(s))return false;}return true;});}catch(e){return false;}}
|
|
736
|
+
RED.nodes.registerType('smithtek-passport-link',{
|
|
737
|
+
category:'Smithtek',paletteLabel:'PassPort Connect',color:'#6D6D6D',defaults:{prefix:{value:''},writeChannel:{value:'',validate:function(v){if(this.mode!=='write'||this.inputMode!=='single')return true;try{return JSON.parse(this.mapping).channels.some(c=>c.name===v);}catch(e){return false;}}},name:{value:''},bus:{value:'',type:'smithtek-mako-rf-bus',required:true},mode:{value:'read',required:true},inputMode:{value:'single'},mapping:{value:'',required:true,validate:valid}},inputs:1,outputs:2,icon:'passport-connect-cropped.png',label:function(){return this.name||'PassPort Connect';},outputLabels:['Named data / write confirmed','Error'],
|
|
738
|
+
oneditprepare:function(){$('#node-input-writeChannel').data('selected',this.writeChannel||'').on('change',function(){$(this).data('selected',$(this).val());});$('#node-input-mode,#node-input-inputMode').on('change',modeUi);$('#node-input-inputMode').val(this.inputMode||'arrays');let map;try{map=typeof this.mapping==='string'?JSON.parse(this.mapping):this.mapping;}catch(e){}$('#pl-unit').val(map?.unitId||1);$('#pl-rows').empty();(map?.channels||[]).forEach(row);$('#pl-unit').on('input change',sync);$('#pl-add').on('click',function(){const channels=collect().channels,kind=$('#pl-add-kind').val(),label=kind==='switches'?'Digital':'Analog';let address=0;channels.filter(c=>c.kind===kind).forEach(c=>address=Math.max(address,c.address+width[c.dataType]));let i=1;while(channels.some(c=>c.name===label+' '+i))i++;row({name:label+' '+i,kind,dataType:kind==='switches'?'boolean':'float32',address});sync();});sync();},oneditsave:function(){sync();this.mapping=$('#node-input-mapping').val();}
|
|
739
|
+
});
|
|
740
|
+
})();
|
|
741
|
+
</script>
|
|
742
|
+
<style>
|
|
743
|
+
.pl-table{width:100%;border-collapse:collapse}.pl-table td,.pl-table th{padding:5px;text-align:left;vertical-align:top}.pl-table input,.pl-table select{width:100%;min-width:75px}.pl-table td:first-child{min-width:140px}.pl-table td:last-child{white-space:nowrap}.pl-table td:last-child button{padding:0 6px}.pl-range{display:grid;grid-template-columns:1fr 1fr;gap:5px;min-width:220px}.pl-range label{font-size:11px;margin:0!important;width:auto!important}.pl-range input[type=checkbox]{width:auto;min-width:0}.pl-table [data-field=scale]{margin-top:5px}
|
|
744
|
+
</style>
|
|
745
|
+
<script type="text/html" data-template-name="smithtek-passport-link">
|
|
746
|
+
<div class="form-row"><label for="node-input-name">Name</label><input id="node-input-name" type="text"></div>
|
|
747
|
+
<div class="form-row"><label for="node-input-bus">Connection</label><input id="node-input-bus"></div>
|
|
748
|
+
<div class="form-row"><label for="node-input-mode">Action</label><select id="node-input-mode"><option value="read">Read channels</option><option value="write">Write channels</option></select></div>
|
|
749
|
+
<div class="form-row pl-write-only"><label for="node-input-inputMode">Write input</label><select id="node-input-inputMode"><option value="single">Single channel</option><option value="arrays">Mapped arrays</option><option value="named">Named values</option></select></div>
|
|
750
|
+
<div class="form-row pl-single-only"><label for="node-input-writeChannel">Write channel</label><select id="node-input-writeChannel"></select></div>
|
|
751
|
+
<div class="form-row pl-read-only"><label for="node-input-prefix">Prefix</label><input id="node-input-prefix" type="text" placeholder="e.g. North Mako"></div>
|
|
752
|
+
<div class="form-row"><label for="pl-unit">Unit ID</label><input id="pl-unit" type="number" min="1" max="247"></div>
|
|
753
|
+
<input id="node-input-mapping" type="hidden">
|
|
754
|
+
<p><b>Separate Read and Write nodes:</b> copy and paste your complete Read node, then change the copy’s Action to Write. This copies the whole table. Choose the row you want to control. Later changes to one node do not update the other.</p>
|
|
755
|
+
<p class="pl-write-only"><b>Single channel:</b> select Pump, then inject a Boolean true/false into msg.payload; select a setpoint and inject a number. Only that channel is written. <b>Named values:</b> send {"Pump":true,"Setpoint":25}; only supplied names are written. Names are case-sensitive and use the table names without the read Prefix. <b>Mapped arrays:</b> send matching mappingId plus Analog and/or Digital arrays (JSON keys analog and switches) in table order. These are ordered values, not named objects.</p>
|
|
756
|
+
<p class="pl-read-only"><b>Prefix:</b> helps you label readings. North Mako plus Level appears as "North Mako Level". Commands still use the original row name, Level.</p>
|
|
757
|
+
|
|
758
|
+
<div style="overflow:auto"><table class="pl-table"><thead><tr><th>Name</th><th>Data type</th><th>Value type</th><th>Address</th><th>Read scaling</th><th>Order / remove</th></tr></thead><tbody id="pl-rows"></tbody></table></div>
|
|
759
|
+
<p><select id="pl-add-kind" aria-label="New channel kind" style="width:120px"><option value="analog">Analog</option><option value="switches">Digital</option></select> <button id="pl-add" type="button" class="red-ui-button">Add channel</button> <span id="pl-count"></span></p>
|
|
760
|
+
<p>Keep this table matched to the PLC. Moving a row does not change its address. Changes here do not change the PLC program. Export again from V-net after changing the PLC table.</p>
|
|
761
|
+
<p>Math and Range change named read values. Arrays in msg.channels keep the original values. Write inputs use device units; scaling is not applied to commands. Update matching nodes after changing the table.</p>
|
|
762
|
+
</script>
|
|
763
|
+
<script type="text/html" data-help-name="smithtek-passport-link">
|
|
764
|
+
<p>PassPort Connect (PPC) is Smithtek’s proprietary communication link between PassPort, Mako and Solaris. It lets your PassPort read measurements and send commands to a Mako or Solaris. For example, you can check a tank level, change a setting or start a pump.</p>
|
|
765
|
+
<p>The smithtek-mako-rf package includes two communication nodes. Original Mako RF is for basic Modbus reads and writes. PassPort Connect is for the matching PassPort Connect table in V-NET2. Choose PassPort Connect for this guide; the two nodes use different message formats.</p>
|
|
766
|
+
<p>In V-NET2, use the PassPort Connect logic block to choose the readings and controls you want to share. Export from that logic block to create matching Read and Write nodes in Node-RED. Read means “tell me the values”; Write means “change this value”.</p>
|
|
767
|
+
<h3>Setup</h3><ol><li>In V-NET2, click New project in the top toolbar. Enter a project name, choose Mako or Solaris, then create the project. Save any existing work you want to keep.</li><li>In the logic-block library on the right of V-NET2, open Communication and click PassPort Connect. Read the introduction, then click Add PassPort Connect to place the logic block on your diagram.</li><li>Click the PassPort Connect logic block on your V-NET2 diagram. In its Properties panel on the left, enter a Unit ID from 1 to 247. Give every PLC sharing a PassPort connection a different Unit ID. Set Connections to the number of communication links this logic block will use.</li><li>In that same Properties panel, click Edit Analog & Digital table. Add a row for each reading or control, enter a clear name such as Tank level or Pump start, and choose its value type and starting value. Click Apply table to save the table to the logic block.</li><li>On the V-NET2 diagram, wire each measurement to its named input on the left of the PassPort Connect logic block. Wire each control from its named output on the right to the equipment output or logic block it should operate.</li><li>Select the PassPort Connect logic block again. In its left-hand Properties panel, click PassPort Connect · export nodes. This opens the connection and export window.</li><li>In that window, under Match the connections, choose the communication port used on your Mako or Solaris and the port used on your PassPort. For example, choose RF on both sides for radio, or Solaris RS485 and PassPort RS485-1 for a wired link. Open Review or change settings before export and check the settings for your chosen connection.</li><li>In the same window, click Export read & write nodes. Choose a filename and folder, then save. V-NET2 creates the Node-RED JSON file and adds any missing Send and Receive wires to the selected PLC port on your diagram. Existing wires are kept. Apply settings is available if you want to save these connection choices without exporting a file.</li><li>Back in V-NET2, save the project using Save in the toolbar. When ready, use Build program to build and upload it to the selected PLC. The export file alone does not update the PLC. If using RF, also set the PassPort radio to match the PLC.</li><li>In Node-RED, open the flow page where you want the nodes. Open the menu, choose Import, select the JSON file saved by V-NET2 and choose Current flow. Click Import and place the Read and Write nodes on that page. Do not paste this JSON into an existing node’s settings.</li><li>In Node-RED, double-click each imported node to check its Action, Unit ID and Connection. Add an Inject node before Read and connect a Debug node to each output. Leave Inject once after startup and Repeat off for this first test. Click Deploy, then press the Inject button to read the PLC.</li></ol>
|
|
768
|
+
<h3>1 · Choose your connection</h3>
|
|
769
|
+
<dl><dt>RF · Mako or Solaris</dt><dd>Choose RF at both ends. Match the frequency, range and Network ID on the PLC and PassPort. If you use an encryption key, match that too.</dd><dt>RS485 · Mako</dt><dd>Choose RS485-1 or RS485-2 on the Mako and the wired port you are using on PassPort. Connect A to A and B to B. Match the baud rate and serial format at both ends.</dd><dt>RS485 · Solaris</dt><dd>Choose RS485 for Solaris and RS485-1 or RS485-2 for PassPort. Connect A to A and B to B. Set the Solaris serial switch to RS485. It uses the shared Monitor / RS485 Send and Receive pins. Match the baud rate and serial format at both ends.</dd><dt>USB · Mako</dt><dd>Connect the Mako USB socket above RS485-1 to a PassPort USB slot with the specified USB-A to USB-A data cable. Choose USB in V-NET2. This uses the Mako Monitor Send and Receive pins. In Node-RED, USB · Auto detect finds a single connected USB serial device. If several are connected, use Refresh USB and choose your device in Connection.</dd></dl>
|
|
770
|
+
<p>USB detection runs on the PassPort, where the cable is plugged in. It finds available USB connections; it cannot prove that a device is a Mako. Confirm the selected device and Unit ID with a test read before sending commands. With several devices connected, choose the right one rather than letting the software guess.</p>
|
|
771
|
+
<p>If no USB device is found, check power and the data cable, then refresh the list. Devices with a unique identity can be found again if their connection number changes. If a device has no unique identity, check the selection again after unplugging or restarting.</p>
|
|
772
|
+
<p>Solaris has one shared Monitor / RS485 port. In your V-NET2 diagram, if a sensor logic block already uses that port, choose RF for the PassPort Connect logic block. The same Send port cannot be connected to both the sensor logic block and the PassPort Connect logic block.</p>
|
|
773
|
+
<p>Changing the export settings does not change the PassPort radio. Set that separately. Short range and Long range are radio settings, not promises about how far a signal will travel.</p>
|
|
774
|
+
<p>Keep the selected connection for PassPort messages. Do not also send ordinary Monitor text over it: mixed messages can stop readings and commands from working.</p>
|
|
775
|
+
<h3>2 · Make your channel table</h3>
|
|
776
|
+
<dl><dt>Float</dt><dd>A 32-bit floating-point number, such as 12.5 volts. Stores about seven significant digits. Large whole numbers can round: 123456789 becomes 123456792. Choose 32-bit unsigned or 32-bit signed when you need that whole number exactly.</dd><dt>16-bit signed</dt><dd>Whole numbers from −32768 to 32767.</dd><dt>16-bit unsigned</dt><dd>Whole numbers from 0 to 65535.</dd><dt>32-bit signed</dt><dd>Whole numbers from −2147483648 to 2147483647.</dd><dt>32-bit unsigned</dt><dd>Whole numbers from 0 to 4294967295.</dd><dt>Digital</dt><dd>On or off: true or false. Use this for switches and simple controls.</dd></dl>
|
|
777
|
+
<p>An Analog row holds a number. A Digital row holds on or off. Mako supports up to 64 rows; Solaris supports up to 16, with space also needed for the rest of your program.</p>
|
|
778
|
+
<p>Use a different name for each row, up to 40 characters. Capital letters matter: Pump and pump are different names. The editor will tell you if a name cannot be used.</p>
|
|
779
|
+
<p>V-NET2 works out the table addresses for you. An address is simply where a value is kept. Leave these alone when using an exported table.</p>
|
|
780
|
+
<p>Keep the PLC table and both Node-RED tables the same. After changing a table in V-NET2, upload the changed PLC program and export fresh nodes. Replace the old nodes so you do not accidentally read or send commands twice.</p>
|
|
781
|
+
<p>Starting values apply when the PLC starts. They do not make Node-RED send a command. A signal wired into a row on the left can keep replacing a command sent from PassPort. Leave that input unwired for a remote-only control.</p>
|
|
782
|
+
<p>Use a separate feedback row when you need to know what actually happened. A Pump start command and a Pump running sensor tell you different things.</p>
|
|
783
|
+
<h3>3 · Read your measurements</h3>
|
|
784
|
+
<p>A Read node waits until another node asks it to work. Connect an Inject node to its input and press the Inject button. Each press reads the whole table. The value inside the Inject message does not choose a channel.</p>
|
|
785
|
+
<p>To start, leave Repeat and Inject once after startup off. After a successful test, you can set Inject to repeat. Allow enough time for one reading to finish before asking again.</p>
|
|
786
|
+
<p>Connect a Debug node to the first output to see successful readings. Values are listed by their table names in msg.payload. For example, {"Level":12.5,"Pump":false} means the level is 12.5 and the Pump value is off.</p>
|
|
787
|
+
<p>The second output reports problems. If part of a read fails, the node reports an error instead of sending an incomplete set of readings.</p>
|
|
788
|
+
<p>The PLC may need several requests to return a table. Values are collected one after another, so they are not all measured at exactly the same moment.</p>
|
|
789
|
+
<p>Prefix helps you tell PLCs apart. Enter North Mako and a row called Level appears as North Mako Level in the readings. The row name used for commands is still Level.</p>
|
|
790
|
+
<p>For more detailed flows, msg.ts records when Node-RED finished the read. It is not the time the PLC measured each value. msg.channels holds the original, unscaled numbers and on/off values; msg.passport holds the result and progress details. Most everyday flows only need msg.payload.</p>
|
|
791
|
+
<h3>4 · Send a command</h3>
|
|
792
|
+
<dl><dt>Single channel · easiest</dt><dd>Choose the row in Write channel. Send a number for Analog, or Boolean true / false for Digital. Only that row changes.</dd><dt>Named values</dt><dd>Send a JSON object such as {"Pump":true,"Setpoint":25}. Only the named rows change. Use the exact table names, without a read Prefix.</dd><dt>Mapped arrays · advanced</dt><dd>Use this only if your flow already works with lists of values. Send the matching mappingId and the full Analog or Digital list in table order. The Digital list is called switches.</dd></dl>
|
|
793
|
+
<p>Open the Write node, choose Single channel, then choose the row you want to control. Exported writers initially select the first row, so always check this before connecting commands.</p>
|
|
794
|
+
<p>In Inject, choose number for a numeric setting or Boolean for an on/off command. Do not choose string: the text “true” is not the same as the Boolean value true. For several named values, choose JSON.</p>
|
|
795
|
+
<p>Analog commands must fit the row type. Whole-number rows need whole numbers. Digital accepts true and false, or 1 and 0. Send an ordinary number even for a 32-bit row; the node handles its size.</p>
|
|
796
|
+
<p>The first output confirms that the PLC answered the command. It does not prove a motor turned or a valve moved. Read a feedback sensor to check the equipment.</p>
|
|
797
|
+
<p>Commands are not automatically repeated after a failed reply. A PLC can receive a command even when its reply is lost. Check the equipment or read it back before repeating a start, reset or pulse command.</p>
|
|
798
|
+
<p>If you change several values together and a later part fails, earlier values may already have changed. Read the values back before deciding what to send next.</p>
|
|
799
|
+
<p>Do not connect Read straight into Write. That can turn every repeat reading into another command. Make separate buttons or control logic for the changes you intend.</p>
|
|
800
|
+
<p>For advanced list-based flows, move the unscaled msg.channels object into msg.payload before using it as a Mapped arrays command. A one-Analog, one-Digital table could use {"mappingId":"the matching table ID","analog":[25],"switches":[true]}. Keep each supplied list complete and in order. The table ID checks the message against the node; it does not check which program was uploaded to the PLC.</p>
|
|
801
|
+
<h3>5 · Short pulses, remote resets and lost communication</h3>
|
|
802
|
+
<p>Pulse on write works like a quick button press. Turn it on for a Digital row in V-NET2. A command turns the output on briefly, then it turns off by itself. The default is 250 milliseconds, or a quarter of a second. You can choose 1 to 1000 milliseconds.</p>
|
|
803
|
+
<p>This is useful for remote resets or restarts. For example, connect a Reset pulse to the reset input of the equipment logic you want to restart. Connect it to the PLC Reset input only when you intend to restart the whole PLC. A PLC restart interrupts communication, so its reply may be lost.</p>
|
|
804
|
+
<p>Each accepted write triggers a pulse, even a repeated value or false. Send one true command for each intended press. Do not send a second false command to release it: that would trigger another pulse. Another command during a pulse extends it. Reading the row does not trigger it.</p>
|
|
805
|
+
<p>Pulse settings belong to the PLC program. Save and upload after changing them, then export matching nodes.</p>
|
|
806
|
+
<p>The communications watchdog is a way for your program to notice missing messages. Set Timeout to the number of seconds it should wait. Zero turns it off.</p>
|
|
807
|
+
<p>If no valid communication arrives for that whole time, Watchdog sends false once. It waits another full timeout before checking for recovery. It sends true once when recent communication has returned, or waits and checks again if it has not. It sends no startup command.</p>
|
|
808
|
+
<p>Choose the response by wiring Watchdog into your program. It does not stop a pump or clear other values by itself. Use regular reads if you rely on them to keep communication active; manual clicks leave gaps between messages.</p>
|
|
809
|
+
<h3>6 · Copy a node and adjust how readings look</h3>
|
|
810
|
+
<p>A useful tip: copy and paste your complete Read node, then change the copy’s Action to Write. This copies the entire channel table and connection settings, so you do not have to work out the table again. Choose Single channel and select the row you want to control before using it.</p>
|
|
811
|
+
<p>The copy is independent. Later edits to one node do not change the other. Keep both tables matched to the uploaded PLC program.</p>
|
|
812
|
+
<p>Scaling changes how a reading is displayed. For example, Math /100 turns 1250 into 12.5. None leaves it unchanged. Math allows one simple operation, such as *2, /100, +5 or -7. Do not enter a long formula.</p>
|
|
813
|
+
<p>Range converts between two scales. For example, Input low 4 and Input high 20, with Output low 0 and Output high 100, turns a 4–20 reading into 0–100. Clamp keeps the displayed result inside that range. Without Clamp, readings outside the input range can produce results below or above the output range.</p>
|
|
814
|
+
<p>Hide range only folds the settings away; it does not turn scaling off. Choose None to stop scaling. Scaling is for Analog rows.</p>
|
|
815
|
+
<p>Scaling and Prefix change named readings only. They do not change the PLC or commands. If /100 displays a stored value of 1250 as 12.5, a Write command still needs 1250 to request that original value.</p>
|
|
816
|
+
<p>You normally do not need to edit addresses. If you do, they must match the PLC. Moving rows in Node-RED does not change their addresses. Exporting again from V-NET2 uses the V-NET2 table and does not keep separate edits made in Node-RED.</p>
|
|
817
|
+
<h3>7 · Sharing a connection and avoiding delays</h3>
|
|
818
|
+
<p>PLCs using the same PassPort RF or wired port share one Connection setting. Select that existing Connection in each Read and Write node. Give each PLC its own Unit ID.</p>
|
|
819
|
+
<p>Think of the connection as a single lane: messages take turns. A long table or a PLC that does not answer can hold up the next message, including a command.</p>
|
|
820
|
+
<p>Timeout is how long PassPort waits for an answer. Read retries is how many extra times it tries a failed read. The exported defaults wait up to 8 seconds and allow 2 extra read attempts. Commands do not use these extra attempts.</p>
|
|
821
|
+
<p>A queue is a waiting list of messages. If you ask for readings too quickly, the list grows and replies get late. Slow down the Inject repeat rate and remove duplicate triggers if this happens.</p>
|
|
822
|
+
<p>Each Read or Write node can have up to 16 waiting messages. The shared connection also has a limit. Use a Catch node to show queue-full errors, as well as Debug on the second output for other problems.</p>
|
|
823
|
+
<p>Start with one PLC and manual reads. Add regular reads and other PLCs gradually, checking how long replies take. Signal conditions and the amount of information affect the speed.</p>
|
|
824
|
+
<p>Signal-strength options used by the older RF node do not add a signal-strength reading to PassPort Connect.</p>
|
|
825
|
+
<h3>8 · If something is not working</h3>
|
|
826
|
+
<dl><dt>Unknown node after import</dt><dd>Check that PassPort Connect is available in your Node-RED palette. If it is missing, ask your installer to update PassPort Connect.</dd><dt>No reading after Deploy</dt><dd>Press the Inject button. Deploy saves the flow; it does not ask for a reading.</dd><dt>No answer or timeout</dt><dd>Check power, cables, Unit ID and matching communication settings. Confirm you uploaded the intended PLC program. For USB, refresh the detected-device list and confirm your selection.</dd><dt>Numbers look wrong</dt><dd>Compare the table and value types with V-NET2. Check whether scaling has changed the displayed number.</dd><dt>A command is rejected</dt><dd>Check the selected row, spelling and value type. If using lists, check they match the whole current table.</dd><dt>A command changes back immediately</dt><dd>Look for a local signal or other logic that keeps writing to that row.</dd><dt>A command reports an error</dt><dd>Check the equipment and read the value back. The command may have arrived even if its reply did not.</dd><dt>Replies are slow or the queue is full</dt><dd>Read less often. Remove duplicate Inject timers and make sure nodes sharing a port use the same Connection.</dd></dl>
|
|
827
|
+
<p>Connect Debug to the second output to see the error message. Add a Catch node for errors such as a full waiting list. Both can report the same problem, so avoid making two automatic responses to one fault.</p>
|
|
828
|
+
<p>The error details can show how many parts completed and whether a command may already have reached the PLC. Use that information before trying again.</p>
|
|
829
|
+
<p>A green completed status means the last request worked. It does not prove the PLC is still connected now. A screen showing old readings should also show when they were last updated.</p>
|
|
830
|
+
<p>Before using a new setup unattended, test the readings, commands and loss-of-communication response on your equipment.</p>
|
|
831
|
+
<h3>9 · Export and import into Node-RED</h3>
|
|
832
|
+
<p>In V-NET2, click the PassPort Connect logic block on your diagram. In the left-hand Properties panel, click PassPort Connect · export nodes. Choose the connections in the window that opens, then click Export read & write nodes and save the JSON file.</p>
|
|
833
|
+
<p>In Node-RED, open the flow page you want to use. Open the menu and choose Import. Select the JSON file saved by V-NET2, choose Current flow, click Import and place the nodes. This adds complete Read and Write nodes with your table. It does not create a new flow tab.</p>
|
|
834
|
+
<p>You can also copy the contents of that JSON file and paste them into the text box in the Node-RED Import window. You must still click Import. Do not paste the complete file into a Read or Write node’s settings.</p>
|
|
835
|
+
<p>In Node-RED, double-click each imported node. Check Unit ID and Connection. If that PassPort port is already in use, choose the existing Connection. Click Done, add your Inject buttons and Debug nodes, then click Deploy.</p>
|
|
836
|
+
<p>The other button in the V-NET2 logic block’s Properties panel is Export connection setup JSON. It saves a reference copy of your table and settings. It does not create Node-RED nodes and cannot be imported as a flow or pasted into a node. Use PassPort Connect · export nodes when you want the Read and Write nodes.</p>
|
|
837
|
+
<p>Neither export uploads the PLC program or changes the PassPort radio. Save and upload the PLC project from V-NET2 separately.</p>
|
|
838
|
+
<p>If PassPort Connect is missing from the Node-RED palette, ask your installer to add or update it.</p>
|
|
839
|
+
<p>Older projects using the ordinary Modbus logic block still have their separate single-node export. Farming quick templates use the PassPort Connect logic block and export the complete Read and Write pair.</p>
|
|
840
|
+
<h3>10 · Try it: battery reading and a relay command</h3>
|
|
841
|
+
<p>Create a test project for your PLC. Add PassPort Connect with Unit ID 7 and one connection. Add Battery as a Float Analog row, and TestRelay as a normal Digital row with Pulse on write off.</p>
|
|
842
|
+
<p>Wire Battery Voltage to Battery on the left. Wire TestRelay on the right to a spare Mako output or Solaris relay. Leave TestRelay on the left unwired. Use an output whose connected equipment is suitable for your test.</p>
|
|
843
|
+
<p>In PassPort Connect, choose RF or an available RS485 connection and match the settings. Export the Read and Write nodes. Save and upload the PLC program when ready.</p>
|
|
844
|
+
<p>In Node-RED, import into Current flow. Connect Inject to Read and Debug to both outputs. Deploy and press Inject. The result should include a Battery number and TestRelay as true or false.</p>
|
|
845
|
+
<p>Open Write, choose Single channel and select TestRelay. Do not leave Battery selected. Make one Inject with Boolean true and another with Boolean false. Leave automatic startup and Repeat off. Wire both to Write, Deploy, and test each button.</p>
|
|
846
|
+
<p>Check the relay itself or a feedback sensor, as well as the reply. For an Analog setpoint later, select that row and send a number in the original units.</p>
|
|
847
|
+
<p>For a ready-wired farm project, open Farmers quick templates. Choose the picture, enter a unique Unit ID and the required sensor settings, then choose Load & export Node-RED. The project uses PassPort Connect, includes battery voltage and exports the complete Read and Write pair onto your current flow.</p>
|
|
848
|
+
</script>
|
package/smithtek-mako-rf.js
CHANGED
|
@@ -3,8 +3,14 @@ module.exports = function (RED) {
|
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
5
|
const ModbusRTU = require("modbus-serial");
|
|
6
|
+
|
|
6
7
|
const rangeScaling = require('./range-scaling');
|
|
7
8
|
|
|
9
|
+
if (RED.httpAdmin) RED.httpAdmin.get('/smithtek-passport/usb', RED.auth.needsPermission('smithtek-mako-rf-bus.read'), async (req,res) => {
|
|
10
|
+
try { res.json(await require('./usb-discovery').choices()); }
|
|
11
|
+
catch (err) { res.status(503).json({error:'Unable to check USB connections. Check the PassPort and try again.'}); }
|
|
12
|
+
});
|
|
13
|
+
|
|
8
14
|
function toNum(v, fallback) {
|
|
9
15
|
const n = Number(v);
|
|
10
16
|
return Number.isFinite(n) ? n : fallback;
|
|
@@ -308,6 +314,8 @@ function toNum(v, fallback) {
|
|
|
308
314
|
// Digital points are numeric at the output boundary. Keep the internal
|
|
309
315
|
// boolean until here so saved math/masks cannot change a bit into another value.
|
|
310
316
|
if (typeof val === 'boolean') val = val ? 1 : 0;
|
|
317
|
+
// Round final readings, including Math/Range results, without stringifying them.
|
|
318
|
+
if (typeof val === 'number' && Number.isFinite(val)) val = Number(val.toFixed(6)) || 0;
|
|
311
319
|
setObjectProperty(decoded, name, val, "=>", prefix);
|
|
312
320
|
}
|
|
313
321
|
|
|
@@ -315,6 +323,7 @@ function toNum(v, fallback) {
|
|
|
315
323
|
}
|
|
316
324
|
|
|
317
325
|
const engine = require('./bus-engine')(ModbusRTU);
|
|
326
|
+
require('./passport-link-node')(RED,engine);
|
|
318
327
|
|
|
319
328
|
function SmithtekMakoRfBusConfig(config) {
|
|
320
329
|
RED.nodes.createNode(this, config);
|
package/usb-discovery.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Enumerate connections only; never probe or send commands to identify a PLC.
|
|
3
|
+
const usbPorts = ports => ports.filter(p => /^\/dev\/(ttyUSB|ttyACM)\d+$/.test(p.path) || (p.vendorId && p.productId && !/^\/dev\/ttyAMA/.test(p.path)));
|
|
4
|
+
const identity = p => p.serialNumber ? 'usb:id:' + encodeURIComponent(JSON.stringify([p.vendorId || '', p.productId || '', p.serialNumber])) : p.path;
|
|
5
|
+
async function list() { return usbPorts(await require('serialport').SerialPort.list()); }
|
|
6
|
+
async function choices(enumerate = list) {
|
|
7
|
+
const ports = await enumerate();
|
|
8
|
+
return ports.map((p,i) => ({value:identity(p),label:'USB '+(i+1)+(p.manufacturer?' · '+p.manufacturer:'')+(p.serialNumber?' · '+p.serialNumber:' · no unique ID')}));
|
|
9
|
+
}
|
|
10
|
+
async function resolve(selection, state, enumerate = list) {
|
|
11
|
+
if (!selection.startsWith('usb:')) return selection;
|
|
12
|
+
const ports = await enumerate();
|
|
13
|
+
const key = state.usbIdentity || selection;
|
|
14
|
+
const matches = key === 'usb:auto' ? ports : ports.filter(p => identity(p) === key);
|
|
15
|
+
if (matches.length !== 1) throw Object.assign(new Error(matches.length ? 'Several USB devices found. Choose your device in Connection.' : 'USB device not found. Check its power and cable, then refresh USB.'), {code:'ECONFIG'});
|
|
16
|
+
// Keep the first device for reconnects within this connection's lifetime.
|
|
17
|
+
state.usbIdentity = identity(matches[0]);
|
|
18
|
+
return matches[0].path;
|
|
19
|
+
}
|
|
20
|
+
module.exports = {usbPorts,identity,list,choices,resolve};
|