@demirdeniz/node-red-contrib-tuya-kepler-device
Version:
A node-red module to interact with the tuya smart devices (updated with Tuya protocol 3.5)
520 lines (482 loc) • 17.4 kB
JavaScript
const TuyaDevice = require('@demirdeniz/tuyapi-newgen');
const packageInfo = require('../package.json');
const utils = require('./utils');
const CLIENT_STATUS = {
DISCONNECTED: 'DISCONNECTED',
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
ERROR: 'ERROR',
};
const EVENT_MODES = {
BOTH: 'event-both',
DATA: 'event-data',
DP_REFRESH: 'event-dp-refresh',
};
module.exports = function (RED) {
function TuyaKeplerDeviceNode(config) {
RED.nodes.createNode(this, config);
config.disableAutoStart = config.disableAutoStart || false;
let node = this;
let isConnected = false;
let shouldTryReconnect = true;
let shouldSubscribeData = true;
let shouldSubscribeRefreshData = true;
this.name = config.deviceName;
this.deviceName = config.deviceName;
this.storeAsCreds = config.storeAsCreds || false;
if (this.storeAsCreds) {
// If the deviceId and key are stored in the creds take it from there
const secretConfig = this.credentials.secretConfig || '{}';
const secret = JSON.parse(secretConfig);
console.log(
'loading the deviceId and deviceKey from secret credentials ...'
);
this.deviceId = secret.deviceId;
this.deviceKey = secret.deviceKey;
} else {
console.log('loading the deviceId and deviceKey from config ...');
this.deviceId = config.deviceId;
this.deviceKey = config.deviceKey;
}
this.deviceIp = config.deviceIp;
this.disableAutoStart = config.disableAutoStart;
this.eventMode = config.eventMode || EVENT_MODES.BOTH;
this.logLevel = config.logLevel || "log-level-disable";
if (this.logLevel == "log-level-debug")
this.enableDebug = true;
// To be used for local debugging.
// need to find a log method control in the node.
/*node.log(
`Recieved the config ${JSON.stringify({
...config,
credentials: this.credentials,
moduleVersion: packageInfo.version,
})}`
);*/
this.retryTimeout =
config.retryTimeout == null ||
typeof config.retryTimeout == 'undefined' ||
(typeof config.retryTimeout == 'string' &&
config.retryTimeout.trim() == '') ||
(typeof config.retryTimeout == 'number' && config.retryTimeout <= 0) ||
isNaN(config.retryTimeout)
? 1000
: config.retryTimeout;
this.findTimeout =
config.findTimeout == null ||
typeof config.findTimeout == 'undefined' ||
(typeof config.findTimeout == 'string' &&
config.findTimeout.trim() == '') ||
(typeof config.findTimeout == 'number' && config.findTimeout <= 0) ||
isNaN(config.findTimeout)
? 1000
: config.findTimeout;
this.tuyaVersion =
config.tuyaVersion == null ||
typeof config.tuyaVersion == 'undefined' ||
(typeof config.tuyaVersion == 'string' &&
config.tuyaVersion.trim() == '') ||
(typeof config.tuyaVersion == 'number' && config.tuyaVersion <= 0) ||
isNaN(config.tuyaVersion)
? '3.1'
: config.tuyaVersion.trim();
/////////////////////// Kepler settings ////////////////////////////
config.enableKeepAlive = config.enableKeepAlive || false;
this.enableKeepAlive = config.enableKeepAlive;
this.initialDelay = parseInt(config.initialDelay) || 10000;
this.socketTimeout = parseInt(config.socketTimeout) || 5000;
this.HeartBeatInterval = parseInt(config.HeartBeatInterval) || 25;
config.issueGetOnConnect = config.issueGetOnConnect || false;
this.issueGetOnConnect = config.issueGetOnConnect;
// if issue Get flag is set ... then clear Refresh flag (if any)
config.issueRefreshOnConnect = config.issueRefreshOnConnect || false;
if(this.issueGetOnConnect)
config.issueRefreshOnConnect = false;
this.issueRefreshOnConnect = config.issueRefreshOnConnect;
////////////////////////////////////////////////////////////////////////
this.deviceStatus = null;
// Variable definition ends here
if (this.eventMode == EVENT_MODES.DATA) {
shouldSubscribeData = true;
shouldSubscribeRefreshData = false;
} else if (this.eventMode == EVENT_MODES.DP_REFRESH) {
shouldSubscribeData = false;
shouldSubscribeRefreshData = true;
} else {
// both case or default case
shouldSubscribeData = true;
shouldSubscribeRefreshData = true;
}
node.log(
`Event subscription : shouldSubscribeData=>${shouldSubscribeData} , shouldSubscribeRefreshData=>${shouldSubscribeRefreshData}`
);
if (this.retryTimeout <= 0) {
this.retryTimeout = 1000;
}
if (this.findTimeout <= 0) {
this.findTimeout = 10000;
}
let findTimeoutHandler = null;
let retryTimerHandler = null;
if (this.initialDelay <= 0) {
this.initialDelay = 10000;
}
if (this.socketTimeout <= 0) {
this.socketTimeout = 5000;
}
if (this.HeartBeatInterval <= 0) {
this.HeartBeatInterval = 25;
}
node.on('input', function (msg) {
node.log(`Recieved input : ${JSON.stringify(msg)}`);
let operation = msg.payload.operation || 'SET';
delete msg.payload.operation;
if (['GET', 'SET', 'REFRESH'].indexOf(operation) != -1) {
// the device has to be connected.
if (!tuyaDevice.isConnected()) {
// error device not connected
let errText = `Device not connected. Can't send the ${operation} commmand`;
node.log(errText);
setStatusOnError(errText, 'Device not connected !', {
context: {
message: errText,
deviceVirtualId: node.deviceId,
deviceIp: node.deviceIp,
deviceName: node.deviceName,
deviceKey: node.deviceKey,
},
});
return;
}
}
switch (operation) {
case 'SET':
tuyaDevice.set(msg.payload);
break;
case 'REFRESH':
tuyaDevice.refresh(msg.payload);
break;
case 'GET':
tuyaDevice.get(msg.payload);
break;
case 'CONTROL':
if (msg.payload.action == 'CONNECT') {
if (!tuyaDevice.isConnected()) {
// Connect only when disconnected
startComm();
}
} else if (msg.payload.action == 'DISCONNECT') {
//Disconnect only when connected.
// Make disconnect force
closeComm();
} else if (msg.payload.action == 'SET_FIND_TIMEOUT') {
if (!isNaN(msg.payload.value) && msg.payload.value > 0) {
setFindTimeout(msg.payload.value);
} else {
node.log('Invalid find timeout ! - ' + msg.payload.value);
}
} else if (msg.payload.action == 'SET_RETRY_TIMEOUT') {
if (!isNaN(msg.payload.value) && msg.payload.value > 0) {
setRetryTimeout(msg.payload.value);
} else {
node.log('Invalid retry timeout ! - ' + msg.payload.value);
}
} else if (msg.payload.action == 'RECONNECT') {
if (tuyaDevice.isConnected) {
closeComm();
}
startComm();
} else if (msg.payload.action == 'SET_EVENT_MODE') {
shouldSubscribeData = true;
shouldSubscribeRefreshData = true;
// if any incorrect value set the event mode as BOTH
node.eventMode = EVENT_MODES.BOTH;
if (msg.payload.value === EVENT_MODES.DATA) {
shouldSubscribeRefreshData = false;
node.eventMode = EVENT_MODES.DATA;
} else if (msg.payload.value === EVENT_MODES.DP_REFRESH) {
shouldSubscribeData = false;
node.eventMode = EVENT_MODES.DP_REFRESH;
}
node.log(
`SET_EVENT_MODE : shouldSubscribeData=>${shouldSubscribeData} , shouldSubscribeRefreshData=>${shouldSubscribeRefreshData}`
);
}
break;
}
});
const enableNode = () => {
console.log('enableNode(): enabling the node', node.id);
startComm();
};
const disableNode = () => {
console.log('disableNode(): disabling the node', node.id);
closeComm();
};
const setFindTimeout = (newTimeout) => {
node.log('setFindTimeout(): Setting new find timeout :' + newTimeout);
node.findTimeout = newTimeout;
};
const setRetryTimeout = (newTimeout) => {
node.log('setRetryTimeout(): Setting new retry timeout :' + newTimeout);
node.retryTimeout = newTimeout;
};
const closeComm = () => {
node.log('closeComm(): Cleaning up the state');
node.log('closeComm(): Clearing the find timeout handler');
clearTimeout(findTimeoutHandler);
shouldTryReconnect = false;
node.log('closeComm(): Disconnecting from Tuya Device');
tuyaDevice.disconnect();
setStatusDisconnected();
};
const startComm = () => {
// This 1 sec timeout will make sure that the diconnect happens ..
// otherwise connect will not hanppen as the state is not changed
findTimeoutHandler = setTimeout(() => {
shouldTryReconnect = true;
node.log(
`startComm(): Connecting to Tuya with params ${JSON.stringify(
utils.maskSensitiveData(connectionParams)
)} , findTimeout : ${node.findTimeout} , retryTimeout: ${
node.retryTimeout
} `
);
findDevice();
}, 1000);
};
const sendDeviceConnectStatus = (data) => {
return {
payload: {
state: node.deviceStatus,
...data,
},
deviceName: node.deviceName
};
};
const setStatusConnecting = function () {
if (node.deviceStatus != CLIENT_STATUS.CONNECTING) {
node.deviceStatus = CLIENT_STATUS.CONNECTING;
node.send([null, sendDeviceConnectStatus()]);
}
return node.status({ fill: 'yellow', shape: 'ring', text: 'connecting' });
};
const setStatusConnected = function () {
if (node.deviceStatus != CLIENT_STATUS.CONNECTED) {
node.deviceStatus = CLIENT_STATUS.CONNECTED;
node.send([null, sendDeviceConnectStatus()]);
}
return node.status({ fill: 'green', shape: 'ring', text: 'connected' });
};
const setStatusDisconnected = function () {
if (node.deviceStatus != CLIENT_STATUS.DISCONNECTED) {
node.deviceStatus = CLIENT_STATUS.DISCONNECTED;
node.send([null, sendDeviceConnectStatus()]);
}
return node.status({ fill: 'red', shape: 'ring', text: 'disconnected' });
};
const setStatusOnError = function (
errorText,
errorShortText = 'error',
data
) {
node.error(errorText, data);
if (node.deviceStatus != CLIENT_STATUS.ERROR) {
node.deviceStatus = CLIENT_STATUS.ERROR;
node.send([null, sendDeviceConnectStatus()]);
}
return node.status({ fill: 'red', shape: 'ring', text: errorShortText });
};
const connectionParams = {
id: node.deviceId,
key: node.deviceKey,
ip: node.deviceIp,
issueGetOnConnect: node.issueGetOnConnect, //false,
nullPayloadOnJSONError: false,
version: node.tuyaVersion,
issueRefreshOnConnect: node.issueRefreshOnConnect, //false,
KeepAlive: node.enableKeepAlive,
initialDelay: node.initialDelay,
socketTimeout: node.socketTimeout,
HeartBeatInterval: node.HeartBeatInterval,
enableDebug: node.enableDebug
};
let tuyaDevice = new TuyaDevice(connectionParams);
let retryConnection = () => {
clearTimeout(retryTimerHandler);
retryTimerHandler = setTimeout(() => {
node.log('Retrying connection...');
connectDevice();
}, node.retryTimeout);
node.log(`Will try to reconnect after ${node.retryTimeout} milliseconds`);
};
node.on('close', function () {
// tidy up any state
// clearInterval(int);
closeComm();
});
// Add event listeners
tuyaDevice.on('connected', () => {
node.log(
'Connected to device! name : ' +
node.deviceName +
', ip : ' +
node.deviceIp
);
setStatusConnected();
});
tuyaDevice.on('disconnected', () => {
node.log(
'Disconnected from tuyaDevice. shouldTryReconnect = ' +
shouldTryReconnect
);
setStatusDisconnected();
if (shouldTryReconnect) {
retryConnection();
}
});
tuyaDevice.on('error', (error) => {
node.log(
'Error from tuyaDevice. shouldTryReconnect = ' +
shouldTryReconnect +
', error = ' +
JSON.stringify(error)
);
// Anonymize
setStatusOnError(error, 'Error : ' + JSON.stringify(error), {
context: {
message: error,
deviceVirtualId: node.deviceId,
deviceIp: node.deviceIp,
deviceKey: node.deviceKey,
},
});
if (
typeof error === 'string' &&
error.startsWith('Timeout waiting for status response')
) {
node.log(
'This error can be due to invalid DPS values. Please check the dps values in the payload !!!!'
);
}
if (shouldTryReconnect) {
retryConnection();
}
});
tuyaDevice.on('dp-refresh', (data) => {
if (shouldSubscribeRefreshData) {
node.log(
`Data from device [event:dp-refresh]: ${JSON.stringify(data)}`
);
setStatusConnected();
node.send([
{
payload: {
data: data,
deviceId: node.deviceId,
deviceName: node.deviceName,
},
},
null,
]);
}
});
tuyaDevice.on('data', (data) => {
if (shouldSubscribeData) {
node.log(`Data from device [event:data]: ${JSON.stringify(data)}`);
setStatusConnected();
node.send([
{
payload: {
data: data,
deviceId: node.deviceId,
deviceName: node.deviceName,
},
},
null,
]);
}
});
let connectDevice = () => {
clearTimeout(findTimeoutHandler);
if (tuyaDevice.isConnected() === false) {
setStatusConnecting();
const connectHandle = tuyaDevice.connect();
connectHandle.catch((e) => {
setStatusDisconnected();
node.log(
`connectDevice(): An error had occurred with tuya API on connect method : ${JSON.stringify(
e
)}`
);
if (shouldTryReconnect) {
node.log('connectDevice(): retrying the connect');
if (findTimeoutHandler) {
clearTimeout(findTimeoutHandler);
}
findTimeoutHandler = setTimeout(findDevice, node.retryTimeout);
} else {
node.log(
'connectDevice(): not retrying the find as shouldTryReconnect = false'
);
}
});
} else {
node.log(
'connectDevice() : already connected. skippig the connect call'
);
setStatusConnected();
}
};
let findDevice = () => {
setStatusConnecting();
node.log('findDevice(): Initiating the find command');
tuyaDevice
.find({
timeout: parseInt(node.findTimeout / 1000),
})
.then(() => {
node.log('findDevice(): Found device, going to connect');
// Connect to device
connectDevice();
})
.catch((e) => {
// We need to retry
setStatusOnError(e.message, "Can't find device", {
context: {
message: e,
deviceVirtualId: node.deviceId,
deviceIp: node.deviceIp,
deviceKey: node.deviceKey,
deviceName: node.deviceName,
},
});
setStatusDisconnected();
if (shouldTryReconnect) {
node.log('findDevice(): Cannot find the device, re-trying...');
findTimeoutHandler = setTimeout(findDevice, node.retryTimeout);
} else {
node.log(
'findDevice(): not retrying the find as shouldTryReconnect = false'
);
}
});
};
// Initial state
setTimeout(() => {
setStatusDisconnected();
}, 500);
// Start probing
if (!node.disableAutoStart) {
node.log('Auto start probe on connect...');
startComm();
} else {
node.log('Auto start probe is disabled ');
}
}
RED.nodes.registerType('tuya-kepler-device', TuyaKeplerDeviceNode, {
credentials: {
secretConfig: { type: 'text' },
},
});
};