UNPKG

node-red-contrib-shelly

Version:
635 lines (546 loc) 25.9 kB
module.exports = function (RED) { 'use strict'; const utils = require('../lib/utils.js'); const shelly = require('../lib/shelly.js'); const configuration = require('../lib/configuration.js'); const { convertStatus2 } = require('./gen2/status-converter.js'); const { inputParserGeneric2Array } = require('./gen2/parsers/generic.js'); const axios = require('axios').default; const fs = require('fs'); // const { readFile } = require('fs/promises'); see #96 nodejs V19 const path = require('path'); // const path = require('node:path'); see #99 nodejs V19 // The name of the script for callback mode. const callbackScript = '../scripts/callback.js'; // The name of the script for callback mode for bluetooth devices. const bluCallbackScript = '../scripts/ble-shelly-blu.js'; // Uploads and enables a skript. async function tryInstallScriptAsync(node, script, scriptName) { let success = false; if (node.hostname !== '') { node.status({ fill: 'yellow', shape: 'ring', text: 'Uploading script...' }); let credentials = shelly.getCredentials(node); try { // Remove all old scripts first let scriptListResponse = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', '/rpc/Script.List', null, null, credentials); for (let scriptItem of scriptListResponse.scripts) { if (scriptItem.name == scriptName) { let stopParams = { id: scriptItem.id }; await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Stop', null, stopParams, credentials); let deleteParams = { id: scriptItem.id }; await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Delete', null, deleteParams, credentials); } } let createParams = { name: scriptName }; let createScriptResonse = await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Create', null, createParams, credentials); let scriptId = createScriptResonse.id; const chunkSize = 1024; let done = false; let first = true; do { let codeToSend; if (script.length > chunkSize) { codeToSend = script.substr(0, chunkSize); script = script.substr(chunkSize); } else { codeToSend = script; done = true; } let putParams = { id: scriptId, code: codeToSend, append: !first, }; await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.PutCode', null, putParams, credentials); first = false; } while (!done); let configParams = { id: scriptId, config: { enable: true }, }; await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.SetConfig', null, configParams, credentials); let startParams = { id: scriptId, }; await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Start', null, startParams, credentials); let statusParams = { id: scriptId, }; let status = await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.GetStatus', null, statusParams, credentials); if (status.running === true) { node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); success = true; } else { node.error('Uploaded script ' + scriptName + ' not running.'); node.status({ fill: 'red', shape: 'ring', text: 'Script not running.' }); } } catch (error) { node.error('Uploading script ' + scriptName + ' failed: ' + error.request._currentUrl + ' --> ' + error.message); node.status({ fill: 'red', shape: 'ring', text: 'Uploading script ' + scriptName + ' failed ' + error.message }); } } else { node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' }); } return success; } async function tryUninstallScriptAsync(node, scriptName) { let success = false; if (node.hostname !== '') { let credentials = shelly.getCredentials(node); try { let scriptListResponse = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', '/rpc/Script.List', null, null, credentials); for (let scriptItem of scriptListResponse.scripts) { if (scriptItem.name == scriptName) { let params = { id: scriptItem.id, }; let status = await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.GetStatus', null, params, credentials); if (status.running === true) { await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Stop', null, params, credentials); } await shelly.shellyRequestAsync(node.axiosInstance, 'POST', '/rpc/Script.Delete', null, params, credentials); } } } catch (error) { if (node.verbose) { node.error('Uninstalling script failed: ' + error.message); } node.status({ fill: 'red', shape: 'ring', text: 'Uninstalling script failed ' }); } } else { node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' }); } return success; } // Installs a webhook. async function tryInstallWebhook2Async(node, webhookUrl, webhookName) { let success = false; if (node.hostname !== '') { node.status({ fill: 'yellow', shape: 'ring', text: 'Installing webhook...' }); let credentials = shelly.getCredentials(node); try { // Remove all old webhooks async. let webhookListResponse = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', '/rpc/Webhook.List', null, null, credentials); for (let webhookItem of webhookListResponse.hooks) { if (webhookItem.name == webhookName) { let deleteParams = { id: webhookItem.id }; /*let deleteWebhookResonse =*/ await shelly.shellyRequestAsync( node.axiosInstance, 'POST', '/rpc/Webhook.Delete', null, deleteParams, credentials ); } } // Create new webhooks. let supportedEventsResponse = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', '/rpc/Webhook.ListSupported', null, null, credentials); let hookTypes = supportedEventsResponse.hook_types; // before fw 1.0 if (hookTypes) { for (let hookType of hookTypes) { let sender = node.hostname; let url = webhookUrl + '?hookType=' + hookType + '&sender=' + sender; let createParams = { name: webhookName, event: hookType, cid: 0, enable: true, urls: [url], }; /*let createWebhookResponse =*/ await shelly.shellyRequestAsync( node.axiosInstance, 'POST', '/rpc/Webhook.Create', null, createParams, credentials ); node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); success = true; } } else { hookTypes = supportedEventsResponse.types; // after fw 1.0 for (let hookType in hookTypes) { if (Object.prototype.hasOwnProperty.call(hookTypes, hookType)) { let sender = node.hostname; let url = webhookUrl + '?hookType=' + hookType + '&sender=' + sender; let createParams = { name: webhookName, event: hookType, cid: 0, enable: true, urls: [url], }; /*let createWebhookResonse =*/ await shelly.shellyRequestAsync( node.axiosInstance, 'POST', '/rpc/Webhook.Create', null, createParams, credentials ); node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); success = true; } } } } catch (error) { if (node.verbose) { node.warn('Installing webhook failed ' + error); // node.status({ fill: "red", shape: "ring", text: "Installing webhook failed "}); } } } else { node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' }); } return success; } // Uninstalls a webhook. async function tryUninstallWebhook2Async(node, webhookName) { let success = false; if (node.hostname !== '') { // node.status({ fill: "yellow", shape: "ring", text: "Uninstalling webhook..." }); let credentials = shelly.getCredentials(node); try { let webhookListResponse = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', '/rpc/Webhook.List', null, null, credentials); for (let webhookItem of webhookListResponse.hooks) { if (webhookItem.name == webhookName) { let deleteParams = { id: webhookItem.id }; /*let deleteWebhookResonse =*/ await shelly.shellyRequestAsync( node.axiosInstance, 'POST', '/rpc/Webhook.Delete', null, deleteParams, credentials ); } } } catch (error) { if (node.verbose) { node.warn('Uninstalling webhook failed ' + error); // node.status({ fill: "red", shape: "ring", text: "Uninstalling webhook failed "}); } } } else { node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' }); } return success; } // inputParserGeneric2 and inputParserGeneric2Array moved to ./gen2/parsers/generic.js // returns an empty array. function inputParserEmptyArray2(/*msg*/) { let requests = []; return requests; } // Returns the input parser for the device type. function getInputParser2(deviceType) { let result; switch (deviceType) { case 'Relay': case 'Button': case 'Measure': case 'Dimmer': case 'RGBW': case 'Thermostat': case 'BluGateway': result = inputParserGeneric2Array; break; default: result = inputParserEmptyArray2; break; } return result; } // starts the polling mode. async function initializer2(node, types) { let success = false; let checkOK = await shelly.tryCheckDeviceType(node, types); if (checkOK === true) { let mode = node.mode; if (mode === 'polling') { shelly.start(node, types); success = true; } else if (mode === 'callback') { node.error('Callback not supported for this type of device.'); node.status({ fill: 'red', shape: 'ring', text: 'Callback not supported' }); } else { // nothing to do. success = true; } } return success; } // starts polling or uploads a skript that calls a REST callback. async function initializer2CallbackAsync(node, types) { let success = false; let checkOK = await shelly.tryCheckDeviceType(node, types); if (checkOK === true) { const scriptName = 'node-red-contrib-shelly'; await tryUninstallScriptAsync(node, scriptName); // we ignore if it failed. let mode = node.mode; if (mode === 'polling') { await shelly.startAsync(node, types); success = true; } else if (mode === 'callback') { let scriptPath = path.resolve(__dirname, callbackScript); const buffer = fs.readFileSync(scriptPath); // const buffer = await readFile(scriptPath); #96 nodejs V19 let script = buffer.toString(); let ipAddress = shelly.getIPAddress(node); let url = 'http://' + ipAddress + ':' + node.server.port + '/callback'; script = utils.replace(script, '%URL%', url); let sender = node.hostname; script = utils.replace(script, '%SENDER%', sender); success = await tryInstallScriptAsync(node, script, scriptName); } else { // nothing to do. success = true; } } return success; } // like initializer2CallbackAsync it installs a callback script // and in addition to that it installs a BLU scanner that emits catured bluetooth messages. async function initializer2BluCallbackAsync(node, types) { let success = false; let checkOK = await shelly.tryCheckDeviceType(node, types); if (checkOK === true) { const scriptName = 'node-red-contrib-shelly-blu'; await tryUninstallScriptAsync(node, scriptName); // we ignore if it failed. let mode = node.mode; if (mode === 'callback') { let scriptPath = path.resolve(__dirname, bluCallbackScript); const buffer = fs.readFileSync(scriptPath); // const buffer = await readFile(scriptPath); #96 nodejs V19 let script = buffer.toString(); success = await tryInstallScriptAsync(node, script, scriptName); } else { // nothing to do. success = true; } if (success) { success = await initializer2CallbackAsync(node, types); } } return success; } // starts polling or installs a webhook that calls a REST callback. async function initializer2WebhookAsync(node, types) { let success = false; let checkOK = await shelly.tryCheckDeviceType(node, types); if (checkOK === true) { const webhookName = 'node-red-contrib-shelly'; await tryUninstallWebhook2Async(node, webhookName); // we ignore if it failed. let mode = node.mode; if (mode === 'polling') { await shelly.startAsync(node, types); success = true; } else if (mode === 'callback') { let ipAddress = shelly.getIPAddress(node); let webhookUrl = 'http://' + ipAddress + ':' + node.server.port + '/webhook'; success = await tryInstallWebhook2Async(node, webhookUrl, webhookName); } else { // nothing to do. success = true; } } return success; } // Gets a function that initialize the device. function getInitializer2(node) { let deviceType = node.deviceType; let result; switch (deviceType) { case 'Button': case 'Relay': case 'Measure': case 'Dimmer': case 'RGBW': if (node.captureBlutooth) { result = initializer2BluCallbackAsync; } else { result = initializer2CallbackAsync; } break; case 'BluGateway': // Here we force the capturing f blutooth messages for this specific device. node.captureBlutooth = true; result = initializer2BluCallbackAsync; break; case 'Sensor': result = initializer2WebhookAsync; break; default: result = initializer2; break; } return result; } // convertStatus2 moved to ./gen2/status-converter.js (testable in isolation) async function executeCommand2(msg, request, node, credentials) { let getStatusRoute = '/rpc/Shelly.GetStatus'; let route = getStatusRoute; try { if (request !== undefined && request.route !== undefined && request.route !== '') { route = request.route; let method = request.method; let data = request.data; let params = request.params; let body = await shelly.shellyRequestAsync(node.axiosInstance, method, route, params, data, credentials, 5020); if (node.getStatusOnCommand) { route = getStatusRoute; let data; let params; let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', route, params, data, credentials, 5021); node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); let status = body; msg.status = status; msg.payload = convertStatus2(status); node.send([msg]); } else { node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); msg.payload = body; node.send([msg]); } } else { route = getStatusRoute; let data; let params; let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', route, params, data, credentials, 5022); node.status({ fill: 'green', shape: 'ring', text: 'Connected.' }); let status = body; msg.status = status; msg.payload = convertStatus2(status); node.send([msg]); } } catch (error) { if (msg.payload) { node.status({ fill: 'yellow', shape: 'ring', text: error.message }); node.warn(error.message); } else { node.status({ fill: 'red', shape: 'ring', text: 'Error: ' + error }); node.warn('Error in executeCommand2: ' + route + ' --> ' + error); } msg.error = { hostname: node.hostname, error: error.message, }; node.send([msg]); } } function ShellyGen2Node(config) { RED.nodes.createNode(this, config); let node = this; node.server = RED.nodes.getNode(config.server); node.outputMode = config.outputmode; if (config.uploadretryinterval !== undefined) { node.initializeRetryInterval = parseInt(config.uploadretryinterval); } else { node.initializeRetryInterval = 5006; } node.verbose = config.verbose; node.hostname = utils.trim(config.hostname); node.authType = 'Digest'; node.pollInterval = parseInt(config.pollinginterval); node.pollStatus = config.pollstatus; node.getStatusOnCommand = config.getstatusoncommand; let deviceType = config.devicetype; node.deviceTypeMustMatchExactly = config.devicetypemustmatchexactly || false; node.captureBlutooth = config.captureblutooth || false; node.mode = config.mode; if (!node.mode) { node.mode = 'polling'; } else if (node.mode === 'callback' && (node.server === undefined || node.server === null)) { node.warn('Callback mode selected but no shelly-gen2-server config is bound on this node — falling back to polling.'); node.status({ fill: 'yellow', shape: 'ring', text: 'No server: polling' }); node.mode = 'polling'; } node.status({}); if (deviceType !== undefined && deviceType !== '') { node.axiosInstance = axios.create({ baseURL: 'http://' + node.hostname + '/', timeout: 5000, }); if (configuration.isExactTypeGen2(deviceType)) { node.model = deviceType; // device type is a specific model here node.deviceType = configuration.getDeviceType(node.model); node.types = [deviceType]; } else { node.model = ''; node.deviceType = deviceType; node.types = configuration.getDeviceTypes2(node.deviceType, node.deviceTypeMustMatchExactly); } node.initializer = getInitializer2(node); node.inputParser = getInputParser2(node.deviceType); (async () => { let initialized = await node.initializer(node, node.types); if (node.closing) return; // if the device is not online, then we wait until it is available and try again. if (!initialized) { let msg = { error: { hostname: node.hostname, message: 'Device is not reachable. Retrying to connect every ' + node.initializeRetryInterval / 1000 + ' seconds.', }, }; node.send([msg]); node.initializeTimer = setInterval(async function () { if (node.closing) return; let initialized = await node.initializer(node, node.types); if (node.closing) return; if (initialized) { clearInterval(node.initializeTimer); } }, node.initializeRetryInterval); } })(); this.on('input', async function (msg) { let credentials = shelly.getCredentials(node, msg); let requests = await node.inputParser(msg, node, credentials); if (requests.length == 0) { let request; // here the request is undefined to trigger a simple get status. executeCommand2(msg, request, node, credentials); } else { requests.forEach((request) => { executeCommand2(msg, request, node, credentials); }); } }); // Callback mode: if (node.server !== null && node.server !== undefined && node.mode === 'callback') { node.onCallback = function (data) { if (data.sender === node.hostname) { if (node.outputMode === 'event') { let msg = { payload: data.event, }; node.send([msg]); } else if (node.outputMode === 'status') { node.emit('input', {}); } else { // not implemented } } }; node.server.addListener('callback', node.onCallback); } this.on('close', function (done) { node.closing = true; node.status({}); if (node.onCallback) { node.server.removeListener('callback', node.onCallback); } // TODO: call node.uninitializer(); clearInterval(node.pollingTimer); clearInterval(node.initializeTimer); done(); }); } else { node.status({ fill: 'red', shape: 'ring', text: 'DeviceType not configured.' }); node.warn('DeviceType not configured'); } } return ShellyGen2Node; };