smithtek-mako-rf 3.4.1 → 3.5.1
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/LICENSE.txt +80 -0
- package/LICENSING.md +90 -0
- package/MAKO-RF.md +22 -12
- package/PASSPORT-CONNECT.md +24 -2
- package/README.md +32 -4
- package/THIRD-PARTY-NOTICES.md +659 -0
- package/UPSTREAM.md +21 -1
- package/bus-engine.js +266 -142
- package/channel-map.mjs +56 -54
- package/package.json +7 -4
- package/passport-link-node.js +39 -18
- package/smithtek-mako-rf.html +782 -765
- package/smithtek-mako-rf.js +7 -2
- package/icons/1.png +0 -0
- package/icons/2.png +0 -0
- package/icons/controlpng.png +0 -0
package/channel-map.mjs
CHANGED
|
@@ -1,54 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
names.
|
|
12
|
-
|
|
13
|
-
if(
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
1
|
+
// Copyright (c) Smithtek Pty Ltd. Smithtek Software Licence (LICENSE.txt).
|
|
2
|
+
// Original Smithtek helper, shared with the Smithtek V-NET2 exporter.
|
|
3
|
+
const hasOwn=(o,k)=>Object.prototype.hasOwnProperty.call(o,k);
|
|
4
|
+
// Shared by the V-net exporter and the local Node-RED node. No transport dependencies.
|
|
5
|
+
export const widths={float32:2,int32:2,uint32:2,int16:1,uint16:1,boolean:1};
|
|
6
|
+
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])]);}
|
|
7
|
+
export function validateMap(map){
|
|
8
|
+
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>128)throw Error('Invalid PassPort channel mapping');
|
|
9
|
+
const names=new Set(),slots=new Set(),used=new Set(),indices={analog:0,switches:0};
|
|
10
|
+
for(const c of map.channels){
|
|
11
|
+
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>127||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');
|
|
12
|
+
if(c.pulseOnWrite&&(c.kind!=='switches'||!Number.isInteger(c.pulseMs)||c.pulseMs<1||c.pulseMs>1000))throw Error('Invalid Digital pulse settings');
|
|
13
|
+
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);}
|
|
14
|
+
}
|
|
15
|
+
if(map.mappingId!==identity(map))throw Error('Mapping identity does not match its channels');return map;
|
|
16
|
+
}
|
|
17
|
+
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);}
|
|
18
|
+
export function encode(c,value){
|
|
19
|
+
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');}
|
|
20
|
+
const limits={int16:[-32768,32767],uint16:[0,65535],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-3.4028234663852886e38,3.4028234663852886e38]};
|
|
21
|
+
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);
|
|
22
|
+
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)];
|
|
23
|
+
}
|
|
24
|
+
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;}
|
|
25
|
+
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;}
|
|
26
|
+
export function plan(map,mode,payload){const all=groups(map);if(!['read','write'].includes(mode))throw Error('Choose Read or Write');let encoded;
|
|
27
|
+
if(mode==='write'){
|
|
28
|
+
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');
|
|
29
|
+
if(Object.keys(payload).some(k=>!['mappingId','analog','switches'].includes(k)))throw Error('Unknown channel array');
|
|
30
|
+
encoded=new Map();let provided=false;
|
|
31
|
+
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]));}
|
|
32
|
+
if(!provided||!encoded.size)throw Error('Provide at least one non-empty channel array');
|
|
33
|
+
}
|
|
34
|
+
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))})}}));
|
|
35
|
+
}
|
|
36
|
+
export function emptyResult(map){return {mappingId:map.mappingId,analog:[],switches:[]};}
|
|
37
|
+
export function applyRead(result,group,data){
|
|
38
|
+
const expected=group.end-group.address;
|
|
39
|
+
// Coil replies are packed into whole bytes. modbus-serial expands the last
|
|
40
|
+
// byte too, so unused trailing bits are valid but are not mapped channels.
|
|
41
|
+
const padded=group.kind==='switches'?Math.ceil(expected/8)*8:expected;
|
|
42
|
+
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'));
|
|
43
|
+
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;}
|
|
44
|
+
|
|
45
|
+
export function namedValues(map,result){return Object.fromEntries(map.channels.map(c=>[c.name,result[c.kind][c.index]]));}
|
|
46
|
+
// Resolve only supplied names. Never fill omitted channels with stale/default values.
|
|
47
|
+
export function planNamed(map,values){
|
|
48
|
+
validateMap(map);if(!values||typeof values!=='object'||Array.isArray(values)||!Object.keys(values).length)throw Error('Provide a named channel object');
|
|
49
|
+
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]));}
|
|
50
|
+
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)){
|
|
51
|
+
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);
|
|
52
|
+
}}return ops;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
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});}
|
|
56
|
+
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]));}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smithtek-mako-rf",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.1",
|
|
4
4
|
"description": "Smithtek Mako RF and PassPort Connect nodes for Mako and Solaris readings and controls",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"mako",
|
|
14
14
|
"solaris"
|
|
15
15
|
],
|
|
16
|
-
"license": "
|
|
17
|
-
"author": "Smithtek",
|
|
16
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
17
|
+
"author": "Smithtek Pty Ltd",
|
|
18
18
|
"homepage": "https://www.smithtek.com.au",
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"modbus-serial": "8.0.23-no-serial-port",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"UPSTREAM.md",
|
|
43
43
|
"channel-transform.js",
|
|
44
44
|
"PASSPORT-CONNECT.md",
|
|
45
|
-
"MAKO-RF.md"
|
|
45
|
+
"MAKO-RF.md",
|
|
46
|
+
"LICENSE.txt",
|
|
47
|
+
"LICENSING.md",
|
|
48
|
+
"THIRD-PARTY-NOTICES.md"
|
|
46
49
|
]
|
|
47
50
|
}
|
package/passport-link-node.js
CHANGED
|
@@ -2,32 +2,53 @@
|
|
|
2
2
|
module.exports=function(RED,engine){
|
|
3
3
|
const mapping=import('./channel-map.mjs'),transform=require('./channel-transform');
|
|
4
4
|
function PassPortLink(config){
|
|
5
|
-
RED.nodes.createNode(this,config);const node=this,states=new Set();node._makoClosed=false;let
|
|
5
|
+
RED.nodes.createNode(this,config);const node=this,states=new Set();node._makoClosed=false;let pending=0;
|
|
6
6
|
node.on('input',(msg,send,done)=>{
|
|
7
7
|
send=send||node.send.bind(node);done=done||function(err){if(err)node.error(err,msg);};
|
|
8
|
-
|
|
9
|
-
const input=RED.util.cloneMessage(msg);
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const m=await mapping;
|
|
8
|
+
let completed=0,total=0,settled=false,counted=false;
|
|
9
|
+
const writing=config.mode==='write',input=RED.util.cloneMessage(msg);
|
|
10
|
+
const item={node,req:{mode:writing?'write':'read'},...(writing?{retries:0}:{}),
|
|
11
|
+
run:async(request,check)=>{
|
|
12
|
+
const m=await mapping;check();
|
|
13
13
|
const map=m.validateMap(typeof config.mapping==='string'?JSON.parse(config.mapping):config.mapping);
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
14
|
+
item.req.unitid=map.unitId;
|
|
15
|
+
const converters=writing?null:transform.converters(map);
|
|
16
|
+
const operations=writing&&config.inputMode==='single'?m.planSingle(map,config.writeChannel,input.payload):writing&&config.inputMode==='named'?m.planNamed(map,input.payload):m.plan(map,config.mode||'read',input.payload);
|
|
17
|
+
total=operations.length;
|
|
17
18
|
const payload=m.emptyResult(map);node.status({fill:'blue',shape:'dot',text:writing?'Writing channels':'Reading channels'});
|
|
18
19
|
for(const operation of operations){
|
|
19
|
-
|
|
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)}));
|
|
20
|
+
check();const res=await request(operation.req);check();
|
|
21
21
|
if(!writing)m.applyRead(payload,operation.group,res&&res.data);completed++;
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
check();
|
|
24
|
+
return {...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
|
+
},
|
|
26
|
+
complete:(err,output)=>{
|
|
27
|
+
if(settled)return;settled=true;if(counted)pending--;
|
|
28
|
+
try{
|
|
29
|
+
if(!node._makoClosed){
|
|
30
|
+
if(err){
|
|
31
|
+
node.status({fill:'red',shape:'ring',text:err.message});
|
|
32
|
+
const code=err.code||'ECOMM';
|
|
33
|
+
send([null,{...input,payload:{ok:false,error:err.message,code,completed,total,writeMayHaveApplied:writing&&(!!item.writeMayHaveApplied||completed>0)},passport:{ok:false,code}}]);
|
|
34
|
+
}else{send([output,null]);node.status({fill:'green',shape:'dot',text:'ID '+item.req.unitid+' complete'});}
|
|
35
|
+
}
|
|
36
|
+
}catch(e){err=e;}
|
|
37
|
+
done(err||undefined);
|
|
38
|
+
}
|
|
27
39
|
};
|
|
28
|
-
|
|
40
|
+
try{
|
|
41
|
+
const bus=RED.nodes.getNode(config.bus);if(!bus||!bus._makoState)throw Error('Choose a PassPort connection');
|
|
42
|
+
const state=bus._makoState;states.add(state);
|
|
43
|
+
const unavailable=engine.available(state);
|
|
44
|
+
if(unavailable){item.complete(unavailable);return;}
|
|
45
|
+
// Preserve the existing per-node bound. Accepted messages now live in the
|
|
46
|
+
// shared FIFO, so a bus reset also cancels every pending PPC read/write.
|
|
47
|
+
if(pending>=16){done(Object.assign(Error('PassPort Connect queue full'),{code:'EQUEUEFULL'}));return;}
|
|
48
|
+
pending++;counted=true;engine.enqueue(state,item);
|
|
49
|
+
}catch(err){item.complete(err);}
|
|
29
50
|
});
|
|
30
|
-
node.on('close',(_removed,done)=>{node._makoClosed=true;states.forEach(state=>engine.detach(state,node));
|
|
51
|
+
node.on('close',(_removed,done)=>{node._makoClosed=true;states.forEach(state=>engine.detach(state,node));states.clear();node.status({});done();});
|
|
31
52
|
}
|
|
32
53
|
RED.nodes.registerType('smithtek-passport-link',PassPortLink);
|
|
33
|
-
};
|
|
54
|
+
};
|