UNPKG

node-red-contrib-shelly

Version:
431 lines (373 loc) 15 kB
const utils = require('./utils.js'); let crypto = require('crypto'); // const crypto = require('node:crypto'); see #99 nodejs V19 const axios = require('axios').default; let nonceCount = 1; // gets all IP addresses: https://stackoverflow.com/questions/3653065/get-local-ip-address-in-node-js?page=2&tab=scoredesc#tab-top function getIPAddresses() { let ipAddresses = []; let interfaces = require('os').networkInterfaces(); for (let devName in interfaces) { let iface = interfaces[devName]; for (let i = 0; i < iface.length; i++) { let alias = iface[i]; if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) { ipAddresses.push(alias.address); } } } return ipAddresses; } // Gets the local IP address from the node or using auto detection. function getIPAddress(node) { let ipAddress; if (node.server.hostip !== undefined && node.server.hostip !== '' && node.server.hostip !== 'hostname') { ipAddress = node.server.hostip; } else if (node.server.hostip === 'hostname' && node.server.hostname !== undefined && node.server.hostname !== '') { ipAddress = node.server.hostname; } else { let ipAddresses = getIPAddresses(); if (ipAddresses !== undefined && ipAddresses.length > 0) { ipAddress = ipAddresses[0]; } else { node.error('Could not detect local IP address: please configure hostname.'); } } return ipAddress; } // Gets a header with the authorization property for the request. function getHeaders(credentials) { let headers = {}; if (credentials) { if (credentials.authType === 'Basic') { if (credentials.username && credentials.password) { // Authorization is case sensitive for some devices like the TRV! headers.Authorization = 'Basic ' + Buffer.from(credentials.username + ':' + credentials.password).toString('base64'); } } } return headers; } // Encrypts a string using SHA-256. function sha256(str) { let result = crypto.createHash('sha256').update(str).digest('hex'); return result; } // see https://shelly-api-docs.shelly.cloud/gen2/General/Authentication // see https://github.com/axios/axios/issues/686 function getDigestAuthorization(response, credentials, config) { let authDetails = response.headers['www-authenticate'].split(', '); let propertiesArray = authDetails.map((v) => v.split('=')); let properties = new Map(propertiesArray.map((obj) => [obj[0], obj[1]])); nonceCount++; // global counter let url = config.url; let method = config.method; // let algorithm = properties.get('algorithm'); // TODO: check if it is still SHA-256 let username = credentials.username; let password = credentials.password; let realm = utils.replace(properties.get('realm'), /"/g, ''); let authParts = [username, realm, password]; let ha1String = authParts.join(':'); let ha1 = sha256(ha1String); let ha2String = method + ':' + url; let ha2 = sha256(ha2String); let nc = ('00000000' + nonceCount).slice(-8); let nonce = utils.replace(properties.get('nonce'), /"/g, ''); let cnonce = crypto.randomBytes(24).toString('hex'); let responseString = ha1 + ':' + nonce + ':' + nc + ':' + cnonce + ':' + 'auth' + ':' + ha2; let responseHash = sha256(responseString); const authorization = 'Digest username="' + username + '", realm="' + realm + '", nonce="' + nonce + '", uri="' + url + '", cnonce="' + cnonce + '", nc=' + nc + ', qop=auth' + ', response="' + responseHash + '", algorithm=SHA-256'; return authorization; } // generic REST request wrapper. async function shellyRequestAsync(axiosInstance, method, route, params, data, credentials, timeout) { if (timeout === undefined || timeout === null) { timeout = 10001; } // We avoid an invalid timeout by taking a default if 0. let requestTimeout = timeout; if (requestTimeout <= 0) { requestTimeout = 10002; } let headers = getHeaders(credentials); let baseUrl = 'http://' + credentials.hostname; let config = { baseURL: baseUrl, url: route, method: method, params: params, data: data, headers: headers, timeout: requestTimeout, validateStatus: (status) => status === 200 || status === 401, }; let result; try { const response = await axiosInstance.request(config); if (response.status == 200) { result = response.data; } else if (response.status == 401) { config.headers = { Authorization: getDigestAuthorization(response, credentials, config), }; const digestResponse = await axiosInstance.request(config); if (digestResponse.status == 200) { result = digestResponse.data; } else { throw new Error(digestResponse.statusText + ' ' + config.url); } } else { throw new Error(response.statusText + ' ' + config.url); } } catch (error) { // Surface the device's response body so callers see the real diagnostic // (Shelly gen2 RPC returns errors like {"error":{"code":-103,"message":"..."}} // in the body; without this enrichment users only see axios's generic // "Request failed with status code 400"). if (error.response && error.response.data !== undefined) { const body = typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data); throw new Error(error.message + ' (' + config.url + '): ' + body); } throw error; } return result; } // checks if the device is reachable and returns the shelly info. Note that /shelly does not require any credentials. async function getShellyInfo(hostname) { let shellyInfo; // (gen 2 return the same info for /rpc/Shelly.GetDeviceInfo) try { let credentials = { hostname: hostname, }; shellyInfo = await shellyRequestAsync(axios, 'GET', '/shelly', null, null, credentials); } catch (error) { shellyInfo = {}; } return shellyInfo; } // extracts the credentials from the message and the node. function getCredentials(node, msg) { let hostname; let username; let password; if (utils.isMsgPayloadValid(msg)) { hostname = msg.payload.hostname; username = msg.payload.username; password = msg.payload.password; } if (hostname === undefined) { hostname = node.hostname; } if (username === undefined) { username = node.credentials.username; } if (password === undefined) { password = node.credentials.password; } let authType = node.authType; if (authType === 'Digest') { username = 'admin'; // see https://shelly-api-docs.shelly.cloud/gen2/General/Authentication } let credentials = { hostname: hostname, authType: authType, username: username, password: password, }; return credentials; } // Hint: the /shelly route can be accessed without authorization async function shellyPing(node, credentials, types) { let found = false; // gen 1 and gen 2 devices support this endpoint (gen 2 return the same info for /rpc/Shelly.GetDeviceInfo) try { let data; let params; let body = await shellyRequestAsync(node.axiosInstance, 'GET', '/shelly', params, data, credentials, node.pollInterval); node.shellyInfo = body; let requiredNodeType; let deviceType; // Generation 1 devices if (node.shellyInfo.type) { deviceType = node.shellyInfo.type; requiredNodeType = 'shelly-gen1'; } // Generation 2 devices else if (node.shellyInfo.model && node.shellyInfo.gen === 2) { deviceType = node.shellyInfo.model; requiredNodeType = 'shelly-gen2'; } // Generation 3 devices else if (node.shellyInfo.model && node.shellyInfo.gen === 3) { deviceType = node.shellyInfo.model; requiredNodeType = 'shelly-gen2'; // right now the protocol is compatible to gen 2 } // Generation 4 devices else if (node.shellyInfo.model && node.shellyInfo.gen === 4) { deviceType = node.shellyInfo.model; requiredNodeType = 'shelly-gen2'; // right now the protocol is compatible to gen 2 } else { // this can not happen right now. requiredNodeType = 'shelly gen-type is not supported'; } if (requiredNodeType === node.type) { for (let i = 0; i < types.length; i++) { let type = types[i]; // Generation 1 devices if (deviceType) { found = deviceType.startsWith(type); if (found) { break; } } } if (found) { node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); } else { node.status({ fill: 'red', shape: 'ring', text: 'Shelly type mismatch: ' + deviceType + ' not found in [' + types.join(',') + ']' }); node.warn('Shelly type mismatch: ' + deviceType); } } else { node.status({ fill: 'red', shape: 'ring', text: 'Wrong node type. Please use ' + requiredNodeType }); node.warn('Wrong node type. Please use ' + requiredNodeType); } } catch (error) { node.status({ fill: 'red', shape: 'ring', text: 'Ping: ' + error.message }); if (node.verbose) { node.warn(error.message); } } return found; } // checks if the device is the configured one. async function tryCheckDeviceType(node, types) { let success = false; let credentials = getCredentials(node); // (gen 2 return the same info for /rpc/Shelly.GetDeviceInfo) try { let shellyInfo = await shellyRequestAsync(node.axiosInstance, 'GET', '/shelly', null, null, credentials); let requiredNodeType; let deviceType; // Generation 1 devices if (shellyInfo.type) { deviceType = shellyInfo.type; requiredNodeType = 'shelly-gen1'; } // Generation 2 devices else if (shellyInfo.model && shellyInfo.gen === 2) { deviceType = shellyInfo.model; requiredNodeType = 'shelly-gen2'; } // Generation 3 devices else if (shellyInfo.model && shellyInfo.gen === 3) { deviceType = shellyInfo.model; requiredNodeType = 'shelly-gen2'; // right now the protocol is compatible to gen 2 } // Generation 4 devices else if (shellyInfo.model && shellyInfo.gen === 4) { deviceType = shellyInfo.model; requiredNodeType = 'shelly-gen2'; // right now the protocol is compatible to gen 2 } else { // this can not happen right now. requiredNodeType = 'shelly gen-type is not supported'; } if (requiredNodeType === node.type) { let found = false; for (let i = 0; i < types.length; i++) { let type = types[i]; if (deviceType) { found = deviceType.startsWith(type); if (found) { break; } } } if (found) { success = true; node.status({ fill: 'green', shape: 'ring', text: '' + deviceType }); } else { node.status({ fill: 'red', shape: 'ring', text: 'Shelly type mismatch: ' + deviceType }); node.warn( 'Shelly type mismatch: ' + deviceType + '. Choose correct type or if the device is not supported yet then report it here:' + 'https://github.com/windkh/node-red-contrib-shelly/issues' ); } } else { node.status({ fill: 'red', shape: 'ring', text: 'Wrong node type. Please use ' + requiredNodeType }); node.warn('Wrong node type. Please use ' + requiredNodeType); } } catch (error) { node.status({ fill: 'yellow', shape: 'ring', text: 'Waiting for device...' }); if (node.verbose) { node.warn(error.message); } } return success; } // Starts polling the status. async function start(node, types) { if (node.hostname !== '') { let credentials = getCredentials(node); // Note: must await — otherwise node.online holds a Promise and the // first reachability transition is missed (Promise === true/false is never true). node.online = await shellyPing(node, credentials, types); if (node.pollInterval > 0) { node.pollingTimer = setInterval(async function () { if (node.closing) return; let found = await shellyPing(node, credentials, types); if (node.closing) return; if (found) { if (node.online === false) { node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); } if (node.pollStatus) { node.emit('input', {}); } } else { if (node.online === true) { node.status({ fill: 'yellow', shape: 'ring', text: 'Polling: device not reachable' }); let msg = { error: { hostname: node.hostname, message: 'Device is not reachable. Retrying to connect every ' + node.initializeRetryInterval / 1000 + ' seconds.', }, }; node.send([msg]); } } node.online = found; }, node.pollInterval); } else { node.status({ fill: 'yellow', shape: 'ring', text: 'Polling is turned off' }); } } else { node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' }); } } async function startAsync(node, types) { start(node, types); } module.exports = { getIPAddress, getIPAddresses, getShellyInfo, shellyRequestAsync, getCredentials, shellyPing, tryCheckDeviceType, start, startAsync, };