UNPKG

node-red-contrib-myhome-bticino-v2

Version:
538 lines (502 loc) 26.1 kB
/*jshint esversion: 7, strict: implied, node: true */ var exports = module.exports = {}; const ACK = '*#*1##'; const NACK = '*#*0##'; const START_COMMAND = '*99*0##'; const START_MONITOR = '*99*1##'; const REFRESH_ALLLIGHTS = '*#1*0##'; const SERVER_REQUIRES_HMAC1 = '*98*1##'; const SERVER_REQUIRES_HMAC2 = '*98*2##'; const INTER_COMMANDS_DELAY = 50; // ms let net = require('net'); ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MHUtils INTERNAL Function : Internal node event logger ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function logNodeEvent (callingNode, logType, riseLevel, logMsg) { // logType can be : debug / log / warn / error // Compute current level based on type provided. Defaults to debug. let logLevels = []; logLevels[0] = 'debug'; logLevels[1] = 'log'; logLevels[2] = 'warn'; logLevels[3] = 'error'; let curLogLevel = Math.max (0 , logLevels.indexOf (logType)); // When the function is called is 'Rise level' mode, apply it now to index if (riseLevel) { curLogLevel++; } // ensure index remains in boundaries curLogLevel = Math.min (curLogLevel , logLevels.length); // Apply log now callingNode[logLevels[curLogLevel]] (logMsg); } exports.logNodeEvent = logNodeEvent; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Function : Gateway connection & log-in ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function processInitialConnection (startCommand, packet, netSocket, callingNode, gateway, persistentObj, error) { // Build (or get) default values from persistent object (which is kept by calling node and provided back at each received packet to be able to fill-in info) persistentObj.state = persistentObj.state || 'disconnected'; persistentObj.HMAC_Auth = persistentObj.HMAC_Auth || []; let logEnabled = persistentObj.logEnabled || false; if (persistentObj.state === 'connected') { // Already connected, no need to do anything return true; } // When we have a non acknowledged return, always abord if (packet === NACK) { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : NACK command received, abording.'); let errorMsg = "Gateway connection/authentication failed (NACK). Last reached state was '" + persistentObj.state + "'"; persistentObj.state = 'disconnected'; error (startCommand, errorMsg); // error callback to stop function } // The connection procedure differs based on how authentication is defined // - Open password check is not aske because we are connecting from an authorized IP range (=returns ACK directly) // - Open password check must be made in basic mode (password is numeric, client receives a hash and 'merges' it with password to return a kind of a hash // - Open password check must be made in HMAC mode (password is alphanumeric, server first responds with the HMAC mode being used, when acknowledged by client, // returns a server random hash (Ra) which the client must use to generate its own random part (Rb), and a full hash result using the password (Ra,Rb,A,B,Kab), // the server finally returns another hash the client was able to compute itself too when sending (Ra,Rb,Kab) if (persistentObj.state === 'disconnected') { if (packet == ACK) { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : handshake acknowledged, asking for authentication if required...'); persistentObj.state = 'handshake'; netSocket.write (startCommand); } return false; } if (persistentObj.state === 'handshake') { // responded to an authentication request if (packet === ACK) { // No password to provide : working in local reserved network addresses logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : request to authenticate acknowledged, no password check required (working in local IP address allowed range)...'); persistentObj.state = 'authenticating'; } else if (packet === SERVER_REQUIRES_HMAC1 || packet === SERVER_REQUIRES_HMAC2) { // The gateway sent back a HMAC authentication request in HMAC format, we acknowledge it to receive a Hash key let hmacType = (packet === SERVER_REQUIRES_HMAC1) ? 'SHA-1' : 'SHA-2 [256]'; logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : request to authenticate acknowledged, authenticating using HMAC (' + hmacType + ') password check required...'); persistentObj.state = 'authenticating_HMAC'; netSocket.write (ACK); return false; } else { // The gateway requires a basic password authentication, retrieve the key to generate a hashed password let hashKey = packet.match(/^\*\#(\d+)\#\#/); if (hashKey === null) { logNodeEvent (callingNode, 'warn', false, 'gateway connection : request to authenticate acknowledged, no valid key received for basic password check.'); } else { // Use it to build hashed password logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : request to authenticate acknowledged, authenticating using basic password check...'); let hashedPwdCommand = '*#' + calcPass (gateway.pass, hashKey[1].toString()) + '##'; persistentObj.state = 'authenticating'; netSocket.write (hashedPwdCommand); } return false; } } if (persistentObj.state === 'authenticating_HMAC') { // The gateway sent a random hashed key (Ra) needed to build a hash with password for connection request (Ra,Rb,A,B,Kab) let Ra = packet.match(/^\*\#(\d+)\#\#/); if (Ra === null) { logNodeEvent (callingNode, 'warn', false, 'gateway connection : HMAC authentication step 1 : invalid random hash (Ra) received from server [' + packet + ']'); } else { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : HMAC authentication step 1 : random hash (Ra) received from server, sending response (Ra,Rb,A,B,Kab)...'); persistentObj.HMAC_Auth = calcHMAC (Ra[1], gateway.pass); persistentObj.state = 'authenticating_HMAC_HashSent'; netSocket.write (persistentObj.HMAC_Auth[0]); } return false; } if (persistentObj.state === 'authenticating_HMAC_HashSent') { // The gateway accepted the hash we send, which means password was OK, and it matches which what we expected (Ra,Rb,Kab) if (packet === persistentObj.HMAC_Auth[1]) { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : HMAC authentication step 2 : hashed response received from server (Ra,Rb,Kab) matched expectation, password was accepted...'); persistentObj.state = 'authenticating'; netSocket.write (ACK); } else { logNodeEvent (callingNode, 'warn', false, 'gateway connection : HMAC authentication step 2 : hashed response received from server (Ra,Rb,Kab) but did not match expectation, abording...'); netSocket.write (NACK); error (startCommand, 'HMAC authentication step 2 : hashed response received from server (Ra,Rb,Kab) but did not match expectation.'); // error callback to stop function } } if (persistentObj.state === 'authenticating') { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : Connection successful !'); persistentObj.state = 'connected'; // When starting the monitoring (i.e. the calling node is the gateway), refresh all connected lights if configured so. // TechNote : the command is delayed by a few seconds. During tests, without such delay, the gateway did not respond (or only partially) is if it was too busy if (startCommand === START_MONITOR && callingNode.lights_onconnect_refreshloads) { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : gathering status of all connected lights within a few seconds...'); setTimeout (function() { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : gathering status of all connected lights started...'); let success_callback = function (commands, cmd_responses, cmd_failed) { logNodeEvent (callingNode, 'debug', logEnabled, 'gateway connection : gathering status of all connected lights started was successful (' + cmd_responses.length + ' responded)'); }; let error_callback = function (cmd_failed, nodeStatusErrorMsg) { logNodeEvent (callingNode, 'warn', logEnabled, 'gateway connection : gathering status of all connected lights started FAILED : ' + nodeStatusErrorMsg); }; executeCommand (callingNode, REFRESH_ALLLIGHTS, gateway, 0, false, success_callback, error_callback); } , 3000); } return true; } } exports.processInitialConnection = processInitialConnection; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Function : Gateway command execution function ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function executeCommand (callingNode, commands, gateway, interCommandsDelay, processNextCmdOnFail, success, error) { let net = require('net'); let client = new net.Socket(); let cmd_responses = []; let cmd_sent = ''; let cmd_sent_count = 0; let cmd_failed = []; let cmd_failed_count = 0; let cmd_success_count = 0; let cmd_lastSent_count = 0; let persistentObj = {}; persistentObj.logEnabled = gateway.log_out_cmd; // Convert commands (which could be an array... or a string) to a single string and only take valid MyHome commands (back) to an array commands = commands.toString().match(/\*.+?##/g); if (commands === null) { success ([], cmd_responses , cmd_failed); return; } function internalError (cmd_failed, errorMsg) { // Disconnect & reset state persistentObj.state = 'disconnected'; client.destroy(); // Build error message and return it if (typeof(cmd_failed) === 'string') { cmd_failed = [cmd_failed]; } let nodeStatusErrorMsg = ''; if (cmd_failed.length > 1) { nodeStatusErrorMsg = cmd_failed.length + ' commands failed.'; } else { nodeStatusErrorMsg = 'command failed: ' + cmd_failed.toString(); } callingNode.error ('commands [' + cmd_failed.toString() + '] failed : ' + errorMsg); error (cmd_failed, nodeStatusErrorMsg); } function writeCommand (command , applyDelay) { let delay = (applyDelay) ? Math.max (interCommandsDelay , INTER_COMMANDS_DELAY) : 0; setTimeout (function() { client.write (command); }, delay); } client.on ('error', function() { internalError (commands, 'Command socket error'); }); client.on ('data', function (data) { let allframes = data.toString(); let bufferedFrames = allframes; while (bufferedFrames.length > 0) { let packetMatch = bufferedFrames.match (/(\*.+?##)(.*)/) || []; let packet = packetMatch[1] || ''; bufferedFrames = packetMatch[2] || ''; if (packet) { logNodeEvent (callingNode, 'debug', false, "Parsing socket data (current: '" + packet + "' / buffered:'" + bufferedFrames + "' / full raw data : '" + allframes + "')"); // As long as initial connection is not OK, all packets are transmitted to a central function managing this if (processInitialConnection (START_COMMAND, packet, client, callingNode, gateway, persistentObj, internalError)) { // We are connected OK, pass the packet to the commands & responses management part parsePacket (packet); } } } }); logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + commands.join(',') + "'), opening connexion to gateway..."); client.connect (gateway.port, gateway.host, function() { // opening command session }); function parsePacket (packet) { if (cmd_sent !== '') { // A command was sent, receiving gateway's response: process the response packet received if (packet === NACK) { // Error when processing a command... cmd_failed_count++; cmd_failed.push (cmd_sent); logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + cmd_sent + "'), command sent, but was not acknowledged (NACK). Command skipped."); cmd_sent = ''; } else if (packet === ACK) { // Command was sent, ACK received. Reset command sen to allow starting a new one cmd_success_count++; logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + cmd_sent + "'), command sent, acknowledged (ACK), responses gathered : " + cmd_lastSent_count + ". This one is done."); cmd_sent = ''; } else { // Command was sent, but we still did not receive an acknowledged receipt, it means the socket is still emitting results of command sent cmd_lastSent_count++; cmd_responses.push (packet); logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + cmd_sent + "'), collecting response(s) [#" + cmd_lastSent_count + "] (current: '" + packet + "')"); return; } } if (cmd_sent === '') { // If command send was reset, it means we can process next one (if any) or finalize if (cmd_sent_count < commands.length && (cmd_failed_count === 0 || processNextCmdOnFail)) { // We are connected, and still have command(s) to send cmd_sent = commands[cmd_sent_count]; cmd_sent_count++; cmd_lastSent_count = 0; logNodeEvent (callingNode, 'debug', persistentObj.logEnabled, "Command '" + cmd_sent + "' sent using gateway..."); // Command is sent directly for first command, but delayed for next ones (if the gateway is not allowed to 'breathe' between commands, it tends to NACK commands) writeCommand (cmd_sent , (cmd_sent_count >> 1)); return; } // All commands were sent, (N)ACK received, no more command to execute. Callback in success or error mode with all results client.destroy(); if (cmd_success_count) { // At least one command was OK, we consider this as a 'success' if (cmd_failed_count) { logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + commands.join(', ') + "'), all commands sent, some failed (NACK) [" + cmd_failed_count + "], some acknowledged (ACK) [" + cmd_success_count + "], responses gathered : " + cmd_responses.length + ". All done now."); } else { logNodeEvent (callingNode, 'debug', false, "mhutils.executeCommand('" + commands.join(', ') + "'), all commands sent, all [" + cmd_success_count + "] acknowledged (ACK), responses gathered : " + cmd_responses.length + ". All done now."); } success (commands, cmd_responses , cmd_failed); } else { // No command was acknowledged, return in error mode internalError (cmd_failed, 'All commands (' + cmd_failed_count + ') were not acknowledged (NACK) when already connected.'); } } } client.on ('close', function() { // to verify that no connections are left open if (client !== undefined) { client.destroy (); } return; }); } exports.executeCommand = executeCommand; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Class : Listeners management ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class eventsMonitor { // This class is intended to manage listeners linked to a specific object in a centralized object // to be able to easily clear them off when entity unloads constructor (monitoredEntity) { this.monitoredEntity = monitoredEntity; this.runningListeners = []; } addMonitoredEvent (eventName, eventFunction) { // Store the registered event to be able to de-actvate it when necessary let newListener = {}; newListener.eventName = eventName; newListener.listenerFunction = eventFunction; this.runningListeners.push (newListener); // Register the listener this.monitoredEntity.addListener (newListener.eventName, newListener.listenerFunction); } clearAllMonitoredEvents () { // Disable all listeners which were activated for (let listenerCount = this.runningListeners.length-1 ; listenerCount >= 0 ; listenerCount--) { let runningListener = this.runningListeners[listenerCount]; // Disable listener on monitored entity this.monitoredEntity.removeListener (runningListener.eventName, runningListener.listenerFunction); // Remove the listener from stored ones since no further call possible on it this.runningListeners.splice (listenerCount, 1); } } } exports.eventsMonitor = eventsMonitor; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Function : Node Secondary output generator (based on load ON/OFF state) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function buildSecondaryOutput (primaryClonedMsg, payloadInfo , config, outputDefaultName, trueTextValue, falseTextValue) { // Build state content based on configured type (text or boolean) let msg2_value; let msg2_type = (config.output2_type === undefined) ? 'boolean' : config.output2_type; if (msg2_type === 'text_state') { // Using default property (state), the ouput is the state itself msg2_value = payloadInfo.state; } else if (msg2_type === 'boolean') { // Using default property (state) to define whether is true/false if (payloadInfo.state === trueTextValue) { msg2_value = true; } else if (payloadInfo.state === falseTextValue) { msg2_value = false; } else { msg2_value = -1; } } else { // Any other value set as 'output2_type' means we have to find this property in current payloadInfo to return its content // Samples : 'brightness' to get 'payloadInfo.brightness', or also 'actuatorStates.actuator_1.state' to get 'payloadInfo.actuatorStates.actuator_1.state' msg2_value = payloadInfo; for (let propertyName of msg2_type.split('.')) { msg2_value = msg2_value[propertyName]; } } // Build & return a new msg object let msg2 = primaryClonedMsg; let msg2_name = (config.output2_name === undefined) ? outputDefaultName : config.output2_name; if (msg2_name === '') { // Output property is empty, which means we have to return a non-object payload msg2.payload = msg2_value; } else { // Build a payload object using the defined property name msg2.payload = {}; msg2.payload[msg2_name] = msg2_value; } return msg2; } exports.buildSecondaryOutput = buildSecondaryOutput; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Function : Merge provided text with a date using specified formats (MM, DD, YYYY,...) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function dateTxtMerge (dateToFormat , textFormat) { // Function to convert a provided date to a fixed 'readable' date+time format // - dateToFormat : any content which can be converted to a valid date-time // - textFormat : the output 'template' as text were replacement by date-time parts // (only YY, YYYY, MM, M, DD, D, hh, nn, ss are supported for now) let dateInit = new Date(dateToFormat); let formattedDate = textFormat .replace('YYYY' , dateInit.getFullYear()) .replace('YY' , dateInit.getFullYear().toString().slice(-2)) .replace('MM' , ('0' + (dateInit.getMonth()+1)).slice(-2)) .replace('M' , (dateInit.getMonth()+1)) .replace('DD' , ('0' + dateInit.getDate()).slice(-2)) .replace('D' , dateInit.getDate()) .replace('hh' , ('0' + dateInit.getHours()).slice(-2)) .replace('nn' , ('0' + dateInit.getMinutes()).slice(-2)) .replace('ss' , ('0' + dateInit.getSeconds()).slice(-2)); return formattedDate; } exports.dateTxtMerge = dateTxtMerge; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // EXTERNAL Function : Convert a number to a human readable / summarized form (1.000 -> k or 1.000.000 -> M) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function numberToAbbr (numberToFormat , suffix) { // Function to convert a provided date number to a 'human readable' (980 remains 980, 1980 becomes 1,98k, 1980654 becomes 1,98M) // - numberToFormat : the number to process // - suffix : optional suffix to append at the end of string returned // When provided number is very large, first bring it back to 'Mega' or 'kilo' corresponding value if (numberToFormat > 10**6) { numberToFormat = numberToFormat / 10**6; suffix = 'M' + suffix; } else if (numberToFormat > 10**3) { numberToFormat = numberToFormat / 10**3; suffix = 'k' + suffix; } // Return string values only keeping 2 decimals + built (and provided) suffix(es) return (Math.round(numberToFormat*100)/100).toLocaleString() + suffix; } exports.numberToAbbr = numberToAbbr; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MHUtils INTERNAL function : used to build HAC hashed value to connect to gateway secured in SHA1 or SHA256 mode ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// function calcHMAC (Ra, password) { const HMAC_COPEN = '736F70653E'; const HMAC_SOPEN = '636F70653E'; let crypto = require ('crypto'); // Define which algorithm is being used based on received Ra length let shaAlgo = ''; if (Ra.length === 80) { shaAlgo = 'sha1'; } else if (Ra.length === 128) { shaAlgo = 'sha256'; } else { return; } // Use a SHA enryptor to build hashed contents let Rb = crypto.createHmac(shaAlgo, Math.random().toString(36)).digest('hex'); let pwd = crypto.createHash(shaAlgo).update(password).digest('hex'); // Build the connection request to be sent (which is *#Rb*HMAC(Ra+Rb+A+B+Kab)##) let contentToHash = digitToHex(Ra) + Rb + HMAC_COPEN + HMAC_SOPEN + pwd; let connectionRequest = '*#' + hexToDigit(Rb) + '*' + hexToDigit(crypto.createHash(shaAlgo).update(contentToHash).digest('hex')) + '##'; // *Rb#HMAC(Ra+Rb+A+B+Kab)## // Build the expected response if connexion is OK (which is *#HMAC(Ra+Rb+Kab)##) contentToHash = digitToHex(Ra) + Rb + pwd; let expectedResponse = '*#' + hexToDigit(crypto.createHash(shaAlgo).update(contentToHash).digest('hex')) + '##' ; // Return connection request & expected response return [connectionRequest, expectedResponse]; } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MHUtils INTERNAL function : required for HMAC connection to convert BUS received values (numbers) to Hexa values function digitToHex (toConvertVal){ let convertedVal = ""; for (let i = 0; i < toConvertVal.length; i=i+2) { let hexVal = parseInt(toConvertVal.slice(i, i+2)).toString(16); convertedVal = convertedVal + hexVal; } return convertedVal; } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MHUtils INTERNAL function : required for HMAC connection to convert Hexa values to BUS acceptable values (numbers) function hexToDigit (toConvertVal){ let convertedVal = ""; for (let i = 0; i < toConvertVal.length; i++) { let hexVal = parseInt(toConvertVal[i], 16); convertedVal = convertedVal + ('0' + hexVal).slice(-2); } return convertedVal; } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // MHUtils INTERNAL function : used in basic authentication mode to 'hash' the Open password function calcPass (pass, nonce) { let flag = true; let num1 = 0x0; let num2 = 0x0; let password = parseInt(pass, 10); for (var c in nonce) { c = nonce[c]; if (c!='0') { if (flag) num2 = password; flag = false; } switch (c) { case '1': num1 = num2 & 0xFFFFFF80; num1 = num1 >>> 7; num2 = num2 << 25; num1 = num1 + num2; break; case '2': num1 = num2 & 0xFFFFFFF0; num1 = num1 >>> 4; num2 = num2 << 28; num1 = num1 + num2; break; case '3': num1 = num2 & 0xFFFFFFF8; num1 = num1 >>> 3; num2 = num2 << 29; num1 = num1 + num2; break; case '4': num1 = num2 << 1; num2 = num2 >>> 31; num1 = num1 + num2; break; case '5': num1 = num2 << 5; num2 = num2 >>> 27; num1 = num1 + num2; break; case '6': num1 = num2 << 12; num2 = num2 >>> 20; num1 = num1 + num2; break; case '7': num1 = num2 & 0x0000FF00; num1 = num1 + (( num2 & 0x000000FF ) << 24 ); num1 = num1 + (( num2 & 0x00FF0000 ) >>> 16 ); num2 = ( num2 & 0xFF000000 ) >>> 8; num1 = num1 + num2; break; case '8': num1 = num2 & 0x0000FFFF; num1 = num1 << 16; num1 = num1 + ( num2 >>> 24 ); num2 = num2 & 0x00FF0000; num2 = num2 >>> 8; num1 = num1 + num2; break; case '9': num1 = ~num2; break; case '0': num1 = num2; break; } num2 = num1; } return (num1 >>> 0).toString(); }