@tuyapi/stub
Version:
🧪 A stub device implementation for local testing.
247 lines (206 loc) • 6.62 kB
JavaScript
const net = require('net');
const dgram = require('dgram');
const {MessageParser, CommandType} = require('tuyapi/lib/message-parser');
const debug = require('debug')('TuyaStub');
/**
* A stub implementation of the
* Tuya protocol for local testing.
* @class
* @param {Object} options
* @param {String} options.id ID of mock device
* @param {String} options.key key of mock device
* @param {String} [options.ip=localhost] IP address of mock device
* @param {Boolean} [options.respondToHeartbeat=true] sends pong if true in response to pings
* @param {Object} options.state inital state of device
* @example
* const stub = new TuyaStub({ id: 'xxxxxxxxxxxxxxxxxxxx',
key: 'xxxxxxxxxxxxxxxx',
state: {'1': false, '2': true}});
*/
class TuyaStub {
constructor({state = {}, id, key, ip = 'localhost', respondToHeartbeat = true}) {
this.state = state;
this.id = id;
this.key = key;
this.ip = ip;
this.respondToHeartbeat = respondToHeartbeat;
this.parser = new MessageParser({key, version: 3.1});
}
/**
* Starts the mocking server.
* @param {Number} [port=6668] port to listen on
*/
startServer(port) {
port = port ? port : 6668;
this.server = net.createServer(socket => {
this.socket = socket;
socket.on('data', data => {
this._handleRequest(data);
});
}).listen(port);
}
/**
* Call to cleanly exit.
*/
shutdown() {
if (this.socket) {
this.socket.destroy();
}
if (this.server) {
this.server.close();
}
if (this.broadcastSocket) {
this.broadcastSocket.close();
clearInterval(this.broadcastInterval);
}
}
/**
* Starts the periodic UDP broadcast.
* @param {Object} options
* @param {Number} [options.port=6666] port to broadcast on
* @param {Number} [options.interval=5] interval, in seconds, to broadcast at
*/
startUDPBroadcast(options) {
// Defaults
options = options ? options : {};
options.port = options.port ? options.port : 6666;
options.interval = options.interval ? options.interval : 5;
// Encode broadcast
const message = this.parser.encode({data: {devId: this.id, gwId: this.id, ip: this.ip}, commandByte: CommandType.DP_QUERY});
// Create and bind socket
this.broadcastSocket = dgram.createSocket({type: 'udp4', reuseAddr: true});
this.broadcastSocket.bind(options.port);
// When socket is ready, start broadcasting
this.broadcastSocket.on('listening', () => {
this.broadcastSocket.setBroadcast(true);
if (!this.broadcastInterval) {
this.broadcastInterval = setInterval(() => {
debug('Sending UDP broadcast...');
this.broadcastSocket.send(message, 0, message.length, options.port, '255.255.255.255');
}, options.interval * 1000);
}
});
}
/**
* Handles incoming requests.
* @private
* @param {Buffer} data to handle
*/
_handleRequest(data) {
debug('Incoming packet(s):');
debug(data.toString('hex'));
const parsedPackets = this.parser.parse(data);
debug('Parsed request:');
debug(parsedPackets);
parsedPackets.forEach(packet => {
if (packet.commandByte === CommandType.DP_QUERY) { // GET request
// Check device ID
if (packet.payload.devId !== this.id) {
throw new Error('devId of request does not match');
}
const response = {
data: {
devId: this.id,
gwId: this.id,
dps: this.state
},
commandByte: 10,
sequenceN: packet.sequenceN
};
// Write response
this.socket.write(this.parser.encode(response));
} else if (packet.commandByte === CommandType.CONTROL) { // SET request
// Decrypt data
const decryptedData = packet.payload;
debug('Decrypted data:');
debug(decryptedData);
// Check device ID
if (decryptedData.devId !== this.id) {
throw new Error('devId of request does not match');
}
// Check timestamp
const now = Math.floor(Date.now() / 1000); // Seconds since epoch
// Timestamp difference must be no more than 10 seconds
if (Math.abs(now - decryptedData.t) > 10) {
throw new Error('Bad timestamp.');
}
// Set properties
Object.keys(decryptedData.dps).forEach(property => {
this.setProperty(property, decryptedData.dps[property]);
});
// Responses for status updates have two parts
const confirmChangeResponse = {
data: {},
commandByte: 7,
sequenceN: packet.sequenceN
};
this.socket.write(this.parser.encode(confirmChangeResponse));
const statusUpdateResponse = {
data: {
devId: this.id,
gwId: this.id,
dps: this.state
},
commandByte: 8
};
this.socket.write(this.parser.encode(statusUpdateResponse));
} else if (packet.commandByte === CommandType.HEART_BEAT && this.respondToHeartbeat) { // Heartbeat packet
// Send response pong
debug('Sending pong...');
const buffer = this.parser.encode({
data: Buffer.allocUnsafe(0),
commandByte: CommandType.HEART_BEAT,
sequenceN: packet.sequenceN
});
this.socket.write(buffer);
}
});
}
/**
* Sets a property of the mock device.
* @param {String} property to change
* @param {any} value to set
* @returns {any} updated property value
*/
setProperty(property, value) {
this.state[property] = value;
if (this.server && this.socket) {
// Write response
const response = {
data: {
devId: this.id,
gwId: this.id,
dps: this.state
},
commandByte: CommandType.CONTROL
};
this.socket.write(this.parser.encode(response));
}
return this.state[property];
}
/**
* Gets a property of the mock device.
* @param {String} property to get
* @returns {any} property value
*/
getProperty(property) {
return this.state[property];
}
/**
* Gets entire state of the mock device.
* @returns {Object} device's state
*/
getState() {
return this.state;
}
/**
* Sets entire state of the mock device.
* @param {Object} state new state
* @returns {Object} updated state
*/
setState(state) {
this.state = state;
return this.state;
}
}
module.exports = TuyaStub;