UNPKG

node-red-contrib-azure-iot-device-enhanced

Version:

Production-ready Node-RED Azure IoT Device node with enhanced reliability, infinite reconnection, and comprehensive error handling. This is a fork of the original node-red-contrib-azure-iot-device by Eric van Uum, significantly enhanced by Payman Abbasian

633 lines (591 loc) 28.7 kB
// Copyright (c) Eric van Uum. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. /** * The "azure-iot-device" node enables you to represent an Azure IoT Device in Node-Red. * The node provide connecting a device using connection string and DPS * You can use a full connection string, a SAS key and a X.509 attestation * * The device node enables D2C, C2D messages, Direct Methods, Desired and Reported properties. * You can connect to IoT Edge as a downstream device, IoT Hub and IoT Central. */ function register(RED) { 'use strict' // All requires and code that depend on RED are inside this function const Client = require('azure-iot-device').Client; const Message = require('azure-iot-device').Message; const Protocols = { amqp: require('azure-iot-device-amqp').Amqp, amqpWs: require('azure-iot-device-amqp').AmqpWs, mqtt: require('azure-iot-device-mqtt').Mqtt, mqttWs: require('azure-iot-device-mqtt').MqttWs }; const ProvisioningProtocols = { amqp: require('azure-iot-provisioning-device-amqp').Amqp, amqpWs: require('azure-iot-provisioning-device-amqp').AmqpWs, mqtt: require('azure-iot-provisioning-device-mqtt').Mqtt, mqttWs: require('azure-iot-provisioning-device-mqtt').MqttWs }; const SecurityClient = { x509: require('azure-iot-security-x509').X509Security, sas: require('azure-iot-security-symmetric-key').SymmetricKeySecurityClient }; const ProvisioningDeviceClient = require('azure-iot-provisioning-device').ProvisioningDeviceClient; const GlobalProvisoningEndpoint = "global.azure-devices-provisioning.net"; const crypto = require('crypto'); const forge = require('node-forge'); var pki = forge.pki; const { config } = require('process'); const statusEnum = { connected: { fill: "green", shape:"dot", text: "Connected" }, connecting: { fill: "blue", shape:"dot", text: "Connecting" }, provisioning: { fill: "blue", shape:"dot", text: "Provisioning" }, disconnected: { fill: "red", shape:"dot", text: "Disconnected" }, error: { fill: "grey", shape:"dot", text: "Error" } }; // Setup node-red node to represent Azure IoT Device function AzureIoTDevice(config) { RED.nodes.createNode(this, config); const node = this; // Validate required configuration if (!config.deviceid || config.deviceid.trim() === '') { error(node, config, 'Device ID is required but not provided'); setStatus(node, statusEnum.error); return; } if (config.connectiontype === 'dps' && (!config.scopeid || config.scopeid.trim() === '')) { error(node, config, 'Scope ID is required for DPS connection but not provided'); setStatus(node, statusEnum.error); return; } node.deviceid = config.deviceid; node.pnpModelid = config.pnpModelid; node.connectiontype = config.connectiontype; node.authenticationmethod = config.authenticationmethod; node.enrollmenttype = config.enrollmenttype; node.iothub = config.iothub; node.isIotcentral = config.isIotcentral; node.scopeid = config.scopeid; node.saskey = config.saskey; node.protocol = config.protocol; node.retryInterval = config.retryInterval; node.methods = config.methods; node.DPSpayload = config.DPSpayload; node.gatewayHostname = config.gatewayHostname; node.cert = config.cert; node.key = config.key; node.passphrase = config.passphrase; node.ca = config.ca; node.methodResponses = []; node.client = null; node.twin = null; node._healthCheckInterval = null; node._connectionState = 'disconnected'; node._lastConnected = null; setStatus(node, statusEnum.disconnected); initiateDevice(node); } // Set status of node on node-red var setStatus = function (node, status) { node.status({ fill: status.fill, shape: status.shape, text: status.text }); node._connectionState = status.text.toLowerCase(); if (status.text === 'Connected') { node._lastConnected = new Date(); } }; // Send catchable error to node-red var error = function (node, payload, message) { var msg = {}; msg.topic = 'error'; msg.message = message; msg.payload = payload; node.error(msg); } // Check if valid PEM cert function verifyCertificatePem(node, pem) { try { // Get the certificate from pem, if successful it is a cert node.log(node.deviceid + ' -> Verifying PEM Certificate'); var cert = pki.certificateFromPem(pem); } catch (err) { return false; } return true; }; // Compute device SAS key function computeDerivedSymmetricKey(masterKey, regId) { return crypto.createHmac('SHA256', Buffer.from(masterKey, 'base64')) .update(regId, 'utf8') .digest('base64'); }; // Close all listeners and clean up function closeAll(node) { node.log(node.deviceid + ' -> Closing all clients.'); // Clear health check interval if (node._healthCheckInterval) { clearInterval(node._healthCheckInterval); node._healthCheckInterval = null; } // Clear reconnect timeout if (node._reconnectTimeout) { clearTimeout(node._reconnectTimeout); node._reconnectTimeout = null; } // Clear methodResponses if (node.methodResponses) node.methodResponses.length = 0; // Remove listeners from twin if (node.twin) { try { node.twin.removeAllListeners && node.twin.removeAllListeners('properties.desired'); node.twin.removeAllListeners && node.twin.removeAllListeners('error'); } catch (err) {} node.twin = null; } // Remove listeners from client if (node.client) { try { node.client.removeAllListeners && node.client.removeAllListeners('error'); node.client.removeAllListeners && node.client.removeAllListeners('disconnect'); node.client.removeAllListeners && node.client.removeAllListeners('message'); if (node.methods) { for (let method in node.methods) { try { node.client.removeAllListeners(node.methods[method].name); } catch (err) {} } } // Close client with timeout fallback let closed = false; const closeTimeout = setTimeout(() => { if (!closed) { node.log(node.deviceid + ' -> Client close timeout, forcing cleanup'); node.client = null; } }, 3000); node.client.close((err, result) => { closed = true; clearTimeout(closeTimeout); if (err) { node.log(node.deviceid + ' -> Azure IoT Device Client close failed: ' + JSON.stringify(err)); } else { node.log(node.deviceid + ' -> Azure IoT Device Client closed.'); } node.client = null; }); } catch (err) { node.client = null; } } } // Initiate provisioning and retry if network not available. function initiateDevice(node) { // Ensure resources are reset node.on('close', function(done) { closeAll(node); done(); }); // Listen to node input to send telemetry or reported properties node.on('input', function (msg) { if (typeof (msg.payload) === "string") { //Converting string to JSON Object try { msg.payload = JSON.parse(msg.payload); } catch (parseErr) { error(node, parseErr, node.deviceid + ' -> Invalid JSON payload: ' + parseErr.message); return; } } if (msg.topic === 'telemetry') { sendDeviceTelemetry(node, msg, msg.properties); } else if (msg.topic === 'property' && node.twin) { sendDeviceProperties(node, msg); } else if (msg.topic === 'response') { node.log(node.deviceid + ' -> Method response received with id: ' + msg.payload.requestId); sendMethodResponse(node, msg) } else { error(node, msg, node.deviceid + ' -> Incorrect input. Must be of type \"telemetry\" or \"property\" or \"response\".'); } }); // Provision device node.retries = 0; provisionDevice(node).then( result => { if (result) { // Connect device to Azure IoT node.retries = 0; connectDevice(node, result).then( result => { // Get the twin, throw error if it fails if (result === null) { retrieveTwin(node).then( result => { node.log(node.deviceid + ' -> Device twin retrieved.'); }).catch( function (err) { error(node, err, node.deviceid + ' -> Retrieving device twin failed'); throw new Error(err); }); } else { throw new Error(result); } }).catch( function(err) { error(node, err, node.deviceid + ' -> Device connection failed'); }); } else { throw new Error(result); } }).catch( function(err) { error(node, err, node.deviceid + ' -> Device provisioning failed.'); }); } // Provision the client function provisionDevice(node) { // Set status setStatus(node, statusEnum.provisioning); // Return a promise to enable retry return new Promise((resolve,reject) => { try { // Log the start node.log(node.deviceid + ' -> Initiate IoT Device settings.'); // Set the security properties var options = {}; if (node.authenticationmethod === "x509") { node.log(node.deviceid + ' -> Validating device certificates.'); // Set cert options // verify PEM work around for SDK issue if (verifyCertificatePem(node, node.cert)) { options = { cert : node.cert, key : node.key, passphrase : node.passphrase }; } else { reject("Invalid certificates."); } }; // Check if connection type is dps, if not skip the provisioning step if (node.connectiontype === "dps") { // Set provisioning protocol to selected (default to AMQP-WS) var provisioningProtocol = (node.protocol === "amqp") ? ProvisioningProtocols.amqp : (node.protocol === "amqpWs") ? ProvisioningProtocols.amqpWs : (node.protocol === "mqtt") ? ProvisioningProtocols.mqtt : (node.protocol === "mqttWs") ? ProvisioningProtocols.mqttWs : ProvisioningProtocols.amqpWs; // Set security client based on SAS or X.509 var saskey = (node.enrollmenttype === "group") ? computeDerivedSymmetricKey(node.saskey, node.deviceid) : node.saskey; var provisioningSecurityClient = (node.authenticationmethod === "sas") ? new SecurityClient.sas(node.deviceid, saskey) : new SecurityClient.x509(node.deviceid, options); // Create provisioning client var provisioningClient = ProvisioningDeviceClient.create(GlobalProvisoningEndpoint, node.scopeid, new provisioningProtocol(), provisioningSecurityClient); // set the provisioning payload (for custom allocation) var payload = {}; if (node.DPSpayload) { // Turn payload into JSON try { payload = JSON.parse(node.DPSpayload); node.log(node.deviceid + ' -> DPS Payload added.'); } catch (err) { // do nothing } } // Register the device. node.log(node.deviceid + ' -> Provision IoT Device using DPS.'); if (node.connectiontype === "constr") { resolve(options); } else { provisioningClient.setProvisioningPayload(JSON.stringify(payload)); provisioningClient.register().then( result => { // Process provisioning details node.log(node.deviceid + ' -> DPS registration succeeded.'); node.log(node.deviceid + ' -> Assigned hub: ' + result.assignedHub); var msg = {}; msg.topic = 'provisioning'; msg.deviceId = result.deviceId; msg.payload = JSON.parse(JSON.stringify(result)); node.send(msg); node.iothub = result.assignedHub; node.deviceid = result.deviceId; setStatus(node, statusEnum.disconnected); resolve(options); }).catch( function(err) { // Handle error error(node, err, node.deviceid + ' -> DPS registration failed.'); setStatus(node, statusEnum.error); reject(err); }); } } else { resolve(options); } } catch (err) { reject("Failed to provision device: " + err); } }); } // Initiate an IoT device node in node-red function connectDevice(node, options){ setStatus(node, statusEnum.connecting); var deviceProtocol = (node.protocol === "amqp") ? Protocols.amqp : (node.protocol === "amqpWs") ? Protocols.amqpWs : (node.protocol === "mqtt") ? Protocols.mqtt : (node.protocol === "mqttWs") ? Protocols.mqttWs : Protocols.amqpWs; var connectionString = 'HostName=' + node.iothub + ';DeviceId=' + node.deviceid; var saskey = (node.connectiontype === "dps" && node.enrollmenttype === "group" && node.authenticationmethod === 'sas') ? computeDerivedSymmetricKey(node.saskey, node.deviceid) : node.saskey; connectionString = connectionString + ((node.authenticationmethod === 'sas') ? (';SharedAccessKey=' + saskey) : ';x509=true'); if (node.gatewayHostname !== "") { node.log(node.deviceid + ' -> Connect through gateway: ' + node.gatewayHostname); try { options.ca = node.ca; connectionString = connectionString + ';GatewayHostName=' + node.gatewayHostname; } catch (err){ error(node, err, node.deviceid + ' -> Certificate file error.'); setStatus(node, statusEnum.error); }; } node.client = Client.fromConnectionString(connectionString, deviceProtocol); if (node.pnpModelid) { options.modelId = node.pnpModelid; node.log(node.deviceid + ' -> Set PnP Model ID: ' + node.pnpModelid); } return new Promise((resolve,reject) => { node.client.setOptions(options).then( result => { node.client.open().then( result => { node.client.on('error', function (err) { error(node, err, node.deviceid + ' -> Device Client error.'); setStatus(node, statusEnum.error); }); // Robust disconnect handler with exponential backoff and no event leak node.client.on('disconnect', function (err) { error(node, err, node.deviceid + ' -> Device Client disconnected.'); setStatus(node, statusEnum.disconnected); closeAll(node); // Add exponential backoff for reconnection (infinite retries) node._retries = (node._retries || 0) + 1; const retryDelay = Math.min(1000 * Math.pow(2, node._retries), 30000); node.log(node.deviceid + ' -> Reconnecting in ' + retryDelay + 'ms (attempt ' + node._retries + ')'); if (node._reconnectTimeout) clearTimeout(node._reconnectTimeout); node._reconnectTimeout = setTimeout(() => { initiateDevice(node); }, retryDelay); }); for (let method in node.methods) { node.log(node.deviceid + ' -> Adding synchronous command: ' + node.methods[method].name); var mthd = node.methods[method].name; node.client.onDeviceMethod(mthd, function(request, response) { node.log(node.deviceid + ' -> Command received: ' + request.methodName); node.log(node.deviceid + ' -> Command payload: ' + JSON.stringify(request.payload)); node.send({payload: request, topic: "command", deviceId: node.deviceid}); getResponse(node, request.requestId).then( message => { var rspns = message.payload; node.log(node.deviceid + ' -> Method response status: ' + rspns.status); node.log(node.deviceid + ' -> Method response payload: ' + JSON.stringify(rspns.payload)); response.send(rspns.status, rspns.payload, function(err) { if (err) { node.log(node.deviceid + ' -> Failed sending method response: ' + err); } else { node.log(node.deviceid + ' -> Successfully sent method response: ' + request.methodName); } }); }) .catch( function(err){ error(node, err, node.deviceid + ' -> Failed sending method response: \"' + request.methodName + '\".'); }); }); } node.log(node.deviceid + ' -> Listening to C2D messages'); node.client.on('message', function (msg) { node.log(node.deviceid + ' -> C2D message received, data: ' + msg.data); var message = { messageId: msg.messageId, data: msg.data.toString('utf8'), properties: msg.properties }; node.send({payload: message, topic: "message", deviceId: node.deviceid}); node.client.complete(msg, function (err) { if (err) { error(node, err, node.deviceid + ' -> C2D Message complete error.'); } else { node.log(node.deviceid + ' -> C2D Message completed.'); } }); }); node.log(node.deviceid + ' -> Device client connected.'); // Reset retry counter on successful connection node._retries = 0; setStatus(node, statusEnum.connected); resolve(null); }).catch( function(err) { error(node, err, node.deviceid + ' -> Device client open failed.'); setStatus(node, statusEnum.error); reject(err); }); }).catch( function(err) { error(node, err, node.deviceid + ' -> Device options setting failed.'); setStatus(node, statusEnum.error); reject(err); }); }); } // Get the device twin function retrieveTwin(node){ // Set the options first and then open the connection node.log(node.deviceid + ' -> Retrieve device twin.'); return new Promise((resolve,reject) => { node.client.getTwin().then( result => { node.log(node.deviceid + ' -> Device twin created.'); node.twin = result; node.log(node.deviceid + ' -> Twin contents: ' + JSON.stringify(node.twin.properties)); // Send the twin properties to Node Red var msg = {}; msg.topic = 'property'; msg.deviceId = node.deviceid; msg.payload = JSON.parse(JSON.stringify(node.twin.properties)); node.send(msg); // Get the desired properties node.twin.on('properties.desired', function(payload) { node.log(node.deviceid + ' -> Desired properties received: ' + JSON.stringify(payload)); var msg = {}; msg.topic = 'property'; msg.deviceId = node.deviceid; msg.payload = payload; node.send(msg); }); }).catch(err => { error(node, err, node.deviceid + ' -> Device twin retrieve failed.'); reject(err); }); }) }; // Send messages to IoT platform (Transparant Edge, IoT Hub, IoT Central) function sendDeviceTelemetry(node, message, properties) { if (validateMessage(message.payload)){ // Create message and set encoding and type var msg = new Message(JSON.stringify(message.payload)); // Check if properties set and add if so if (properties){ for (let property in properties) { msg.properties.add(properties[property].key, properties[property].value); } } msg.contentEncoding = 'utf-8'; msg.contentType = 'application/json'; // Send the message if (node.client) { node.client.sendEvent(msg, function(err, res) { if(err) { error(node, err, node.deviceid + ' -> An error ocurred when sending telemetry.'); setStatus(node, statusEnum.error); } else { node.log(node.deviceid + ' -> Telemetry sent: ' + JSON.stringify(message.payload)); setStatus(node, statusEnum.connected); } }); } else { error(node, message, node.deviceid + ' -> Unable to send telemetry, device not connected.'); setStatus(node, statusEnum.error); } } else { error(node, message, node.deviceid + ' -> Invalid telemetry format.'); } }; // Send device reported properties. function sendDeviceProperties(node, message) { if (node.twin) { node.twin.properties.reported.update(message.payload, function (err) { if (err) { error(node, err, node.deviceid + ' -> Sending device properties failed.'); setStatus(node, statusEnum.error); } else { node.log(node.deviceid + ' -> Device properties sent: ' + JSON.stringify(message.payload)); setStatus(node, statusEnum.connected); } }); } else { error(node, message, node.deviceid + ' -> Unable to send device properties, device not connected.'); } }; // Send device direct method response. function sendMethodResponse(node, message) { // Push the reponse to the array var methodResponse = message.payload; node.log(node.deviceid + ' -> Creating response for command: ' + methodResponse.methodName); node.methodResponses.push( {requestId: methodResponse.requestId, response: message} ); }; // @returns true if message object is valid for IoT telemetry function validateMessage(message) { // Allow any valid JSON serializable payload if (message === null || message === undefined) { return false; } // Allow primitive types if (typeof message !== 'object') { return true; } // Allow arrays and objects - IoT telemetry often contains arrays if (Array.isArray(message)) { return true; // Arrays are valid telemetry } // For objects, do basic validation but be permissive try { JSON.stringify(message); // Test if serializable return true; } catch (e) { return false; // Not JSON serializable } } // Registration of the node into Node-RED RED.nodes.registerType("azureiotdevice", AzureIoTDevice, { defaults: { deviceid: {value: ""}, pnpModelid: {value: ""}, connectiontype: {value: ""}, authenticationmethod: {value: ""}, enrollmenttype: {value: ""}, iothub: {value: ""}, isIotcentral: {value: false}, scopeid: {value: ""}, saskey: {value: ""}, certname: {value: ""}, keyname: {value: ""}, passphrase: {value:""}, protocol: {value: ""}, retryInterval: {value: 10}, methods: {value: []}, DPSpayload: {value: ""}, isDownstream: {value: false}, gatewayHostname: {value: ""}, caname: {value:""}, cert: {type:"text"}, key: {type:"text"}, ca: {type:"text"} } }); } // Get method response using promise, and retry, and slow backoff function getResponse(node, requestId) { const maxRetries = 20; const baseTimeout = 1000; let currentRetry = 0; return new Promise((resolve, reject) => { const checkResponse = () => { const methodResponse = node.methodResponses.find(m => m.requestId === requestId); if (methodResponse) { const index = node.methodResponses.findIndex(m => m.requestId === requestId); if (index !== -1) node.methodResponses.splice(index, 1); resolve(methodResponse.response); return; } currentRetry++; if (currentRetry >= maxRetries) { reject(new Error(node.deviceid + ' -> Method Response timeout after ' + maxRetries + ' retries')); return; } const delay = baseTimeout * Math.min(currentRetry, 10); setTimeout(checkResponse, delay); }; checkResponse(); }); } // Export the register function directly for Node-RED compatibility module.exports = register;