@arduino/node-red-contrib-arduino-iot-cloud
Version:
Node-RED nodes to talk to Arduino IoT Cloud
663 lines (579 loc) • 19.4 kB
JavaScript
/*
* Copyright 2019 ARDUINO SA (http://www.arduino.cc/)
* This file is part of node-red-contrib-arduino-iot-cloud.
* Copyright (c) 2019
*
* This software is released under:
* The GNU General Public License, which covers the main part of
* node-red-contrib-arduino-iot-cloud
* The terms of this license can be found at:
* https://www.gnu.org/licenses/gpl-3.0.en.html
*
* You can be released from the requirements of the above licenses by purchasing
* a commercial license. Buying such a license is mandatory if you want to modify or
* otherwise use the software for commercial activities involving the Arduino
* software without disclosing the source code of your own applications. To purchase
* a commercial license, send an email to license@arduino.cc.
*
*/
/*
SenML labels
https://tools.ietf.org/html/draft-ietf-core-senml-16#section-4.3
+---------------+-------+------------+------------+------------+
| Name | Label | CBOR Label | JSON Type | XML Type |
+---------------+-------+------------+------------+------------+
| Base Name | bn | -2 | String | string |
| Base Time | bt | -3 | Number | double |
| Base Unit | bu | -4 | String | string |
| Base Value | bv | -5 | Number | double |
| Base Sum | bs | -6 | Number | double |
| Version | bver | -1 | Number | int |
| Name | n | 0 | String | string |
| Unit | u | 1 | String | string |
| Value | v | 2 | Number | double |
| String Value | vs | 3 | String | string |
| Boolean Value | vb | 4 | Boolean | boolean |
| Data Value | vd | 8 | String (*) | string (*) |
| Value Sum | s | 5 | Number | double |
| Time | t | 6 | Number | double |
| Update Time | ut | 7 | Number | double |
+---------------+-------+------------+------------+------------+
*/
const mqtt = require('mqtt');
const CBOR = require('@arduino/cbor-js');
const jws = require('jws');
const ArduinoCloudError = require('./ArduinoCloudError');
const arduinoCloudPort = 8443;
const arduinoCloudHost = 'wss.iot.arduino.cc';
class ArduinoClientMqtt {
constructor() {
this.connection = null;
this.connectionOptions = null;
this.subscribedTopics = {};
this.propertyCallback = {};
this.numSubscriptions = 0;
}
// Connect establishes a connection with mqtt, using token as the password, and returns a promise
// of a Symbol identifying the mqtt client
connect(options) {
return new Promise((resolve, reject) => {
let ssl = false;
if (options.ssl !== false) {
ssl = true;
}
this.opts = {
host: options.host || arduinoCloudHost,
port: options.port || arduinoCloudPort,
ssl,
token: options.token,
onOffline: options.onOffline,
onDisconnect: options.onDisconnect,
onConnected: options.onConnected,
useCloudProtocolV2: options.useCloudProtocolV2 || false,
};
if (this.connection) {
return reject(new Error('connection failed: connection already open'));
}
if (!this.opts.host) {
return reject(new Error('connection failed: you need to provide a valid host (broker)'));
}
if (!this.opts.token) {
return reject(new Error('connection failed: you need to provide a valid token'));
}
const userid = jws.decode(options.token).payload["http://arduino.cc/id"];
const clientID = `${userid}:${new Date().getTime()}`;
const connectionOpts = {
clientId: clientID,
username: userid,
password: this.opts.token,
properties: {},
protocolVersion: 4,
connectTimeout: 30000,
keepalive: 30,
clean: true
};
const client = mqtt.connect('wss://' + this.opts.host + ':' + this.opts.port + '/mqtt', connectionOpts);
this.connection = client;
client.topics = {};
client.on("connect", () => {
if (typeof this.opts.onConnected === 'function') {
this.opts.onConnected();
}
return resolve(this.connection);
});
client.on("error", (err) => {
reject(
new ArduinoCloudError(5, err.toString()),
);
});
client.on("message", (topic, msg) => {
if (topic.indexOf('/s/o') > -1) {
client.topics[topic].forEach((cb) => {
cb(msg.toString());
});
} else {
// const buf = new ArrayBuffer(msg.payloadBytes.length);
// const bufView = new Uint8Array(buf);
// for (let i = 0, strLen = msg.payloadBytes.length; i < strLen; i += 1) {
// bufView[i] = msg.payloadBytes[i];
// }
const propertyValue = CBOR.decode(toArrayBuffer(msg));
const propertyNameId = 0;
const attributeNameId = 1;
let valueToSend = {};
let propertyNameKeyPrevious = '';
let propertyNameKey = '';
propertyValue.forEach((p) => {
// Support cbor labels
propertyNameKey = p.n !== undefined ? p.n : p['0'];
const propertyNameKeySplit = propertyNameKey.split(':');
const valueKey = p.v !== undefined ? 'v' : '2';
const valueStringKey = p.vs !== undefined ? 'vs' : '3';
const valueBooleanKey = p.vb !== undefined ? 'vb' : '4';
let value = null;
propertyNameKey = propertyNameKeySplit[propertyNameId];
if (this.propertyCallback[topic][propertyNameKey]) {
if (!(p[valueKey] === undefined)) {
value = p[valueKey];
} else if (!(p[valueStringKey] === undefined)) {
value = p[valueStringKey];
} else if (!(p[valueBooleanKey] === undefined)) {
value = p[valueBooleanKey];
}
}
if (propertyNameKeyPrevious === '') {
propertyNameKeyPrevious = propertyNameKeySplit[propertyNameId];
}
if (propertyNameKeyPrevious !== propertyNameKey) {
if (this.propertyCallback[topic][propertyNameKeyPrevious]) {
for(var i=0; i<this.propertyCallback[topic][propertyNameKeyPrevious].length; i++){
this.propertyCallback[topic][propertyNameKeyPrevious][i].callback(valueToSend);
}
}
propertyNameKeyPrevious = propertyNameKey;
valueToSend = {};
}
if (propertyNameKeySplit.length === 1 && value !== null) {
valueToSend = value;
} else {
const attributeName = propertyNameKeySplit[attributeNameId];
valueToSend[attributeName] = value;
}
});
if (valueToSend !== {} && this.propertyCallback[topic][propertyNameKey]) {
for(var i=0; i<this.propertyCallback[topic][propertyNameKey].length; i++){
this.propertyCallback[topic][propertyNameKey][i].callback(valueToSend);
}
}
}
});
if (typeof this.opts.onOffline === 'function') {
client.on("offline", () => {
this.opts.onOffline();
});
}
if (typeof this.opts.onDisconnect === 'function') {
client.on("disconnect", () => {
this.opts.onDisconnect();
});
}
});
}
disconnect() {
return new Promise((resolve, reject) => {
if (!this.connection) {
return reject(new Error('disconnection failed: connection closed'));
}
try {
this.connection.end(true);
} catch (error) {
return reject(error);
}
// Remove the connection
this.connection = null;
// Remove property callbacks to allow resubscribing in a later connect()
Object.keys(this.propertyCallback).forEach((topic) => {
if (this.propertyCallback[topic]) {
delete this.propertyCallback[topic];
}
});
// Clean up subscribed topics - a new connection might not need the same topics
Object.keys(this.subscribedTopics).forEach((topic) => {
delete this.subscribedTopics[topic];
});
return resolve();
});
}
async reconnect() {
this.connection.reconnect();
};
async updateToken(token) {
// This infinite loop will exit once the reconnection is successful -
// and will pause between each reconnection tentative, every 5 secs.
// eslint-disable-next-line no-constant-condition
while (true) {
try {
if (this.connection) {
// Disconnect to the connection that is using the old token
this.connection.end();
// Remove the connection
this.connection = null;
}
// Reconnect using the new token
const reconnectOptions = Object.assign({}, this.opts, { token });
await this.connect(reconnectOptions);
// Re-subscribe to all topics subscribed before the reconnection
Object.values(this.subscribedTopics).forEach((subscribeParams) => {
this.subscribe(subscribeParams.topic, subscribeParams.cb);
});
if (typeof this.opts.onConnected === 'function') {
// Call the connection callback (with the reconnection param set to true)
this.opts.onConnected(true);
}
// Exit the infinite loop
return;
} catch (error) {
// Expose paho-mqtt errors
// eslint-disable-next-line no-console
console.error(error);
// Something went wrong during the reconnection - retry in 5 secs.
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
}
}
};
subscribe(topic, cb) {
return new Promise((resolve, reject) => {
if (!this.connection) {
return reject(new Error('subscription failed: connection closed'));
}
return this.connection.subscribe(topic, (err) => {
if (!err) {
if (!this.connection.topics[topic]) {
this.connection.topics[topic] = [];
}
this.connection.topics[topic].push(cb);
return resolve(topic);
} else {
reject(new Error(`subscription failed: ${err.toString()}`));
}
});
});
}
unsubscribe(topic) {
return new Promise((resolve, reject) => {
if (!this.connection) {
return reject(new Error('disconnection failed: connection closed'));
}
return this.connection.unsubscribe(topic, null, (err) => {
if (err)
reject();
else
resolve(topic);
});
});
}
arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
};
sendMessage(topic, message) {
return new Promise((resolve, reject) => {
if (!this.connection) {
return reject(new Error('disconnection failed: connection closed'));
}
this.connection.publish(topic, message);
return resolve();
});
}
openCloudMonitor(deviceId, cb) {
const cloudMonitorOutputTopic = `/a/d/${deviceId}/s/o`;
return subscribe(cloudMonitorOutputTopic, cb);
};
writeCloudMonitor(deviceId, message) {
const cloudMonitorInputTopic = `/a/d/${deviceId}/s/i`;
return sendMessage(cloudMonitorInputTopic, message);
};
closeCloudMonitor(deviceId) {
const cloudMonitorOutputTopic = `/a/d/${deviceId}/s/o`;
return unsubscribe(cloudMonitorOutputTopic);
};
toCloudProtocolV2(cborValue) {
const cloudV2CBORValue = {};
let cborLabel = null;
Object.keys(cborValue).forEach((label) => {
switch (label) {
case 'bn':
cborLabel = -2;
break;
case 'bt':
cborLabel = -3;
break;
case 'bu':
cborLabel = -4;
break;
case 'bv':
cborLabel = -5;
break;
case 'bs':
cborLabel = -6;
break;
case 'bver':
cborLabel = -1;
break;
case 'n':
cborLabel = 0;
break;
case 'u':
cborLabel = 1;
break;
case 'v':
cborLabel = 2;
break;
case 'vs':
cborLabel = 3;
break;
case 'vb':
cborLabel = 4;
break;
case 'vd':
cborLabel = 8;
break;
case 's':
cborLabel = 5;
break;
case 't':
cborLabel = 6;
break;
case 'ut':
cborLabel = 7;
break;
default:
cborLabel = label;
}
cloudV2CBORValue[cborLabel] = cborValue[label];
});
return cloudV2CBORValue;
};
sendProperty(thingId, name, value, timestamp) {
const propertyInputTopic = `/a/t/${thingId}/e/i`;
if (timestamp && !Number.isInteger(timestamp)) {
throw new Error('Timestamp must be Integer');
}
if (name === undefined || typeof name !== 'string') {
throw new Error('Name must be a valid string');
}
if (typeof value === 'object') {
const objectKeys = Object.keys(value);
const cborValues = objectKeys.map((key, i) => {
const cborValue = {
n: `${name}:${key}`,
};
if (i === 0) {
cborValue.bt = timestamp || new Date().getTime();
}
switch (typeof value[key]) {
case 'string':
cborValue.vs = value[key];
break;
case 'number':
cborValue.v = value[key];
break;
case 'boolean':
cborValue.vb = value[key];
break;
default:
break;
}
return cborValue;
})
.map((cborValue) => {
if (this.connectionOptions.useCloudProtocolV2) {
return toCloudProtocolV2(cborValue);
}
return cborValue;
});
return sendMessage(propertyInputTopic, CBOR.encode(cborValues, true));
}
let cborValue = {
bt: timestamp || new Date().getTime(),
n: name,
};
switch (typeof value) {
case 'string':
cborValue.vs = value;
break;
case 'number':
cborValue.v = value;
break;
case 'boolean':
cborValue.vb = value;
break;
default:
break;
}
if (this.connectionOptions.useCloudProtocolV2) {
cborValue = toCloudProtocolV2(cborValue);
}
return sendMessage(propertyInputTopic, CBOR.encode([cborValue], true));
};
getSenml(deviceId, name, value, timestamp) {
if (timestamp && !Number.isInteger(timestamp)) {
throw new Error('Timestamp must be Integer');
}
if (name === undefined || typeof name !== 'string') {
throw new Error('Name must be a valid string');
}
if (typeof value === 'object') {
const objectKeys = Object.keys(value);
const senMls = objectKeys.map((key, i) => {
const senMl = {
n: `${name}:${key}`,
};
if (i === 0) {
senMl.bt = timestamp || new Date().getTime();
if (deviceId) {
senMl.bn = `urn:uuid:${deviceId}`;
}
}
switch (typeof value[key]) {
case 'string':
senMl.vs = value[key];
break;
case 'number':
senMl.v = value[key];
break;
case 'boolean':
senMl.vb = value[key];
break;
default:
break;
}
return senMl;
})
.map((senMl) => {
if (this.connectionOptions.useCloudProtocolV2) {
return toCloudProtocolV2(senMl);
}
return senMl;
});
return senMls;
}
const senMl = {
bt: timestamp || new Date().getTime(),
n: name,
};
if (deviceId) {
senMl.bn = `urn:uuid:${deviceId}`;
}
switch (typeof value) {
case 'string':
senMl.vs = value;
break;
case 'number':
senMl.v = value;
break;
case 'boolean':
senMl.vb = value;
break;
default:
break;
}
if (this.connectionOptions.useCloudProtocolV2) {
return toCloudProtocolV2(senMl);
}
return senMl;
};
getCborValue(senMl) {
const cborEncoded = CBOR.encode(senMl);
return arrayBufferToBase64(cborEncoded);
};
onPropertyValue(thingId, name, cb,nodeId) {
if (!name) {
throw new Error('Invalid property name');
}
if (typeof cb !== 'function') {
throw new Error('Invalid callback');
}
var node;
if(!nodeId){
node=1
}else{
node=nodeId;
}
const propOutputTopic = `/a/t/${thingId}/e/o`;
this.subscribedTopics[thingId] = {
topic: propOutputTopic,
cb,
};
this.numSubscriptions++;
if (!this.propertyCallback[propOutputTopic]) {
this.propertyCallback[propOutputTopic] = {};
this.propertyCallback[propOutputTopic][name] = [];
this.propertyCallback[propOutputTopic][name].push({
nodeId: node,
callback:cb
});
return this.subscribe(propOutputTopic, cb);
}
if (this.propertyCallback[propOutputTopic] && !this.propertyCallback[propOutputTopic][name]) {
this.propertyCallback[propOutputTopic][name] = [];
this.propertyCallback[propOutputTopic][name].push({
nodeId: node,
callback:cb
});
}else if(this.propertyCallback[propOutputTopic] && this.propertyCallback[propOutputTopic][name]){
this.propertyCallback[propOutputTopic][name].push({
nodeId: node,
callback:cb
});
}
return Promise.resolve(propOutputTopic);
};
removePropertyValueCallback(thingId, name, nodeId) {
if (!name) {
throw new Error('Invalid property name');
}
var node;
if(!nodeId){
node=1
}else{
node=nodeId;
}
const propOutputTopic = `/a/t/${thingId}/e/o`;
if (!this.propertyCallback[propOutputTopic] || !this.propertyCallback[propOutputTopic][name]) {
return Promise.resolve(this.numSubscriptions);
}
var pos=-1;
for(var i=0; i<this.propertyCallback[propOutputTopic][name].length; i++){
var cbObject=this.propertyCallback[propOutputTopic][name][i];
if(cbObject.nodeId===node){
pos=i;
break;
}
}
if(pos!=-1){
this.propertyCallback[propOutputTopic][name].splice(pos,1);
}
if(this.propertyCallback[propOutputTopic][name].length===0){
delete this.propertyCallback[propOutputTopic][name];
}
this.numSubscriptions--;
return Promise.resolve(this.numSubscriptions);
};
}
function toArrayBuffer(buf) {
var ab = new ArrayBuffer(buf.length);
var view = new Uint8Array(ab);
for (var i = 0; i < buf.length; ++i) {
view[i] = buf[i];
}
return ab;
}
exports.ArduinoClientMqtt = ArduinoClientMqtt;