Skip to content

Commit 7c379ec

Browse files
Merge pull request #121 from node-red-hitachi/plugfest202103
improve CoAP and IPv6 support
2 parents 9e6e4cc + 045cf71 commit 7c379ec

File tree

4 files changed

+126
-74
lines changed

4 files changed

+126
-74
lines changed

lib/webofthings/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const fs = require('fs');
44
const path = require('path');
55
const mustache = require('mustache');
66
const obfuscator = require('javascript-obfuscator');
7+
const axios = require('axios').default;
78

89
const wotutils = require('./wotutils');
910

@@ -13,7 +14,8 @@ async function getSpec(src) {
1314
let spec;
1415
if (typeof src === "string") {
1516
if (/^https?:/.test(src)) {
16-
spec = JSON.parse(util.skipBom(await axios.get(src)));
17+
const response = await axios.get(src);
18+
spec = response.data;
1719
} else {
1820
spec = JSON.parse(util.skipBom(await fs.promises.readFile(src)));
1921
}

lib/webofthings/wotutils.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,12 @@ function normalizeTd(td) {
7373

7474
function formconv(intr, f, affordance) {
7575
if (f.hasOwnProperty("href")) {
76-
f.href = url.resolve(baseUrl, f.href)
77-
.replace(/%7B/gi,'{')
78-
.replace(/%7D/gi,'}');
76+
if (td.base) {
77+
f.href = new URL(f.href, td.base).toString()
78+
} else {
79+
f.href = new URL(f.href).toString()
80+
}
81+
f.href = f.href.replace(/%7B/gi,'{').replace(/%7D/gi,'}');
7982
}
8083
if (f.hasOwnProperty("security") && typeof f.security === 'string') {
8184
f.security = [f.security];

templates/webofthings/node.js.mustache

Lines changed: 111 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ module.exports = function (RED) {
55
const HttpsProxyAgent = require('https-proxy-agent');
66
const WebSocket = require('ws');
77
const urltemplate = require('url-template');
8-
const Ajv = require('ajv');
8+
const Ajv = require('ajv').default;
9+
const nodecoap = require('coap');
910
1011
function extractTemplate(href, context={}) {
1112
return urltemplate.parse(href).expand(context);
@@ -68,14 +69,33 @@ module.exports = function (RED) {
6869
}
6970
}
7071

72+
function createCoapReqOpts(resource, method, observe=false) {
73+
let urlobj = new URL(resource);
74+
let hostname = urlobj.hostname;
75+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
76+
// remove square brackets from IPv6 address
77+
hostname = hostname.slice(1,-1);
78+
}
79+
let query = urlobj.search;
80+
if (query.startsWith('?')) {
81+
query = query.slice(1);
82+
}
83+
return {
84+
hostname: hostname,
85+
port: urlobj.port,
86+
method: method,
87+
pathname: urlobj.pathname,
88+
query: query,
89+
observe: observe
90+
};
91+
}
92+
7193
function bindingCoap(node, send, done, form, options={}) { // options.psk
72-
const coap = require("node-coap-client").CoapClient;
7394
node.trace("bindingCoap called");
7495
const msg = options.msg || {};
7596
const resource = extractTemplate(form.href, options.urivars);
7697
let payload = null;
7798
let method = null;
78-
7999
if (options.interaction === "property-read") {
80100
method = form.hasOwnProperty("cov:methodName") ?
81101
coapMethodCodeToName(form['cov:methodName']) : 'get';
@@ -89,78 +109,105 @@ module.exports = function (RED) {
89109
payload = options.reqbody;
90110
}
91111

92-
node.trace(`CoAP request: resource=${resource}, method=${method}, payload=${payload}`);
93-
coap.request(resource, method, payload)
94-
.then(response => { // code, format, payload
95-
node.trace(`CoAP response: code=${response.code.toString()}, format=${response.format}, payload=${response.payload}`);
96-
if (response.format === 50) { // application/json; rfc7252 section 12.3, table 9
112+
const coapreqopt = createCoapReqOpts(resource, method, false);
113+
114+
node.trace(`CoAP request: reqopt=${JSON.stringify(coapreqopt)},payload=${payload}`);
115+
let outmsg = nodecoap.request(coapreqopt);
116+
if (payload) {
117+
outmsg.write(payload);
118+
}
119+
outmsg.on('response', response => {
120+
const cf = response.options.find(e=>e.name === 'Content-Format');
121+
if (cf && cf.value === 'application/json') {
97122
try {
98-
msg.payload = JSON.parse(response.payload);
123+
msg.payload = JSON.parse(response.payload.toString());
124+
if (options.outschema) {
125+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
126+
if (!ajv.validate(options.outschema, msg.payload)) {
127+
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
128+
}
129+
}
99130
} catch (e) {
100131
msg.payload = response.payload;
101132
}
133+
} else if (cf && cf.value === 'text/plain') {
134+
msg.payload = response.payload.toString();
102135
} else {
103136
msg.payload = response.payload;
104137
}
105-
if (options.outschema) {
106-
const ajv = new Ajv({allErrors: true});
107-
if (!ajv.validate(options.outschema, msg.payload)) {
108-
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
109-
}
110-
}
111138
send(msg);
112-
if (done) {
113-
done();
114-
}
115-
})
116-
.catch(err => {
117-
node.log(`Error: ${err.toString()}`);
118-
msg.payload = `${err.toString()}: ${resource}`;
139+
response.on('end', () => { if (done) { done(); }});
140+
});
141+
const errorHandler = (err) => {
142+
node.warn(`CoAP request error: ${err.message}`);
143+
delete msg.payload;
144+
msg.error = err;
119145
send(msg);
120-
if (done) {
121-
done();
122-
}
123-
});
146+
if (done) { done() };
147+
};
148+
outmsg.on('timeout', errorHandler);
149+
outmsg.on('error', errorHandler);
150+
outmsg.end();
124151
}
125152

126153
function bindingCoapObserve(node, form, options={}) {
127-
const coap = require("node-coap-client").CoapClient;
128154
node.status({fill:"yellow",shape:"dot",text:"CoAP try to observe ..."});
129155
const resource = extractTemplate(form.href,options.urivars);
130156
const method = form.hasOwnProperty("cov:methodName") ?
131157
coapMethodCodeToName(form['cov:methodName']) : 'get';
132158
const payload = options.reqbody;
133-
const callback = response => { // code, format, payload
134-
const msg = {};
135-
node.trace(`CoAP observe: code=${response.code.toString()}, format=${response.format}, payload=${response.payload}`);
136-
if (response.format === 50) { // application/json; rfc7252 section 12.3, table 9
137-
try {
138-
msg.payload = JSON.parse(response.payload);
139-
} catch (e) {
140-
msg.payload = response.payload;
141-
}
142-
} else {
143-
msg.payload = response.payload;
144-
}
145-
if (options.outschema) {
146-
const ajv = new Ajv({allErrors: true});
147-
if (!ajv.validate(options.outschema, msg.payload)) {
148-
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
159+
let observingStream;
160+
161+
const coapreqopt = createCoapReqOpts(resource, method, true);
162+
node.trace(`CoAP observe request: reqopt=${JSON.stringify(coapreqopt)},payload=${payload}`);
163+
164+
let outmsg = nodecoap.request(coapreqopt);
165+
if (payload) {
166+
outmsg.write(payload);
167+
}
168+
outmsg.on('response', response => {
169+
observingStream = response;
170+
const cf = response.options.find(e=>e.name === 'Content-Format');
171+
response.on('end', () => {});
172+
response.on('data', chunk => {
173+
const msg = {};
174+
if (cf && cf.value === 'application/json') {
175+
try {
176+
msg.payload = JSON.parse(chunk.toString());
177+
if (options.outschema) {
178+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
179+
if (!ajv.validate(options.outschema, msg.payload)) {
180+
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
181+
}
182+
}
183+
} catch (e) {
184+
msg.payload = chunk;
185+
}
186+
} else if (cf && cf.value === 'text/plain') {
187+
msg.payload = chunk.toString();
188+
} else {
189+
msg.payload = chunk;
149190
}
150-
}
151-
node.send(msg);
152-
};
153-
coap.observe(resource,method,callback,payload,options)
154-
.then(() => {
155-
node.status({fill:"green",shape:"dot",text:"CoAP Observing"});
156-
})
157-
.catch(err => {
158-
node.status({fill:"red",shape:"dot",text:`CoAP Error: ${err}`});
159-
node.log(`Error: ${err.toString()}`);
191+
node.send(msg);
192+
});
160193
});
194+
const errorHandler = (err) => {
195+
node.warn(`CoAP request error: ${err.message}`);
196+
node.status({fill:'red',shape:'dot',text:`CoAP Error: ${err}`});
197+
delete msg.payload;
198+
msg.error = err;
199+
node.send(msg);
200+
};
201+
outmsg.on('timeout', errorHandler);
202+
outmsg.on('error', errorHandler);
203+
outmsg.end();
204+
node.status({fill:'green',shape:'dot',text:'CoAP Observing'});
161205
node.on('close', () => {
162206
node.trace('Close node');
163-
coap.stopObserving(resource);
207+
if (observingStream) {
208+
observingStream.close();
209+
observingStream = null;
210+
};
164211
node.status({});
165212
});
166213
}
@@ -215,7 +262,7 @@ module.exports = function (RED) {
215262
msg.payload = data;
216263
}
217264
if (options.outschema) {
218-
const ajv = new Ajv({allErrors: true});
265+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
219266
if (!ajv.validate(options.outschema, msg.payload)) {
220267
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
221268
}
@@ -282,7 +329,7 @@ module.exports = function (RED) {
282329
}
283330
// TODO: validation of return value
284331
if (options.outschema) {
285-
const ajv = new Ajv();
332+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
286333
if (!ajv.validate(options.outschema, msg.payload)) {
287334
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
288335
}
@@ -370,7 +417,7 @@ module.exports = function (RED) {
370417
}
371418
// TODO: validation of return value
372419
if (options.outschema) {
373-
const ajv = new Ajv({allErrors: true});
420+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
374421
if (!ajv.validate(options.outschema, msg.payload)) {
375422
node.warn(`output schema validation error: ${ajv.errorsText()}`, msg);
376423
}
@@ -430,15 +477,15 @@ module.exports = function (RED) {
430477
const auth = makeauth(normTd, form, username, password, token);
431478
const urivars = prop.hasOwnProperty("uriVariables") ? msg.payload : {};
432479
if (prop.uriVariables) {
433-
const ajv = new Ajv({allErrors: true});
480+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
434481
if (!ajv.validate(prop.uriVariables, urivars)) {
435482
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
436483
}
437484
}
438485
if (form.href.match(/^https?:/)) {
439486
bindingHttp(node, send, done, form, {interaction:"property-read", auth, msg, urivars, outschema: prop});
440487
} else if (form.href.match(/^coaps?:/)) {
441-
bindingCoap(node, send, done, form, send, done, {interaction:"property-read", auth, msg, urivars, outschema: prop});
488+
bindingCoap(node, send, done, form, {interaction:"property-read", auth, msg, urivars, outschema: prop});
442489
}
443490
});
444491
} else if (node.proptype === "write") {
@@ -447,7 +494,7 @@ module.exports = function (RED) {
447494
const prop = normTd.properties[node.propname];
448495
const form = prop.forms[node.formindex];// formSelection("property-write", prop.forms);
449496
const auth = makeauth(normTd, form, username, password, token);
450-
const ajv = new Ajv({allErrors: true});
497+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
451498
if (!ajv.validate(prop, msg.payload)) {
452499
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
453500
}
@@ -464,7 +511,7 @@ module.exports = function (RED) {
464511
const auth = makeauth(normTd, form, username, password, token);
465512
const urivars = prop.hasOwnProperty("uriVariables") ? msg.payload : {};
466513
if (prop.uriVariables) {
467-
const ajv = new Ajv({allErrors: true});
514+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
468515
if (!ajv.validate(prop.uriVariables, urivars)) {
469516
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
470517
}
@@ -485,13 +532,13 @@ module.exports = function (RED) {
485532
const auth = makeauth(normTd, form, username, password, token);
486533
const urivars = act.hasOwnProperty("uriVariables") ? msg.payload : {};
487534
if (act.uriVariables) {
488-
const ajv = new Ajv({allErrors: true});
535+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
489536
if (!ajv.validate(act.uriVariables, urivars)) {
490537
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
491538
}
492539
}
493540
if (act.input) {
494-
const ajv = new Ajv({allErrors: true});
541+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
495542
if (!ajv.validate(act.input, msg.payload)) {
496543
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
497544
}
@@ -508,7 +555,7 @@ module.exports = function (RED) {
508555
const auth = makeauth(normTd, form, username, password, token);
509556
const urivars = ev.hasOwnProperty("uriVariables") ? msg.payload : {};
510557
if (ev.uriVariables) {
511-
const ajv = new Ajv({allErrors: true});
558+
const ajv = new Ajv({allErrors: true, strict: false, validateFormats: false});
512559
if (!ajv.validate(ev.uriVariables, urivars)) {
513560
node.warn(`input schema validation error: ${ajv.errorsText()}`, msg);
514561
}

templates/webofthings/package.json.mustache

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@
1717
{{/keywords}}
1818
],
1919
"dependencies": {
20-
"https-proxy-agent": "^3.0.1",
20+
"https-proxy-agent": "^5.0.0",
2121
"request": "^2.88.2",
22-
"ws": "^7.2.0",
22+
"ws": "^7.4.3",
2323
"url-template": "^2.0.8",
24-
"ajv": "^6.10.2",
25-
"node-coap-client": "^1.0.2"
24+
"ajv": "^7.1.1",
25+
"coap": "^0.24.0"
2626
},
2727
"devDependencies": {
28-
"node-red": "^1.0.4",
29-
"node-red-node-test-helper": "^0.2.3"
28+
"node-red": "^1.2.9",
29+
"node-red-node-test-helper": "^0.2.7"
3030
},
3131
"license": "{{&licenseName}}",
3232
"wot": {

0 commit comments

Comments
 (0)