@tuyapi/link
Version:
📡 Effortlessly connect devices to WiFi and the cloud
389 lines (337 loc) • 11.2 kB
JavaScript
const dgram = require('dgram');
const delay = require('delay');
const debug = require('debug')('@tuyapi/link:manual');
const os = require('os');
/**
* A lower level option for linking
* devices. Use only if you're not generating
* a token through `@tuyapi/cloud`. Exported
* as `(@tuyapi/link).manual`.
* @class
* @example
* const register = new TuyaLink.manual();
*/
class TuyaLink {
constructor() {
this.abortBroadcasting = false;
this.bindAddr = undefined;
}
/**
* Thin wrapper for this.sendSmartLinkStart()
* and this.sendSmartLinkData(). Unless you
* have a special use case, prefer this method
* over calling this.sendSmartLinkStart() and
* this.sendSmartLinkData() directly.
* @param {Object} options
* options
* @param {String} options.region
* region (see smartLinkEncode() for options)
* @param {String} options.token
* generated token to send
* @param {String} options.secret
* generated secret to send
* @param {String} options.ssid
* SSID to connect to
* @param {String} options.wifiPassword
* password of WiFi
* @example
* device.registerSmartLink({region: 'AZ',
* token: '00000000',
* secret: '0101',
* ssid: 'Example SSID',
* wifiPassword: 'example-password'}).then(() => {
* console.log('Done!');
* });
* @returns {Promise<Undefined>} A Promise that resolves when all data has been transmitted
*/
async registerSmartLink(options) {
// Check arguments
if (options.region.length !== 2 || !['AZ', 'CN', 'EU'].includes(options.region)) {
throw new Error('Invalid region');
}
if (options.token.length !== 8) {
throw new Error('Invalid token');
}
if (options.secret.length !== 4) {
throw new Error('Invalid secret');
}
if (options.ssid.length > 32) {
throw new Error('Invalid SSID');
}
if (options.wifiPassword.length > 64) {
throw new Error('Invalid WiFi password');
}
if (options.bindAddr !== undefined) {
if (typeof options.bindAddr !== 'string') {
throw new TypeError('Invalid binding address');
}
const interfaces = os.networkInterfaces();
if (!Object.keys(interfaces).some(name =>
!interfaces[name].some(assigned => assigned.address.toLowerCase() === options.bindAddr.toLowerCase())
)) {
throw new Error('Invalid binding address');
}
this.bindAddr = options.bindAddr;
}
debug('Sending SmartLink initialization packets');
await this.sendSmartLinkStart();
debug('Sending SmartLink data packets');
await this.sendSmartLinkData(this.smartLinkEncode(options));
debug('Finished sending packets.');
}
/**
* Transmits start pattern of packets
* (1, 3, 6, 10) 32 times with
* a delay between transmits.
* @returns {Promise<Undefined>} A Promise that resolves when data has been transmitted
*/
async sendSmartLinkStart() {
const gap = 2;
/* eslint-disable no-await-in-loop */ // 143/2
for (let x = 0; x < 32; x++) {
await this._broadcastUDP(1);
await delay(gap);
await this._broadcastUDP(3);
await delay(gap);
await this._broadcastUDP(6);
await delay(gap);
await this._broadcastUDP(10);
await delay(gap);
await this._broadcastUDP(1);
await delay(gap);
await this._broadcastUDP(3);
await delay(gap);
await this._broadcastUDP(6);
await delay(gap);
await this._broadcastUDP(10);
await delay(40);// 70+x%8)
}
/* eslint-enable no-await-in-loop */
}
/**
* Transmits provided data
* as UDP packet lengths 30
* times with a delay between
* transmits.
* @param {Array} data of packet lengths to send
* @returns {Promise<Undefined>} A Promise that resolves when data has been transmitted
*/
async sendSmartLinkData(data) {
const gap = 2;
/* eslint-disable no-await-in-loop */
for (let x = 0; x < 10 && !this.abortBroadcasting; x++) {
await delay(160);
await this._asyncForEach(data, async b => {
await this._broadcastUDP(b);
await delay(gap);
}); // 17, 40, 53, 79
}
/* eslint-enable no-await-in-loop */
this.abortBroadcasting = false;
}
/**
* Aborts broadcasting UDP packets.
*/
abortBroadcastingData() {
debug('Aborting broadcast of data...');
this.abortBroadcasting = true;
}
/**
* Encodes data as UDP packet
* lengths.
* @param {Object} options options
* @param {String} options.region
* two-letter region (AZ=Americas, CN=Asia, EU=Europe)
* @param {String} options.token token
* @param {String} options.secret secret
* @param {String} options.ssid SSID
* @param {String} options.wifiPassword
* password of WiFi
* @returns {Array} array of packet lengths
*/
smartLinkEncode(options) {
// Convert strings to Buffers
const wifiPasswordBytes = Buffer.from(options.wifiPassword);
const regionTokenSecretBytes = Buffer.from(options.region +
options.token + options.secret);
const ssidBytes = Buffer.from(options.ssid);
// Calculate size of byte array
// (must add 1 byte for lengths)
const rawByteArray = Buffer.alloc(1 +
wifiPasswordBytes.length +
1 +
regionTokenSecretBytes.length +
ssidBytes.length);
let rawByteArrayIndex = 0;
// Write WiFi password length
rawByteArray.writeInt8(this._getLength(options.wifiPassword), rawByteArrayIndex);
rawByteArrayIndex++;
// Write WiFi password
wifiPasswordBytes.copy(rawByteArray, rawByteArrayIndex);
rawByteArrayIndex += wifiPasswordBytes.length;
// Write region token secret length
rawByteArray.writeInt8(this._getLength(regionTokenSecretBytes), rawByteArrayIndex);
rawByteArrayIndex++;
// Write region token secret bytes
regionTokenSecretBytes.copy(rawByteArray, rawByteArrayIndex);
rawByteArrayIndex += regionTokenSecretBytes.length;
// Write WiFi SSID bytes
ssidBytes.copy(rawByteArray, rawByteArrayIndex);
rawByteArrayIndex += ssidBytes.length;
if (rawByteArray.length !== rawByteArrayIndex) {
throw new Error('Byte buffer filled improperly');
}
// Now, encode above data into packet lengths
const rawDataLengthRoundedUp = this._rounder(rawByteArray.length, 4);
const encodedData = [];
// First 4 bytes of header
const stringLength = (wifiPasswordBytes.length +
regionTokenSecretBytes.length + ssidBytes.length + 2) % 256;
const stringLengthCRC = this._tuyaCRC8([stringLength]);
// Length encoded into the first two bytes based at 16 and then 32
encodedData[0] = (stringLength / 16) | 16;
encodedData[1] = (stringLength % 16) | 32;
// Length CRC encoded into the next two bytes based at 48 and 64
encodedData[2] = (stringLengthCRC / 16) | 48;
encodedData[3] = (stringLengthCRC % 16) | 64;
// Rest of data
let encodedDataIndex = 4;
let sequenceCounter = 0;
for (let x = 0; x < rawDataLengthRoundedUp; x += 4) {
// Build CRC buffer, using data from rawByteArray or 0 values if too long
const crcData = [];
crcData[0] = sequenceCounter++;
crcData[1] = x + 0 < rawByteArray.length ? rawByteArray[x + 0] : 0;
crcData[2] = x + 1 < rawByteArray.length ? rawByteArray[x + 1] : 0;
crcData[3] = x + 2 < rawByteArray.length ? rawByteArray[x + 2] : 0;
crcData[4] = x + 3 < rawByteArray.length ? rawByteArray[x + 3] : 0;
// Calculate the CRC
const crc = this._tuyaCRC8(crcData);
// Move data to encodedData array
// CRC
encodedData[encodedDataIndex++] = (crc % 128) | 128;
// Sequence number
encodedData[encodedDataIndex++] = (crcData[0] % 128) | 128;
// Data
encodedData[encodedDataIndex++] = (crcData[1] % 256) | 256;
encodedData[encodedDataIndex++] = (crcData[2] % 256) | 256;
encodedData[encodedDataIndex++] = (crcData[3] % 256) | 256;
encodedData[encodedDataIndex++] = (crcData[4] % 256) | 256;
}
return encodedData;
}
/**
* Un-references UDP instance
* so that a script can cleanly
* exit.
*/
cleanup() {
if (this.udpClient) {
this.udpClient.unref();
}
}
/**
* Returns the length in bytes
* of a string.
* @param {String} str input string
* @returns {Number} length in bytes
* @private
*/
_getLength(str) {
return Buffer.byteLength(str, 'utf8');
}
/**
* Rounds input `x` to the next
* highest multiple of `g`.
* @param {Number} x input number
* @param {Number} g rounding factor
* @returns {Number} rounded result
* @private
*/
_rounder(x, g) {
return Math.ceil(x / g) * g;
}
/**
* Calculates a modified CRC8
* of a given arary of data.
* @param {Array} p input data
* @returns {Number} CRC result
* @private
*/
_tuyaCRC8(p) {
let crc = 0;
let i = 0;
const len = p.length;
while (i < len) {
crc = this._calcrc1Byte(crc ^ p[i]);
i++;
}
return crc;
}
/**
* Calculates a modified
* CRC8 of one byte.
* @param {Number} abyte one byte as an integer
* @returns {Number} resulting CRC8 byte
* @private
*/
_calcrc1Byte(abyte) {
const crc1Byte = Buffer.alloc(1);
crc1Byte[0] = 0;
for (let i = 0; i < 8; i++) {
if (((crc1Byte[0] ^ abyte) & 0x01) > 0) {
crc1Byte[0] ^= 0x18;
crc1Byte[0] >>= 1;
crc1Byte[0] |= 0x80;
} else {
crc1Byte[0] >>= 1;
}
abyte >>= 1;
}
return crc1Byte[0];
}
/**
* Broadcasts input number as the
* length of a UDP packet.
* @param {Number} len length of packet to broadcast
* @returns {Promise<Undefined>}
* A Promise that resolves when input has been broadcasted
* @private
*/
_broadcastUDP(len) {
// Create and bind UDP socket
if (!this.udpClient) {
this.udpClient = dgram.createSocket({type: 'udp4', recvBufferSize: 0, sendBufferSize: 0});
this.udpClient.on('listening', function () {
this.setBroadcast(true);
});
this.udpClient.bind(0, this.bindAddr);
}
// 0-filled buffer
const message = Buffer.alloc(len);
return new Promise((resolve, reject) => {
this.udpClient.send(message, 0, message.length, 30011, '255.255.255.255', err => {
if (err) {
reject(err);
}
resolve();
});
});
}
/**
* A helper that provides an easy
* way to iterate over an array with
* an asynchronous function.
* @param {Array} array input array to iterate over
* @param {function(item, index, array)} callback
* function to call for iterations
* @private
*/
async _asyncForEach(array, callback) {
for (let index = 0; index < array.length; index++) {
// eslint-disable-next-line no-await-in-loop
await callback(array[index], index, array);
}
}
}
module.exports = TuyaLink;