node-red-contrib-shelly
Version:
697 lines (597 loc) • 29.4 kB
JavaScript
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 { convertStatus1 } = require('./gen1/status-converter.js');
const { combineUrl } = require('./gen1/parsers/util.js');
const { inputParserRelay1Async } = require('./gen1/parsers/relay.js');
const { inputParserRoller1Async } = require('./gen1/parsers/roller.js');
const { inputParserDimmer1Async } = require('./gen1/parsers/dimmer.js');
const { inputParserThermostat1Async } = require('./gen1/parsers/thermostat.js');
const { inputParserSensor1Async } = require('./gen1/parsers/sensor.js');
const { inputParserButton1Async } = require('./gen1/parsers/button.js');
const { inputParserRGBW1Async } = require('./gen1/parsers/rgbw.js');
const axios = require('axios').default;
// combineUrl moved to ./gen1/parsers/util.js
// inputParserRelay1Async moved to ./gen1/parsers/relay.js
// (Measure stays inline below because it has side effects — EM data download.)
async function inputParserMeasure1Async(msg, node, credentials) {
let route;
if (utils.isMsgPayloadValid(msg)) {
let command = msg.payload;
let relay = 0;
if (command.relay !== undefined) {
relay = command.relay;
}
let turn;
if (command.on !== undefined) {
if (command.on == true) {
turn = 'on';
} else {
turn = 'off';
}
} else if (command.turn !== undefined) {
turn = command.turn;
}
let timerSeconds;
if (command.timer !== undefined) {
timerSeconds = command.timer;
}
let parameters = '';
if (turn !== undefined) {
parameters += '&turn=' + turn;
}
if (timerSeconds !== undefined) {
parameters += '&timer=' + timerSeconds;
}
if (parameters !== '') {
route = combineUrl('/relay/' + relay, parameters);
}
// Download EM data if required.
let emetersToDownload;
if (command.download !== undefined) {
emetersToDownload = command.download;
}
// special download code for EM devices that can store historical data.
if (emetersToDownload) {
let data = [];
for (let i = 0; i < emetersToDownload.length; i++) {
let emeter = emetersToDownload[i];
let downloadRoute = '/emeter/' + emeter + '/em_data.csv';
node.status({ fill: 'green', shape: 'ring', text: 'Downloading CSV ' + emeter });
try {
let timeout = 60000; // download can take very long of there is a lot of data.
let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', downloadRoute, null, null, credentials, timeout);
data.push(body);
} catch (error) {
node.error('Downloading CSV failed ' + emeter + ': ' + error.message);
node.status({ fill: 'red', shape: 'ring', text: 'Downloading CSV failed ' + emeter });
node.warn('Downloading CSV failed ' + emeter);
}
}
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
msg.payload = data;
node.send([null, msg]);
}
}
return route;
}
// inputParserRoller1Async moved to ./gen1/parsers/roller.js
// inputParserDimmer1Async moved to ./gen1/parsers/dimmer.js
// inputParserThermostat1Async moved to ./gen1/parsers/thermostat.js
// inputParserSensor1Async moved to ./gen1/parsers/sensor.js
// inputParserButton1Async moved to ./gen1/parsers/button.js
// inputParserRGBW1Async moved to ./gen1/parsers/rgbw.js
// no operation function
function inputParserEmpty1() {}
// Returns the input parser for the device type.
function getInputParser1(deviceType) {
let result;
switch (deviceType) {
case 'Relay':
result = inputParserRelay1Async;
break;
case 'Measure':
result = inputParserMeasure1Async;
break;
case 'Roller':
result = inputParserRoller1Async;
break;
case 'Dimmer':
result = inputParserDimmer1Async;
break;
case 'Thermostat':
result = inputParserThermostat1Async;
break;
case 'Sensor':
result = inputParserSensor1Async;
break;
case 'Button':
result = inputParserButton1Async;
break;
case 'RGBW':
result = inputParserRGBW1Async;
break;
default:
result = inputParserEmpty1;
break;
}
return result;
}
// initializes a RGBW node.
async function initializerRGBW1Async(node, types) {
let success = false;
let checkOK = await shelly.tryCheckDeviceType(node, types);
if (checkOK === true) {
try {
let credentials = shelly.getCredentials(node);
let settingsRoute = '/settings';
let settings = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', settingsRoute, null, null, credentials);
node.rgbwMode = settings.mode;
success = await initializer1WebhookAsync(node, types);
} catch (error) {
node.status({ fill: 'red', shape: 'ring', text: 'Failed to get mode from settings.' });
node.warn('Failed to get mode from settings. ' + error);
}
}
return success;
}
async function initializer1(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 installs a webhook that calls a REST callback.
async function initializer1WebhookAsync(node, types) {
let success = false;
let checkOK = await shelly.tryCheckDeviceType(node, types);
if (checkOK === true) {
const sender = node.hostname;
await tryUninstallWebhook1Async(node, sender); // 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 tryInstallWebhook1Async(node, webhookUrl, sender);
} else {
// nothing to do.
success = true;
}
} else {
success = false;
}
return success;
}
// Installs a webhook.
async function tryInstallWebhook1Async(node, webhookUrl, sender) {
let success = false;
if (node.hostname !== '') {
node.status({ fill: 'yellow', shape: 'ring', text: 'Installing webhook...' });
let credentials = shelly.getCredentials(node);
let hookTypes = getHookTypes1(node.deviceType);
// delete http://192.168.33.1/settings/actions?index=0&name=report_url&urls[]=
// create http://192.168.33.1/settings/actions?index=0&name=report_url&enabled=true&urls[]=http://192.168.1.4/webhook
try {
if (hookTypes[0] && hookTypes[0].action === '*') {
hookTypes = await getHookTypesFromDevice1(node);
}
if (hookTypes.length !== 0) {
for (let i = 0; i < hookTypes.length; i++) {
let hookType = hookTypes[i];
let name = hookType.action;
let index = hookType.index;
let url = webhookUrl + '?data=' + name + '?' + index + '?' + sender; // note that & can not be used in gen1!!!
let deleteRoute = '/settings/actions?index=' + index + '&name=' + name + '&enabled=false&urls[]=';
try {
let timeout = node.pollInterval;
let deleteResult = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', deleteRoute, null, null, credentials, timeout);
let actionsAfterDelete = deleteResult.actions[name][0];
if (actionsAfterDelete.enabled === false) {
// 1st try to set the action using the standard method
let createRoute = '/settings/actions?index=' + index + '&name=' + name + '&enabled=true&urls[]=' + url;
let createResult = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', createRoute, null, null, credentials, timeout);
let actionsAfterCreate = createResult.actions[name][0];
if (actionsAfterCreate.enabled === true && actionsAfterCreate.urls.indexOf(url) > -1) {
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
success = true;
} else {
// 2nd: maybe the device supports intervals
let createRoute2 =
'/settings/actions?index=' + index + '&name=' + name + '&enabled=true&urls[0][url]=' + url + '&urls[0][int]=0000-0000';
let createResult2 = await shelly.shellyRequestAsync(
node.axiosInstance,
'GET',
createRoute2,
null,
null,
credentials,
timeout
);
let actionsAfterCreate2 = createResult2.actions[name][0];
if (actionsAfterCreate2.enabled === true) {
if (actionsAfterCreate2.urls[0].url === url) {
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
success = true;
} else {
console.warn('Failed to install webhook ' + name + ' for ' + sender);
success = false;
break;
}
} else {
console.warn('Failed to install webhook ' + name + ' for ' + sender);
success = false;
break;
}
}
} else {
console.warn('Failed to delete webhook ' + name + ' for ' + sender);
success = false;
break;
}
} catch (error) {
node.status({ fill: 'yellow', shape: 'ring', text: 'Installing webhook....' });
}
}
} else {
node.status({ fill: 'red', shape: 'ring', text: 'Device does not support callbacks' });
node.warn('Installing webhook failed (' + sender + ') ' + 'hooktypes are empty!');
}
} catch (error) {
if (node.verbose) {
node.warn('Installing webhook failed (' + sender + ') ' + 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 tryUninstallWebhook1Async(node, sender) {
let success = false;
if (node.hostname !== '') {
// node.status({ fill: "yellow", shape: "ring", text: "Uninstalling webhook..." });
let credentials = shelly.getCredentials(node);
let hookTypes = getHookTypes1(node.deviceType);
// delete http://192.168.33.1/settings/actions?index=0&name=report_url&urls[]=
try {
if (hookTypes[0] && hookTypes[0].action === '*') {
hookTypes = await getHookTypesFromDevice1(node);
}
if (hookTypes.length !== 0) {
for (let i = 0; i < hookTypes.length; i++) {
let hookType = hookTypes[i];
let name = hookType.action;
let index = hookType.index;
let urls = hookType.urls;
// We only delete the hook from us: find the sender url in the hook url.
for (let j = 0; j < urls.length; j++) {
let url = urls[j];
// This is a vage assumption but it is the best we have at the moment to identify our hooks.
if (url.includes(sender)) {
let deleteRoute = '/settings/actions?index=' + index + '&name=' + name + '&enabled=false&urls[]=';
try {
let timeout = node.pollInterval;
let deleteResult = await shelly.shellyRequestAsync(
node.axiosInstance,
'GET',
deleteRoute,
null,
null,
credentials,
timeout
);
let actionsAfterDelete = deleteResult.actions[name][0];
if (actionsAfterDelete.enabled === false) {
// failed
} else {
console.warn('Failed to delete webhook ' + name + ' for ' + sender);
success = false;
break;
}
} catch (error) {
if (node.verbose) {
node.warn('Uninstalling webhook failed (' + sender + ') ' + error);
// node.status({ fill: "yellow", shape: "ring", text: "Uninstalling webhook...." });
}
}
}
}
}
} else {
node.status({ fill: 'red', shape: 'ring', text: 'Device does not support callbacks' });
node.warn('Installing webhook failed (' + sender + ') ' + 'hooktypes are empty!');
}
} catch (error) {
if (node.verbose) {
node.warn('Installing webhook failed (' + sender + ') ' + error);
// node.status({ fill: "red", shape: "ring", text: "Uninstalling webhook failed "});
}
}
} else {
node.status({ fill: 'red', shape: 'ring', text: 'Hostname not configured' });
}
return success;
}
// Gets a function that initialize the device.
function getInitializer1(deviceType) {
let result;
switch (deviceType) {
case 'RGBW':
result = initializerRGBW1Async;
break;
case 'Measure':
case 'Roller':
case 'Dimmer':
case 'Sensor':
case 'Thermostat':
case 'Button':
case 'Relay':
result = initializer1WebhookAsync;
break;
default:
result = initializer1;
break;
}
return result;
}
// convertStatus1 moved to ./gen1/status-converter.js (testable in isolation)
let gen1HookTypes = new Map([
['Relay', [{ action: '*', index: 0 }]],
['Measure', [{ action: '*', index: 0 }]],
['Roller', [{ action: '*', index: 0 }]],
['Dimmer', [{ action: '*', index: 0 }]],
['Thermostat', [{ action: '*', index: 0 }]],
['Sensor', [{ action: '*', index: 0 }]],
['Button', [{ action: '*', index: 0 }]],
['RGBW', [{ action: '*', index: 0 }]],
]);
function getHookTypes1(deviceType) {
let hookTypes = gen1HookTypes.get(deviceType);
if (hookTypes === undefined) {
hookTypes = [];
}
return hookTypes;
}
async function getHookTypesFromDevice1(node) {
let credentials = shelly.getCredentials(node);
let actionsRoute = '/settings/actions';
let result = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', actionsRoute, null, null, credentials);
let hookTypes = [];
let actions = Object.keys(result.actions);
for (let i = 0; i < actions.length; i++) {
let action = actions[i];
let actionItems = result.actions[action];
for (let j = 0; j < actionItems.length; j++) {
let item = actionItems[j];
let index = item.index;
let hookType = {
action: action,
index: index,
urls: item.urls,
};
hookTypes.push(hookType);
}
}
return hookTypes;
}
async function executeCommand1(msg, route, node, credentials) {
let getStatusRoute = '/status';
if (route && route !== '') {
try {
let data;
let params;
let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', route, params, data, credentials, 5010);
if (node.getStatusOnCommand) {
try {
let data;
let params;
let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', getStatusRoute, params, data, credentials, 5011);
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
let status = body;
msg.status = status;
msg.payload = convertStatus1(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 executeCommand1: ' + route + ' --> ' + error);
}
}
} else {
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
msg.payload = body;
node.send([msg]);
}
} catch (error) {
node.status({ fill: 'red', shape: 'ring', text: 'Error: ' + error });
node.warn('Error in executeCommand1: ' + route + ' --> ' + error);
msg.error = {
hostname: node.hostname,
error: error.message,
};
node.send([msg]);
}
} else {
try {
let data;
let params;
let body = await shelly.shellyRequestAsync(node.axiosInstance, 'GET', getStatusRoute, params, data, credentials, 5012);
node.status({ fill: 'green', shape: 'ring', text: 'Connected.' });
let status = body;
msg.status = status;
msg.payload = convertStatus1(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 executeCommand1: ' + getStatusRoute + ' --> ' + error);
}
}
}
}
async function applySettings1Async(settings, node, credentials) {
let success = false;
if (settings !== undefined && Array.isArray(settings)) {
for (let i = 0; i < settings.length; i++) {
let setting = settings[i];
let device = setting.device;
let index = setting.index;
let attribute = setting.attribute;
let value = setting.value;
if (device !== undefined && attribute !== undefined && value !== undefined) {
let settingRoute;
if (index !== undefined) {
settingRoute = '/settings/' + device + '/' + index + '?' + attribute + '=' + value;
} else {
settingRoute = '/settings/' + device + '?' + attribute + '=' + value;
}
try {
/*let body =*/ await shelly.shellyRequestAsync(node.axiosInstance, 'GET', settingRoute, null, null, credentials);
success = true;
} catch (error) {
node.status({ fill: 'red', shape: 'ring', text: 'Failed to set settings to: ' + settingRoute });
node.error('Failed to set settings to: ' + settingRoute + ': ' + error.message);
}
} else {
node.error('Failed to set settings as input is not complete: device, attribute and value must be specified. ' + setting);
}
}
}
return success;
}
// --------------------------------------------------------------------------------------------
// The shelly node controls a shelly generation 1 device.
function ShellyGen1Node(config) {
RED.nodes.createNode(this, config);
let node = this;
node.server = RED.nodes.getNode(config.server);
node.outputMode = config.outputmode;
if (config.uploadretryinterval !== undefined && config.uploadretryinterval !== '') {
node.initializeRetryInterval = parseInt(config.uploadretryinterval);
} else {
node.initializeRetryInterval = 5005;
}
node.verbose = config.verbose;
node.hostname = utils.trim(config.hostname);
node.authType = 'Basic';
node.pollInterval = parseInt(config.pollinginterval);
node.pollStatus = config.pollstatus;
node.getStatusOnCommand = config.getstatusoncommand;
node.rgbwMode = 'color';
let deviceType = config.devicetype;
node.deviceTypeMustMatchExactly = config.devicetypemustmatchexactly || 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-gen1-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.isExactTypeGen1(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.getDeviceTypes1(deviceType, node.deviceTypeMustMatchExactly);
}
node.initializer = getInitializer1(node.deviceType);
node.inputParser = getInputParser1(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 settings = msg.settings;
/*let success =*/ await applySettings1Async(settings, node, credentials);
let route = await node.inputParser(msg, node, credentials);
executeCommand1(msg, route, 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 ShellyGen1Node;
};