├── .babelrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── device.ts ├── find.ts ├── index.ts └── lib │ ├── constants.ts │ ├── crc.ts │ ├── crypto.ts │ ├── frame.ts │ ├── helpers.ts │ └── messenger.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": "6.0.0" 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled code 2 | dist 3 | 4 | # Playground 5 | dev.js 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | script: 5 | - npm run travis 6 | deploy: 7 | provider: npm 8 | skip_cleanup: true 9 | email: code+tuyapinpm@maxisom.me 10 | api_key: 11 | secure: UPdqs8gF5FCIFTX8Nrt+yapnbOE7uw6i5zVyCQApB1OKplLHc3jenHcgkoXO5wl9XmAOLBeCkGjcHML+1dLhVsQ7quDXzRlyYsj4MUaIaRP6qGBZlD8k1A/0MZAGjyQKFbomaRe3jx8CjyNqdWjiIG/rBjbq2TdZbHlxTw5mgZTbqBG4phD6ybuabH0VLKyyYlL5EKeRlaaHiwG07ORSl3+7Vc0C1SG9lneog4JKgocrC3Fg4oqejZvhHYBv6Vgb2wUn2h/Yh7yPoyPaHkWW66V947eK0z0hDJ//wJw71dtilSXqmnrQgUbyBRaV6IFZS57AYiaaPaMl4Pf73ggWzrAaU7gOKNYDtLXslI22bmYkQcfgIzOJZQDaH4YuYbSlp39tHrexeJPjL1leKx04uSg7X2s4/RWcRGZhR/dA8cqX+gGTuof3l4DE6gsI8HkSu3UTP7sCgDmFGNf7eSfHALYzcZwbcZGpL8Zmm6628JDD4E+QDpSi+OrVQCRmXEG8kHHYknaM9oqSkrYiepwhIhUiqLy4/dpQxPcQ91I5JveGRHNaEFTs5eN+xt3MuzkxImBuw2i/HT4LDOz+IoEzuVrEEkqsl/aeR9JFPx8e02HM7L3R4MZqdKnQjyMDa+SxCw7iH6ctydtqoHvbm0jgZlpYkQxwnsoWksfKRV36czU= 12 | on: 13 | tags: true 14 | repo: TuyaAPI/driver 15 | branch: master 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 TuyAPI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## @tuyapi/driver 2 | 3 | [![Build Status](https://travis-ci.com/TuyaAPI/driver.svg?branch=master)](https://travis-ci.com/TuyaAPI/driver) [![GitHub stars](https://img.shields.io/github/stars/TuyaAPI/driver)](https://github.com/TuyaAPI/driver/stargazers) [![GitHub license](https://img.shields.io/github/license/TuyaAPI/driver)](https://github.com/TuyaAPI/driver/blob/master/LICENSE) 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuyapi/driver", 3 | "version": "0.0.6", 4 | "description": "⚡️ next-gen driver for Tuya devices", 5 | "keywords": [ 6 | "🚗", 7 | "tuya", 8 | "iot", 9 | "plug", 10 | "bulb", 11 | "smart", 12 | "switch", 13 | "api", 14 | "driver", 15 | "socket", 16 | "protocol" 17 | ], 18 | "main": "dist/index.js", 19 | "files": [ 20 | "dist/" 21 | ], 22 | "scripts": { 23 | "lint": "xo", 24 | "lint-fix": "xo --fix", 25 | "test": "npm run lint", 26 | "babel": "babel dist -d dist", 27 | "build": "tsc && npm run babel", 28 | "watch": "tsc --watch", 29 | "prepack": "npm run build", 30 | "travis": "npm test && npm run build" 31 | }, 32 | "author": "Max Isom (https://maxisom.me/)", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@types/debug": "4.1.5", 36 | "@typescript-eslint/eslint-plugin": "2.12.0", 37 | "@typescript-eslint/parser": "2.12.0", 38 | "babel-cli": "6.26.0", 39 | "babel-preset-env": "1.7.0", 40 | "eslint-config-xo-typescript": "0.23.0", 41 | "husky": "3.1.0", 42 | "typescript": "3.7.4", 43 | "xo": "0.25.3" 44 | }, 45 | "xo": { 46 | "space": true, 47 | "extends": "xo-typescript", 48 | "rules": { 49 | "@typescript-eslint/indent": [ 50 | "error", 51 | 2, 52 | { 53 | "SwitchCase": 1 54 | } 55 | ] 56 | }, 57 | "extensions": [ 58 | "ts" 59 | ] 60 | }, 61 | "dependencies": { 62 | "core-js": "^3.2.1", 63 | "debug": "^4.1.1", 64 | "regenerator-runtime": "^0.13.3" 65 | }, 66 | "husky": { 67 | "hooks": { 68 | "pre-commit": "npm test && npm run build" 69 | } 70 | }, 71 | "publishConfig": { 72 | "access": "public" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "baseBranches": ["development"] 6 | } 7 | -------------------------------------------------------------------------------- /src/device.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {Socket} from 'net'; 3 | import debug from 'debug'; 4 | import Messenger from './lib/messenger'; 5 | import Frame from './lib/frame'; 6 | import {COMMANDS, SUPPORTED_PROTOCOLS} from './lib/constants'; 7 | import {DeviceError} from './lib/helpers'; 8 | 9 | interface Device { 10 | readonly ip: string; 11 | readonly port: number; 12 | readonly key: string; 13 | readonly id: string; 14 | readonly gwId: string; 15 | readonly version: number; 16 | connected: boolean; 17 | } 18 | 19 | class Device extends EventEmitter implements Device { 20 | private readonly _messenger: Messenger; 21 | 22 | private readonly _socket: Socket; 23 | 24 | private _state: object; 25 | 26 | private _lastHeartbeat: Date; 27 | 28 | private readonly _heartbeatInterval: number; 29 | 30 | constructor({ip, id, gwId = id, key, version = 3.1, port = 6668, heartbeatInterval = 1000}: { 31 | ip: string; port?: number; key: string; id: string; gwId?: string; version?: number; heartbeatInterval?: number; 32 | }) { 33 | super(); 34 | 35 | // Check protocol version 36 | if (!SUPPORTED_PROTOCOLS.includes(version)) { 37 | throw new Error(`Protocol version ${version} is unsupported.`); 38 | } 39 | 40 | // Copy arguments 41 | Object.assign(this, {ip, port, key, id, gwId, version}); 42 | 43 | // Create messenger 44 | this._messenger = new Messenger({key, version}); 45 | 46 | // Init with empty state 47 | this._state = {}; 48 | 49 | // Starts disconnecteds 50 | this.connected = false; 51 | 52 | // Init socket 53 | this._socket = new Socket(); 54 | 55 | // Set last heartbeat 56 | this._lastHeartbeat = new Date(); 57 | 58 | // Set up heartbeating interval 59 | this._heartbeatInterval = heartbeatInterval; 60 | 61 | // Set up socket handlers 62 | this._socket.on('connect', this._handleSocketConnect.bind(this)); 63 | this._socket.on('close', this._handleSocketClose.bind(this)); 64 | this._socket.on('data', this._handleSocketData.bind(this)); 65 | this._socket.on('error', this._handleSocketError.bind(this)); 66 | } 67 | 68 | connect(): void { 69 | if (this.connected) { 70 | // Already connected, don't have to do anything 71 | return; 72 | } 73 | 74 | // Connect to device 75 | this._log('Connecting...'); 76 | this._socket.connect(this.port, this.ip); 77 | 78 | // TODO: we should probably set a timeout on connect. Otherwise we just rely 79 | // on TCP to retry sending SYN packets. 80 | } 81 | 82 | disconnect(): void { 83 | if (this.connected) { 84 | this._socket.destroy(); 85 | } 86 | } 87 | 88 | get(): object { 89 | return this._state; 90 | } 91 | 92 | set(dps: object): void { 93 | const timestamp = Math.round(new Date().getTime() / 1000); 94 | 95 | const frame = new Frame(); 96 | 97 | frame.command = COMMANDS.CONTROL; 98 | frame.setPayload({ 99 | gwId: this.gwId, 100 | devId: this.id, 101 | uid: '', 102 | t: timestamp, 103 | dps 104 | }); 105 | 106 | frame.encrypt(this.key); 107 | 108 | this.send(this._messenger.encode(frame)); 109 | } 110 | 111 | update(): void { 112 | const frame = new Frame(); 113 | 114 | frame.command = COMMANDS.DP_QUERY; 115 | frame.setPayload({ 116 | gwId: this.gwId, 117 | devId: this.id 118 | }); 119 | 120 | const request = this._messenger.encode(frame); 121 | 122 | this.send(request); 123 | } 124 | 125 | send(frame: Frame): void { 126 | this._log('Sending:', frame.packet.toString('hex')); 127 | 128 | this._socket.write(frame.packet); 129 | } 130 | 131 | private _recursiveHeartbeat(): void { 132 | if (new Date().getTime() - this._lastHeartbeat.getTime() > this._heartbeatInterval * 2) { 133 | // Heartbeat timeout 134 | // Should we emit error on timeout? 135 | return this.disconnect(); 136 | } 137 | 138 | const frame = new Frame(); 139 | 140 | frame.command = COMMANDS.HEART_BEAT; 141 | 142 | this.send(this._messenger.encode(frame)); 143 | 144 | setTimeout(this._recursiveHeartbeat.bind(this), this._heartbeatInterval); 145 | } 146 | 147 | private _handleSocketConnect(): void { 148 | this.connected = true; 149 | 150 | this._log('Connected.'); 151 | 152 | this.emit('connect'); 153 | 154 | this._lastHeartbeat = new Date(); 155 | 156 | // Fetch default property 157 | this.update(); 158 | 159 | // Start heartbeat pings 160 | this._recursiveHeartbeat(); 161 | } 162 | 163 | private _handleSocketClose(): void { 164 | this.connected = false; 165 | 166 | this._log('Disconnected.'); 167 | 168 | this.emit('disconnected'); 169 | } 170 | 171 | private _handleSocketData(data: Buffer): void { 172 | this._log('Received:', data.toString('hex')); 173 | 174 | this._messenger.splitPackets(data).forEach(packet => { 175 | const frame = this._messenger.decode(packet); 176 | 177 | // Emit Frame as data event 178 | this.emit('data', frame); 179 | 180 | // Check return code 181 | if (frame.returnCode !== 0) { 182 | // As a non-zero return code should not occur during normal operation, we throw here instead of emitting an error 183 | throw new DeviceError(frame.payload.toString('ascii')); 184 | } 185 | 186 | // Check if it's a heartbeat packet 187 | if (frame.command === COMMANDS.HEART_BEAT) { 188 | this._lastHeartbeat = new Date(); 189 | return; 190 | } 191 | 192 | // Atempt to convert to JSON 193 | let parsedData; 194 | 195 | try { 196 | parsedData = JSON.parse(frame.payload.toString('ascii')); 197 | } catch (_) { 198 | // Not JSON data 199 | return; 200 | } 201 | 202 | if ('dps' in parsedData) { 203 | // State update event 204 | this._state = {...this._state, ...parsedData.dps}; 205 | this.emit('state-change', this._state); 206 | } 207 | }); 208 | } 209 | 210 | private _handleSocketError(error: Error): void { 211 | this._log('Error from socket:', error); 212 | } 213 | 214 | private _log(...message: any[]): void { 215 | const d = debug('@tuyapi/driver'); 216 | 217 | d(`${this.ip}:`, ...message); 218 | } 219 | } 220 | 221 | export default Device; 222 | -------------------------------------------------------------------------------- /src/find.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import * as dgram from 'dgram'; 3 | import Messenger from './lib/messenger'; 4 | 5 | class Find extends EventEmitter { 6 | private readonly _messenger: Messenger; 7 | private readonly _listener: dgram.Socket; 8 | private readonly _listenerEncrypted: dgram.Socket; 9 | 10 | constructor() { 11 | super(); 12 | 13 | this._messenger = new Messenger({key: '', version: 0}); 14 | 15 | this._listener = dgram.createSocket({type: 'udp4', reuseAddr: true}); 16 | this._listenerEncrypted = dgram.createSocket({type: 'udp4', reuseAddr: true}); 17 | 18 | this._listener.on('message', this._broadcastHandler.bind(this)); 19 | 20 | this._listenerEncrypted.on('message', this._broadcastHandler.bind(this)); 21 | } 22 | 23 | start(): void { 24 | this._listener.bind(6666); 25 | this._listenerEncrypted.bind(6667); 26 | } 27 | 28 | stop(): void { 29 | this._listener.close(); 30 | this._listener.removeAllListeners(); 31 | this._listenerEncrypted.close(); 32 | this._listenerEncrypted.removeAllListeners(); 33 | } 34 | 35 | private _broadcastHandler(message: Buffer): void { 36 | try { 37 | const frame = this._messenger.decode(message); 38 | 39 | const payload = JSON.parse(frame.payload.toString('ascii')); 40 | 41 | this.emit('broadcast', payload); 42 | } catch (error) { 43 | console.log(error); 44 | // It's possible another application is 45 | // using ports 6666 or 6667, so we shouldn't 46 | // throw on failure. 47 | this.emit('error', error); 48 | } 49 | } 50 | } 51 | 52 | export default Find; 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Polyfills 2 | /* eslint-disable import/no-unassigned-import */ 3 | import 'core-js/stable'; 4 | import 'regenerator-runtime/runtime'; 5 | 6 | import Device from './device'; 7 | import Find from './find'; 8 | 9 | import * as Constants from './lib/constants'; 10 | import crc from './lib/crc'; 11 | import * as crypto from './lib/crypto'; 12 | import Frame from './lib/frame'; 13 | import Messenger from './lib/messenger'; 14 | 15 | export {Device, Find, Constants, crc, crypto, Frame, Messenger}; 16 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | const SUPPORTED_PROTOCOLS = [3.1, 3.3]; 2 | 3 | const UDP_KEY = 'yGAdlopoPVldABfn'; 4 | 5 | const HEADER_SIZE = 16; 6 | 7 | enum COMMANDS { 8 | UDP = 0, 9 | AP_CONFIG = 1, 10 | ACTIVE = 2, 11 | BIND = 3, 12 | RENAME_GW = 4, 13 | RENAME_DEVICE = 5, 14 | UNBIND = 6, 15 | CONTROL = 7, 16 | STATUS = 8, 17 | HEART_BEAT = 9, 18 | DP_QUERY = 10, 19 | QUERY_WIFI = 11, 20 | TOKEN_BIND = 12, 21 | CONTROL_NEW = 13, 22 | ENABLE_WIFI = 14, 23 | DP_QUERY_NEW = 16, 24 | SCENE_EXECUTE = 17, 25 | UDP_NEW = 19, 26 | AP_CONFIG_NEW = 20, 27 | LAN_GW_ACTIVE = 240, 28 | LAN_SUB_DEV_REQUEST = 241, 29 | LAN_DELETE_SUB_DEV = 242, 30 | LAN_REPORT_SUB_DEV = 243, 31 | LAN_SCENE = 244, 32 | LAN_PUBLISH_CLOUD_CONFIG = 245, 33 | LAN_PUBLISH_APP_CONFIG = 246, 34 | LAN_EXPORT_APP_CONFIG = 247, 35 | LAN_PUBLISH_SCENE_PANEL = 248, 36 | LAN_REMOVE_GW = 249, 37 | LAN_CHECK_GW_UPDATE = 250, 38 | LAN_GW_UPDATE = 251, 39 | LAN_SET_GW_CHANNEL = 252 40 | } 41 | 42 | export {UDP_KEY, HEADER_SIZE, COMMANDS, SUPPORTED_PROTOCOLS}; 43 | -------------------------------------------------------------------------------- /src/lib/crc.ts: -------------------------------------------------------------------------------- 1 | /* Reverse engineered by kueblc */ 2 | 3 | /* eslint-disable array-element-newline */ 4 | const crc32Table = [ 5 | 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 6 | 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3, 7 | 0x0EDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 8 | 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, 9 | 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE, 10 | 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, 11 | 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 12 | 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5, 13 | 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 14 | 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, 15 | 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940, 16 | 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, 17 | 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 18 | 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F, 19 | 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 20 | 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, 21 | 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A, 22 | 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, 23 | 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 24 | 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01, 25 | 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 26 | 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, 27 | 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C, 28 | 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, 29 | 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 30 | 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB, 31 | 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 32 | 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, 33 | 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086, 34 | 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, 35 | 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 36 | 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD, 37 | 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, 38 | 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, 39 | 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8, 40 | 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, 41 | 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 42 | 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7, 43 | 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 44 | 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, 45 | 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252, 46 | 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, 47 | 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 48 | 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79, 49 | 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 50 | 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, 51 | 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04, 52 | 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, 53 | 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 54 | 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713, 55 | 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, 56 | 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, 57 | 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E, 58 | 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, 59 | 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 60 | 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45, 61 | 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 62 | 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, 63 | 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0, 64 | 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, 65 | 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 66 | 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF, 67 | 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 68 | 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D 69 | ]; 70 | 71 | /** 72 | * Computes a Tuya flavored CRC32 73 | * @param {Iterable} bytes 74 | * @returns {Number} Tuya CRC32 75 | */ 76 | function crc32(bytes: Buffer): number { 77 | let crc = 0xFFFFFFFF; 78 | 79 | for (const b of bytes) { 80 | crc = (crc >>> 8) ^ crc32Table[(crc ^ b) & 255]; 81 | } 82 | 83 | return crc ^ 0xFFFFFFFF; 84 | } 85 | 86 | export default crc32; 87 | -------------------------------------------------------------------------------- /src/lib/crypto.ts: -------------------------------------------------------------------------------- 1 | import {createHash, createCipheriv, createDecipheriv} from 'crypto'; 2 | import {UDP_KEY} from './constants'; 3 | 4 | const UDP_HASHED_KEY = createHash('md5').update(UDP_KEY, 'utf8').digest().toString(); 5 | 6 | function md5(data: string): string { 7 | return createHash('md5').update(data, 'utf8').digest('hex'); 8 | } 9 | 10 | function encrypt(key: string, data: Buffer): Buffer { 11 | const cipher = createCipheriv('aes-128-ecb', key, ''); 12 | 13 | return Buffer.concat([cipher.update(data), cipher.final()]); 14 | } 15 | 16 | function decrypt(key: string, data: Buffer): Buffer { 17 | try { 18 | const decipher = createDecipheriv('aes-128-ecb', key, ''); 19 | return Buffer.concat([decipher.update(data), decipher.final()]); 20 | } catch (_) { 21 | if (key !== UDP_HASHED_KEY) { 22 | // Try the universal key, in case it's a new UDP message format 23 | return decrypt(UDP_HASHED_KEY, data); 24 | } 25 | 26 | throw new Error('Decrypt failed.'); 27 | } 28 | } 29 | 30 | export {encrypt, decrypt, md5}; 31 | -------------------------------------------------------------------------------- /src/lib/frame.ts: -------------------------------------------------------------------------------- 1 | import {COMMANDS} from './constants'; 2 | import {encrypt, decrypt} from './crypto'; 3 | 4 | interface FrameInterface { 5 | version: number; 6 | command: COMMANDS; 7 | payload: Buffer; 8 | packet: Buffer; 9 | encrypted: boolean; 10 | returnCode: number; 11 | } 12 | 13 | class Frame implements FrameInterface { 14 | version: number; 15 | 16 | command: COMMANDS; 17 | 18 | payload: Buffer; 19 | 20 | packet: Buffer; 21 | 22 | encrypted: boolean; 23 | 24 | returnCode: number; 25 | 26 | constructor() { 27 | this.version = 3.1; 28 | this.command = COMMANDS.UDP; 29 | this.payload = Buffer.from(''); 30 | this.packet = Buffer.from(''); 31 | this.encrypted = false; 32 | this.returnCode = 0; 33 | } 34 | 35 | setPayload(data: Buffer | object): Frame { 36 | if (data instanceof Buffer) { 37 | this.payload = data; 38 | } else if (typeof data === 'object') { 39 | this.payload = Buffer.from(JSON.stringify(data)); 40 | } 41 | 42 | return this; 43 | } 44 | 45 | encrypt(key: string): Frame { 46 | if (!this.encrypted) { 47 | this.payload = encrypt(key, this.payload); 48 | 49 | this.encrypted = true; 50 | } 51 | 52 | return this; 53 | } 54 | 55 | decrypt(key: string): Frame { 56 | if (this.encrypted) { 57 | this.payload = decrypt(key, this.payload); 58 | this.encrypted = false; 59 | } 60 | 61 | return this; 62 | } 63 | } 64 | 65 | export default Frame; 66 | -------------------------------------------------------------------------------- /src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | class DeviceError extends Error { 2 | constructor(m: string) { 3 | super(`Error from device: ${m}`); 4 | Error.captureStackTrace(this, DeviceError); 5 | } 6 | } 7 | 8 | export {DeviceError}; 9 | -------------------------------------------------------------------------------- /src/lib/messenger.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import Frame from './frame'; 3 | import crc from './crc'; 4 | import {md5} from './crypto'; 5 | import {COMMANDS, HEADER_SIZE} from './constants'; 6 | 7 | class Messenger extends EventEmitter { 8 | private readonly _key: string; 9 | 10 | private readonly _version: number; 11 | 12 | constructor({key, version}: {key: string; version: number}) { 13 | super(); 14 | 15 | // Copy arguments 16 | this._key = key; 17 | this._version = version; 18 | } 19 | 20 | encode(frame: Frame): Frame { 21 | frame.packet = this.wrapPacket(this.versionPacket(frame), frame.command); 22 | 23 | return frame; 24 | } 25 | 26 | splitPackets(p: Buffer): Buffer[] { 27 | const packets: Buffer[] = []; 28 | 29 | const empty = Buffer.from(''); 30 | 31 | while (!p.equals(empty)) { 32 | const startIndex = p.indexOf(Buffer.from('000055aa', 'hex')); 33 | const endIndex = p.indexOf(Buffer.from('0000aa55', 'hex')) + 4; 34 | 35 | packets.push(p.slice(startIndex, endIndex)); 36 | 37 | p = p.slice(endIndex, p.length); 38 | } 39 | 40 | return packets; 41 | } 42 | 43 | decode(packet: Buffer): Frame { 44 | this.checkPacket(packet); 45 | 46 | // Get command byte 47 | const command = packet.readUInt32BE(8); 48 | 49 | // Get payload size 50 | const payloadSize = packet.readUInt32BE(12); 51 | 52 | // Check for payload 53 | if (packet.length - 8 < payloadSize) { 54 | throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`); 55 | } 56 | 57 | // Get the return code, 0 = success 58 | // This field is only present in messages from the devices 59 | // Absent in messages sent to device 60 | const returnCode = packet.readUInt32BE(16); 61 | 62 | // Get the payloads 63 | let offset = HEADER_SIZE; 64 | 65 | if (returnCode === 0) { 66 | offset = HEADER_SIZE + 4; 67 | } 68 | 69 | let payload = packet.slice(offset, HEADER_SIZE + payloadSize - 8); 70 | 71 | // Check CRC 72 | const expectedCrc = packet.readInt32BE(HEADER_SIZE + payloadSize - 8); 73 | const computedCrc = crc(packet.slice(0, payloadSize + 8)); 74 | 75 | if (expectedCrc !== computedCrc) { 76 | throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${packet.toString('hex')}`); 77 | } 78 | 79 | const frame = new Frame(); 80 | 81 | frame.version = this._version; 82 | frame.packet = packet; 83 | frame.command = command; 84 | frame.returnCode = returnCode; 85 | 86 | // Check if packet is encrypted 87 | if (payload.indexOf(this._version.toString()) === 0) { 88 | frame.encrypted = true; 89 | 90 | // Remove packet header 91 | if (this._version === 3.3) { 92 | payload = payload.slice(15); 93 | } else { 94 | payload = payload.slice(19); 95 | } 96 | 97 | frame.payload = Buffer.from(payload.toString('ascii'), 'base64'); 98 | 99 | frame.decrypt(this._key); 100 | } else { 101 | frame.payload = payload; 102 | } 103 | 104 | return frame; 105 | } 106 | 107 | checkPacket(packet: Buffer): void { 108 | // Check for length 109 | // At minimum requires: prefix (4), sequence (4), command (4), length (4), 110 | // CRC (4), and suffix (4) for 24 total bytes 111 | // Messages from the device also include return code (4), for 28 total bytes 112 | if (packet.length < 24) { 113 | throw new TypeError(`Packet too short. Length: ${packet.length}.`); 114 | } 115 | 116 | // Check for prefix 117 | const prefix = packet.readUInt32BE(0); 118 | 119 | if (prefix !== 0x000055AA) { 120 | throw new TypeError(`Prefix does not match: ${packet.toString('hex')}`); 121 | } 122 | 123 | // Check for suffix 124 | const suffix = packet.readUInt32BE(packet.length - 4); 125 | 126 | if (suffix !== 0x0000AA55) { 127 | throw new TypeError(`Suffix does not match: ${packet.toString('hex')}`); 128 | } 129 | } 130 | 131 | wrapPacket(packet: Buffer, command: COMMANDS): Buffer { 132 | const len = packet.length; 133 | 134 | const buffer = Buffer.alloc(len + 24); 135 | 136 | // Add prefix, command, and length 137 | buffer.writeUInt32BE(0x000055AA, 0); 138 | buffer.writeUInt32BE(command, 8); 139 | buffer.writeUInt32BE(len + 8, 12); 140 | 141 | // Add payload, crc, and suffix 142 | packet.copy(buffer, 16); 143 | 144 | const code = crc(buffer.slice(0, len + 16)); 145 | 146 | buffer.writeInt32BE(code, len + 16); 147 | buffer.writeUInt32BE(0x0000AA55, len + 20); 148 | 149 | packet = buffer; 150 | 151 | return packet; 152 | } 153 | 154 | versionPacket(frame: Frame): Buffer { 155 | let packet = frame.payload; 156 | 157 | if (this._version === 3.3) { 158 | // V3.3 is always encrypted 159 | frame.encrypt(this._key); 160 | packet = frame.payload; 161 | 162 | // Check if we need an extended header, only for certain Commands 163 | if (frame.command !== COMMANDS.DP_QUERY) { 164 | // Add 3.3 header 165 | const buffer = Buffer.alloc(packet.length + 15); 166 | Buffer.from('3.3').copy(buffer, 0); 167 | packet.copy(buffer, 15); 168 | 169 | packet = buffer; 170 | } 171 | } else if (frame.encrypted) { 172 | const hash = md5(`data=${frame.payload.toString('base64')}||lpv=${this._version}||${this._key}`).slice(8, 24); 173 | 174 | packet = Buffer.from(`${this._version.toString()}${hash}${packet.toString('base64')}`); 175 | } 176 | 177 | return packet; 178 | } 179 | } 180 | 181 | export default Messenger; 182 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2017"], 4 | "target": "es2015", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "strict": true 9 | }, 10 | "include": ["src"], 11 | "exclude": ["node_modules", "**/test/*"] 12 | } 13 | --------------------------------------------------------------------------------