text: const net = require('net');
  const async = require('async');
  const crypto = require('crypto');
  const EventEmitter = require('events');
  
  const isNonEmptyPlainObject = o => {
      if (!o) return false;
      for (let i in o) return true;
      return false;
  };
  
  class TuyaAccessory extends EventEmitter {
      constructor(props) {
          super();
  
          if (!(props.id && props.key && props.ip) && !props.fake) return console.log('[TuyaAccessory] Insufficient details to initialize:', props);
  
          this.context = {version: '3.1', port: 6668, ...props};
  
          this.state = {};
          this._cachedBuffer = Buffer.allocUnsafe(0);
  
          this._msgQueue = async.queue(this[this.context.version < 3.2 ? '_msgHandler_3_1': '_msgHandler_3_3'].bind(this), 1);
  
          if (this.context.version >= 3.2) {
              this.context.pingGap = Math.min(this.context.pingGap || 9, 9);
              console.log(`[TuyaAccessory] Changing ping gap for ${this.context.name} to ${this.context.pingGap}s`);
          }
  
          this.connected = false;
          if (props.connect !== false) this._connect();
  
          this._connectionAttempts = 0;
          this._sendCounter = 0;
      }
  
      _connect() {
          if (this.context.fake) {
              this.connected = true;
              return setTimeout(() => {
                  this.emit('change', {}, this.state);
              }, 1000);
          }
  
          this._socket = net.Socket();
  
          this._incrementAttemptCounter();
  
          (this._socket.reconnect = () => {
              if (this._socket._pinger) {
                  clearTimeout(this._socket._pinger);
                  this._socket._pinger = null;
              }
  
              if (this._socket._connTimeout) {
                  clearTimeout(this._socket._connTimeout);
                  this._socket._connTimeout = null;
              }
  
              this._socket.setKeepAlive(true);
              this._socket.setNoDelay(true);
  
              this._socket._connTimeout = setTimeout(() => {
                  this._socket.emit('error', new Error('ERR_CONNECTION_TIMED_OUT'));
                  //this._socket.destroy();
                  //process.nextTick(this._connect.bind(this));
              }, (this.context.connectTimeout || 30) * 1000);
  
              this._socket.connect(this.context.port, this.context.ip);
          })();
  
          this._socket._ping = () => {
              if (this._socket._pinger) clearTimeout(this._socket._pinger);
              this._socket._pinger = setTimeout(() => {
                  //Retry ping
                  this._socket._pinger = setTimeout(() => {
                      this._socket.emit('error', new Error('ERR_PING_TIMED_OUT'));
                  }, 5000);
  
                  this._send({
                      cmd: 9
                  });
              }, (this.context.pingTimeout || 30) * 1000);
  
              this._send({
                  cmd: 9
              });
          };
  
          this._socket.on('connect', () => {
              clearTimeout(this._socket._connTimeout);
  
              this.connected = true;
              this.emit('connect');
              setTimeout(() => this._socket._ping(), 1000);
  
  
              if (this.context.intro === false) {
                  this.emit('change', {}, this.state);
                  process.nextTick(this.update.bind(this));
              }
          });
  
          this._socket.on('ready', () => {
              if (this.context.intro === false) return;
              this.connected = true;
              this.update();
          });
  
          this._socket.on('data', msg => {
              this._cachedBuffer = Buffer.concat([this._cachedBuffer, msg]);
  
              do {
                  let startingIndex = this._cachedBuffer.indexOf('000055aa', 'hex');
                  if (startingIndex === -1) {
                      this._cachedBuffer = Buffer.allocUnsafe(0);
                      break;
                  }
                  if (startingIndex !== 0) this._cachedBuffer = this._cachedBuffer.slice(startingIndex);
  
                  let endingIndex = this._cachedBuffer.indexOf('0000aa55', 'hex');
                  if (endingIndex === -1) break;
  
                  endingIndex += 4;
  
                  this._msgQueue.push({msg: this._cachedBuffer.slice(0, endingIndex)});
  
                  this._cachedBuffer = this._cachedBuffer.slice(endingIndex);
              } while (this._cachedBuffer.length);
          });
  
          this._socket.on('error', err => {
              this.connected = false;
              console.log(`[TuyaAccessory] Socket had a problem and will reconnect to ${this.context.name} (${err && err.code || err})`);
  
              if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE') && this._connectionAttempts < 10) {
                  return process.nextTick(this._socket.reconnect.bind(this));
              }
  
              this._socket.destroy();
  
              let delay = 5000;
              if (err) {
                  if (err.code === 'ENOBUFS') {
                      console.warn('[TuyaAccessory] Operating system complained of resource exhaustion; did I open too many sockets?');
                      console.log('[TuyaAccessory] Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
                      delay = 60000;
                  } else if (this._connectionAttempts > 10) {
                      console.log('[TuyaAccessory] Slowing down retry attempts; if you see this happening often, it could mean some sort of incompatibility.');
                      delay = 60000;
                  }
              }
  
              setTimeout(() => {
                  process.nextTick(this._connect.bind(this));
              }, delay);
          });
  
          this._socket.on('close', err => {
              this.connected = false;
              //console.log('[TuyaAccessory] Closed connection with', this.context.name);
          });
  
          this._socket.on('end', () => {
              this.connected = false;
              console.log('[TuyaAccessory] Disconnected from', this.context.name);
          });
      }
  
      _incrementAttemptCounter() {
          this._connectionAttempts++;
          setTimeout(() => {
              this._connectionAttempts--;
          }, 10000);
      }
  
      _msgHandler_3_1(task, callback) {
          if (!(task.msg instanceof Buffer)) return callback();
  
          const len = task.msg.length;
          if (len < 16 ||
              task.msg.readUInt32BE(0) !== 0x000055aa ||
              task.msg.readUInt32BE(len - 4) !== 0x0000aa55
          ) return callback();
  
          const size = task.msg.readUInt32BE(12);
          if (len - 8 < size) return callback();
  
          const cmd = task.msg.readUInt32BE(8);
          let data = task.msg.slice(len - size, len - 8).toString('utf8').trim().replace(/\0/g, '');
  
          if (this.context.intro === false && cmd !== 9)
              console.log('[TuyaAccessory] Message from', this.context.name + ':', data);
  
          switch (cmd) {
              case 7:
                  // ignoring
                  break;
  
              case 9:
                  if (this._socket._pinger) clearTimeout(this._socket._pinger);
                  this._socket._pinger = setTimeout(() => {
                      this._socket._ping();
                  }, (this.context.pingGap || 20) * 1000);
                  break;
  
              case 8:
                  let decryptedMsg;
                  try {
                      const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, '');
                      decryptedMsg = decipher.update(data.substr(19), 'base64', 'utf8');
                      decryptedMsg += decipher.final('utf8');
                  } catch(ex) {
                      decryptedMsg = data.substr(19).toString('utf8');
                  }
  
                  try {
                      data = JSON.parse(decryptedMsg);
                  } catch (ex) {
                      data = decryptedMsg;
                      console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, data);
                      console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
                      break;
                  }
  
                  if (data && data.dps) {
                      //console.log('[TuyaAccessory] Update from', this.context.name, 'with command', cmd + ':', data.dps);
                      this._change(data.dps);
                  }
                  break;
  
              case 10:
                  if (data) {
                      if (data === 'json obj data unvalid' || data === 'data format error') {
                          console.log(`[TuyaAccessory] ${this.context.name} (${this.context.version}) didn't respond with its current state.`);
                          this.emit('change', {}, this.state);
                          break;
                      }
  
                      try {
                          data = JSON.parse(data);
                      } catch (ex) {
                          console.log(`[TuyaAccessory] Malformed update from ${this.context.name} with command ${cmd}:`, data);
                          console.log(`[TuyaAccessory] Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
                          break;
                      }
  
                      if (data && data.dps) this._change(data.dps);
                  }
                  break;
  
              default:
                  console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, data);
                  console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
          }
  
          callback();
      }
  
      _msgHandler_3_3(task, callback) {
          if (!(task.msg instanceof Buffer)) return callback;
  
          const len = task.msg.length;
          if (len < 16 ||
              task.msg.readUInt32BE(0) !== 0x000055aa ||
              task.msg.readUInt32BE(len - 4) !== 0x0000aa55
          ) return callback();
  
          const size = task.msg.readUInt32BE(12);
          if (len - 8 < size) return callback();
  
          const cmd = task.msg.readUInt32BE(8);
  
          if (cmd === 7) return callback(); // ignoring
          if (cmd === 9) {
              if (this._socket._pinger) clearTimeout(this._socket._pinger);
              this._socket._pinger = setTimeout(() => {
                  this._socket._ping();
              }, (this.context.pingGap || 20) * 1000);
  
              return callback();
          }
  
          let versionPos = task.msg.indexOf('3.3');
          if (versionPos === -1) versionPos = task.msg.indexOf('3.2');
          const cleanMsg = task.msg.slice(versionPos === -1 ? len - size + ((task.msg.readUInt32BE(16) & 0xFFFFFF00) ? 0 : 4) : 15 + versionPos, len - 8);
  
          let decryptedMsg;
          try {
              const decipher = crypto.createDecipheriv('aes-128-ecb', this.context.key, '');
              decryptedMsg = decipher.update(cleanMsg, 'buffer', 'utf8');
              decryptedMsg += decipher.final('utf8');
          } catch (ex) {
              decryptedMsg = cleanMsg.toString('utf8');
          }
  
          if (cmd === 10 && (decryptedMsg === 'json obj data unvalid' || decryptedMsg === 'data format error')) {
              console.log(`[TuyaAccessory] ${this.context.name} (${this.context.version}) didn't respond with its current state.`);
              this.emit('change', {}, this.state);
              return callback();
          }
  
          let data;
          try {
              data = JSON.parse(decryptedMsg);
          } catch(ex) {
              console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
              console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
              return callback();
          }
  
          switch (cmd) {
              case 8:
              case 10:
                  if (data) {
                      if (data.dps) {
                          console.log(`[TuyaAccessory] Heard back from ${this.context.name} with command ${cmd}`);
                          this._change(data.dps);
                      } else {
                          console.log(`[TuyaAccessory] Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
                          console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
                      }
                  }
                  break;
  
              default:
                  console.log(`[TuyaAccessory] Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
                  console.log(`[TuyaAccessory] Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
          }
  
          callback();
      }
  
      update(o) {
          const dps = {};
          let hasDataPoint = false;
          o && Object.keys(o).forEach(key => {
              if (!isNaN(key)) {
                  dps['' + key] = o[key];
                  hasDataPoint = true;
              }
          });
  
          if (this.context.fake) {
              if (hasDataPoint) this._fakeUpdate(dps);
              return true;
          }
  
          let result = false;
          if (hasDataPoint) {
              console.log("[TuyaAccessory] Sending", this.context.name, JSON.stringify(dps));
              result = this._send({
                  data: {
                      devId: this.context.id,
                      uid: '',
                      t: (Date.now() / 1000).toFixed(0),
                      dps: dps
                  },
                  cmd: 7
              });
              if (result !== true) console.log("[TuyaAccessory] Result", result);
              if (this.context.sendEmptyUpdate) {
                  console.log("[TuyaAccessory] Sending", this.context.name, 'empty signature');
                  this._send({cmd: 7});
              }
          } else {
              console.log(`[TuyaAccessory] Sending first query to ${this.context.name} (${this.context.version})`);
              result = this._send({
                  data: {
                      gwId: this.context.id,
                      devId: this.context.id
                  },
                  cmd: 10
              });
          }
  
          return result;
      }
  
      _change(data) {
          if (!isNonEmptyPlainObject(data)) return;
  
          const changes = {};
          Object.keys(data).forEach(key => {
              if (data[key] !== this.state[key]) {
                  changes[key] = data[key];
              }
          });
  
          if (isNonEmptyPlainObject(changes)) {
              this.state = {...this.state, ...data};
              this.emit('change', changes, this.state);
          }
      }
  
      _send(o) {
          if (this.context.fake) return;
          if (!this.connected) return false;
  
          if (this.context.version < 3.2) return this._send_3_1(o);
          return this._send_3_3(o);
      }
  
      _send_3_1(o) {
          const {cmd, data} = {...o};
  
          let msg = '';
  
          //data
          if (data) {
              switch (cmd) {
                  case 7:
                      const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
                      let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64');
                      encrypted += cipher.final('base64');
  
                      const hash = crypto.createHash('md5').update(`data=${encrypted}||lpv=${this.context.version}||${this.context.key}`, 'utf8').digest('hex').substr(8, 16);
  
                      msg = this.context.version + hash + encrypted;
                      break;
  
                  case 10:
                      msg = JSON.stringify(data);
                      break;
  
              }
          }
  
          const payload = Buffer.from(msg);
          const prefix = Buffer.from('000055aa00000000000000' + cmd.toString(16).padStart(2, '0'), 'hex');
          const suffix = Buffer.concat([payload, Buffer.from('000000000000aa55', 'hex')]);
  
          const len = Buffer.allocUnsafe(4);
          len.writeInt32BE(suffix.length, 0);
  
          return this._socket.write(Buffer.concat([prefix, len, suffix]));
      }
  
      _send_3_3(o) {
          const {cmd, data} = {...o};
  
          // If sending empty dp-update command, we should not increment the sequence
          if (cmd !== 7 || data) this._sendCounter++;
  
          const hex = [
              '000055aa', //header
              this._sendCounter.toString(16).padStart(8, '0'), //sequence
              cmd.toString(16).padStart(8, '0'), //command
              '00000000' //size
          ];
          //version
          if (cmd === 7 && !data) hex.push('00000000');
          else if (cmd !== 9 && cmd !== 10) hex.push('332e33000000000000000000000000');
          //data
          if (data) {
              const cipher = crypto.createCipheriv('aes-128-ecb', this.context.key, '');
              let encrypted = cipher.update(Buffer.from(JSON.stringify(data)), 'utf8', 'hex');
              encrypted += cipher.final('hex');
              hex.push(encrypted);
          }
          //crc32
          hex.push('00000000');
          //tail
          hex.push('0000aa55');
  
          const payload = Buffer.from(hex.join(''), 'hex');
          //length
          payload.writeUInt32BE(payload.length - 16, 12);
          //crc
          payload.writeInt32BE(getCRC32(payload.slice(0, payload.length - 8)), payload.length - 8);
  
          return this._socket.write(payload);
      }
  
      _fakeUpdate(dps) {
          console.log('[TuyaAccessory] Fake update:', JSON.stringify(dps));
          Object.keys(dps).forEach(dp => {
              this.state[dp] = dps[dp];
          });
          setTimeout(() => {
              this.emit('change', dps, this.state);
          }, 1000);
      }
  }
  
  const crc32LookupTable = [];
  (() => {
      for (let i = 0; i < 256; i++) {
          let crc = i;
          for (let j = 8; j > 0; j--) crc = (crc & 1) ? (crc >>> 1) ^ 3988292384 : crc >>> 1;
          crc32LookupTable.push(crc);
      }
  })();
  
  const getCRC32 = buffer => {
      let crc = 0xffffffff;
      for (let i = 0, len = buffer.length; i < len; i++) crc = crc32LookupTable[buffer[i] ^ (crc & 0xff)] ^ (crc >>> 8);
      return ~crc;
  };
  
  
  module.exports = TuyaAccessory;