├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── SETUP.md ├── SETUP_DEPRECATED.md ├── assets │ ├── anchor.js │ ├── bass-addons.css │ ├── bass.css │ ├── fonts │ │ ├── EOT │ │ │ ├── SourceCodePro-Bold.eot │ │ │ └── SourceCodePro-Regular.eot │ │ ├── LICENSE.txt │ │ ├── OTF │ │ │ ├── SourceCodePro-Bold.otf │ │ │ └── SourceCodePro-Regular.otf │ │ ├── TTF │ │ │ ├── SourceCodePro-Bold.ttf │ │ │ └── SourceCodePro-Regular.ttf │ │ ├── WOFF │ │ │ ├── OTF │ │ │ │ ├── SourceCodePro-Bold.otf.woff │ │ │ │ └── SourceCodePro-Regular.otf.woff │ │ │ └── TTF │ │ │ │ ├── SourceCodePro-Bold.ttf.woff │ │ │ │ └── SourceCodePro-Regular.ttf.woff │ │ ├── WOFF2 │ │ │ ├── OTF │ │ │ │ ├── SourceCodePro-Bold.otf.woff2 │ │ │ │ └── SourceCodePro-Regular.otf.woff2 │ │ │ └── TTF │ │ │ │ ├── SourceCodePro-Bold.ttf.woff2 │ │ │ │ └── SourceCodePro-Regular.ttf.woff2 │ │ └── source-code-pro.css │ ├── github.css │ ├── site.js │ ├── split.css │ ├── split.js │ └── style.css ├── images │ ├── device-data.png │ ├── proxy-config.png │ ├── proxy-toggle.png │ ├── record-toggle.png │ └── wifi-config.png └── index.html ├── documentation.yml ├── index.d.ts ├── index.js ├── lib ├── cipher.js ├── config.js ├── crc.js ├── message-parser.js └── utils.js ├── package-lock.json ├── package.json ├── renovate.json └── test ├── arguments.js ├── cipher.js ├── find.js ├── parser.js └── stub.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [codetheweb] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Debug Output** 21 | Post the output of your program/app/script when run with the `DEBUG` environment variable set to `*`. Example: `DEBUG=* node test.js`. Copy the output and paste it below (in between the code fences): 22 | 23 | ``` 24 | 25 | ``` 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Desktop (please complete the following information):** 31 | - OS: [e.g. macOS] 32 | - OS Version [e.g. 22] 33 | - Node Version [output of `node -v`] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: {} 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install and cache dependencies 17 | uses: bahmutov/npm-install@v1 18 | 19 | - name: Run linter 20 | run: npm run lint 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: {} 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [12.x, 14.x, 16.x, 18.x, 20.x, 22.x, 24.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install and cache dependencies 20 | uses: bahmutov/npm-install@v1 21 | 22 | - name: Run tests 23 | run: npm run coverage 24 | 25 | - name: Coveralls Parallel 26 | uses: coverallsapp/github-action@master 27 | with: 28 | github-token: ${{ secrets.github_token }} 29 | flag-name: run-${{ matrix.node-version }} 30 | parallel: true 31 | 32 | finish: 33 | needs: test 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Coveralls finished 37 | uses: coverallsapp/github-action@master 38 | with: 39 | github-token: ${{ secrets.github_token }} 40 | parallel-finished: true 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # playground for testing during development 2 | dev.js 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # code editor conigurations 64 | .idea/ 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | Thank you for considering contributing to TuyAPI! We welcome all kinds of contributions, whether it's a small README fix or a new feature. 4 | 5 | Please take a moment to read through this short guide so both the core maintainers' time and your time can be better utilized. 6 | 7 | ## What you can do 8 | 9 | Really, anything you want that's in the scope of this project (you may also want to check out the [@TuyAPI](https://github.com/tuyaapi) organization for other cool projects you could contribute to). 10 | 11 | Some ideas: 12 | 13 | - Improve documentation 14 | - Track down bugs 15 | - Write tutorials for new users 16 | - Add a new feature 17 | - Respond to new [issues](https://github.com/codetheweb/tuyapi/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) that are asking for support and not reporting a bug 18 | - Improve test coverage 19 | - Refactor and clean up existing code 20 | 21 | ## Expectations 22 | 23 | Before making any major changes to a user-facing interface or adding a new feature, please open an [issue](https://github.com/codetheweb/tuyapi/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) with a description of the changes you're planning on making. Getting feedback before you begin programming ensures that the maintainers' time as well as yours is used more efficiently. 24 | 25 | **Before you open a Pull Request, make sure all tests pass!!!! (see next section).** 26 | 27 | ## Getting started 28 | 29 | You're ready to start programming? Great, here's what you should do: 30 | 31 | 1. Fork this repository to your account (click the **Fork** button at the top right). 32 | 2. Clone your forked repository to your computer with `git clone {forked repo url}` 33 | 3. Run `npm i` to install dependencies. 34 | 4. Make a new branch with `git branch`. If you're adding a new feature, use a `feature-*` prefix (ex. `feature-contributing`). If you're fixing a bug, use a `bugfix-*` prefix. Use your best judgement on anything else. 35 | 5. Make your changes. (Tip: you can use a file named `dev.js` as a local playground to test your new code in, since the file is ignored by Git.) 36 | 6. Ensure all tests pass with `npm test`. If you get a lot of style-related errors, consider running `npx xo --fix` to automatically fix most of them. 37 | 7. Commit your changes with `git commit -a`. Be sure to describe the changes you've made in a clear and concise way. 38 | 8. Push the changes to your repo with `git push origin master`. 39 | 9. Open a [Pull Request](https://github.com/codetheweb/tuyapi/compare) to the **base: master** branch. Click **"compare across forks"** and select the master branch of your fork. 40 | 41 | Expect to hear back within a week or so (usually sooner). Thank you for your contribution! 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2025 Max Isom 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 🌧 🔌 2 | 3 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 4 | [![Build Status](https://travis-ci.com/codetheweb/tuyapi.svg?branch=master)](https://travis-ci.com/codetheweb/tuyapi) 5 | [![Coverage Status](https://coveralls.io/repos/github/codetheweb/tuyapi/badge.svg?branch=master)](https://coveralls.io/github/codetheweb/tuyapi?branch=master) 6 | ![Node Version](https://img.shields.io/badge/node-%3E=8-blue.svg) 7 | 8 | A library for communicating with devices that use the [Tuya](http://tuya.com) cloud network. These devices are branded under many different names, but if your device works with the TuyaSmart app or port 6668 is open on your device chances are this library will work. 9 | 10 | ## Installation 11 | 12 | `npm install codetheweb/tuyapi` 13 | 14 | ## Basic Usage 15 | 16 | See the [setup instructions](docs/SETUP.md) for how to find the needed parameters. 17 | 18 | These examples should report the current status, set the default property to the opposite of what it currently is, then report the changed status. 19 | They will need to be adapted if your device does not have a boolean property at index 1 (i.e. it doesn't have an on/off property). Index 20 seems to be another somewhat common on/off property. 20 | 21 | ### Asynchronous (event based, recommended) 22 | ```javascript 23 | const TuyAPI = require('tuyapi'); 24 | 25 | const device = new TuyAPI({ 26 | id: 'xxxxxxxxxxxxxxxxxxxx', 27 | key: 'xxxxxxxxxxxxxxxx'}); 28 | 29 | let stateHasChanged = false; 30 | 31 | // Find device on network 32 | device.find().then(() => { 33 | // Connect to device 34 | device.connect(); 35 | }); 36 | 37 | // Add event listeners 38 | device.on('connected', () => { 39 | console.log('Connected to device!'); 40 | }); 41 | 42 | device.on('disconnected', () => { 43 | console.log('Disconnected from device.'); 44 | }); 45 | 46 | device.on('error', error => { 47 | console.log('Error!', error); 48 | }); 49 | 50 | device.on('data', data => { 51 | console.log('Data from device:', data); 52 | 53 | console.log(`Boolean status of default property: ${data.dps['1']}.`); 54 | 55 | // Set default property to opposite 56 | if (!stateHasChanged) { 57 | device.set({set: !(data.dps['1'])}); 58 | 59 | // Otherwise we'll be stuck in an endless 60 | // loop of toggling the state. 61 | stateHasChanged = true; 62 | } 63 | }); 64 | 65 | // Disconnect after 10 seconds 66 | setTimeout(() => { device.disconnect(); }, 10000); 67 | ``` 68 | 69 | ### Synchronous 70 | ```javascript 71 | const TuyAPI = require('tuyapi'); 72 | 73 | const device = new TuyAPI({ 74 | id: 'xxxxxxxxxxxxxxxxxxxx', 75 | key: 'xxxxxxxxxxxxxxxx', 76 | issueGetOnConnect: false}); 77 | 78 | (async () => { 79 | await device.find(); 80 | 81 | await device.connect(); 82 | 83 | let status = await device.get(); 84 | 85 | console.log(`Current status: ${status}.`); 86 | 87 | await device.set({set: !status}); 88 | 89 | status = await device.get(); 90 | 91 | console.log(`New status: ${status}.`); 92 | 93 | device.disconnect(); 94 | })(); 95 | ``` 96 | 97 | ### Data not updating? 98 | 99 | Some new devices don't send data updates if the app isn't open. 100 | 101 | These devices need to be "forced" to send updates. You can do so by calling `refresh()` (see docs), which will emit a `dp-refresh` event. 102 | 103 | ```javascript 104 | const TuyAPI = require('tuyapi'); 105 | 106 | const device = new TuyAPI({ 107 | id: 'xxxxxxxxxxxxxxxxxxxx', 108 | key: 'xxxxxxxxxxxxxxxx', 109 | ip: 'xxx.xxx.xxx.xxx', 110 | version: '3.3', 111 | issueRefreshOnConnect: true}); 112 | 113 | // Find device on network 114 | device.find().then(() => { 115 | // Connect to device 116 | device.connect(); 117 | }); 118 | 119 | // Add event listeners 120 | device.on('connected', () => { 121 | console.log('Connected to device!'); 122 | }); 123 | 124 | device.on('disconnected', () => { 125 | console.log('Disconnected from device.'); 126 | }); 127 | 128 | device.on('error', error => { 129 | console.log('Error!', error); 130 | }); 131 | 132 | device.on('dp-refresh', data => { 133 | console.log('DP_REFRESH data from device: ', data); 134 | }); 135 | 136 | device.on('data', data => { 137 | console.log('DATA from device: ', data); 138 | 139 | }); 140 | 141 | // Disconnect after 10 seconds 142 | setTimeout(() => { device.disconnect(); }, 1000); 143 | ``` 144 | 145 | 146 | ## 📝 Notes 147 | - Only one TCP connection can be in use with a device at once. If using this, do not have the app on your phone open. 148 | - Some devices ship with older firmware that may not work with `tuyapi`. If you're experiencing issues, please try updating the device's firmware in the official app. 149 | - Newer firmware may use protocol 3.3. If you are not using `find()`, you will need to manually pass `version: 3.3` to the constructor. 150 | - TuyAPI does not support sensors due to the fact that they only connect to the network when their state changes. There are no plans to add support as it's out of scope to intercept network requests. 151 | - The key parameter for devices changes every time a device is removed and re-added to the TuyaSmart app. If you're getting decrypt errors, try getting the key again - it might have changed. 152 | 153 | 154 | ## 📓 Documentation 155 | 156 | See the [docs](https://codetheweb.github.io/tuyapi/index.html). 157 | 158 | ## Current State & the Future of TuyAPI 159 | 160 | The goal of this repository specifically is to provide a bit of a middle ground between implementing everything from scratch and having everything handled for you. 161 | 162 | I realize this is a bit wishy-washy and most users would prefer one or the other. I started a new library a while ago to address this and incorporate some of the lessons we've learned over the years: [@tuyapi/driver](https://github.com/TuyaAPI/driver). The intention is that this library would be fairly low-level, and then more user-friendly libraries could be built on top of it to provide common functionality for, say, setting RGB light values (probably named `@tuyapi/devices`). 163 | 164 | Unfortunately, not much progress has been made in that regard for a few reasons. First, besides the occasional [coffee](https://www.buymeacoffee.com/maxisom) (thank you 😀) I don't get paid for this. And it's hard to be motivated to work on it when I don't actually use it day-to-day. For lack of a beter explanation, it's just not "fun" anymore. Also: trying to play wack-a-mole with a large corporation is kinda exhausting. 165 | 166 | **TL;DR**: all that to say that I personally will not be further developing Tuya-related projects for the foreseeable future besides fixing reproducable bugs. I plan to still respond to support requests and bug reports, but please be patient. 😀 167 | 168 | ## Contributing 169 | 170 | See [CONTRIBUTING](https://github.com/codetheweb/tuyapi/blob/master/CONTRIBUTING.md). 171 | 172 | ## Contributors 173 | 174 | - [codetheweb](https://github.com/codetheweb) 175 | - [blackrozes](https://github.com/blackrozes) 176 | - [clach04](https://github.com/clach04) 177 | - [jepsonrob](https://github.com/jepsonrob) 178 | - [tjfontaine](https://github.com/tjfontaine) 179 | - [NorthernMan54](https://github.com/NorthernMan54) 180 | - [Apollon77](https://github.com/Apollon77) 181 | - [dresende](https://github.com/dresende) 182 | - [kaveet](https://github.com/kaveet) 183 | - [johnyorke](https://github.com/johnyorke) 184 | - [jpillora](https://github.com/jpillora) 185 | - [neojski](https://github.com/neojski) 186 | - [unparagoned](https://github.com/unparagoned) 187 | - [kueblc](https://github.com/kueblc) 188 | - [stevoh6](https://github.com/stevoh6) 189 | - [imbenwolf](https://github.com/imbenwolf) 190 | 191 | (If you're not on the above list, open a PR.) 192 | 193 | ## Related 194 | 195 | ### Flash alternative firmware 196 | - [tuya-convert](https://github.com/ct-Open-Source/tuya-convert) a project that allows you to flash custom firmware OTA on devices 197 | 198 | ### Ports 199 | - [TinyTuya](https://github.com/jasonacox/tinytuya) a Python port by [jasonacox](https://github.com/jasonacox) and [uzlonewolf](https://github.com/uzlonewolf) 200 | - [aiotuya](https://github.com/frawau/aiotuya) a Python port by [frawau](https://github.com/frawau) 201 | - [m4rcus.TuyaCore](https://github.com/Marcus-L/m4rcus.TuyaCore) a .NET port by [Marcus-L](https://github.com/Marcus-L) 202 | - [TuyaKit](https://github.com/eppz/.NET.Library.TuyaKit) a .NET port by [eppz](https://github.com/eppz) 203 | - [py60800/tuya](https://github.com/py60800/tuya) a Go port by [py60800](https://github.com/py60800) 204 | - [rust-tuyapi](https://github.com/EmilSodergren/rust-tuyapi) a Rust port by [EmilSodergren](https://github.com/EmilSodergren) 205 | - [GoTuya](https://github.com/Binozo/GoTuya) a Go port by [Binozo](https://github.com/Binozo) 206 | 207 | ### Clients for Tuya's Cloud 208 | - [cloudtuya](https://github.com/unparagoned/cloudtuya) by [unparagoned](https://github.com/unparagoned/) 209 | 210 | ### Projects built with TuyAPI 211 | - [tuya-cli](https://github.com/TuyaAPI/cli): a CLI interface for Tuya devices 212 | - [homebridge-tuya](https://github.com/iRayanKhan/homebridge-tuya): a [Homebridge](https://github.com/nfarina/homebridge) plugin for Tuya devices 213 | - [tuyaweb](https://github.com/bmachek/tuyaweb): a web interface for controlling devices by [bmachek](https://github.com/bmachek) 214 | - [homebridge-igenix-air-conditioner](https://github.com/ellneal/homebridge-igenix-air-conditioner): a [Homebridge](https://github.com/nfarina/homebridge) plugin for the Igenix IG9901WIFI air conditioner 215 | - [magichome-led-controller](https://github.com/cajonKA/magichome-led-controller-node): a node to use magichome led RGB controller in [node-red](https://github.com/node-red/node-red) 216 | - [ioBroker.tuya](https://github.com/Apollon77/ioBroker.tuya): an ioBroker (http://iobroker.net/) adapter to get data and control devices incl. schema parsing 217 | - [node-red-contrib-tuya-smart-device](https://github.com/vinodsr/node-red-contrib-tuya-smart-device): A Node-RED node based on TuyAPI to control Tuya devices with tons of options. 218 | - [node-red-contrib-tuya-smart](https://github.com/hgross/node-red-contrib-tuya-smart): A NodeRED input node utilizing tuyapi to connect the smart home 219 | - [tuyadump](https://github.com/py60800/tuyadump) a Go project to decode device traffic in real time 220 | - [tuya-mqtt](https://github.com/TheAgentK/tuya-mqtt) a simple MQTT interface for TuyAPI 221 | - [smart-home-panel](https://github.com/MadeleineSmith/smart-home-panel-fe) A website for controlling a smart light bulb 222 | - [GoTuya](https://github.com/Binozo/GoTuya) An easy-to-use api to control Tuya devices on the local network 223 | - [luminea2mqtt](https://github.com/dennis9819/luminea2mqtt/tree/master) An expandable luminea2mqtt bridge with HA Autodiscover 224 | 225 | 226 | To add your project to either of the above lists, please open a pull request. 227 | 228 | [![forthebadge](https://forthebadge.com/images/badges/made-with-javascript.svg)](https://forthebadge.com) 229 | [![forthebadge](https://forthebadge.com/images/badges/built-with-love.svg)](https://forthebadge.com) 230 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | **YMMV**: Tuya likes to change their website frequently and the below instructions may be slightly out of date. If something looks wrong, please open a new issue. 2 | 3 | **Note**: both methods below require that your device works with the official Tuya Smart app. If your device only works with one specific app, it almost certainly won't work with TuyAPI. 4 | 5 | All methods below require you to install the CLI tool before proceeding. 6 | 7 | Install it by running `npm i @tuyapi/cli -g`. If it returns an error, you may need to prefix the command with `sudo`. (Tip: using `sudo` to install global packages is not considered best practice. See [this NPM article](https://docs.npmjs.com/getting-started/fixing-npm-permissions) for some help.) 8 | 9 | ## Listing Tuya devices from the **Tuya Smart** or **Smart Life** apps 10 | 11 | This method is fast and easy. If you're having trouble manually linking your device with the below method, we recommend you try this. All devices that you want to use **must** be registered in either the Tuya Smart app or the Smart Life app. 12 | 13 | 1. Follow steps 1 through 3 from the "Linking a Tuya device with Smart Link" method below. 14 | 2. Go to Cloud -> Development and click the project you created earlier. Then click the "Devices" tab. Click the "Link Tuya App account" tab, and select the right data center in the upper right dropdown (eg Western America). 15 | 3. Click "Add App Account" and scan the QR code from your smart phone/tablet app by going to the 'Me' tab in the app, and tapping a QR code / Scan button in the upper right. Your account will now be linked. 16 | 4. On the command line, run `tuya-cli wizard`. It will prompt you for required information, and will then list out all your device names, IDs, and keys for use with TuyAPI. Copy and save this information to a safe place for later reference. 17 | 18 | ## Linking a Tuya device with Smart Link 19 | 20 | This method requires you to create a developer account on [iot.tuya.com](https://iot.tuya.com). It doesn't matter if the device(s) are currently registered in the Tuya Smart app or Smart Life app or not. 21 | 22 | 1. Create a new account on [iot.tuya.com](https://iot.tuya.com) and make sure you are logged in. **Select United States as your country when signing up.** This seems to skip a [required verify step](https://github.com/codetheweb/tuyapi/issues/425). 23 | 2. Go to Cloud -> Development in the left nav drawer. If you haven't already, you will need to "purchase" the Trial Plan before you can proceed with this step. You will not have to add any form of payment, and the purchase is of no charge. Once in the Projects tab, click "Create". **Make sure you select "Smart Home" for both the "Industry" field and the development method.** Select your country of use in the for the location access option, and feel free to skip the services option in the next window. After you've created a new project, click into it. The "Access ID/Client ID" and "Access Secret/Client Secret" are the API Key and API Secret values need in step 7. 24 | 3. Go to Cloud -> Development -> "MyProject" -> Service API -> "Go to authorize". "Select API" > click subscribe on "IoT Core", "Authorization", and "Smart Home Scene Linkage" in the dropdown. Click subscribe again on every service (also check your PopUp blocker). Click "basic edition" and "buy now" (basic edition is free). Check if the 3 services are listed under Cloud -> Projects -> "MyProject" -> API. If not, click "Add Authorization" and select them. 25 | 4. Go to App -> App SDK -> Development in the nav drawer. Click "Create" and enter whatever you want for the package names and Channel ID (for the Android package name, you must enter a string beginning with `com.`). Take note of the **Channel ID** you entered. This is equivalent to the `schema` value needed in step 7. Ignore any app key and app secret values you see in this section as they are not used. 26 | 5. Go to Cloud -> Development and click the project you created earlier. Then click "Link Device". Click the "Link devices by Apps" tab, and click "Add Apps". Check the app you just created and click "Ok". 27 | 6. Put your devices into linking mode. This process is specific to each type of device, find instructions in the Tuya Smart app. Usually this consists of turning it on and off several times or holding down a button. 28 | 7. On the command line, run `tuya-cli link --api-key --api-secret --schema --ssid --password --region us`. For the region parameter, choose the two-letter country code from `us`, `eu`, and `cn` that is geographically closest to you. 29 | 8. Your devices should link in under a minute and the parameters required to control them will be printed out to the console. If you experience problems, first make sure any smart phone/tablet app that you use with your devices is completely closed and not attempting to communicate with any of the devices. 30 | 31 | ### Troubleshooting 32 | 33 | **`Error: sign invalid`** 34 | 35 | This means that one of the parameters you're passing in (`api-key`, `api-secret`, `schema`) is incorrect. Double check the values. 36 | 37 | **`Device(s) failed to be registered! Error: Timed out waiting for devices to connect.`** 38 | 39 | This can happen for a number of reasons. It means that the device never authenticated against Tuya's API (although it *does not* necessarily mean that the device could not connect to WiFi). Try the following: 40 | - Making sure that your computer is connected to your network via WiFi **only** (unplug ethernet if necessary) 41 | - Making sure that your network is 2.4 Ghz (devices will also connect if you have both 2.4 Ghz and 5 Ghz bands under the same SSID) 42 | - Using a different OS 43 | - Removing special characters from your network's SSID 44 | 45 | ## **DEPRECATED** - Linking a Tuya Device with MITM 46 | 47 | This method is deprecated because Tuya-branded apps have started to encrypt their traffic in an effort to prevent MITM attacks like this one. If this method doesn't work, try the above. 48 | 49 | 1. Add any devices you want to use with `tuyapi` to the Tuya Smart app. 50 | 2. Install AnyProxy by running `npm i anyproxy -g`. Then run `anyproxy-ca`. 51 | 3. Run `tuya-cli list-app`. It will print out a QR code; scan it with your phone and install the root certificate. After installation, [trust the installed root certificate](https://support.apple.com/en-nz/HT204477). 52 | 4. [Configure the proxy](http://www.iphonehacks.com/2017/02/how-to-configure-use-proxy-iphone-ipad.html) on your phone with the parameters provided in the console. 53 | 5. Enable full trust of certificate by going to Settings > General > About > Certificate Trust Settings 54 | 6. Open Tuya Smart and refresh the list of devices by "pulling down". 55 | 7. A list of ID and key pairs should appear in the console. 56 | 8. It's recommended to untrust the root certificate after you're done for security purposes. 57 | -------------------------------------------------------------------------------- /docs/SETUP_DEPRECATED.md: -------------------------------------------------------------------------------- 1 | Setup 2 | ========= 3 | 4 | ## macOS 5 | 6 | 1. Download [Charles](https://www.charlesproxy.com). 7 | 2. Turn off the local proxy for your computer: 8 | 9 | ![proxy toggle](images/proxy-toggle.png) 10 | 11 | 3. And turn off recording for now (with the red button), so it's easier to find the correct data later on: 12 | 13 | ![record toggle](images/record-toggle.png) 14 | 15 | 4. Setup Charles' [SSL certificate](https://www.charlesproxy.com/documentation/using-charles/ssl-certificates/) for your phone. If the app throws network errors, you may have to take an additional step to fully trust the Charles SSL certificate: 16 | 17 | `Settings > General > About > Certificate Trust Testings` 18 | 19 | 5. Proxy your phone's traffic through Charles (IP is the IP of your computer): 20 | 21 | ![proxy config](images/proxy-config.png) 22 | 23 | 6. Launch the app that came with your device. If you've already added the device you want to configure to the app, remove it now. 24 | 7. Add your device. Before tapping "Continue" after entering your network's password, pause and turn back on traffic recording in Charles. 25 | 26 | ![wifi config](images/wifi-config.png) 27 | 28 | 8. When the device is added in the app, turn off traffic recording in Charles. 29 | 9. Find the HTTPS request where `a=s.m.dev.list`: 30 | 31 | ![device data](images/device-data.png) 32 | 33 | 10. Find the parameters needed for constructing a TuyAPI instance from the contents of the response: 34 | ``` 35 | { 36 | id: uuid, 37 | uid: productId, 38 | key: localKey 39 | } 40 | ``` 41 | 42 | 43 | ## Android 44 | 45 | 46 | ### Capture https traffic 47 | 48 | Only requires an Android device. Root not required, this captures the stream from the Android application to the Jinvoo/Tuya web servers. It does NOT capture between Android device and remote control device. 49 | 50 | 1) Remove registration for existing device if present 51 | 52 | 2) Install "Packet Capture" https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture (follow instructions, install cert, then start capturing, its possibly to use the green triangle/play button with a "1" on it to only capture from the Jinvoo app). 53 | 54 | 3) Run Jinvoo Smart App (https://play.google.com/store/apps/details?id=com.xenon.jinvoo version 1.0.3 known to work) to (re-)add device. 55 | 56 | 4) Hit stop button back in "Packet Capture" app. 57 | 58 | 5) review captured packets (first or last large one, 9Kb of 16Kb) use macOS step 11 for guide. 59 | 60 | ### Extract details from android config file 61 | 62 | 63 | #### Smart Life App 64 | From https://github.com/codetheweb/tuyapi/issues/5#issuecomment-352932467 65 | 66 | If you have a rooted Android phone, you can retrieve the settings from the app (Smart Life) data storage. The keys/configured devices are located at /data/data/com.tuya.smartlife/shared_prefs/dev_data_storage.xml 67 | 68 | There's a string in there (the only data) called "tuya_data". You need to html entity decode the string and it contains a JSON string (yes, this is slightly ridiculous). Inside the JSON string are the keys. 69 | 70 | #### Jinvoo Smart App 71 | 72 | The Jinvoo SMart app is similar to the Smart Life app but has a slightly different location. `/data/data/com.xenon.jinvoo/shared_prefs/gw_storage.xml`. Python script to dump out the information along with useful schema information: 73 | 74 | #!/usr/bin/env python 75 | # -*- coding: us-ascii -*- 76 | # vim:ts=4:sw=4:softtabstop=4:smarttab:expandtab 77 | # 78 | 79 | import codecs 80 | import os 81 | import json 82 | import xml.etree.ElementTree as ET 83 | 84 | try: 85 | # Python 2.6-2.7 86 | from HTMLParser import HTMLParser 87 | except ImportError: 88 | # Python 3 89 | from html.parser import HTMLParser ## FIXME use html.unescape()? 90 | 91 | 92 | xml_in_filename = 'com.xenon.jinvoo/shared_prefs/gw_storage.xml' 93 | h = open(xml_in_filename, 'r') 94 | xml = h.read() 95 | h.close() 96 | 97 | builder = ET.XMLTreeBuilder() 98 | builder.feed(xml) 99 | tree = builder.close() 100 | 101 | 102 | h = HTMLParser() 103 | for entry in tree.findall('string'): 104 | if entry.get('name') == 'gw_dev': 105 | # found it, need content 106 | config = entry.text 107 | s = h.unescape(config) 108 | config_dict = json.loads(s) 109 | #print(config_dict) 110 | print(len(config_dict)) 111 | for device in config_dict: 112 | #print(device) 113 | for key in ['name', 'localKey', 'uuid', 'gwType', 'verSw', 'iconUrl']: # and/or 'gwId', 'devId' 114 | print('%s = %r' % (key, device[key])) 115 | # there is a bunch of interesting meta data about the device 116 | print('schema =\\') 117 | schema = device['devices'][0]['schema'] # NOTE I've only seen single entries 118 | schema = h.unescape(schema) 119 | schema_dict = json.loads(schema) 120 | print(json.dumps(schema_dict, indent=4)) 121 | print('') 122 | print(json.dumps(config_dict, indent=4)) 123 | -------------------------------------------------------------------------------- /docs/assets/anchor.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * AnchorJS - v4.0.0 - 2017-06-02 3 | * https://github.com/bryanbraun/anchorjs 4 | * Copyright (c) 2017 Bryan Braun; Licensed MIT 5 | */ 6 | /* eslint-env amd, node */ 7 | 8 | // https://github.com/umdjs/umd/blob/master/templates/returnExports.js 9 | (function (root, factory) { 10 | 'use strict'; 11 | if (typeof define === 'function' && define.amd) { 12 | // AMD. Register as an anonymous module. 13 | define([], factory); 14 | } else if (typeof module === 'object' && module.exports) { 15 | // Node. Does not work with strict CommonJS, but 16 | // only CommonJS-like environments that support module.exports, 17 | // like Node. 18 | module.exports = factory(); 19 | } else { 20 | // Browser globals (root is window) 21 | root.AnchorJS = factory(); 22 | root.anchors = new root.AnchorJS(); 23 | } 24 | })(this, function () { 25 | 'use strict'; 26 | function AnchorJS(options) { 27 | this.options = options || {}; 28 | this.elements = []; 29 | 30 | /** 31 | * Assigns options to the internal options object, and provides defaults. 32 | * @param {Object} opts - Options object 33 | */ 34 | function _applyRemainingDefaultOptions(opts) { 35 | opts.icon = opts.hasOwnProperty('icon') ? opts.icon : '\ue9cb'; // Accepts characters (and also URLs?), like '#', '¶', '❡', or '§'. 36 | opts.visible = opts.hasOwnProperty('visible') ? opts.visible : 'hover'; // Also accepts 'always' & 'touch' 37 | opts.placement = opts.hasOwnProperty('placement') 38 | ? opts.placement 39 | : 'right'; // Also accepts 'left' 40 | opts.class = opts.hasOwnProperty('class') ? opts.class : ''; // Accepts any class name. 41 | // Using Math.floor here will ensure the value is Number-cast and an integer. 42 | opts.truncate = opts.hasOwnProperty('truncate') 43 | ? Math.floor(opts.truncate) 44 | : 64; // Accepts any value that can be typecast to a number. 45 | } 46 | 47 | _applyRemainingDefaultOptions(this.options); 48 | 49 | /** 50 | * Checks to see if this device supports touch. Uses criteria pulled from Modernizr: 51 | * https://github.com/Modernizr/Modernizr/blob/da22eb27631fc4957f67607fe6042e85c0a84656/feature-detects/touchevents.js#L40 52 | * @returns {Boolean} - true if the current device supports touch. 53 | */ 54 | this.isTouchDevice = function () { 55 | return !!( 56 | 'ontouchstart' in window || 57 | (window.DocumentTouch && document instanceof DocumentTouch) 58 | ); 59 | }; 60 | 61 | /** 62 | * Add anchor links to page elements. 63 | * @param {String|Array|Nodelist} selector - A CSS selector for targeting the elements you wish to add anchor links 64 | * to. Also accepts an array or nodeList containing the relavant elements. 65 | * @returns {this} - The AnchorJS object 66 | */ 67 | this.add = function (selector) { 68 | var elements, 69 | elsWithIds, 70 | idList, 71 | elementID, 72 | i, 73 | index, 74 | count, 75 | tidyText, 76 | newTidyText, 77 | readableID, 78 | anchor, 79 | visibleOptionToUse, 80 | indexesToDrop = []; 81 | 82 | // We reapply options here because somebody may have overwritten the default options object when setting options. 83 | // For example, this overwrites all options but visible: 84 | // 85 | // anchors.options = { visible: 'always'; } 86 | _applyRemainingDefaultOptions(this.options); 87 | 88 | visibleOptionToUse = this.options.visible; 89 | if (visibleOptionToUse === 'touch') { 90 | visibleOptionToUse = this.isTouchDevice() ? 'always' : 'hover'; 91 | } 92 | 93 | // Provide a sensible default selector, if none is given. 94 | if (!selector) { 95 | selector = 'h2, h3, h4, h5, h6'; 96 | } 97 | 98 | elements = _getElements(selector); 99 | 100 | if (elements.length === 0) { 101 | return this; 102 | } 103 | 104 | _addBaselineStyles(); 105 | 106 | // We produce a list of existing IDs so we don't generate a duplicate. 107 | elsWithIds = document.querySelectorAll('[id]'); 108 | idList = [].map.call(elsWithIds, function assign(el) { 109 | return el.id; 110 | }); 111 | 112 | for (i = 0; i < elements.length; i++) { 113 | if (this.hasAnchorJSLink(elements[i])) { 114 | indexesToDrop.push(i); 115 | continue; 116 | } 117 | 118 | if (elements[i].hasAttribute('id')) { 119 | elementID = elements[i].getAttribute('id'); 120 | } else if (elements[i].hasAttribute('data-anchor-id')) { 121 | elementID = elements[i].getAttribute('data-anchor-id'); 122 | } else { 123 | tidyText = this.urlify(elements[i].textContent); 124 | 125 | // Compare our generated ID to existing IDs (and increment it if needed) 126 | // before we add it to the page. 127 | newTidyText = tidyText; 128 | count = 0; 129 | do { 130 | if (index !== undefined) { 131 | newTidyText = tidyText + '-' + count; 132 | } 133 | 134 | index = idList.indexOf(newTidyText); 135 | count += 1; 136 | } while (index !== -1); 137 | index = undefined; 138 | idList.push(newTidyText); 139 | 140 | elements[i].setAttribute('id', newTidyText); 141 | elementID = newTidyText; 142 | } 143 | 144 | readableID = elementID.replace(/-/g, ' '); 145 | 146 | // The following code builds the following DOM structure in a more effiecient (albeit opaque) way. 147 | // ''; 148 | anchor = document.createElement('a'); 149 | anchor.className = 'anchorjs-link ' + this.options.class; 150 | anchor.href = '#' + elementID; 151 | anchor.setAttribute('aria-label', 'Anchor link for: ' + readableID); 152 | anchor.setAttribute('data-anchorjs-icon', this.options.icon); 153 | 154 | if (visibleOptionToUse === 'always') { 155 | anchor.style.opacity = '1'; 156 | } 157 | 158 | if (this.options.icon === '\ue9cb') { 159 | anchor.style.font = '1em/1 anchorjs-icons'; 160 | 161 | // We set lineHeight = 1 here because the `anchorjs-icons` font family could otherwise affect the 162 | // height of the heading. This isn't the case for icons with `placement: left`, so we restore 163 | // line-height: inherit in that case, ensuring they remain positioned correctly. For more info, 164 | // see https://github.com/bryanbraun/anchorjs/issues/39. 165 | if (this.options.placement === 'left') { 166 | anchor.style.lineHeight = 'inherit'; 167 | } 168 | } 169 | 170 | if (this.options.placement === 'left') { 171 | anchor.style.position = 'absolute'; 172 | anchor.style.marginLeft = '-1em'; 173 | anchor.style.paddingRight = '0.5em'; 174 | elements[i].insertBefore(anchor, elements[i].firstChild); 175 | } else { 176 | // if the option provided is `right` (or anything else). 177 | anchor.style.paddingLeft = '0.375em'; 178 | elements[i].appendChild(anchor); 179 | } 180 | } 181 | 182 | for (i = 0; i < indexesToDrop.length; i++) { 183 | elements.splice(indexesToDrop[i] - i, 1); 184 | } 185 | this.elements = this.elements.concat(elements); 186 | 187 | return this; 188 | }; 189 | 190 | /** 191 | * Removes all anchorjs-links from elements targed by the selector. 192 | * @param {String|Array|Nodelist} selector - A CSS selector string targeting elements with anchor links, 193 | * OR a nodeList / array containing the DOM elements. 194 | * @returns {this} - The AnchorJS object 195 | */ 196 | this.remove = function (selector) { 197 | var index, 198 | domAnchor, 199 | elements = _getElements(selector); 200 | 201 | for (var i = 0; i < elements.length; i++) { 202 | domAnchor = elements[i].querySelector('.anchorjs-link'); 203 | if (domAnchor) { 204 | // Drop the element from our main list, if it's in there. 205 | index = this.elements.indexOf(elements[i]); 206 | if (index !== -1) { 207 | this.elements.splice(index, 1); 208 | } 209 | // Remove the anchor from the DOM. 210 | elements[i].removeChild(domAnchor); 211 | } 212 | } 213 | return this; 214 | }; 215 | 216 | /** 217 | * Removes all anchorjs links. Mostly used for tests. 218 | */ 219 | this.removeAll = function () { 220 | this.remove(this.elements); 221 | }; 222 | 223 | /** 224 | * Urlify - Refine text so it makes a good ID. 225 | * 226 | * To do this, we remove apostrophes, replace nonsafe characters with hyphens, 227 | * remove extra hyphens, truncate, trim hyphens, and make lowercase. 228 | * 229 | * @param {String} text - Any text. Usually pulled from the webpage element we are linking to. 230 | * @returns {String} - hyphen-delimited text for use in IDs and URLs. 231 | */ 232 | this.urlify = function (text) { 233 | // Regex for finding the nonsafe URL characters (many need escaping): & +$,:;=?@"#{}|^~[`%!'<>]./()*\ 234 | var nonsafeChars = /[& +$,:;=?@"#{}|^~[`%!'<>\]\.\/\(\)\*\\]/g, 235 | urlText; 236 | 237 | // The reason we include this _applyRemainingDefaultOptions is so urlify can be called independently, 238 | // even after setting options. This can be useful for tests or other applications. 239 | if (!this.options.truncate) { 240 | _applyRemainingDefaultOptions(this.options); 241 | } 242 | 243 | // Note: we trim hyphens after truncating because truncating can cause dangling hyphens. 244 | // Example string: // " ⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean." 245 | urlText = text 246 | .trim() // "⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean." 247 | .replace(/\'/gi, '') // "⚡⚡ Dont forget: URL fragments should be i18n-friendly, hyphenated, short, and clean." 248 | .replace(nonsafeChars, '-') // "⚡⚡-Dont-forget--URL-fragments-should-be-i18n-friendly--hyphenated--short--and-clean-" 249 | .replace(/-{2,}/g, '-') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-short-and-clean-" 250 | .substring(0, this.options.truncate) // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-" 251 | .replace(/^-+|-+$/gm, '') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated" 252 | .toLowerCase(); // "⚡⚡-dont-forget-url-fragments-should-be-i18n-friendly-hyphenated" 253 | 254 | return urlText; 255 | }; 256 | 257 | /** 258 | * Determines if this element already has an AnchorJS link on it. 259 | * Uses this technique: http://stackoverflow.com/a/5898748/1154642 260 | * @param {HTMLElemnt} el - a DOM node 261 | * @returns {Boolean} true/false 262 | */ 263 | this.hasAnchorJSLink = function (el) { 264 | var hasLeftAnchor = 265 | el.firstChild && 266 | (' ' + el.firstChild.className + ' ').indexOf(' anchorjs-link ') > -1, 267 | hasRightAnchor = 268 | el.lastChild && 269 | (' ' + el.lastChild.className + ' ').indexOf(' anchorjs-link ') > -1; 270 | 271 | return hasLeftAnchor || hasRightAnchor || false; 272 | }; 273 | 274 | /** 275 | * Turns a selector, nodeList, or array of elements into an array of elements (so we can use array methods). 276 | * It also throws errors on any other inputs. Used to handle inputs to .add and .remove. 277 | * @param {String|Array|Nodelist} input - A CSS selector string targeting elements with anchor links, 278 | * OR a nodeList / array containing the DOM elements. 279 | * @returns {Array} - An array containing the elements we want. 280 | */ 281 | function _getElements(input) { 282 | var elements; 283 | if (typeof input === 'string' || input instanceof String) { 284 | // See https://davidwalsh.name/nodelist-array for the technique transforming nodeList -> Array. 285 | elements = [].slice.call(document.querySelectorAll(input)); 286 | // I checked the 'input instanceof NodeList' test in IE9 and modern browsers and it worked for me. 287 | } else if (Array.isArray(input) || input instanceof NodeList) { 288 | elements = [].slice.call(input); 289 | } else { 290 | throw new Error('The selector provided to AnchorJS was invalid.'); 291 | } 292 | return elements; 293 | } 294 | 295 | /** 296 | * _addBaselineStyles 297 | * Adds baseline styles to the page, used by all AnchorJS links irregardless of configuration. 298 | */ 299 | function _addBaselineStyles() { 300 | // We don't want to add global baseline styles if they've been added before. 301 | if (document.head.querySelector('style.anchorjs') !== null) { 302 | return; 303 | } 304 | 305 | var style = document.createElement('style'), 306 | linkRule = 307 | ' .anchorjs-link {' + 308 | ' opacity: 0;' + 309 | ' text-decoration: none;' + 310 | ' -webkit-font-smoothing: antialiased;' + 311 | ' -moz-osx-font-smoothing: grayscale;' + 312 | ' }', 313 | hoverRule = 314 | ' *:hover > .anchorjs-link,' + 315 | ' .anchorjs-link:focus {' + 316 | ' opacity: 1;' + 317 | ' }', 318 | anchorjsLinkFontFace = 319 | ' @font-face {' + 320 | ' font-family: "anchorjs-icons";' + // Icon from icomoon; 10px wide & 10px tall; 2 empty below & 4 above 321 | ' src: url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype");' + 322 | ' }', 323 | pseudoElContent = 324 | ' [data-anchorjs-icon]::after {' + 325 | ' content: attr(data-anchorjs-icon);' + 326 | ' }', 327 | firstStyleEl; 328 | 329 | style.className = 'anchorjs'; 330 | style.appendChild(document.createTextNode('')); // Necessary for Webkit. 331 | 332 | // We place it in the head with the other style tags, if possible, so as to 333 | // not look out of place. We insert before the others so these styles can be 334 | // overridden if necessary. 335 | firstStyleEl = document.head.querySelector('[rel="stylesheet"], style'); 336 | if (firstStyleEl === undefined) { 337 | document.head.appendChild(style); 338 | } else { 339 | document.head.insertBefore(style, firstStyleEl); 340 | } 341 | 342 | style.sheet.insertRule(linkRule, style.sheet.cssRules.length); 343 | style.sheet.insertRule(hoverRule, style.sheet.cssRules.length); 344 | style.sheet.insertRule(pseudoElContent, style.sheet.cssRules.length); 345 | style.sheet.insertRule(anchorjsLinkFontFace, style.sheet.cssRules.length); 346 | } 347 | } 348 | 349 | return AnchorJS; 350 | }); 351 | -------------------------------------------------------------------------------- /docs/assets/bass-addons.css: -------------------------------------------------------------------------------- 1 | .input { 2 | font-family: inherit; 3 | display: block; 4 | width: 100%; 5 | height: 2rem; 6 | padding: .5rem; 7 | margin-bottom: 1rem; 8 | border: 1px solid #ccc; 9 | font-size: .875rem; 10 | border-radius: 3px; 11 | box-sizing: border-box; 12 | } 13 | -------------------------------------------------------------------------------- /docs/assets/bass.css: -------------------------------------------------------------------------------- 1 | /*! Basscss | http://basscss.com | MIT License */ 2 | 3 | .h1{ font-size: 2rem } 4 | .h2{ font-size: 1.5rem } 5 | .h3{ font-size: 1.25rem } 6 | .h4{ font-size: 1rem } 7 | .h5{ font-size: .875rem } 8 | .h6{ font-size: .75rem } 9 | 10 | .font-family-inherit{ font-family:inherit } 11 | .font-size-inherit{ font-size:inherit } 12 | .text-decoration-none{ text-decoration:none } 13 | 14 | .bold{ font-weight: bold; font-weight: bold } 15 | .regular{ font-weight:normal } 16 | .italic{ font-style:italic } 17 | .caps{ text-transform:uppercase; letter-spacing: .2em; } 18 | 19 | .left-align{ text-align:left } 20 | .center{ text-align:center } 21 | .right-align{ text-align:right } 22 | .justify{ text-align:justify } 23 | 24 | .nowrap{ white-space:nowrap } 25 | .break-word{ word-wrap:break-word } 26 | 27 | .line-height-1{ line-height: 1 } 28 | .line-height-2{ line-height: 1.125 } 29 | .line-height-3{ line-height: 1.25 } 30 | .line-height-4{ line-height: 1.5 } 31 | 32 | .list-style-none{ list-style:none } 33 | .underline{ text-decoration:underline } 34 | 35 | .truncate{ 36 | max-width:100%; 37 | overflow:hidden; 38 | text-overflow:ellipsis; 39 | white-space:nowrap; 40 | } 41 | 42 | .list-reset{ 43 | list-style:none; 44 | padding-left:0; 45 | } 46 | 47 | .inline{ display:inline } 48 | .block{ display:block } 49 | .inline-block{ display:inline-block } 50 | .table{ display:table } 51 | .table-cell{ display:table-cell } 52 | 53 | .overflow-hidden{ overflow:hidden } 54 | .overflow-scroll{ overflow:scroll } 55 | .overflow-auto{ overflow:auto } 56 | 57 | .clearfix:before, 58 | .clearfix:after{ 59 | content:" "; 60 | display:table 61 | } 62 | .clearfix:after{ clear:both } 63 | 64 | .left{ float:left } 65 | .right{ float:right } 66 | 67 | .fit{ max-width:100% } 68 | 69 | .max-width-1{ max-width: 24rem } 70 | .max-width-2{ max-width: 32rem } 71 | .max-width-3{ max-width: 48rem } 72 | .max-width-4{ max-width: 64rem } 73 | 74 | .border-box{ box-sizing:border-box } 75 | 76 | .align-baseline{ vertical-align:baseline } 77 | .align-top{ vertical-align:top } 78 | .align-middle{ vertical-align:middle } 79 | .align-bottom{ vertical-align:bottom } 80 | 81 | .m0{ margin:0 } 82 | .mt0{ margin-top:0 } 83 | .mr0{ margin-right:0 } 84 | .mb0{ margin-bottom:0 } 85 | .ml0{ margin-left:0 } 86 | .mx0{ margin-left:0; margin-right:0 } 87 | .my0{ margin-top:0; margin-bottom:0 } 88 | 89 | .m1{ margin: .5rem } 90 | .mt1{ margin-top: .5rem } 91 | .mr1{ margin-right: .5rem } 92 | .mb1{ margin-bottom: .5rem } 93 | .ml1{ margin-left: .5rem } 94 | .mx1{ margin-left: .5rem; margin-right: .5rem } 95 | .my1{ margin-top: .5rem; margin-bottom: .5rem } 96 | 97 | .m2{ margin: 1rem } 98 | .mt2{ margin-top: 1rem } 99 | .mr2{ margin-right: 1rem } 100 | .mb2{ margin-bottom: 1rem } 101 | .ml2{ margin-left: 1rem } 102 | .mx2{ margin-left: 1rem; margin-right: 1rem } 103 | .my2{ margin-top: 1rem; margin-bottom: 1rem } 104 | 105 | .m3{ margin: 2rem } 106 | .mt3{ margin-top: 2rem } 107 | .mr3{ margin-right: 2rem } 108 | .mb3{ margin-bottom: 2rem } 109 | .ml3{ margin-left: 2rem } 110 | .mx3{ margin-left: 2rem; margin-right: 2rem } 111 | .my3{ margin-top: 2rem; margin-bottom: 2rem } 112 | 113 | .m4{ margin: 4rem } 114 | .mt4{ margin-top: 4rem } 115 | .mr4{ margin-right: 4rem } 116 | .mb4{ margin-bottom: 4rem } 117 | .ml4{ margin-left: 4rem } 118 | .mx4{ margin-left: 4rem; margin-right: 4rem } 119 | .my4{ margin-top: 4rem; margin-bottom: 4rem } 120 | 121 | .mxn1{ margin-left: -.5rem; margin-right: -.5rem; } 122 | .mxn2{ margin-left: -1rem; margin-right: -1rem; } 123 | .mxn3{ margin-left: -2rem; margin-right: -2rem; } 124 | .mxn4{ margin-left: -4rem; margin-right: -4rem; } 125 | 126 | .ml-auto{ margin-left:auto } 127 | .mr-auto{ margin-right:auto } 128 | .mx-auto{ margin-left:auto; margin-right:auto; } 129 | 130 | .p0{ padding:0 } 131 | .pt0{ padding-top:0 } 132 | .pr0{ padding-right:0 } 133 | .pb0{ padding-bottom:0 } 134 | .pl0{ padding-left:0 } 135 | .px0{ padding-left:0; padding-right:0 } 136 | .py0{ padding-top:0; padding-bottom:0 } 137 | 138 | .p1{ padding: .5rem } 139 | .pt1{ padding-top: .5rem } 140 | .pr1{ padding-right: .5rem } 141 | .pb1{ padding-bottom: .5rem } 142 | .pl1{ padding-left: .5rem } 143 | .py1{ padding-top: .5rem; padding-bottom: .5rem } 144 | .px1{ padding-left: .5rem; padding-right: .5rem } 145 | 146 | .p2{ padding: 1rem } 147 | .pt2{ padding-top: 1rem } 148 | .pr2{ padding-right: 1rem } 149 | .pb2{ padding-bottom: 1rem } 150 | .pl2{ padding-left: 1rem } 151 | .py2{ padding-top: 1rem; padding-bottom: 1rem } 152 | .px2{ padding-left: 1rem; padding-right: 1rem } 153 | 154 | .p3{ padding: 2rem } 155 | .pt3{ padding-top: 2rem } 156 | .pr3{ padding-right: 2rem } 157 | .pb3{ padding-bottom: 2rem } 158 | .pl3{ padding-left: 2rem } 159 | .py3{ padding-top: 2rem; padding-bottom: 2rem } 160 | .px3{ padding-left: 2rem; padding-right: 2rem } 161 | 162 | .p4{ padding: 4rem } 163 | .pt4{ padding-top: 4rem } 164 | .pr4{ padding-right: 4rem } 165 | .pb4{ padding-bottom: 4rem } 166 | .pl4{ padding-left: 4rem } 167 | .py4{ padding-top: 4rem; padding-bottom: 4rem } 168 | .px4{ padding-left: 4rem; padding-right: 4rem } 169 | 170 | .col{ 171 | float:left; 172 | box-sizing:border-box; 173 | } 174 | 175 | .col-right{ 176 | float:right; 177 | box-sizing:border-box; 178 | } 179 | 180 | .col-1{ 181 | width:8.33333%; 182 | } 183 | 184 | .col-2{ 185 | width:16.66667%; 186 | } 187 | 188 | .col-3{ 189 | width:25%; 190 | } 191 | 192 | .col-4{ 193 | width:33.33333%; 194 | } 195 | 196 | .col-5{ 197 | width:41.66667%; 198 | } 199 | 200 | .col-6{ 201 | width:50%; 202 | } 203 | 204 | .col-7{ 205 | width:58.33333%; 206 | } 207 | 208 | .col-8{ 209 | width:66.66667%; 210 | } 211 | 212 | .col-9{ 213 | width:75%; 214 | } 215 | 216 | .col-10{ 217 | width:83.33333%; 218 | } 219 | 220 | .col-11{ 221 | width:91.66667%; 222 | } 223 | 224 | .col-12{ 225 | width:100%; 226 | } 227 | @media (min-width: 40em){ 228 | 229 | .sm-col{ 230 | float:left; 231 | box-sizing:border-box; 232 | } 233 | 234 | .sm-col-right{ 235 | float:right; 236 | box-sizing:border-box; 237 | } 238 | 239 | .sm-col-1{ 240 | width:8.33333%; 241 | } 242 | 243 | .sm-col-2{ 244 | width:16.66667%; 245 | } 246 | 247 | .sm-col-3{ 248 | width:25%; 249 | } 250 | 251 | .sm-col-4{ 252 | width:33.33333%; 253 | } 254 | 255 | .sm-col-5{ 256 | width:41.66667%; 257 | } 258 | 259 | .sm-col-6{ 260 | width:50%; 261 | } 262 | 263 | .sm-col-7{ 264 | width:58.33333%; 265 | } 266 | 267 | .sm-col-8{ 268 | width:66.66667%; 269 | } 270 | 271 | .sm-col-9{ 272 | width:75%; 273 | } 274 | 275 | .sm-col-10{ 276 | width:83.33333%; 277 | } 278 | 279 | .sm-col-11{ 280 | width:91.66667%; 281 | } 282 | 283 | .sm-col-12{ 284 | width:100%; 285 | } 286 | 287 | } 288 | @media (min-width: 52em){ 289 | 290 | .md-col{ 291 | float:left; 292 | box-sizing:border-box; 293 | } 294 | 295 | .md-col-right{ 296 | float:right; 297 | box-sizing:border-box; 298 | } 299 | 300 | .md-col-1{ 301 | width:8.33333%; 302 | } 303 | 304 | .md-col-2{ 305 | width:16.66667%; 306 | } 307 | 308 | .md-col-3{ 309 | width:25%; 310 | } 311 | 312 | .md-col-4{ 313 | width:33.33333%; 314 | } 315 | 316 | .md-col-5{ 317 | width:41.66667%; 318 | } 319 | 320 | .md-col-6{ 321 | width:50%; 322 | } 323 | 324 | .md-col-7{ 325 | width:58.33333%; 326 | } 327 | 328 | .md-col-8{ 329 | width:66.66667%; 330 | } 331 | 332 | .md-col-9{ 333 | width:75%; 334 | } 335 | 336 | .md-col-10{ 337 | width:83.33333%; 338 | } 339 | 340 | .md-col-11{ 341 | width:91.66667%; 342 | } 343 | 344 | .md-col-12{ 345 | width:100%; 346 | } 347 | 348 | } 349 | @media (min-width: 64em){ 350 | 351 | .lg-col{ 352 | float:left; 353 | box-sizing:border-box; 354 | } 355 | 356 | .lg-col-right{ 357 | float:right; 358 | box-sizing:border-box; 359 | } 360 | 361 | .lg-col-1{ 362 | width:8.33333%; 363 | } 364 | 365 | .lg-col-2{ 366 | width:16.66667%; 367 | } 368 | 369 | .lg-col-3{ 370 | width:25%; 371 | } 372 | 373 | .lg-col-4{ 374 | width:33.33333%; 375 | } 376 | 377 | .lg-col-5{ 378 | width:41.66667%; 379 | } 380 | 381 | .lg-col-6{ 382 | width:50%; 383 | } 384 | 385 | .lg-col-7{ 386 | width:58.33333%; 387 | } 388 | 389 | .lg-col-8{ 390 | width:66.66667%; 391 | } 392 | 393 | .lg-col-9{ 394 | width:75%; 395 | } 396 | 397 | .lg-col-10{ 398 | width:83.33333%; 399 | } 400 | 401 | .lg-col-11{ 402 | width:91.66667%; 403 | } 404 | 405 | .lg-col-12{ 406 | width:100%; 407 | } 408 | 409 | } 410 | .flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex } 411 | 412 | @media (min-width: 40em){ 413 | .sm-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex } 414 | } 415 | 416 | @media (min-width: 52em){ 417 | .md-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex } 418 | } 419 | 420 | @media (min-width: 64em){ 421 | .lg-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex } 422 | } 423 | 424 | .flex-column{ -webkit-box-orient:vertical; -webkit-box-direction:normal; -webkit-flex-direction:column; -ms-flex-direction:column; flex-direction:column } 425 | .flex-wrap{ -webkit-flex-wrap:wrap; -ms-flex-wrap:wrap; flex-wrap:wrap } 426 | 427 | .items-start{ -webkit-box-align:start; -webkit-align-items:flex-start; -ms-flex-align:start; -ms-grid-row-align:flex-start; align-items:flex-start } 428 | .items-end{ -webkit-box-align:end; -webkit-align-items:flex-end; -ms-flex-align:end; -ms-grid-row-align:flex-end; align-items:flex-end } 429 | .items-center{ -webkit-box-align:center; -webkit-align-items:center; -ms-flex-align:center; -ms-grid-row-align:center; align-items:center } 430 | .items-baseline{ -webkit-box-align:baseline; -webkit-align-items:baseline; -ms-flex-align:baseline; -ms-grid-row-align:baseline; align-items:baseline } 431 | .items-stretch{ -webkit-box-align:stretch; -webkit-align-items:stretch; -ms-flex-align:stretch; -ms-grid-row-align:stretch; align-items:stretch } 432 | 433 | .self-start{ -webkit-align-self:flex-start; -ms-flex-item-align:start; align-self:flex-start } 434 | .self-end{ -webkit-align-self:flex-end; -ms-flex-item-align:end; align-self:flex-end } 435 | .self-center{ -webkit-align-self:center; -ms-flex-item-align:center; align-self:center } 436 | .self-baseline{ -webkit-align-self:baseline; -ms-flex-item-align:baseline; align-self:baseline } 437 | .self-stretch{ -webkit-align-self:stretch; -ms-flex-item-align:stretch; align-self:stretch } 438 | 439 | .justify-start{ -webkit-box-pack:start; -webkit-justify-content:flex-start; -ms-flex-pack:start; justify-content:flex-start } 440 | .justify-end{ -webkit-box-pack:end; -webkit-justify-content:flex-end; -ms-flex-pack:end; justify-content:flex-end } 441 | .justify-center{ -webkit-box-pack:center; -webkit-justify-content:center; -ms-flex-pack:center; justify-content:center } 442 | .justify-between{ -webkit-box-pack:justify; -webkit-justify-content:space-between; -ms-flex-pack:justify; justify-content:space-between } 443 | .justify-around{ -webkit-justify-content:space-around; -ms-flex-pack:distribute; justify-content:space-around } 444 | 445 | .content-start{ -webkit-align-content:flex-start; -ms-flex-line-pack:start; align-content:flex-start } 446 | .content-end{ -webkit-align-content:flex-end; -ms-flex-line-pack:end; align-content:flex-end } 447 | .content-center{ -webkit-align-content:center; -ms-flex-line-pack:center; align-content:center } 448 | .content-between{ -webkit-align-content:space-between; -ms-flex-line-pack:justify; align-content:space-between } 449 | .content-around{ -webkit-align-content:space-around; -ms-flex-line-pack:distribute; align-content:space-around } 450 | .content-stretch{ -webkit-align-content:stretch; -ms-flex-line-pack:stretch; align-content:stretch } 451 | .flex-auto{ 452 | -webkit-box-flex:1; 453 | -webkit-flex:1 1 auto; 454 | -ms-flex:1 1 auto; 455 | flex:1 1 auto; 456 | min-width:0; 457 | min-height:0; 458 | } 459 | .flex-none{ -webkit-box-flex:0; -webkit-flex:none; -ms-flex:none; flex:none } 460 | .fs0{ flex-shrink: 0 } 461 | 462 | .order-0{ -webkit-box-ordinal-group:1; -webkit-order:0; -ms-flex-order:0; order:0 } 463 | .order-1{ -webkit-box-ordinal-group:2; -webkit-order:1; -ms-flex-order:1; order:1 } 464 | .order-2{ -webkit-box-ordinal-group:3; -webkit-order:2; -ms-flex-order:2; order:2 } 465 | .order-3{ -webkit-box-ordinal-group:4; -webkit-order:3; -ms-flex-order:3; order:3 } 466 | .order-last{ -webkit-box-ordinal-group:100000; -webkit-order:99999; -ms-flex-order:99999; order:99999 } 467 | 468 | .relative{ position:relative } 469 | .absolute{ position:absolute } 470 | .fixed{ position:fixed } 471 | 472 | .top-0{ top:0 } 473 | .right-0{ right:0 } 474 | .bottom-0{ bottom:0 } 475 | .left-0{ left:0 } 476 | 477 | .z1{ z-index: 1 } 478 | .z2{ z-index: 2 } 479 | .z3{ z-index: 3 } 480 | .z4{ z-index: 4 } 481 | 482 | .border{ 483 | border-style:solid; 484 | border-width: 1px; 485 | } 486 | 487 | .border-top{ 488 | border-top-style:solid; 489 | border-top-width: 1px; 490 | } 491 | 492 | .border-right{ 493 | border-right-style:solid; 494 | border-right-width: 1px; 495 | } 496 | 497 | .border-bottom{ 498 | border-bottom-style:solid; 499 | border-bottom-width: 1px; 500 | } 501 | 502 | .border-left{ 503 | border-left-style:solid; 504 | border-left-width: 1px; 505 | } 506 | 507 | .border-none{ border:0 } 508 | 509 | .rounded{ border-radius: 3px } 510 | .circle{ border-radius:50% } 511 | 512 | .rounded-top{ border-radius: 3px 3px 0 0 } 513 | .rounded-right{ border-radius: 0 3px 3px 0 } 514 | .rounded-bottom{ border-radius: 0 0 3px 3px } 515 | .rounded-left{ border-radius: 3px 0 0 3px } 516 | 517 | .not-rounded{ border-radius:0 } 518 | 519 | .hide{ 520 | position:absolute !important; 521 | height:1px; 522 | width:1px; 523 | overflow:hidden; 524 | clip:rect(1px, 1px, 1px, 1px); 525 | } 526 | 527 | @media (max-width: 40em){ 528 | .xs-hide{ display:none !important } 529 | } 530 | 531 | @media (min-width: 40em) and (max-width: 52em){ 532 | .sm-hide{ display:none !important } 533 | } 534 | 535 | @media (min-width: 52em) and (max-width: 64em){ 536 | .md-hide{ display:none !important } 537 | } 538 | 539 | @media (min-width: 64em){ 540 | .lg-hide{ display:none !important } 541 | } 542 | 543 | .display-none{ display:none !important } 544 | 545 | -------------------------------------------------------------------------------- /docs/assets/fonts/EOT/SourceCodePro-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/EOT/SourceCodePro-Bold.eot -------------------------------------------------------------------------------- /docs/assets/fonts/EOT/SourceCodePro-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/EOT/SourceCodePro-Regular.eot -------------------------------------------------------------------------------- /docs/assets/fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | 5 | This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /docs/assets/fonts/OTF/SourceCodePro-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/OTF/SourceCodePro-Bold.otf -------------------------------------------------------------------------------- /docs/assets/fonts/OTF/SourceCodePro-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/OTF/SourceCodePro-Regular.otf -------------------------------------------------------------------------------- /docs/assets/fonts/TTF/SourceCodePro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/TTF/SourceCodePro-Bold.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/TTF/SourceCodePro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/TTF/SourceCodePro-Regular.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF/OTF/SourceCodePro-Bold.otf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF/OTF/SourceCodePro-Bold.otf.woff -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF/OTF/SourceCodePro-Regular.otf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF/OTF/SourceCodePro-Regular.otf.woff -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF/TTF/SourceCodePro-Bold.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF/TTF/SourceCodePro-Bold.ttf.woff -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF/TTF/SourceCodePro-Regular.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF/TTF/SourceCodePro-Regular.ttf.woff -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF2/OTF/SourceCodePro-Bold.otf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Bold.otf.woff2 -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF2/OTF/SourceCodePro-Regular.otf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF2/OTF/SourceCodePro-Regular.otf.woff2 -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF2/TTF/SourceCodePro-Bold.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Bold.ttf.woff2 -------------------------------------------------------------------------------- /docs/assets/fonts/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/assets/fonts/WOFF2/TTF/SourceCodePro-Regular.ttf.woff2 -------------------------------------------------------------------------------- /docs/assets/fonts/source-code-pro.css: -------------------------------------------------------------------------------- 1 | @font-face{ 2 | font-family: 'Source Code Pro'; 3 | font-weight: 400; 4 | font-style: normal; 5 | font-stretch: normal; 6 | src: url('EOT/SourceCodePro-Regular.eot') format('embedded-opentype'), 7 | url('WOFF2/TTF/SourceCodePro-Regular.ttf.woff2') format('woff2'), 8 | url('WOFF/OTF/SourceCodePro-Regular.otf.woff') format('woff'), 9 | url('OTF/SourceCodePro-Regular.otf') format('opentype'), 10 | url('TTF/SourceCodePro-Regular.ttf') format('truetype'); 11 | } 12 | 13 | @font-face{ 14 | font-family: 'Source Code Pro'; 15 | font-weight: 700; 16 | font-style: normal; 17 | font-stretch: normal; 18 | src: url('EOT/SourceCodePro-Bold.eot') format('embedded-opentype'), 19 | url('WOFF2/TTF/SourceCodePro-Bold.ttf.woff2') format('woff2'), 20 | url('WOFF/OTF/SourceCodePro-Bold.otf.woff') format('woff'), 21 | url('OTF/SourceCodePro-Bold.otf') format('opentype'), 22 | url('TTF/SourceCodePro-Bold.ttf') format('truetype'); 23 | } 24 | -------------------------------------------------------------------------------- /docs/assets/github.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | github.com style (c) Vasily Polovnyov 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 0.5em; 11 | color: #333; 12 | background: #f8f8f8; 13 | -webkit-text-size-adjust: none; 14 | } 15 | 16 | .hljs-comment, 17 | .diff .hljs-header, 18 | .hljs-javadoc { 19 | color: #998; 20 | font-style: italic; 21 | } 22 | 23 | .hljs-keyword, 24 | .css .rule .hljs-keyword, 25 | .hljs-winutils, 26 | .nginx .hljs-title, 27 | .hljs-subst, 28 | .hljs-request, 29 | .hljs-status { 30 | color: #1184CE; 31 | } 32 | 33 | .hljs-number, 34 | .hljs-hexcolor, 35 | .ruby .hljs-constant { 36 | color: #ed225d; 37 | } 38 | 39 | .hljs-string, 40 | .hljs-tag .hljs-value, 41 | .hljs-phpdoc, 42 | .hljs-dartdoc, 43 | .tex .hljs-formula { 44 | color: #ed225d; 45 | } 46 | 47 | .hljs-title, 48 | .hljs-id, 49 | .scss .hljs-preprocessor { 50 | color: #900; 51 | font-weight: bold; 52 | } 53 | 54 | .hljs-list .hljs-keyword, 55 | .hljs-subst { 56 | font-weight: normal; 57 | } 58 | 59 | .hljs-class .hljs-title, 60 | .hljs-type, 61 | .vhdl .hljs-literal, 62 | .tex .hljs-command { 63 | color: #458; 64 | font-weight: bold; 65 | } 66 | 67 | .hljs-tag, 68 | .hljs-tag .hljs-title, 69 | .hljs-rules .hljs-property, 70 | .django .hljs-tag .hljs-keyword { 71 | color: #000080; 72 | font-weight: normal; 73 | } 74 | 75 | .hljs-attribute, 76 | .hljs-variable, 77 | .lisp .hljs-body { 78 | color: #008080; 79 | } 80 | 81 | .hljs-regexp { 82 | color: #009926; 83 | } 84 | 85 | .hljs-symbol, 86 | .ruby .hljs-symbol .hljs-string, 87 | .lisp .hljs-keyword, 88 | .clojure .hljs-keyword, 89 | .scheme .hljs-keyword, 90 | .tex .hljs-special, 91 | .hljs-prompt { 92 | color: #990073; 93 | } 94 | 95 | .hljs-built_in { 96 | color: #0086b3; 97 | } 98 | 99 | .hljs-preprocessor, 100 | .hljs-pragma, 101 | .hljs-pi, 102 | .hljs-doctype, 103 | .hljs-shebang, 104 | .hljs-cdata { 105 | color: #999; 106 | font-weight: bold; 107 | } 108 | 109 | .hljs-deletion { 110 | background: #fdd; 111 | } 112 | 113 | .hljs-addition { 114 | background: #dfd; 115 | } 116 | 117 | .diff .hljs-change { 118 | background: #0086b3; 119 | } 120 | 121 | .hljs-chunk { 122 | color: #aaa; 123 | } 124 | -------------------------------------------------------------------------------- /docs/assets/site.js: -------------------------------------------------------------------------------- 1 | /* global anchors */ 2 | 3 | // add anchor links to headers 4 | anchors.options.placement = 'left'; 5 | anchors.add('h3'); 6 | 7 | // Filter UI 8 | var tocElements = document.getElementById('toc').getElementsByTagName('li'); 9 | 10 | document.getElementById('filter-input').addEventListener('keyup', function (e) { 11 | var i, element, children; 12 | 13 | // enter key 14 | if (e.keyCode === 13) { 15 | // go to the first displayed item in the toc 16 | for (i = 0; i < tocElements.length; i++) { 17 | element = tocElements[i]; 18 | if (!element.classList.contains('display-none')) { 19 | location.replace(element.firstChild.href); 20 | return e.preventDefault(); 21 | } 22 | } 23 | } 24 | 25 | var match = function () { 26 | return true; 27 | }; 28 | 29 | var value = this.value.toLowerCase(); 30 | 31 | if (!value.match(/^\s*$/)) { 32 | match = function (element) { 33 | var html = element.firstChild.innerHTML; 34 | return html && html.toLowerCase().indexOf(value) !== -1; 35 | }; 36 | } 37 | 38 | for (i = 0; i < tocElements.length; i++) { 39 | element = tocElements[i]; 40 | children = Array.from(element.getElementsByTagName('li')); 41 | if (match(element) || children.some(match)) { 42 | element.classList.remove('display-none'); 43 | } else { 44 | element.classList.add('display-none'); 45 | } 46 | } 47 | }); 48 | 49 | var items = document.getElementsByClassName('toggle-sibling'); 50 | for (var j = 0; j < items.length; j++) { 51 | items[j].addEventListener('click', toggleSibling); 52 | } 53 | 54 | function toggleSibling() { 55 | var stepSibling = this.parentNode.getElementsByClassName('toggle-target')[0]; 56 | var icon = this.getElementsByClassName('icon')[0]; 57 | var klass = 'display-none'; 58 | if (stepSibling.classList.contains(klass)) { 59 | stepSibling.classList.remove(klass); 60 | icon.innerHTML = '▾'; 61 | } else { 62 | stepSibling.classList.add(klass); 63 | icon.innerHTML = '▸'; 64 | } 65 | } 66 | 67 | function showHashTarget(targetId) { 68 | if (targetId) { 69 | var hashTarget = document.getElementById(targetId); 70 | // new target is hidden 71 | if ( 72 | hashTarget && 73 | hashTarget.offsetHeight === 0 && 74 | hashTarget.parentNode.parentNode.classList.contains('display-none') 75 | ) { 76 | hashTarget.parentNode.parentNode.classList.remove('display-none'); 77 | } 78 | } 79 | } 80 | 81 | function scrollIntoView(targetId) { 82 | // Only scroll to element if we don't have a stored scroll position. 83 | if (targetId && !history.state) { 84 | var hashTarget = document.getElementById(targetId); 85 | if (hashTarget) { 86 | hashTarget.scrollIntoView(); 87 | } 88 | } 89 | } 90 | 91 | function gotoCurrentTarget() { 92 | showHashTarget(location.hash.substring(1)); 93 | scrollIntoView(location.hash.substring(1)); 94 | } 95 | 96 | window.addEventListener('hashchange', gotoCurrentTarget); 97 | gotoCurrentTarget(); 98 | 99 | var toclinks = document.getElementsByClassName('pre-open'); 100 | for (var k = 0; k < toclinks.length; k++) { 101 | toclinks[k].addEventListener('mousedown', preOpen, false); 102 | } 103 | 104 | function preOpen() { 105 | showHashTarget(this.hash.substring(1)); 106 | } 107 | 108 | var split_left = document.querySelector('#split-left'); 109 | var split_right = document.querySelector('#split-right'); 110 | var split_parent = split_left.parentNode; 111 | var cw_with_sb = split_left.clientWidth; 112 | split_left.style.overflow = 'hidden'; 113 | var cw_without_sb = split_left.clientWidth; 114 | split_left.style.overflow = ''; 115 | 116 | Split(['#split-left', '#split-right'], { 117 | elementStyle: function (dimension, size, gutterSize) { 118 | return { 119 | 'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)' 120 | }; 121 | }, 122 | gutterStyle: function (dimension, gutterSize) { 123 | return { 124 | 'flex-basis': gutterSize + 'px' 125 | }; 126 | }, 127 | gutterSize: 20, 128 | sizes: [33, 67] 129 | }); 130 | 131 | // Chrome doesn't remember scroll position properly so do it ourselves. 132 | // Also works on Firefox and Edge. 133 | 134 | function updateState() { 135 | history.replaceState( 136 | { 137 | left_top: split_left.scrollTop, 138 | right_top: split_right.scrollTop 139 | }, 140 | document.title 141 | ); 142 | } 143 | 144 | function loadState(ev) { 145 | if (ev) { 146 | // Edge doesn't replace change history.state on popstate. 147 | history.replaceState(ev.state, document.title); 148 | } 149 | if (history.state) { 150 | split_left.scrollTop = history.state.left_top; 151 | split_right.scrollTop = history.state.right_top; 152 | } 153 | } 154 | 155 | window.addEventListener('load', function () { 156 | // Restore after Firefox scrolls to hash. 157 | setTimeout(function () { 158 | loadState(); 159 | // Update with initial scroll position. 160 | updateState(); 161 | // Update scroll positions only after we've loaded because Firefox 162 | // emits an initial scroll event with 0. 163 | split_left.addEventListener('scroll', updateState); 164 | split_right.addEventListener('scroll', updateState); 165 | }, 1); 166 | }); 167 | 168 | window.addEventListener('popstate', loadState); 169 | -------------------------------------------------------------------------------- /docs/assets/split.css: -------------------------------------------------------------------------------- 1 | .gutter { 2 | background-color: #f5f5f5; 3 | background-repeat: no-repeat; 4 | background-position: 50%; 5 | } 6 | 7 | .gutter.gutter-vertical { 8 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); 9 | cursor: ns-resize; 10 | } 11 | 12 | .gutter.gutter-horizontal { 13 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); 14 | cursor: ew-resize; 15 | } 16 | -------------------------------------------------------------------------------- /docs/assets/split.js: -------------------------------------------------------------------------------- 1 | /*! Split.js - v1.5.11 */ 2 | 3 | (function (global, factory) { 4 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 5 | typeof define === 'function' && define.amd ? define(factory) : 6 | (global.Split = factory()); 7 | }(this, (function () { 'use strict'; 8 | 9 | // The programming goals of Split.js are to deliver readable, understandable and 10 | // maintainable code, while at the same time manually optimizing for tiny minified file size, 11 | // browser compatibility without additional requirements, graceful fallback (IE8 is supported) 12 | // and very few assumptions about the user's page layout. 13 | var global = window; 14 | var document = global.document; 15 | 16 | // Save a couple long function names that are used frequently. 17 | // This optimization saves around 400 bytes. 18 | var addEventListener = 'addEventListener'; 19 | var removeEventListener = 'removeEventListener'; 20 | var getBoundingClientRect = 'getBoundingClientRect'; 21 | var gutterStartDragging = '_a'; 22 | var aGutterSize = '_b'; 23 | var bGutterSize = '_c'; 24 | var HORIZONTAL = 'horizontal'; 25 | var NOOP = function () { return false; }; 26 | 27 | // Figure out if we're in IE8 or not. IE8 will still render correctly, 28 | // but will be static instead of draggable. 29 | var isIE8 = global.attachEvent && !global[addEventListener]; 30 | 31 | // Helper function determines which prefixes of CSS calc we need. 32 | // We only need to do this once on startup, when this anonymous function is called. 33 | // 34 | // Tests -webkit, -moz and -o prefixes. Modified from StackOverflow: 35 | // http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167 36 | var calc = (['', '-webkit-', '-moz-', '-o-'] 37 | .filter(function (prefix) { 38 | var el = document.createElement('div'); 39 | el.style.cssText = "width:" + prefix + "calc(9px)"; 40 | 41 | return !!el.style.length 42 | }) 43 | .shift()) + "calc"; 44 | 45 | // Helper function checks if its argument is a string-like type 46 | var isString = function (v) { return typeof v === 'string' || v instanceof String; }; 47 | 48 | // Helper function allows elements and string selectors to be used 49 | // interchangeably. In either case an element is returned. This allows us to 50 | // do `Split([elem1, elem2])` as well as `Split(['#id1', '#id2'])`. 51 | var elementOrSelector = function (el) { 52 | if (isString(el)) { 53 | var ele = document.querySelector(el); 54 | if (!ele) { 55 | throw new Error(("Selector " + el + " did not match a DOM element")) 56 | } 57 | return ele 58 | } 59 | 60 | return el 61 | }; 62 | 63 | // Helper function gets a property from the properties object, with a default fallback 64 | var getOption = function (options, propName, def) { 65 | var value = options[propName]; 66 | if (value !== undefined) { 67 | return value 68 | } 69 | return def 70 | }; 71 | 72 | var getGutterSize = function (gutterSize, isFirst, isLast, gutterAlign) { 73 | if (isFirst) { 74 | if (gutterAlign === 'end') { 75 | return 0 76 | } 77 | if (gutterAlign === 'center') { 78 | return gutterSize / 2 79 | } 80 | } else if (isLast) { 81 | if (gutterAlign === 'start') { 82 | return 0 83 | } 84 | if (gutterAlign === 'center') { 85 | return gutterSize / 2 86 | } 87 | } 88 | 89 | return gutterSize 90 | }; 91 | 92 | // Default options 93 | var defaultGutterFn = function (i, gutterDirection) { 94 | var gut = document.createElement('div'); 95 | gut.className = "gutter gutter-" + gutterDirection; 96 | return gut 97 | }; 98 | 99 | var defaultElementStyleFn = function (dim, size, gutSize) { 100 | var style = {}; 101 | 102 | if (!isString(size)) { 103 | if (!isIE8) { 104 | style[dim] = calc + "(" + size + "% - " + gutSize + "px)"; 105 | } else { 106 | style[dim] = size + "%"; 107 | } 108 | } else { 109 | style[dim] = size; 110 | } 111 | 112 | return style 113 | }; 114 | 115 | var defaultGutterStyleFn = function (dim, gutSize) { 116 | var obj; 117 | 118 | return (( obj = {}, obj[dim] = (gutSize + "px"), obj )); 119 | }; 120 | 121 | // The main function to initialize a split. Split.js thinks about each pair 122 | // of elements as an independant pair. Dragging the gutter between two elements 123 | // only changes the dimensions of elements in that pair. This is key to understanding 124 | // how the following functions operate, since each function is bound to a pair. 125 | // 126 | // A pair object is shaped like this: 127 | // 128 | // { 129 | // a: DOM element, 130 | // b: DOM element, 131 | // aMin: Number, 132 | // bMin: Number, 133 | // dragging: Boolean, 134 | // parent: DOM element, 135 | // direction: 'horizontal' | 'vertical' 136 | // } 137 | // 138 | // The basic sequence: 139 | // 140 | // 1. Set defaults to something sane. `options` doesn't have to be passed at all. 141 | // 2. Initialize a bunch of strings based on the direction we're splitting. 142 | // A lot of the behavior in the rest of the library is paramatized down to 143 | // rely on CSS strings and classes. 144 | // 3. Define the dragging helper functions, and a few helpers to go with them. 145 | // 4. Loop through the elements while pairing them off. Every pair gets an 146 | // `pair` object and a gutter. 147 | // 5. Actually size the pair elements, insert gutters and attach event listeners. 148 | var Split = function (idsOption, options) { 149 | if ( options === void 0 ) options = {}; 150 | 151 | var ids = idsOption; 152 | var dimension; 153 | var clientAxis; 154 | var position; 155 | var positionEnd; 156 | var clientSize; 157 | var elements; 158 | 159 | // Allow HTMLCollection to be used as an argument when supported 160 | if (Array.from) { 161 | ids = Array.from(ids); 162 | } 163 | 164 | // All DOM elements in the split should have a common parent. We can grab 165 | // the first elements parent and hope users read the docs because the 166 | // behavior will be whacky otherwise. 167 | var firstElement = elementOrSelector(ids[0]); 168 | var parent = firstElement.parentNode; 169 | var parentStyle = getComputedStyle ? getComputedStyle(parent) : null; 170 | var parentFlexDirection = parentStyle ? parentStyle.flexDirection : null; 171 | 172 | // Set default options.sizes to equal percentages of the parent element. 173 | var sizes = getOption(options, 'sizes') || ids.map(function () { return 100 / ids.length; }); 174 | 175 | // Standardize minSize to an array if it isn't already. This allows minSize 176 | // to be passed as a number. 177 | var minSize = getOption(options, 'minSize', 100); 178 | var minSizes = Array.isArray(minSize) ? minSize : ids.map(function () { return minSize; }); 179 | 180 | // Get other options 181 | var expandToMin = getOption(options, 'expandToMin', false); 182 | var gutterSize = getOption(options, 'gutterSize', 10); 183 | var gutterAlign = getOption(options, 'gutterAlign', 'center'); 184 | var snapOffset = getOption(options, 'snapOffset', 30); 185 | var dragInterval = getOption(options, 'dragInterval', 1); 186 | var direction = getOption(options, 'direction', HORIZONTAL); 187 | var cursor = getOption( 188 | options, 189 | 'cursor', 190 | direction === HORIZONTAL ? 'col-resize' : 'row-resize' 191 | ); 192 | var gutter = getOption(options, 'gutter', defaultGutterFn); 193 | var elementStyle = getOption( 194 | options, 195 | 'elementStyle', 196 | defaultElementStyleFn 197 | ); 198 | var gutterStyle = getOption(options, 'gutterStyle', defaultGutterStyleFn); 199 | 200 | // 2. Initialize a bunch of strings based on the direction we're splitting. 201 | // A lot of the behavior in the rest of the library is paramatized down to 202 | // rely on CSS strings and classes. 203 | if (direction === HORIZONTAL) { 204 | dimension = 'width'; 205 | clientAxis = 'clientX'; 206 | position = 'left'; 207 | positionEnd = 'right'; 208 | clientSize = 'clientWidth'; 209 | } else if (direction === 'vertical') { 210 | dimension = 'height'; 211 | clientAxis = 'clientY'; 212 | position = 'top'; 213 | positionEnd = 'bottom'; 214 | clientSize = 'clientHeight'; 215 | } 216 | 217 | // 3. Define the dragging helper functions, and a few helpers to go with them. 218 | // Each helper is bound to a pair object that contains its metadata. This 219 | // also makes it easy to store references to listeners that that will be 220 | // added and removed. 221 | // 222 | // Even though there are no other functions contained in them, aliasing 223 | // this to self saves 50 bytes or so since it's used so frequently. 224 | // 225 | // The pair object saves metadata like dragging state, position and 226 | // event listener references. 227 | 228 | function setElementSize(el, size, gutSize, i) { 229 | // Split.js allows setting sizes via numbers (ideally), or if you must, 230 | // by string, like '300px'. This is less than ideal, because it breaks 231 | // the fluid layout that `calc(% - px)` provides. You're on your own if you do that, 232 | // make sure you calculate the gutter size by hand. 233 | var style = elementStyle(dimension, size, gutSize, i); 234 | 235 | Object.keys(style).forEach(function (prop) { 236 | // eslint-disable-next-line no-param-reassign 237 | el.style[prop] = style[prop]; 238 | }); 239 | } 240 | 241 | function setGutterSize(gutterElement, gutSize, i) { 242 | var style = gutterStyle(dimension, gutSize, i); 243 | 244 | Object.keys(style).forEach(function (prop) { 245 | // eslint-disable-next-line no-param-reassign 246 | gutterElement.style[prop] = style[prop]; 247 | }); 248 | } 249 | 250 | function getSizes() { 251 | return elements.map(function (element) { return element.size; }) 252 | } 253 | 254 | // Supports touch events, but not multitouch, so only the first 255 | // finger `touches[0]` is counted. 256 | function getMousePosition(e) { 257 | if ('touches' in e) { return e.touches[0][clientAxis] } 258 | return e[clientAxis] 259 | } 260 | 261 | // Actually adjust the size of elements `a` and `b` to `offset` while dragging. 262 | // calc is used to allow calc(percentage + gutterpx) on the whole split instance, 263 | // which allows the viewport to be resized without additional logic. 264 | // Element a's size is the same as offset. b's size is total size - a size. 265 | // Both sizes are calculated from the initial parent percentage, 266 | // then the gutter size is subtracted. 267 | function adjust(offset) { 268 | var a = elements[this.a]; 269 | var b = elements[this.b]; 270 | var percentage = a.size + b.size; 271 | 272 | a.size = (offset / this.size) * percentage; 273 | b.size = percentage - (offset / this.size) * percentage; 274 | 275 | setElementSize(a.element, a.size, this[aGutterSize], a.i); 276 | setElementSize(b.element, b.size, this[bGutterSize], b.i); 277 | } 278 | 279 | // drag, where all the magic happens. The logic is really quite simple: 280 | // 281 | // 1. Ignore if the pair is not dragging. 282 | // 2. Get the offset of the event. 283 | // 3. Snap offset to min if within snappable range (within min + snapOffset). 284 | // 4. Actually adjust each element in the pair to offset. 285 | // 286 | // --------------------------------------------------------------------- 287 | // | | <- a.minSize || b.minSize -> | | 288 | // | | | <- this.snapOffset || this.snapOffset -> | | | 289 | // | | | || | | | 290 | // | | | || | | | 291 | // --------------------------------------------------------------------- 292 | // | <- this.start this.size -> | 293 | function drag(e) { 294 | var offset; 295 | var a = elements[this.a]; 296 | var b = elements[this.b]; 297 | 298 | if (!this.dragging) { return } 299 | 300 | // Get the offset of the event from the first side of the 301 | // pair `this.start`. Then offset by the initial position of the 302 | // mouse compared to the gutter size. 303 | offset = 304 | getMousePosition(e) - 305 | this.start + 306 | (this[aGutterSize] - this.dragOffset); 307 | 308 | if (dragInterval > 1) { 309 | offset = Math.round(offset / dragInterval) * dragInterval; 310 | } 311 | 312 | // If within snapOffset of min or max, set offset to min or max. 313 | // snapOffset buffers a.minSize and b.minSize, so logic is opposite for both. 314 | // Include the appropriate gutter sizes to prevent overflows. 315 | if (offset <= a.minSize + snapOffset + this[aGutterSize]) { 316 | offset = a.minSize + this[aGutterSize]; 317 | } else if ( 318 | offset >= 319 | this.size - (b.minSize + snapOffset + this[bGutterSize]) 320 | ) { 321 | offset = this.size - (b.minSize + this[bGutterSize]); 322 | } 323 | 324 | // Actually adjust the size. 325 | adjust.call(this, offset); 326 | 327 | // Call the drag callback continously. Don't do anything too intensive 328 | // in this callback. 329 | getOption(options, 'onDrag', NOOP)(); 330 | } 331 | 332 | // Cache some important sizes when drag starts, so we don't have to do that 333 | // continously: 334 | // 335 | // `size`: The total size of the pair. First + second + first gutter + second gutter. 336 | // `start`: The leading side of the first element. 337 | // 338 | // ------------------------------------------------ 339 | // | aGutterSize -> ||| | 340 | // | ||| | 341 | // | ||| | 342 | // | ||| <- bGutterSize | 343 | // ------------------------------------------------ 344 | // | <- start size -> | 345 | function calculateSizes() { 346 | // Figure out the parent size minus padding. 347 | var a = elements[this.a].element; 348 | var b = elements[this.b].element; 349 | 350 | var aBounds = a[getBoundingClientRect](); 351 | var bBounds = b[getBoundingClientRect](); 352 | 353 | this.size = 354 | aBounds[dimension] + 355 | bBounds[dimension] + 356 | this[aGutterSize] + 357 | this[bGutterSize]; 358 | this.start = aBounds[position]; 359 | this.end = aBounds[positionEnd]; 360 | } 361 | 362 | function innerSize(element) { 363 | // Return nothing if getComputedStyle is not supported (< IE9) 364 | // Or if parent element has no layout yet 365 | if (!getComputedStyle) { return null } 366 | 367 | var computedStyle = getComputedStyle(element); 368 | 369 | if (!computedStyle) { return null } 370 | 371 | var size = element[clientSize]; 372 | 373 | if (size === 0) { return null } 374 | 375 | if (direction === HORIZONTAL) { 376 | size -= 377 | parseFloat(computedStyle.paddingLeft) + 378 | parseFloat(computedStyle.paddingRight); 379 | } else { 380 | size -= 381 | parseFloat(computedStyle.paddingTop) + 382 | parseFloat(computedStyle.paddingBottom); 383 | } 384 | 385 | return size 386 | } 387 | 388 | // When specifying percentage sizes that are less than the computed 389 | // size of the element minus the gutter, the lesser percentages must be increased 390 | // (and decreased from the other elements) to make space for the pixels 391 | // subtracted by the gutters. 392 | function trimToMin(sizesToTrim) { 393 | // Try to get inner size of parent element. 394 | // If it's no supported, return original sizes. 395 | var parentSize = innerSize(parent); 396 | if (parentSize === null) { 397 | return sizesToTrim 398 | } 399 | 400 | if (minSizes.reduce(function (a, b) { return a + b; }, 0) > parentSize) { 401 | return sizesToTrim 402 | } 403 | 404 | // Keep track of the excess pixels, the amount of pixels over the desired percentage 405 | // Also keep track of the elements with pixels to spare, to decrease after if needed 406 | var excessPixels = 0; 407 | var toSpare = []; 408 | 409 | var pixelSizes = sizesToTrim.map(function (size, i) { 410 | // Convert requested percentages to pixel sizes 411 | var pixelSize = (parentSize * size) / 100; 412 | var elementGutterSize = getGutterSize( 413 | gutterSize, 414 | i === 0, 415 | i === sizesToTrim.length - 1, 416 | gutterAlign 417 | ); 418 | var elementMinSize = minSizes[i] + elementGutterSize; 419 | 420 | // If element is too smal, increase excess pixels by the difference 421 | // and mark that it has no pixels to spare 422 | if (pixelSize < elementMinSize) { 423 | excessPixels += elementMinSize - pixelSize; 424 | toSpare.push(0); 425 | return elementMinSize 426 | } 427 | 428 | // Otherwise, mark the pixels it has to spare and return it's original size 429 | toSpare.push(pixelSize - elementMinSize); 430 | return pixelSize 431 | }); 432 | 433 | // If nothing was adjusted, return the original sizes 434 | if (excessPixels === 0) { 435 | return sizesToTrim 436 | } 437 | 438 | return pixelSizes.map(function (pixelSize, i) { 439 | var newPixelSize = pixelSize; 440 | 441 | // While there's still pixels to take, and there's enough pixels to spare, 442 | // take as many as possible up to the total excess pixels 443 | if (excessPixels > 0 && toSpare[i] - excessPixels > 0) { 444 | var takenPixels = Math.min( 445 | excessPixels, 446 | toSpare[i] - excessPixels 447 | ); 448 | 449 | // Subtract the amount taken for the next iteration 450 | excessPixels -= takenPixels; 451 | newPixelSize = pixelSize - takenPixels; 452 | } 453 | 454 | // Return the pixel size adjusted as a percentage 455 | return (newPixelSize / parentSize) * 100 456 | }) 457 | } 458 | 459 | // stopDragging is very similar to startDragging in reverse. 460 | function stopDragging() { 461 | var self = this; 462 | var a = elements[self.a].element; 463 | var b = elements[self.b].element; 464 | 465 | if (self.dragging) { 466 | getOption(options, 'onDragEnd', NOOP)(getSizes()); 467 | } 468 | 469 | self.dragging = false; 470 | 471 | // Remove the stored event listeners. This is why we store them. 472 | global[removeEventListener]('mouseup', self.stop); 473 | global[removeEventListener]('touchend', self.stop); 474 | global[removeEventListener]('touchcancel', self.stop); 475 | global[removeEventListener]('mousemove', self.move); 476 | global[removeEventListener]('touchmove', self.move); 477 | 478 | // Clear bound function references 479 | self.stop = null; 480 | self.move = null; 481 | 482 | a[removeEventListener]('selectstart', NOOP); 483 | a[removeEventListener]('dragstart', NOOP); 484 | b[removeEventListener]('selectstart', NOOP); 485 | b[removeEventListener]('dragstart', NOOP); 486 | 487 | a.style.userSelect = ''; 488 | a.style.webkitUserSelect = ''; 489 | a.style.MozUserSelect = ''; 490 | a.style.pointerEvents = ''; 491 | 492 | b.style.userSelect = ''; 493 | b.style.webkitUserSelect = ''; 494 | b.style.MozUserSelect = ''; 495 | b.style.pointerEvents = ''; 496 | 497 | self.gutter.style.cursor = ''; 498 | self.parent.style.cursor = ''; 499 | document.body.style.cursor = ''; 500 | } 501 | 502 | // startDragging calls `calculateSizes` to store the inital size in the pair object. 503 | // It also adds event listeners for mouse/touch events, 504 | // and prevents selection while dragging so avoid the selecting text. 505 | function startDragging(e) { 506 | // Right-clicking can't start dragging. 507 | if ('button' in e && e.button !== 0) { 508 | return 509 | } 510 | 511 | // Alias frequently used variables to save space. 200 bytes. 512 | var self = this; 513 | var a = elements[self.a].element; 514 | var b = elements[self.b].element; 515 | 516 | // Call the onDragStart callback. 517 | if (!self.dragging) { 518 | getOption(options, 'onDragStart', NOOP)(getSizes()); 519 | } 520 | 521 | // Don't actually drag the element. We emulate that in the drag function. 522 | e.preventDefault(); 523 | 524 | // Set the dragging property of the pair object. 525 | self.dragging = true; 526 | 527 | // Create two event listeners bound to the same pair object and store 528 | // them in the pair object. 529 | self.move = drag.bind(self); 530 | self.stop = stopDragging.bind(self); 531 | 532 | // All the binding. `window` gets the stop events in case we drag out of the elements. 533 | global[addEventListener]('mouseup', self.stop); 534 | global[addEventListener]('touchend', self.stop); 535 | global[addEventListener]('touchcancel', self.stop); 536 | global[addEventListener]('mousemove', self.move); 537 | global[addEventListener]('touchmove', self.move); 538 | 539 | // Disable selection. Disable! 540 | a[addEventListener]('selectstart', NOOP); 541 | a[addEventListener]('dragstart', NOOP); 542 | b[addEventListener]('selectstart', NOOP); 543 | b[addEventListener]('dragstart', NOOP); 544 | 545 | a.style.userSelect = 'none'; 546 | a.style.webkitUserSelect = 'none'; 547 | a.style.MozUserSelect = 'none'; 548 | a.style.pointerEvents = 'none'; 549 | 550 | b.style.userSelect = 'none'; 551 | b.style.webkitUserSelect = 'none'; 552 | b.style.MozUserSelect = 'none'; 553 | b.style.pointerEvents = 'none'; 554 | 555 | // Set the cursor at multiple levels 556 | self.gutter.style.cursor = cursor; 557 | self.parent.style.cursor = cursor; 558 | document.body.style.cursor = cursor; 559 | 560 | // Cache the initial sizes of the pair. 561 | calculateSizes.call(self); 562 | 563 | // Determine the position of the mouse compared to the gutter 564 | self.dragOffset = getMousePosition(e) - self.end; 565 | } 566 | 567 | // adjust sizes to ensure percentage is within min size and gutter. 568 | sizes = trimToMin(sizes); 569 | 570 | // 5. Create pair and element objects. Each pair has an index reference to 571 | // elements `a` and `b` of the pair (first and second elements). 572 | // Loop through the elements while pairing them off. Every pair gets a 573 | // `pair` object and a gutter. 574 | // 575 | // Basic logic: 576 | // 577 | // - Starting with the second element `i > 0`, create `pair` objects with 578 | // `a = i - 1` and `b = i` 579 | // - Set gutter sizes based on the _pair_ being first/last. The first and last 580 | // pair have gutterSize / 2, since they only have one half gutter, and not two. 581 | // - Create gutter elements and add event listeners. 582 | // - Set the size of the elements, minus the gutter sizes. 583 | // 584 | // ----------------------------------------------------------------------- 585 | // | i=0 | i=1 | i=2 | i=3 | 586 | // | | | | | 587 | // | pair 0 pair 1 pair 2 | 588 | // | | | | | 589 | // ----------------------------------------------------------------------- 590 | var pairs = []; 591 | elements = ids.map(function (id, i) { 592 | // Create the element object. 593 | var element = { 594 | element: elementOrSelector(id), 595 | size: sizes[i], 596 | minSize: minSizes[i], 597 | i: i, 598 | }; 599 | 600 | var pair; 601 | 602 | if (i > 0) { 603 | // Create the pair object with its metadata. 604 | pair = { 605 | a: i - 1, 606 | b: i, 607 | dragging: false, 608 | direction: direction, 609 | parent: parent, 610 | }; 611 | 612 | pair[aGutterSize] = getGutterSize( 613 | gutterSize, 614 | i - 1 === 0, 615 | false, 616 | gutterAlign 617 | ); 618 | pair[bGutterSize] = getGutterSize( 619 | gutterSize, 620 | false, 621 | i === ids.length - 1, 622 | gutterAlign 623 | ); 624 | 625 | // if the parent has a reverse flex-direction, switch the pair elements. 626 | if ( 627 | parentFlexDirection === 'row-reverse' || 628 | parentFlexDirection === 'column-reverse' 629 | ) { 630 | var temp = pair.a; 631 | pair.a = pair.b; 632 | pair.b = temp; 633 | } 634 | } 635 | 636 | // Determine the size of the current element. IE8 is supported by 637 | // staticly assigning sizes without draggable gutters. Assigns a string 638 | // to `size`. 639 | // 640 | // IE9 and above 641 | if (!isIE8) { 642 | // Create gutter elements for each pair. 643 | if (i > 0) { 644 | var gutterElement = gutter(i, direction, element.element); 645 | setGutterSize(gutterElement, gutterSize, i); 646 | 647 | // Save bound event listener for removal later 648 | pair[gutterStartDragging] = startDragging.bind(pair); 649 | 650 | // Attach bound event listener 651 | gutterElement[addEventListener]( 652 | 'mousedown', 653 | pair[gutterStartDragging] 654 | ); 655 | gutterElement[addEventListener]( 656 | 'touchstart', 657 | pair[gutterStartDragging] 658 | ); 659 | 660 | parent.insertBefore(gutterElement, element.element); 661 | 662 | pair.gutter = gutterElement; 663 | } 664 | } 665 | 666 | setElementSize( 667 | element.element, 668 | element.size, 669 | getGutterSize( 670 | gutterSize, 671 | i === 0, 672 | i === ids.length - 1, 673 | gutterAlign 674 | ), 675 | i 676 | ); 677 | 678 | // After the first iteration, and we have a pair object, append it to the 679 | // list of pairs. 680 | if (i > 0) { 681 | pairs.push(pair); 682 | } 683 | 684 | return element 685 | }); 686 | 687 | function adjustToMin(element) { 688 | var isLast = element.i === pairs.length; 689 | var pair = isLast ? pairs[element.i - 1] : pairs[element.i]; 690 | 691 | calculateSizes.call(pair); 692 | 693 | var size = isLast 694 | ? pair.size - element.minSize - pair[bGutterSize] 695 | : element.minSize + pair[aGutterSize]; 696 | 697 | adjust.call(pair, size); 698 | } 699 | 700 | elements.forEach(function (element) { 701 | var computedSize = element.element[getBoundingClientRect]()[dimension]; 702 | 703 | if (computedSize < element.minSize) { 704 | if (expandToMin) { 705 | adjustToMin(element); 706 | } else { 707 | // eslint-disable-next-line no-param-reassign 708 | element.minSize = computedSize; 709 | } 710 | } 711 | }); 712 | 713 | function setSizes(newSizes) { 714 | var trimmed = trimToMin(newSizes); 715 | trimmed.forEach(function (newSize, i) { 716 | if (i > 0) { 717 | var pair = pairs[i - 1]; 718 | 719 | var a = elements[pair.a]; 720 | var b = elements[pair.b]; 721 | 722 | a.size = trimmed[i - 1]; 723 | b.size = newSize; 724 | 725 | setElementSize(a.element, a.size, pair[aGutterSize], a.i); 726 | setElementSize(b.element, b.size, pair[bGutterSize], b.i); 727 | } 728 | }); 729 | } 730 | 731 | function destroy(preserveStyles, preserveGutter) { 732 | pairs.forEach(function (pair) { 733 | if (preserveGutter !== true) { 734 | pair.parent.removeChild(pair.gutter); 735 | } else { 736 | pair.gutter[removeEventListener]( 737 | 'mousedown', 738 | pair[gutterStartDragging] 739 | ); 740 | pair.gutter[removeEventListener]( 741 | 'touchstart', 742 | pair[gutterStartDragging] 743 | ); 744 | } 745 | 746 | if (preserveStyles !== true) { 747 | var style = elementStyle( 748 | dimension, 749 | pair.a.size, 750 | pair[aGutterSize] 751 | ); 752 | 753 | Object.keys(style).forEach(function (prop) { 754 | elements[pair.a].element.style[prop] = ''; 755 | elements[pair.b].element.style[prop] = ''; 756 | }); 757 | } 758 | }); 759 | } 760 | 761 | if (isIE8) { 762 | return { 763 | setSizes: setSizes, 764 | destroy: destroy, 765 | } 766 | } 767 | 768 | return { 769 | setSizes: setSizes, 770 | getSizes: getSizes, 771 | collapse: function collapse(i) { 772 | adjustToMin(elements[i]); 773 | }, 774 | destroy: destroy, 775 | parent: parent, 776 | pairs: pairs, 777 | } 778 | }; 779 | 780 | return Split; 781 | 782 | }))); 783 | -------------------------------------------------------------------------------- /docs/assets/style.css: -------------------------------------------------------------------------------- 1 | .documentation { 2 | font-family: Helvetica, sans-serif; 3 | color: #666; 4 | line-height: 1.5; 5 | background: #f5f5f5; 6 | } 7 | 8 | .black { 9 | color: #666; 10 | } 11 | 12 | .bg-white { 13 | background-color: #fff; 14 | } 15 | 16 | h4 { 17 | margin: 20px 0 10px 0; 18 | } 19 | 20 | .documentation h3 { 21 | color: #000; 22 | } 23 | 24 | .border-bottom { 25 | border-color: #ddd; 26 | } 27 | 28 | a { 29 | color: #1184ce; 30 | text-decoration: none; 31 | } 32 | 33 | .documentation a[href]:hover { 34 | text-decoration: underline; 35 | } 36 | 37 | a:hover { 38 | cursor: pointer; 39 | } 40 | 41 | .py1-ul li { 42 | padding: 5px 0; 43 | } 44 | 45 | .max-height-100 { 46 | max-height: 100%; 47 | } 48 | 49 | .height-viewport-100 { 50 | height: 100vh; 51 | } 52 | 53 | section:target h3 { 54 | font-weight: 700; 55 | } 56 | 57 | .documentation td, 58 | .documentation th { 59 | padding: 0.25rem 0.25rem; 60 | } 61 | 62 | h1:hover .anchorjs-link, 63 | h2:hover .anchorjs-link, 64 | h3:hover .anchorjs-link, 65 | h4:hover .anchorjs-link { 66 | opacity: 1; 67 | } 68 | 69 | .fix-3 { 70 | width: 25%; 71 | max-width: 244px; 72 | } 73 | 74 | .fix-3 { 75 | width: 25%; 76 | max-width: 244px; 77 | } 78 | 79 | @media (min-width: 52em) { 80 | .fix-margin-3 { 81 | margin-left: 25%; 82 | } 83 | } 84 | 85 | .pre, 86 | pre, 87 | code, 88 | .code { 89 | font-family: Source Code Pro, Menlo, Consolas, Liberation Mono, monospace; 90 | font-size: 14px; 91 | } 92 | 93 | .fill-light { 94 | background: #f9f9f9; 95 | } 96 | 97 | .width2 { 98 | width: 1rem; 99 | } 100 | 101 | .input { 102 | font-family: inherit; 103 | display: block; 104 | width: 100%; 105 | height: 2rem; 106 | padding: 0.5rem; 107 | margin-bottom: 1rem; 108 | border: 1px solid #ccc; 109 | font-size: 0.875rem; 110 | border-radius: 3px; 111 | box-sizing: border-box; 112 | } 113 | 114 | table { 115 | border-collapse: collapse; 116 | } 117 | 118 | .prose table th, 119 | .prose table td { 120 | text-align: left; 121 | padding: 8px; 122 | border: 1px solid #ddd; 123 | } 124 | 125 | .prose table th:nth-child(1) { 126 | border-right: none; 127 | } 128 | .prose table th:nth-child(2) { 129 | border-left: none; 130 | } 131 | 132 | .prose table { 133 | border: 1px solid #ddd; 134 | } 135 | 136 | .prose-big { 137 | font-size: 18px; 138 | line-height: 30px; 139 | } 140 | 141 | .quiet { 142 | opacity: 0.7; 143 | } 144 | 145 | .minishadow { 146 | box-shadow: 2px 2px 10px #f3f3f3; 147 | } 148 | -------------------------------------------------------------------------------- /docs/images/device-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/images/device-data.png -------------------------------------------------------------------------------- /docs/images/proxy-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/images/proxy-config.png -------------------------------------------------------------------------------- /docs/images/proxy-toggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/images/proxy-toggle.png -------------------------------------------------------------------------------- /docs/images/record-toggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/images/record-toggle.png -------------------------------------------------------------------------------- /docs/images/wifi-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codetheweb/tuyapi/0f134079e19c6cb70126463761963e7d2bec6a7b/docs/images/wifi-config.png -------------------------------------------------------------------------------- /documentation.yml: -------------------------------------------------------------------------------- 1 | toc: 2 | - TuyaDevice 3 | - name: Events 4 | description: | 5 | Events that TuyAPI emits. 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'tuyapi' { 2 | import { EventEmitter } from 'events'; 3 | 4 | interface TuyaDeviceOptions { 5 | ip?: string; 6 | port?: number; 7 | id: string; 8 | gwID?: string; 9 | key: string; 10 | productKey?: string; 11 | version?: number|string; 12 | nullPayloadOnJSONError?: boolean; 13 | issueGetOnConnect?: boolean; 14 | issueRefreshOnConnect?: boolean; 15 | issueRefreshOnPing?: boolean; 16 | } 17 | 18 | type UnionTypes = Object|number|string|boolean; 19 | 20 | interface Object { 21 | [key: string]: Object|number|string|boolean|Array; 22 | } 23 | 24 | interface DPSObject { 25 | dps: Object; 26 | } 27 | 28 | interface GetOptions { 29 | schema?: boolean; 30 | dps?: number; 31 | cid?: string; 32 | } 33 | 34 | interface RefreshOptions extends GetOptions { 35 | requestedDPS?: Array; 36 | } 37 | 38 | interface SingleSetOptions { 39 | dps: number; 40 | set: string|number|boolean; 41 | cid?: string; 42 | multiple?: boolean; 43 | shouldWaitForResponse?: boolean; 44 | } 45 | interface MultipleSetOptions { 46 | multiple: boolean; 47 | data: Object; 48 | shouldWaitForResponse?: boolean; 49 | } 50 | 51 | interface FindOptions { 52 | timeout?: number; 53 | all?: boolean; 54 | } 55 | 56 | type EventDataFn = ( 57 | data: DPSObject, 58 | commandByte: number, 59 | sequenceN: number 60 | ) => void; 61 | 62 | interface Events { 63 | "connected": () => void; 64 | "heartbeat": () => void; 65 | "disconnected": () => void; 66 | "error": (error: Error) => void; 67 | "dp-refresh": EventDataFn; 68 | "data": EventDataFn; 69 | } 70 | 71 | export default class TuyaDevice extends EventEmitter { 72 | constructor(options: TuyaDeviceOptions); 73 | 74 | connect(): Promise; 75 | disconnect(): void; 76 | isConnected(): boolean; 77 | 78 | get(options: GetOptions): Promise; 79 | refresh(options: RefreshOptions): Promise; 80 | set(options: SingleSetOptions|MultipleSetOptions): Promise; 81 | toggle(property: number): Promise; 82 | find(options?: FindOptions): Promise>; 83 | 84 | on(event: K, listener: Events[K]): this; 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Import packages 2 | const dgram = require('dgram'); 3 | const net = require('net'); 4 | const {EventEmitter} = require('events'); 5 | const pTimeout = require('p-timeout'); 6 | const pRetry = require('p-retry'); 7 | const {default: PQueue} = require('p-queue'); 8 | const debug = require('debug')('TuyAPI'); 9 | 10 | // Helpers 11 | const {isValidString} = require('./lib/utils'); 12 | const {MessageParser, CommandType} = require('./lib/message-parser'); 13 | const {UDP_KEY} = require('./lib/config'); 14 | 15 | /** 16 | * Represents a Tuya device. 17 | * 18 | * You *must* pass either an IP or an ID. If 19 | * you're experiencing problems when only passing 20 | * one, try passing both if possible. 21 | * @class 22 | * @param {Object} options Options object 23 | * @param {String} [options.ip] IP of device 24 | * @param {Number} [options.port=6668] port of device 25 | * @param {String} [options.id] ID of device (also called `devId`) 26 | * @param {String} [options.gwID=''] gateway ID (not needed for most devices), 27 | * if omitted assumed to be the same as `options.id` 28 | * @param {String} options.key encryption key of device (also called `localKey`) 29 | * @param {String} [options.productKey] product key of device (currently unused) 30 | * @param {Number} [options.version=3.1] protocol version 31 | * @param {Boolean} [options.nullPayloadOnJSONError=false] if true, emits a data event 32 | * containing a payload of null values for on-device JSON parsing errors 33 | * @param {Boolean} [options.issueGetOnConnect=true] if true, sends GET request after 34 | * connection is established. This should probably be `false` in synchronous usage. 35 | * @param {Boolean} [options.issueRefreshOnConnect=false] if true, sends DP_REFRESH request after 36 | * connection is established. This should probably be `false` in synchronous usage. 37 | * @param {Boolean} [options.issueRefreshOnPing=false] if true, sends DP_REFRESH and GET request after 38 | * every ping. This should probably be `false` in synchronous usage. 39 | * @example 40 | * const tuya = new TuyaDevice({id: 'xxxxxxxxxxxxxxxxxxxx', 41 | * key: 'xxxxxxxxxxxxxxxx'}) 42 | */ 43 | class TuyaDevice extends EventEmitter { 44 | constructor({ 45 | ip, 46 | port = 6668, 47 | id, 48 | gwID = id, 49 | key, 50 | productKey, 51 | version = 3.1, 52 | nullPayloadOnJSONError = false, 53 | issueGetOnConnect = true, 54 | issueRefreshOnConnect = false, 55 | issueRefreshOnPing = false 56 | } = {}) { 57 | super(); 58 | 59 | // Set device to user-passed options 60 | version = version.toString(); 61 | this.device = {ip, port, id, gwID, key, productKey, version}; 62 | this.globalOptions = { 63 | issueGetOnConnect, 64 | issueRefreshOnConnect, 65 | issueRefreshOnPing 66 | }; 67 | 68 | this.nullPayloadOnJSONError = nullPayloadOnJSONError; 69 | 70 | // Check arguments 71 | if (!(isValidString(id) || 72 | isValidString(ip))) { 73 | throw new TypeError('ID and IP are missing from device.'); 74 | } 75 | 76 | // Check key 77 | if (!isValidString(this.device.key) || this.device.key.length !== 16) { 78 | throw new TypeError('Key is missing or incorrect.'); 79 | } 80 | 81 | // Handles encoding/decoding, encrypting/decrypting messages 82 | this.device.parser = new MessageParser({ 83 | key: this.device.key, 84 | version: this.device.version 85 | }); 86 | 87 | // Contains array of found devices when calling .find() 88 | this.foundDevices = []; 89 | 90 | // Private instance variables 91 | 92 | // Socket connected state 93 | this._connected = false; 94 | 95 | this._responseTimeout = 2; // Seconds 96 | this._connectTimeout = 5; // Seconds 97 | this._pingPongPeriod = 10; // Seconds 98 | this._pingPongTimeout = null; 99 | this._lastPingAt = new Date(); 100 | 101 | this._currentSequenceN = 0; 102 | this._resolvers = {}; 103 | this._setQueue = new PQueue({ 104 | concurrency: 1 105 | }); 106 | 107 | // List of dps which needed CommandType.DP_REFRESH (command 18) to force refresh their values. 108 | // Power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices. 109 | this._dpRefreshIds = [4, 5, 6, 18, 19, 20]; 110 | this._tmpLocalKey = null; 111 | this._tmpRemoteKey = null; 112 | this.sessionKey = null; 113 | } 114 | 115 | /** 116 | * Gets a device's current status. 117 | * Defaults to returning only the value of the first DPS index. 118 | * @param {Object} [options] Options object 119 | * @param {Boolean} [options.schema] 120 | * true to return entire list of properties from device 121 | * @param {Number} [options.dps=1] 122 | * DPS index to return 123 | * @param {String} [options.cid] 124 | * if specified, use device id of zigbee gateway and cid of subdevice to get its status 125 | * @example 126 | * // get first, default property from device 127 | * tuya.get().then(status => console.log(status)) 128 | * @example 129 | * // get second property from device 130 | * tuya.get({dps: 2}).then(status => console.log(status)) 131 | * @example 132 | * // get all available data from device 133 | * tuya.get({schema: true}).then(data => console.log(data)) 134 | * @returns {Promise} 135 | * returns boolean if single property is requested, otherwise returns object of results 136 | */ 137 | async get(options = {}) { 138 | const payload = { 139 | gwId: this.device.gwID, 140 | devId: this.device.id, 141 | t: Math.round(new Date().getTime() / 1000).toString(), 142 | dps: {}, 143 | uid: this.device.id 144 | }; 145 | 146 | if (options.cid) { 147 | payload.cid = options.cid; 148 | } 149 | 150 | const commandByte = this.device.version === '3.4' || this.device.version === '3.5' ? CommandType.DP_QUERY_NEW : CommandType.DP_QUERY; 151 | 152 | // Create byte buffer 153 | const buffer = this.device.parser.encode({ 154 | data: payload, 155 | commandByte, 156 | sequenceN: ++this._currentSequenceN 157 | }); 158 | 159 | let data; 160 | // Send request to read data - should work in most cases beside Protocol 3.2 161 | if (this.device.version !== '3.2') { 162 | debug('GET Payload:'); 163 | debug(payload); 164 | 165 | data = await this._send(buffer); 166 | } 167 | 168 | // If data read failed with defined error messages or device uses Protocol 3.2 we need to read differently 169 | if ( 170 | this.device.version === '3.2' || 171 | data === 'json obj data unvalid' || data === 'data format error' /* || data === 'devid not found' */ 172 | ) { 173 | // Some devices don't respond to DP_QUERY so, for DPS get commands, fall 174 | // back to using SEND with null value. This appears to always work as 175 | // long as the DPS key exist on the device. 176 | // For schema there's currently no fallback options 177 | debug('GET needs to use SEND instead of DP_QUERY to get data'); 178 | const setOptions = { 179 | dps: options.dps ? options.dps : 1, 180 | set: null, 181 | isSetCallToGetData: true 182 | }; 183 | data = await this.set(setOptions); 184 | } 185 | 186 | if (typeof data !== 'object' || options.schema === true) { 187 | // Return whole response 188 | return data; 189 | } 190 | 191 | if (options.dps) { 192 | // Return specific property 193 | return data.dps[options.dps]; 194 | } 195 | 196 | // Return first property by default 197 | return data.dps ? data.dps['1'] : undefined; 198 | } 199 | 200 | /** 201 | * Refresh a device's current status. 202 | * Defaults to returning all values. 203 | * @param {Object} [options] Options object 204 | * @param {Boolean} [options.schema] 205 | * true to return entire list of properties from device 206 | * @param {Number} [options.dps=1] 207 | * DPS index to return 208 | * @param {String} [options.cid] 209 | * if specified, use device id of zigbee gateway and cid of subdevice to refresh its status 210 | * @param {Array.Number} [options.requestedDPS=[4,5,6,18,19,20]] 211 | * only set this if you know what you're doing 212 | * @example 213 | * // get first, default property from device 214 | * tuya.refresh().then(status => console.log(status)) 215 | * @example 216 | * // get second property from device 217 | * tuya.refresh({dps: 2}).then(status => console.log(status)) 218 | * @example 219 | * // get all available data from device 220 | * tuya.refresh({schema: true}).then(data => console.log(data)) 221 | * @returns {Promise} 222 | * returns object of results 223 | */ 224 | refresh(options = {}) { 225 | const payload = { 226 | gwId: this.device.gwID, 227 | devId: this.device.id, 228 | t: Math.round(new Date().getTime() / 1000).toString(), 229 | dpId: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds, 230 | uid: this.device.id 231 | }; 232 | 233 | if (options.cid) { 234 | payload.cid = options.cid; 235 | } 236 | 237 | debug('GET Payload (refresh):'); 238 | debug(payload); 239 | 240 | const sequenceN = ++this._currentSequenceN; 241 | // Create byte buffer 242 | const buffer = this.device.parser.encode({ 243 | data: payload, 244 | commandByte: CommandType.DP_REFRESH, 245 | sequenceN 246 | }); 247 | 248 | // Send request and parse response 249 | return new Promise((resolve, reject) => { 250 | this._expectRefreshResponseForSequenceN = sequenceN; 251 | // Send request 252 | this._send(buffer).then(async data => { 253 | if (data === 'json obj data unvalid') { 254 | // Some devices don't respond to DP_QUERY so, for DPS get commands, fall 255 | // back to using SEND with null value. This appears to always work as 256 | // long as the DPS key exist on the device. 257 | // For schema there's currently no fallback options 258 | const setOptions = { 259 | dps: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds, 260 | set: null, 261 | isSetCallToGetData: true 262 | }; 263 | data = await this.set(setOptions); 264 | } 265 | 266 | if (typeof data !== 'object' || options.schema === true) { 267 | // Return whole response 268 | resolve(data); 269 | } else if (options.dps) { 270 | // Return specific property 271 | resolve(data.dps[options.dps]); 272 | } else { 273 | // Return all dps by default 274 | resolve(data.dps); 275 | } 276 | }) 277 | .catch(reject); 278 | }); 279 | } 280 | 281 | /** 282 | * Sets a property on a device. 283 | * @param {Object} options Options object 284 | * @param {Number} [options.dps=1] DPS index to set 285 | * @param {*} [options.set] value to set 286 | * @param {String} [options.cid] 287 | * if specified, use device id of zigbee gateway and cid of subdevice to set its property 288 | * @param {Boolean} [options.multiple=false] 289 | * Whether or not multiple properties should be set with options.data 290 | * @param {Boolean} [options.isSetCallToGetData=false] 291 | * Wether or not the set command is used to get data 292 | * @param {Object} [options.data={}] Multiple properties to set at once. See above. 293 | * @param {Boolean} [options.shouldWaitForResponse=true] see 294 | * [#420](https://github.com/codetheweb/tuyapi/issues/420) and 295 | * [#421](https://github.com/codetheweb/tuyapi/pull/421) for details 296 | * @example 297 | * // set default property 298 | * tuya.set({set: true}).then(() => console.log('device was turned on')) 299 | * @example 300 | * // set custom property 301 | * tuya.set({dps: 2, set: false}).then(() => console.log('device was turned off')) 302 | * @example 303 | * // set multiple properties 304 | * tuya.set({ 305 | * multiple: true, 306 | * data: { 307 | * '1': true, 308 | * '2': 'white' 309 | * }}).then(() => console.log('device was changed')) 310 | * @example 311 | * // set custom property for a specific (virtual) deviceId 312 | * tuya.set({ 313 | * dps: 2, 314 | * set: false, 315 | * devId: '04314116cc50e346566e' 316 | * }).then(() => console.log('device was turned off')) 317 | * @returns {Promise} - returns response from device 318 | */ 319 | set(options) { 320 | // Check arguments 321 | if (options === undefined || Object.entries(options).length === 0) { 322 | throw new TypeError('No arguments were passed.'); 323 | } 324 | 325 | // Defaults 326 | let dps; 327 | 328 | if (options.multiple === true) { 329 | dps = options.data; 330 | } else if (options.dps === undefined) { 331 | dps = { 332 | 1: options.set 333 | }; 334 | } else { 335 | dps = { 336 | [options.dps.toString()]: options.set 337 | }; 338 | } 339 | 340 | options.shouldWaitForResponse = typeof options.shouldWaitForResponse === 'undefined' ? true : options.shouldWaitForResponse; 341 | 342 | // When set has only null values then it is used to get data 343 | if (!options.isSetCallToGetData) { 344 | options.isSetCallToGetData = true; 345 | Object.keys(dps).forEach(key => { 346 | options.isSetCallToGetData = options.isSetCallToGetData && dps[key] === null; 347 | }); 348 | } 349 | 350 | // Get time 351 | const timeStamp = parseInt(Date.now() / 1000, 10); 352 | 353 | // Construct payload 354 | let payload = { 355 | t: timeStamp, 356 | dps 357 | }; 358 | 359 | if (options.cid) { 360 | payload.cid = options.cid; 361 | } else { 362 | payload = { 363 | devId: options.devId || this.device.id, 364 | gwId: this.device.gwID, 365 | uid: '', 366 | ...payload 367 | }; 368 | } 369 | 370 | if (this.device.version === '3.4' || this.device.version === '3.5') { 371 | /* 372 | { 373 | "data": { 374 | "cid": "xxxxxxxxxxxxxxxx", 375 | "ctype": 0, 376 | "dps": { 377 | "1": "manual" 378 | } 379 | }, 380 | "protocol": 5, 381 | "t": 1633243332 382 | } 383 | */ 384 | payload = { 385 | data: { 386 | ctype: 0, 387 | ...payload 388 | }, 389 | protocol: 5, 390 | t: timeStamp 391 | }; 392 | delete payload.data.t; 393 | } 394 | 395 | debug('SET Payload:'); 396 | debug(payload); 397 | 398 | const commandByte = this.device.version === '3.4' || this.device.version === '3.5' ? CommandType.CONTROL_NEW : CommandType.CONTROL; 399 | const sequenceN = ++this._currentSequenceN; 400 | // Encode into packet 401 | const buffer = this.device.parser.encode({ 402 | data: payload, 403 | encrypted: true, // Set commands must be encrypted 404 | commandByte, 405 | sequenceN 406 | }); 407 | 408 | // Make sure we only resolve or reject once 409 | let resolvedOrRejected = false; 410 | 411 | // Queue this request and limit concurrent set requests to one 412 | return this._setQueue.add(() => pTimeout(new Promise((resolve, reject) => { 413 | if (options.shouldWaitForResponse && this._setResolver) { 414 | throw new Error('A set command is already in progress. Can not issue a second one that also should return a response.'); 415 | } 416 | 417 | // Send request and wait for response 418 | try { 419 | if (this.device.version === '3.5') { 420 | this._currentSequenceN++; 421 | } 422 | 423 | // Send request 424 | this._send(buffer).catch(error => { 425 | if (options.shouldWaitForResponse && !resolvedOrRejected) { 426 | resolvedOrRejected = true; 427 | reject(error); 428 | } 429 | }); 430 | if (options.shouldWaitForResponse) { 431 | this._setResolver = data => { 432 | if (!resolvedOrRejected) { 433 | resolvedOrRejected = true; 434 | resolve(data); 435 | } 436 | }; 437 | 438 | this._setResolveAllowGet = options.isSetCallToGetData; 439 | } else { 440 | resolvedOrRejected = true; 441 | resolve(); 442 | } 443 | } catch (error) { 444 | resolvedOrRejected = true; 445 | reject(error); 446 | } 447 | }), this._responseTimeout * 2500, () => { 448 | // Only gets here on timeout so clear resolver function and emit error 449 | this._setResolver = undefined; 450 | this._setResolveAllowGet = undefined; 451 | delete this._resolvers[sequenceN]; 452 | this._expectRefreshResponseForSequenceN = undefined; 453 | 454 | this.emit( 455 | 'error', 456 | 'Timeout waiting for status response from device id: ' + this.device.id 457 | ); 458 | if (!resolvedOrRejected) { 459 | resolvedOrRejected = true; 460 | throw new Error('Timeout waiting for status response from device id: ' + this.device.id); 461 | } 462 | })); 463 | } 464 | 465 | /** 466 | * Sends a query to a device. Helper function 467 | * that connects to a device if necessary and 468 | * wraps the entire operation in a retry. 469 | * @private 470 | * @param {Buffer} buffer buffer of data 471 | * @returns {Promise} returned data for request 472 | */ 473 | _send(buffer) { 474 | const sequenceNo = this._currentSequenceN; 475 | // Retry up to 5 times 476 | return pRetry(() => { 477 | return new Promise((resolve, reject) => { 478 | // Send data 479 | this.connect().then(() => { 480 | try { 481 | this.client.write(buffer); 482 | 483 | // Add resolver function 484 | this._resolvers[sequenceNo] = data => resolve(data); 485 | } catch (error) { 486 | reject(error); 487 | } 488 | }) 489 | .catch(error => reject(error)); 490 | }); 491 | }, { 492 | onFailedAttempt: error => { 493 | debug(`Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`); 494 | }, retries: 5}); 495 | } 496 | 497 | /** 498 | * Sends a heartbeat ping to the device 499 | * @private 500 | */ 501 | async _sendPing() { 502 | debug(`Pinging ${this.device.ip}`); 503 | 504 | // Create byte buffer 505 | const buffer = this.device.parser.encode({ 506 | data: Buffer.allocUnsafe(0), 507 | commandByte: CommandType.HEART_BEAT, 508 | sequenceN: ++this._currentSequenceN 509 | }); 510 | 511 | // Check for response 512 | const now = new Date(); 513 | 514 | if (this._pingPongTimeout === null) { 515 | // If we do not expect a pong from a former ping, we need to set a timeout 516 | this._pingPongTimeout = setTimeout(() => { 517 | if (this._lastPingAt < now) { 518 | this.disconnect(); 519 | } 520 | }, this._responseTimeout * 1000); 521 | } else { 522 | debug('There was no response to the last ping.'); 523 | } 524 | 525 | // Send ping 526 | this.client.write(buffer); 527 | if (this.globalOptions.issueRefreshOnPing) { 528 | this.refresh().then(() => this.get()).catch(error => { 529 | debug('Error refreshing/getting on ping: ' + error); 530 | this.emit('error', error); 531 | }); 532 | } 533 | } 534 | 535 | /** 536 | * Create a deferred promise that resolves as soon as the connection is established. 537 | */ 538 | createDeferredConnectPromise() { 539 | let res; 540 | let rej; 541 | 542 | this.connectPromise = new Promise((resolve, reject) => { 543 | res = resolve; 544 | rej = reject; 545 | }); 546 | 547 | this.connectPromise.resolve = res; 548 | this.connectPromise.reject = rej; 549 | } 550 | 551 | /** 552 | * Finish connecting and resolve 553 | */ 554 | _finishConnect() { 555 | this._connected = true; 556 | 557 | /** 558 | * Emitted when socket is connected 559 | * to device. This event may be emitted 560 | * multiple times within the same script, 561 | * so don't use this as a trigger for your 562 | * initialization code. 563 | * @event TuyaDevice#connected 564 | */ 565 | this.emit('connected'); 566 | 567 | // Periodically send heartbeat ping 568 | this._pingPongInterval = setInterval(async () => { 569 | await this._sendPing(); 570 | }, this._pingPongPeriod * 1000); 571 | 572 | // Automatically ask for dp_refresh so we 573 | // can emit a `dp_refresh` event as soon as possible 574 | if (this.globalOptions.issueRefreshOnConnect) { 575 | this.refresh().catch(error => { 576 | debug('Error refreshing on connect: ' + error); 577 | this.emit('error', error); 578 | }); 579 | } 580 | 581 | // Automatically ask for current state so we 582 | // can emit a `data` event as soon as possible 583 | if (this.globalOptions.issueGetOnConnect) { 584 | this.get().catch(error => { 585 | debug('Error getting on connect: ' + error); 586 | this.emit('error', error); 587 | }); 588 | } 589 | 590 | // Resolve 591 | if (this.connectPromise) { 592 | this.connectPromise.resolve(true); 593 | delete this.connectPromise; 594 | } 595 | } 596 | 597 | /** 598 | * Connects to the device. Can be called even 599 | * if device is already connected. 600 | * @returns {Promise} `true` if connect succeeds 601 | * @emits TuyaDevice#connected 602 | * @emits TuyaDevice#disconnected 603 | * @emits TuyaDevice#data 604 | * @emits TuyaDevice#error 605 | */ 606 | connect() { 607 | if (this.isConnected()) { 608 | // Return if already connected 609 | return Promise.resolve(true); 610 | } 611 | 612 | if (this.connectPromise) { 613 | // If a connect approach still in progress simply return same Promise 614 | return this.connectPromise; 615 | } 616 | 617 | this.createDeferredConnectPromise(); 618 | 619 | this.client = new net.Socket(); 620 | 621 | // Default connect timeout is ~1 minute, 622 | // 5 seconds is a more reasonable default 623 | // since `retry` is used. 624 | this.client.setTimeout(this._connectTimeout * 1000, () => { 625 | /** 626 | * Emitted on socket error, usually a 627 | * result of a connection timeout. 628 | * Also emitted on parsing errors. 629 | * @event TuyaDevice#error 630 | * @property {Error} error error event 631 | */ 632 | // this.emit('error', new Error('connection timed out')); 633 | this.client.destroy(); 634 | this.emit('error', new Error('connection timed out')); 635 | if (this.connectPromise) { 636 | this.connectPromise.reject(new Error('connection timed out')); 637 | delete this.connectPromise; 638 | } 639 | }); 640 | 641 | // Add event listeners to socket 642 | 643 | // Parse response data 644 | this.client.on('data', data => { 645 | debug(`Received data: ${data.toString('hex')}`); 646 | 647 | let packets; 648 | 649 | try { 650 | packets = this.device.parser.parse(data); 651 | 652 | if (this.nullPayloadOnJSONError) { 653 | for (const packet of packets) { 654 | if (packet.payload && packet.payload === 'json obj data unvalid') { 655 | this.emit('error', packet.payload); 656 | 657 | packet.payload = { 658 | dps: { 659 | 1: null, 660 | 2: null, 661 | 3: null, 662 | 101: null, 663 | 102: null, 664 | 103: null 665 | } 666 | }; 667 | } 668 | } 669 | } 670 | } catch (error) { 671 | debug(error); 672 | this.emit('error', error); 673 | return; 674 | } 675 | 676 | packets.forEach(packet => { 677 | debug('Parsed:'); 678 | debug(packet); 679 | 680 | this._packetHandler.bind(this)(packet); 681 | }); 682 | }); 683 | 684 | // Handle errors 685 | this.client.on('error', err => { 686 | debug('Error event from socket.', this.device.ip, err); 687 | 688 | this.emit('error', new Error('Error from socket: ' + err.message)); 689 | 690 | if (!this._connected && this.connectPromise) { 691 | this.connectPromise.reject(err); 692 | delete this.connectPromise; 693 | } 694 | 695 | this.client.destroy(); 696 | }); 697 | 698 | // Handle socket closure 699 | this.client.on('close', () => { 700 | debug(`Socket closed: ${this.device.ip}`); 701 | 702 | this.disconnect(); 703 | }); 704 | 705 | this.client.on('connect', async () => { 706 | debug('Socket connected.'); 707 | 708 | // Remove connect timeout 709 | this.client.setTimeout(0); 710 | 711 | if (this.device.version === '3.4' || this.device.version === '3.5') { 712 | // Negotiate session key then emit 'connected' 713 | // 16 bytes random + 32 bytes hmac 714 | try { 715 | this._tmpLocalKey = this.device.parser.cipher.random(); 716 | const buffer = this.device.parser.encode({ 717 | data: this._tmpLocalKey, 718 | encrypted: true, 719 | commandByte: CommandType.SESS_KEY_NEG_START, 720 | sequenceN: ++this._currentSequenceN 721 | }); 722 | 723 | debug('Protocol 3.4, 3.5: Negotiate Session Key - Send Msg 0x03'); 724 | this.client.write(buffer); 725 | } catch (error) { 726 | debug('Error binding key for protocol 3.4, 3.5: ' + error); 727 | } 728 | 729 | return; 730 | } 731 | 732 | this._finishConnect(); 733 | }); 734 | 735 | debug(`Connecting to ${this.device.ip}...`); 736 | this.client.connect(this.device.port, this.device.ip); 737 | 738 | return this.connectPromise; 739 | } 740 | 741 | _packetHandler(packet) { 742 | // Protocol 3.4, 3.5 - Response to Msg 0x03 743 | if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) { 744 | if (!this.connectPromise) { 745 | debug('Protocol 3.4, 3.5: Ignore Key exchange message because no connection in progress.'); 746 | return; 747 | } 748 | 749 | // 16 bytes _tmpRemoteKey and hmac on _tmpLocalKey 750 | this._tmpRemoteKey = packet.payload.subarray(0, 16); 751 | debug('Protocol 3.4, 3.5: Local Random Key: ' + this._tmpLocalKey.toString('hex')); 752 | debug('Protocol 3.4, 3.5: Remote Random Key: ' + this._tmpRemoteKey.toString('hex')); 753 | 754 | if (this.device.version === '3.4' || this.device.version === '3.5') { 755 | this._currentSequenceN = packet.sequenceN - 1; 756 | } 757 | 758 | const calcLocalHmac = this.device.parser.cipher.hmac(this._tmpLocalKey).toString('hex'); 759 | const expLocalHmac = packet.payload.slice(16, 16 + 32).toString('hex'); 760 | if (expLocalHmac !== calcLocalHmac) { 761 | const err = new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${packet.payload.toString('hex')}`); 762 | if (this.connectPromise) { 763 | this.connectPromise.reject(err); 764 | delete this.connectPromise; 765 | } 766 | 767 | this.emit('error', err); 768 | return; 769 | } 770 | 771 | // Send response 0x05 772 | const buffer = this.device.parser.encode({ 773 | data: this.device.parser.cipher.hmac(this._tmpRemoteKey), 774 | encrypted: true, 775 | commandByte: CommandType.SESS_KEY_NEG_FINISH, 776 | sequenceN: ++this._currentSequenceN 777 | }); 778 | 779 | this.client.write(buffer); 780 | 781 | // Calculate session key 782 | this.sessionKey = Buffer.from(this._tmpLocalKey); 783 | for (let i = 0; i < this._tmpLocalKey.length; i++) { 784 | this.sessionKey[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i]; 785 | } 786 | 787 | if (this.device.version === '3.4') { 788 | this.sessionKey = this.device.parser.cipher._encrypt34({data: this.sessionKey}); 789 | } else if (this.device.version === '3.5') { 790 | this.sessionKey = this.device.parser.cipher._encrypt35({data: this.sessionKey, iv: this._tmpLocalKey}); 791 | } 792 | 793 | debug('Protocol 3.4, 3.5: Session Key: ' + this.sessionKey.toString('hex')); 794 | debug('Protocol 3.4, 3.5: Initialization done'); 795 | 796 | this.device.parser.cipher.setSessionKey(this.sessionKey); 797 | this.device.key = this.sessionKey; 798 | 799 | return this._finishConnect(); 800 | } 801 | 802 | if (packet.commandByte === CommandType.HEART_BEAT) { 803 | debug(`Pong from ${this.device.ip}`); 804 | /** 805 | * Emitted when a heartbeat ping is returned. 806 | * @event TuyaDevice#heartbeat 807 | */ 808 | this.emit('heartbeat'); 809 | 810 | clearTimeout(this._pingPongTimeout); 811 | this._pingPongTimeout = null; 812 | this._lastPingAt = new Date(); 813 | 814 | return; 815 | } 816 | 817 | if ( 818 | ( 819 | packet.commandByte === CommandType.CONTROL || 820 | packet.commandByte === CommandType.CONTROL_NEW 821 | ) && packet.payload === false) { 822 | debug('Got SET ack.'); 823 | return; 824 | } 825 | 826 | // Returned DP refresh response is always empty. Device respond with command 8 without dps 1 instead. 827 | if (packet.commandByte === CommandType.DP_REFRESH) { 828 | // If we did not get any STATUS packet, we need to resolve the promise. 829 | if (typeof this._setResolver === 'function') { 830 | debug('Received DP_REFRESH empty response packet without STATUS packet from set command - resolve'); 831 | this._setResolver(packet.payload); 832 | 833 | // Remove resolver 834 | this._setResolver = undefined; 835 | this._setResolveAllowGet = undefined; 836 | delete this._resolvers[packet.sequenceN]; 837 | this._expectRefreshResponseForSequenceN = undefined; 838 | } else if (packet.sequenceN in this._resolvers) { 839 | // Call data resolver for sequence number 840 | 841 | debug('Received DP_REFRESH response packet - resolve'); 842 | this._resolvers[packet.sequenceN](packet.payload); 843 | 844 | // Remove resolver 845 | delete this._resolvers[packet.sequenceN]; 846 | this._expectRefreshResponseForSequenceN = undefined; 847 | } else if (this._expectRefreshResponseForSequenceN && this._expectRefreshResponseForSequenceN in this._resolvers) { 848 | debug('Received DP_REFRESH response packet without data - resolve'); 849 | this._resolvers[this._expectRefreshResponseForSequenceN](packet.payload); 850 | 851 | // Remove resolver 852 | delete this._resolvers[this._expectRefreshResponseForSequenceN]; 853 | this._expectRefreshResponseForSequenceN = undefined; 854 | } else { 855 | debug('Received DP_REFRESH response packet - no resolver found for sequence number' + packet.sequenceN); 856 | } 857 | 858 | return; 859 | } 860 | 861 | if (packet.commandByte === CommandType.STATUS && packet.payload && packet.payload.dps && typeof packet.payload.dps[1] === 'undefined') { 862 | debug('Received DP_REFRESH packet.'); 863 | /** 864 | * Emitted when dp_refresh data is proactive returned from device, omitting dps 1 865 | * Only changed dps are returned. 866 | * @event TuyaDevice#dp-refresh 867 | * @property {Object} data received data 868 | * @property {Number} commandByte 869 | * commandByte of result( 8=proactive update from device) 870 | * @property {Number} sequenceN the packet sequence number 871 | */ 872 | this.emit('dp-refresh', packet.payload, packet.commandByte, packet.sequenceN); 873 | } else { 874 | debug('Received DATA packet'); 875 | debug('data: ' + packet.commandByte + ' : ' + (Buffer.isBuffer(packet.payload) ? packet.payload.toString('hex') : JSON.stringify(packet.payload))); 876 | /** 877 | * Emitted when data is returned from device. 878 | * @event TuyaDevice#data 879 | * @property {Object} data received data 880 | * @property {Number} commandByte 881 | * commandByte of result 882 | * (e.g. 7=requested response, 8=proactive update from device) 883 | * @property {Number} sequenceN the packet sequence number 884 | */ 885 | this.emit('data', packet.payload, packet.commandByte, packet.sequenceN); 886 | } 887 | 888 | // Status response to SET command 889 | if ( 890 | packet.commandByte === CommandType.STATUS && 891 | typeof this._setResolver === 'function' 892 | ) { 893 | this._setResolver(packet.payload); 894 | 895 | // Remove resolver 896 | this._setResolver = undefined; 897 | this._setResolveAllowGet = undefined; 898 | delete this._resolvers[packet.sequenceN]; 899 | this._expectRefreshResponseForSequenceN = undefined; 900 | return; 901 | } 902 | 903 | // Status response to SET command which was used to GET data and returns DP_QUERY response 904 | if ( 905 | packet.commandByte === CommandType.DP_QUERY && 906 | typeof this._setResolver === 'function' && 907 | this._setResolveAllowGet === true 908 | ) { 909 | this._setResolver(packet.payload); 910 | 911 | // Remove resolver 912 | this._setResolver = undefined; 913 | this._setResolveAllowGet = undefined; 914 | delete this._resolvers[packet.sequenceN]; 915 | this._expectRefreshResponseForSequenceN = undefined; 916 | return; 917 | } 918 | 919 | // Call data resolver for sequence number 920 | if (packet.sequenceN in this._resolvers) { 921 | this._resolvers[packet.sequenceN](packet.payload); 922 | 923 | // Remove resolver 924 | delete this._resolvers[packet.sequenceN]; 925 | this._expectRefreshResponseForSequenceN = undefined; 926 | } 927 | } 928 | 929 | /** 930 | * Disconnects from the device, use to 931 | * close the socket and exit gracefully. 932 | */ 933 | disconnect() { 934 | if (!this._connected) { 935 | return; 936 | } 937 | 938 | debug('Disconnect'); 939 | 940 | this._connected = false; 941 | this.device.parser.cipher.setSessionKey(null); 942 | 943 | // Clear timeouts 944 | clearInterval(this._pingPongInterval); 945 | clearTimeout(this._pingPongTimeout); 946 | 947 | if (this.client) { 948 | this.client.destroy(); 949 | } 950 | 951 | /** 952 | * Emitted when a socket is disconnected 953 | * from device. Not an exclusive event: 954 | * `error` and `disconnected` may be emitted 955 | * at the same time if, for example, the device 956 | * goes off the network. 957 | * @event TuyaDevice#disconnected 958 | */ 959 | this.emit('disconnected'); 960 | } 961 | 962 | /** 963 | * Returns current connection status to device. 964 | * @returns {Boolean} 965 | * (`true` if connected, `false` otherwise.) 966 | */ 967 | isConnected() { 968 | return this._connected; 969 | } 970 | 971 | /** 972 | * @deprecated since v3.0.0. Will be removed in v4.0.0. Use find() instead. 973 | * @param {Object} options Options object 974 | * @returns {Promise} Promise that resolves to `true` if device is found, `false` otherwise. 975 | */ 976 | resolveId(options) { 977 | console.warn('resolveId() is deprecated since v4.0.0. Will be removed in v5.0.0. Use find() instead.'); 978 | return this.find(options); 979 | } 980 | 981 | /** 982 | * Finds an ID or IP, depending on what's missing. 983 | * If you didn't pass an ID or IP to the constructor, 984 | * you must call this before anything else. 985 | * @param {Object} [options] Options object 986 | * @param {Boolean} [options.all] 987 | * true to return array of all found devices 988 | * @param {Number} [options.timeout=10] 989 | * how long, in seconds, to wait for device 990 | * to be resolved before timeout error is thrown 991 | * @example 992 | * tuya.find().then(() => console.log('ready!')) 993 | * @returns {Promise} 994 | * true if ID/IP was found and device is ready to be used 995 | */ 996 | find({timeout = 10, all = false} = {}) { 997 | if (isValidString(this.device.id) && 998 | isValidString(this.device.ip)) { 999 | // Don't need to do anything 1000 | debug('IP and ID are already both resolved.'); 1001 | return Promise.resolve(true); 1002 | } 1003 | 1004 | // Create new listeners 1005 | const listener = dgram.createSocket({type: 'udp4', reuseAddr: true}); 1006 | listener.bind(6666); 1007 | 1008 | const listenerEncrypted = dgram.createSocket({type: 'udp4', reuseAddr: true}); 1009 | listenerEncrypted.bind(6667); 1010 | 1011 | const broadcastHandler = (resolve, reject) => message => { 1012 | debug('Received UDP message.'); 1013 | 1014 | const parser = new MessageParser({key: UDP_KEY, version: this.device.version}); 1015 | 1016 | let dataRes; 1017 | try { 1018 | dataRes = parser.parse(message)[0]; 1019 | } catch (error) { 1020 | debug(error); 1021 | 1022 | const devParser = new MessageParser({key: this.device.key, version: this.device.version}); 1023 | try { 1024 | dataRes = devParser.parse(message)[0]; 1025 | } catch (devError) { 1026 | debug(devError); 1027 | reject(error); 1028 | return; 1029 | } 1030 | } 1031 | 1032 | debug('UDP data:'); 1033 | debug(dataRes); 1034 | 1035 | if (typeof dataRes.payload === 'string') { 1036 | debug('Received string payload. Ignoring.'); 1037 | return; 1038 | } 1039 | 1040 | const thisID = dataRes.payload.gwId; 1041 | const thisIP = dataRes.payload.ip; 1042 | 1043 | // Try auto determine power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices 1044 | const thisDPS = dataRes.payload.dps; 1045 | if (thisDPS && typeof thisDPS[19] === 'undefined') { 1046 | this._dpRefreshIds = [4, 5, 6]; 1047 | } else { 1048 | this._dpRefreshIds = [18, 19, 20]; 1049 | } 1050 | 1051 | // Add to array if it doesn't exist 1052 | if (!this.foundDevices.some(e => (e.id === thisID && e.ip === thisIP))) { 1053 | this.foundDevices.push({id: thisID, ip: thisIP}); 1054 | } 1055 | 1056 | if (!all && 1057 | (this.device.id === thisID || this.device.ip === thisIP) && 1058 | dataRes.payload) { 1059 | // Add IP 1060 | this.device.ip = dataRes.payload.ip; 1061 | 1062 | // Add ID and gwID 1063 | this.device.id = dataRes.payload.gwId; 1064 | this.device.gwID = dataRes.payload.gwId; 1065 | 1066 | // Change product key if necessary 1067 | this.device.productKey = dataRes.payload.productKey; 1068 | 1069 | // Change protocol version if necessary 1070 | if (this.device.version !== dataRes.payload.version) { 1071 | this.device.version = dataRes.payload.version; 1072 | 1073 | // Update the parser 1074 | this.device.parser = new MessageParser({ 1075 | key: this.device.key, 1076 | version: this.device.version 1077 | }); 1078 | } 1079 | 1080 | // Cleanup 1081 | listener.close(); 1082 | listener.removeAllListeners(); 1083 | listenerEncrypted.close(); 1084 | listenerEncrypted.removeAllListeners(); 1085 | resolve(true); 1086 | } 1087 | }; 1088 | 1089 | debug(`Finding missing IP ${this.device.ip} or ID ${this.device.id}`); 1090 | 1091 | // Find IP for device 1092 | return pTimeout(new Promise((resolve, reject) => { // Timeout 1093 | listener.on('message', broadcastHandler(resolve, reject)); 1094 | 1095 | listener.on('error', err => { 1096 | reject(err); 1097 | }); 1098 | 1099 | listenerEncrypted.on('message', broadcastHandler(resolve, reject)); 1100 | 1101 | listenerEncrypted.on('error', err => { 1102 | reject(err); 1103 | }); 1104 | }), timeout * 1000, () => { 1105 | // Have to do this so we exit cleanly 1106 | listener.close(); 1107 | listener.removeAllListeners(); 1108 | listenerEncrypted.close(); 1109 | listenerEncrypted.removeAllListeners(); 1110 | 1111 | // Return all devices 1112 | if (all) { 1113 | return this.foundDevices; 1114 | } 1115 | 1116 | // Otherwise throw error 1117 | throw new Error('find() timed out. Is the device powered on and the ID or IP correct?'); 1118 | }); 1119 | } 1120 | 1121 | /** 1122 | * Toggles a boolean property. 1123 | * @param {Number} [property=1] property to toggle 1124 | * @returns {Promise} the resulting state 1125 | */ 1126 | async toggle(property = '1') { 1127 | property = property.toString(); 1128 | 1129 | // Get status 1130 | const status = await this.get({dps: property}); 1131 | 1132 | // Set to opposite 1133 | await this.set({set: !status, dps: property}); 1134 | 1135 | // Return new status 1136 | return this.get({dps: property}); 1137 | } 1138 | } 1139 | 1140 | module.exports = TuyaDevice; 1141 | -------------------------------------------------------------------------------- /lib/cipher.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | /** 3 | * Low-level class for encrypting and decrypting payloads. 4 | * @class 5 | * @param {Object} options - Options for the cipher. 6 | * @param {String} options.key localKey of cipher 7 | * @param {Number} options.version protocol version 8 | * @example 9 | * const cipher = new TuyaCipher({key: 'xxxxxxxxxxxxxxxx', version: 3.1}) 10 | */ 11 | class TuyaCipher { 12 | constructor(options) { 13 | this.sessionKey = null; 14 | this.key = options.key; 15 | this.version = options.version.toString(); 16 | } 17 | 18 | /** 19 | * Sets the session key used for Protocol 3.4, 3.5 20 | * @param {Buffer} sessionKey Session key 21 | */ 22 | setSessionKey(sessionKey) { 23 | this.sessionKey = sessionKey; 24 | } 25 | 26 | /** 27 | * Encrypts data. 28 | * @param {Object} options Options for encryption 29 | * @param {String} options.data data to encrypt 30 | * @param {Boolean} [options.base64=true] `true` to return result in Base64 31 | * @example 32 | * TuyaCipher.encrypt({data: 'hello world'}) 33 | * @returns {Buffer|String} returns Buffer unless options.base64 is true 34 | */ 35 | encrypt(options) { 36 | if (this.version === '3.4') { 37 | return this._encrypt34(options); 38 | } 39 | 40 | if (this.version === '3.5') { 41 | return this._encrypt35(options); 42 | } 43 | 44 | return this._encryptPre34(options); 45 | } 46 | 47 | /** 48 | * Encrypt data for protocol 3.3 and before 49 | * @param {Object} options Options for encryption 50 | * @param {String} options.data data to encrypt 51 | * @param {Boolean} [options.base64=true] `true` to return result in Base64 52 | * @returns {Buffer|String} returns Buffer unless options.base64 is true 53 | */ 54 | _encryptPre34(options) { 55 | const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), ''); 56 | 57 | let encrypted = cipher.update(options.data, 'utf8', 'base64'); 58 | encrypted += cipher.final('base64'); 59 | 60 | // Default base64 enable 61 | if (options.base64 === false) { 62 | return Buffer.from(encrypted, 'base64'); 63 | } 64 | 65 | return encrypted; 66 | } 67 | 68 | /** 69 | * Encrypt data for protocol 3.4 70 | * @param {Object} options Options for encryption 71 | * @param {String} options.data data to encrypt 72 | * @param {Boolean} [options.base64=true] `true` to return result in Base64 73 | * @returns {Buffer|String} returns Buffer unless options.base64 is true 74 | */ 75 | _encrypt34(options) { 76 | const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), null); 77 | cipher.setAutoPadding(false); 78 | const encrypted = cipher.update(options.data); 79 | cipher.final(); 80 | 81 | // Default base64 enable TODO: check if this is needed? 82 | // if (options.base64 === false) { 83 | // return Buffer.from(encrypted, 'base64'); 84 | // } 85 | 86 | return encrypted; 87 | } 88 | 89 | /** 90 | * Encrypt data for protocol 3.5 91 | * @param {Object} options Options for encryption 92 | * @param {String} options.data data to encrypt 93 | * @param {Boolean} [options.base64=true] `true` to return result in Base64 94 | * @returns {Buffer|String} returns Buffer unless options.base64 is true 95 | */ 96 | _encrypt35(options) { 97 | let encrypted; 98 | let localIV = Buffer.from((Date.now() * 10).toString().slice(0, 12)); 99 | if (options.iv !== undefined) { 100 | localIV = options.iv.slice(0, 12); 101 | } 102 | 103 | const cipher = crypto.createCipheriv('aes-128-gcm', this.getKey(), localIV); 104 | if (options.aad === undefined) { 105 | encrypted = Buffer.concat([cipher.update(options.data), cipher.final()]); 106 | } else { 107 | cipher.setAAD(options.aad); 108 | encrypted = Buffer.concat([localIV, cipher.update(options.data), cipher.final(), cipher.getAuthTag(), Buffer.from([0x00, 0x00, 0x99, 0x66])]); 109 | } 110 | 111 | return encrypted; 112 | } 113 | 114 | /** 115 | * Decrypts data. 116 | * @param {String|Buffer} data to decrypt 117 | * @param {String} [version] protocol version 118 | * @returns {Object|String} 119 | * returns object if data is JSON, else returns string 120 | */ 121 | decrypt(data, version) { 122 | version = version || this.version; 123 | if (version === '3.4') { 124 | return this._decrypt34(data); 125 | } 126 | 127 | if (version === '3.5') { 128 | return this._decrypt35(data); 129 | } 130 | 131 | return this._decryptPre34(data); 132 | } 133 | 134 | /** 135 | * Decrypts data for protocol 3.3 and before 136 | * @param {String|Buffer} data to decrypt 137 | * @returns {Object|String} 138 | * returns object if data is JSON, else returns string 139 | */ 140 | _decryptPre34(data) { 141 | // Incoming data format 142 | let format = 'buffer'; 143 | 144 | if (data.indexOf(this.version) === 0) { 145 | if (this.version === '3.3' || this.version === '3.2') { 146 | // Remove 3.3/3.2 header 147 | data = data.slice(15); 148 | } else { 149 | // Data has version number and is encoded in base64 150 | 151 | // Remove prefix of version number and MD5 hash 152 | data = data.slice(19).toString(); 153 | // Decode incoming data as base64 154 | format = 'base64'; 155 | } 156 | } 157 | 158 | // Decrypt data 159 | let result; 160 | try { 161 | const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), ''); 162 | result = decipher.update(data, format, 'utf8'); 163 | result += decipher.final('utf8'); 164 | } catch (_) { 165 | throw new Error('Decrypt failed'); 166 | } 167 | 168 | // Try to parse data as JSON, 169 | // otherwise return as string. 170 | try { 171 | return JSON.parse(result); 172 | } catch (_) { 173 | return result; 174 | } 175 | } 176 | 177 | /** 178 | * Decrypts data for protocol 3.4 179 | * @param {String|Buffer} data to decrypt 180 | * @returns {Object|String} 181 | * returns object if data is JSON, else returns string 182 | */ 183 | _decrypt34(data) { 184 | let result; 185 | try { 186 | const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), null); 187 | decipher.setAutoPadding(false); 188 | result = decipher.update(data); 189 | decipher.final(); 190 | // Remove padding 191 | result = result.slice(0, (result.length - result[result.length - 1])); 192 | } catch (_) { 193 | throw new Error('Decrypt failed'); 194 | } 195 | 196 | // Try to parse data as JSON, 197 | // otherwise return as string. 198 | // 3.4 protocol 199 | // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}} 200 | try { 201 | if (result.indexOf(this.version) === 0) { 202 | result = result.slice(15); 203 | } 204 | 205 | const res = JSON.parse(result); 206 | if ('data' in res) { 207 | const resData = res.data; 208 | resData.t = res.t; 209 | return resData; // Or res.data // for compatibility with tuya-mqtt 210 | } 211 | 212 | return res; 213 | } catch (_) { 214 | return result; 215 | } 216 | } 217 | 218 | /** 219 | * Decrypts data for protocol 3.5 220 | * @param {String|Buffer} data to decrypt 221 | * @returns {Object|String} 222 | * returns object if data is JSON, else returns string 223 | */ 224 | _decrypt35(data) { 225 | let result; 226 | const header = data.slice(0, 14); 227 | const iv = data.slice(14, 26); 228 | const tag = data.slice(data.length - 16); 229 | data = data.slice(26, data.length - 16); 230 | 231 | try { 232 | const decipher = crypto.createDecipheriv('aes-128-gcm', this.getKey(), iv); 233 | decipher.setAuthTag(tag); 234 | decipher.setAAD(header); 235 | 236 | result = Buffer.concat([decipher.update(data), decipher.final()]); 237 | result = result.slice(4); // Remove 32bit return code 238 | } catch (_) { 239 | throw new Error('Decrypt failed'); 240 | } 241 | 242 | // Try to parse data as JSON, otherwise return as string. 243 | // 3.5 protocol 244 | // {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}} 245 | try { 246 | if (result.indexOf(this.version) === 0) { 247 | result = result.slice(15); 248 | } 249 | 250 | const res = JSON.parse(result); 251 | if ('data' in res) { 252 | const resData = res.data; 253 | resData.t = res.t; 254 | return resData; // Or res.data // for compatibility with tuya-mqtt 255 | } 256 | 257 | return res; 258 | } catch (_) { 259 | return result; 260 | } 261 | } 262 | 263 | /** 264 | * Calculates a MD5 hash. 265 | * @param {String} data to hash 266 | * @returns {String} characters 8 through 16 of hash of data 267 | */ 268 | md5(data) { 269 | const md5hash = crypto.createHash('md5').update(data, 'utf8').digest('hex'); 270 | return md5hash.slice(8, 24); 271 | } 272 | 273 | /** 274 | * Gets the key used for encryption/decryption 275 | * @returns {String} sessionKey (if set for protocol 3.4, 3.5) or key 276 | */ 277 | getKey() { 278 | return this.sessionKey === null ? this.key : this.sessionKey; 279 | } 280 | 281 | /** 282 | * Returns the HMAC for the current key (sessionKey if set for protocol 3.4, 3.5 or key) 283 | * @param {Buffer} data data to hash 284 | * @returns {Buffer} HMAC 285 | */ 286 | hmac(data) { 287 | return crypto.createHmac('sha256', this.getKey()).update(data, 'utf8').digest(); // .digest('hex'); 288 | } 289 | 290 | /** 291 | * Returns 16 random bytes 292 | * @returns {Buffer} Random bytes 293 | */ 294 | random() { 295 | return crypto.randomBytes(16); 296 | } 297 | } 298 | module.exports = TuyaCipher; 299 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | const UDP_KEY_STRING = 'yGAdlopoPVldABfn'; 4 | 5 | const UDP_KEY = crypto.createHash('md5').update(UDP_KEY_STRING, 'utf8').digest(); 6 | 7 | module.exports = {UDP_KEY}; 8 | -------------------------------------------------------------------------------- /lib/crc.js: -------------------------------------------------------------------------------- 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 The bytes to compute the CRC32 for 74 | * @returns {Number} Tuya CRC32 75 | */ 76 | function crc32(bytes) { 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 | module.exports = crc32; 87 | -------------------------------------------------------------------------------- /lib/message-parser.js: -------------------------------------------------------------------------------- 1 | const Cipher = require('./cipher'); 2 | const crc = require('./crc'); 3 | 4 | const HEADER_SIZE = 16; 5 | const HEADER_SIZE_3_5 = 4; 6 | 7 | /** 8 | * Human-readable definitions 9 | * of command bytes. 10 | * See also https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h 11 | * @readonly 12 | * @private 13 | */ 14 | const CommandType = { 15 | UDP: 0, 16 | AP_CONFIG: 1, 17 | ACTIVE: 2, 18 | BIND: 3, // ?? Leave in for backward compatibility 19 | SESS_KEY_NEG_START: 3, // Negotiate session key 20 | RENAME_GW: 4, // ?? Leave in for backward compatibility 21 | SESS_KEY_NEG_RES: 4, // Negotiate session key response 22 | RENAME_DEVICE: 5, // ?? Leave in for backward compatibility 23 | SESS_KEY_NEG_FINISH: 5, // Finalize session key negotiation 24 | UNBIND: 6, 25 | CONTROL: 7, 26 | STATUS: 8, 27 | HEART_BEAT: 9, 28 | DP_QUERY: 10, 29 | QUERY_WIFI: 11, 30 | TOKEN_BIND: 12, 31 | CONTROL_NEW: 13, 32 | ENABLE_WIFI: 14, 33 | DP_QUERY_NEW: 16, 34 | SCENE_EXECUTE: 17, 35 | DP_REFRESH: 18, // Request refresh of DPS UPDATEDPS / LAN_QUERY_DP 36 | UDP_NEW: 19, 37 | AP_CONFIG_NEW: 20, 38 | BOARDCAST_LPV34: 35, 39 | LAN_EXT_STREAM: 40, 40 | LAN_GW_ACTIVE: 240, 41 | LAN_SUB_DEV_REQUEST: 241, 42 | LAN_DELETE_SUB_DEV: 242, 43 | LAN_REPORT_SUB_DEV: 243, 44 | LAN_SCENE: 244, 45 | LAN_PUBLISH_CLOUD_CONFIG: 245, 46 | LAN_PUBLISH_APP_CONFIG: 246, 47 | LAN_EXPORT_APP_CONFIG: 247, 48 | LAN_PUBLISH_SCENE_PANEL: 248, 49 | LAN_REMOVE_GW: 249, 50 | LAN_CHECK_GW_UPDATE: 250, 51 | LAN_GW_UPDATE: 251, 52 | LAN_SET_GW_CHANNEL: 252 53 | }; 54 | 55 | /** 56 | * A complete packet. 57 | * @typedef {Object} Packet 58 | * @property {Buffer|Object|String} payload 59 | * Buffer if hasn't been decoded, object or 60 | * string if it has been 61 | * @property {Buffer} leftover 62 | * bytes adjacent to the parsed packet 63 | * @property {Number} commandByte 64 | * @property {Number} sequenceN 65 | */ 66 | 67 | /** 68 | * Low-level class for parsing packets. 69 | * @class 70 | * @param {Object} options Options 71 | * @param {String} options.key localKey of cipher 72 | * @param {Number} [options.version=3.1] protocol version 73 | * @example 74 | * const parser = new MessageParser({key: 'xxxxxxxxxxxxxxxx', version: 3.1}) 75 | */ 76 | class MessageParser { 77 | constructor({key, version = 3.1} = {}) { 78 | // Ensure the version is a string 79 | version = version.toString(); 80 | this.version = version; 81 | 82 | if (key) { 83 | if (key.length !== 16) { 84 | throw new TypeError('Incorrect key format'); 85 | } 86 | 87 | // Create a Cipher if we have a valid key 88 | this.cipher = new Cipher({key, version}); 89 | 90 | this.key = key; 91 | } 92 | } 93 | 94 | /** 95 | * Parses a Buffer of data containing at least 96 | * one complete packet at the beginning of the buffer. 97 | * Will return multiple packets if necessary. 98 | * @param {Buffer} buffer of data to parse 99 | * @returns {Packet} packet of data 100 | */ 101 | parsePacket(buffer) { 102 | // Check for length 103 | // At minimum requires: prefix (4), sequence (4), command (4), length (4), 104 | // CRC (4), and suffix (4) for 24 total bytes 105 | // Messages from the device also include return code (4), for 28 total bytes 106 | if (buffer.length < 24) { 107 | throw new TypeError(`Packet too short. Length: ${buffer.length}.`); 108 | } 109 | 110 | // Check for prefix 111 | const prefix = buffer.readUInt32BE(0); 112 | 113 | // Only for 3.4 and 3.5 packets 114 | if (prefix !== 0x000055AA && prefix !== 0x00006699) { 115 | throw new TypeError(`Prefix does not match: ${buffer.toString('hex')}`); 116 | } 117 | 118 | // Check for extra data 119 | let leftover = false; 120 | 121 | let suffixLocation = buffer.indexOf('0000AA55', 0, 'hex'); 122 | if (suffixLocation === -1) {// Couldn't find 0000AA55 during parse 123 | suffixLocation = buffer.indexOf('00009966', 0, 'hex'); 124 | } 125 | 126 | if (suffixLocation !== buffer.length - 4) { 127 | leftover = buffer.slice(suffixLocation + 4); 128 | buffer = buffer.slice(0, suffixLocation + 4); 129 | } 130 | 131 | // Check for suffix 132 | const suffix = buffer.readUInt32BE(buffer.length - 4); 133 | 134 | if (suffix !== 0x0000AA55 && suffix !== 0x00009966) { 135 | throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`); 136 | } 137 | 138 | let sequenceN; 139 | let commandByte; 140 | let payloadSize; 141 | let overwriteVersion; 142 | 143 | if (suffix === 0x0000AA55) { 144 | // Get sequence number 145 | sequenceN = buffer.readUInt32BE(4); 146 | 147 | // Get command byte 148 | commandByte = buffer.readUInt32BE(8); 149 | 150 | // Get payload size 151 | payloadSize = buffer.readUInt32BE(12); 152 | 153 | // Check for payload 154 | if (buffer.length - 8 < payloadSize) { 155 | throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`); 156 | } 157 | } else if (suffix === 0x00009966) { 158 | // When this suffix comes in we should have 3.5 version 159 | overwriteVersion = '3.5'; 160 | 161 | // Get sequence number 162 | sequenceN = buffer.readUInt32BE(6); 163 | 164 | // Get command byte 165 | commandByte = buffer.readUInt32BE(10); 166 | 167 | // Get payload size 168 | payloadSize = buffer.readUInt32BE(14) + 14; // Add additional bytes for extras 169 | 170 | // Check for payload 171 | if (buffer.length - 8 < payloadSize) { 172 | throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`); 173 | } 174 | } else { 175 | throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`); // Should never happen 176 | } 177 | 178 | const packageFromDiscovery = ( 179 | commandByte === CommandType.UDP || 180 | commandByte === CommandType.UDP_NEW || 181 | commandByte === CommandType.BOARDCAST_LPV34 182 | ); 183 | 184 | // Get the return code, 0 = success 185 | // This field is only present in messages from the devices 186 | // Absent in messages sent to device 187 | const returnCode = buffer.readUInt32BE(16); 188 | 189 | // Get the payload 190 | // Adjust for messages lacking a return code 191 | let payload; 192 | if (overwriteVersion === '3.5' || this.version === '3.5') { 193 | payload = buffer.slice(HEADER_SIZE_3_5, HEADER_SIZE_3_5 + payloadSize); 194 | sequenceN = buffer.slice(6, 10).readUInt32BE(); 195 | commandByte = buffer.slice(10, 14).readUInt32BE(); 196 | } else { 197 | if (returnCode & 0xFFFFFF00) { 198 | if (this.version === '3.4' && !packageFromDiscovery) { 199 | payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24); 200 | } else if (this.version === '3.5' && !packageFromDiscovery) { 201 | payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24); 202 | } else { 203 | payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8); 204 | } 205 | } else if (this.version === '3.4' && !packageFromDiscovery) { 206 | payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24); 207 | } else if (this.version === '3.5' && !packageFromDiscovery) { 208 | payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24); 209 | } else { 210 | payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8); 211 | } 212 | 213 | // Check CRC 214 | if (this.version === '3.4' && !packageFromDiscovery) { 215 | const expectedCrc = buffer.slice(HEADER_SIZE + payloadSize - 0x24, buffer.length - 4).toString('hex'); 216 | const computedCrc = this.cipher.hmac(buffer.slice(0, HEADER_SIZE + payloadSize - 0x24)).toString('hex'); 217 | 218 | if (expectedCrc !== computedCrc) { 219 | throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`); 220 | } 221 | } else if (this.version !== '3.5') { 222 | const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8); 223 | const computedCrc = crc(buffer.slice(0, payloadSize + 8)); 224 | 225 | if (expectedCrc !== computedCrc) { 226 | throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`); 227 | } 228 | } 229 | } 230 | 231 | return {payload, leftover, commandByte, sequenceN, version: overwriteVersion || this.version}; 232 | } 233 | 234 | /** 235 | * Attempts to decode a given payload into 236 | * an object or string. 237 | * @param {Buffer} data to decode 238 | * @param {String} version of protocol 239 | * @returns {Object|String} 240 | * object if payload is JSON, otherwise string 241 | */ 242 | getPayload(data, version) { 243 | if (data.length === 0) { 244 | return false; 245 | } 246 | 247 | // Try to decrypt data first. 248 | try { 249 | if (!this.cipher) { 250 | throw new Error('Missing key or version in constructor.'); 251 | } 252 | 253 | data = this.cipher.decrypt(data, version); 254 | } catch (_) { 255 | data = data.toString('utf8'); 256 | } 257 | 258 | // Incoming 3.5 data isn't 0 because of iv and tag so check size after 259 | if (version === '3.5') { 260 | if (data.length === 0) { 261 | return false; 262 | } 263 | } 264 | 265 | // Try to parse data as JSON. 266 | // If error, return as string. 267 | if (typeof data === 'string') { 268 | try { 269 | data = JSON.parse(data); 270 | } catch (_) { } 271 | } 272 | 273 | return data; 274 | } 275 | 276 | /** 277 | * Recursive function to parse 278 | * a series of packets. Prefer using 279 | * the parse() wrapper over using this 280 | * directly. 281 | * @private 282 | * @param {Buffer} buffer to parse 283 | * @param {Array} packets that have been parsed 284 | * @returns {Array.} array of parsed packets 285 | */ 286 | parseRecursive(buffer, packets) { 287 | const result = this.parsePacket(buffer); 288 | 289 | result.payload = this.getPayload(result.payload, result.version); 290 | 291 | packets.push(result); 292 | 293 | if (result.leftover) { 294 | return this.parseRecursive(result.leftover, packets); 295 | } 296 | 297 | return packets; 298 | } 299 | 300 | /** 301 | * Given a buffer potentially containing 302 | * multiple packets, this parses and returns 303 | * all of them. 304 | * @param {Buffer} buffer to parse 305 | * @returns {Array.} parsed packets 306 | */ 307 | parse(buffer) { 308 | return this.parseRecursive(buffer, []); 309 | } 310 | 311 | /** 312 | * Encodes a payload into a Tuya-protocol-compliant packet. 313 | * @param {Object} options Options for encoding 314 | * @param {Buffer|String|Object} options.data data to encode 315 | * @param {Boolean} options.encrypted whether or not to encrypt the data 316 | * @param {Number} options.commandByte 317 | * command byte of packet (use CommandType definitions) 318 | * @param {Number} [options.sequenceN] optional, sequence number 319 | * @returns {Buffer} Encoded Buffer 320 | */ 321 | encode(options) { 322 | // Check command byte 323 | if (!Object.values(CommandType).includes(options.commandByte)) { 324 | throw new TypeError('Command byte not defined.'); 325 | } 326 | 327 | // Convert Objects to Strings, Strings to Buffers 328 | if (!(options.data instanceof Buffer)) { 329 | if (typeof options.data !== 'string') { 330 | options.data = JSON.stringify(options.data); 331 | } 332 | 333 | options.data = Buffer.from(options.data); 334 | } 335 | 336 | if (this.version === '3.4') { 337 | return this._encode34(options); 338 | } 339 | 340 | if (this.version === '3.5') { 341 | return this._encode35(options); 342 | } 343 | 344 | return this._encodePre34(options); 345 | } 346 | 347 | /** 348 | * Encodes a payload into a Tuya-protocol-compliant packet for protocol version 3.3 and below. 349 | * @param {Object} options Options for encoding 350 | * @param {Buffer|String|Object} options.data data to encode 351 | * @param {Boolean} options.encrypted whether or not to encrypt the data 352 | * @param {Number} options.commandByte 353 | * command byte of packet (use CommandType definitions) 354 | * @param {Number} [options.sequenceN] optional, sequence number 355 | * @returns {Buffer} Encoded Buffer 356 | */ 357 | _encodePre34(options) { 358 | // Construct payload 359 | let payload = options.data; 360 | 361 | // Protocol 3.3 and 3.2 is always encrypted 362 | if (this.version === '3.3' || this.version === '3.2') { 363 | // Encrypt data 364 | payload = this.cipher.encrypt({ 365 | data: payload, 366 | base64: false 367 | }); 368 | 369 | // Check if we need an extended header, only for certain CommandTypes 370 | if (options.commandByte !== CommandType.DP_QUERY && 371 | options.commandByte !== CommandType.DP_REFRESH) { 372 | // Add 3.3 header 373 | const buffer = Buffer.alloc(payload.length + 15); 374 | Buffer.from('3.3').copy(buffer, 0); 375 | payload.copy(buffer, 15); 376 | payload = buffer; 377 | } 378 | } else if (options.encrypted) { 379 | // Protocol 3.1 and below, only encrypt data if necessary 380 | payload = this.cipher.encrypt({ 381 | data: payload 382 | }); 383 | 384 | // Create MD5 signature 385 | const md5 = this.cipher.md5('data=' + payload + 386 | '||lpv=' + this.version + 387 | '||' + this.key); 388 | 389 | // Create byte buffer from hex data 390 | payload = Buffer.from(this.version + md5 + payload); 391 | } 392 | 393 | // Allocate buffer with room for payload + 24 bytes for 394 | // prefix, sequence, command, length, crc, and suffix 395 | const buffer = Buffer.alloc(payload.length + 24); 396 | 397 | // Add prefix, command, and length 398 | buffer.writeUInt32BE(0x000055AA, 0); 399 | buffer.writeUInt32BE(options.commandByte, 8); 400 | buffer.writeUInt32BE(payload.length + 8, 12); 401 | 402 | if (options.sequenceN) { 403 | buffer.writeUInt32BE(options.sequenceN, 4); 404 | } 405 | 406 | // Add payload, crc, and suffix 407 | payload.copy(buffer, 16); 408 | const calculatedCrc = crc(buffer.slice(0, payload.length + 16)) & 0xFFFFFFFF; 409 | 410 | buffer.writeInt32BE(calculatedCrc, payload.length + 16); 411 | buffer.writeUInt32BE(0x0000AA55, payload.length + 20); 412 | 413 | return buffer; 414 | } 415 | 416 | /** 417 | * Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.4 418 | * @param {Object} options Options for encoding 419 | * @param {Buffer|String|Object} options.data data to encode 420 | * @param {Boolean} options.encrypted whether or not to encrypt the data 421 | * @param {Number} options.commandByte 422 | * command byte of packet (use CommandType definitions) 423 | * @param {Number} [options.sequenceN] optional, sequence number 424 | * @returns {Buffer} Encoded Buffer 425 | */ 426 | _encode34(options) { 427 | let payload = options.data; 428 | 429 | if (options.commandByte !== CommandType.DP_QUERY && 430 | options.commandByte !== CommandType.HEART_BEAT && 431 | options.commandByte !== CommandType.DP_QUERY_NEW && 432 | options.commandByte !== CommandType.SESS_KEY_NEG_START && 433 | options.commandByte !== CommandType.SESS_KEY_NEG_FINISH && 434 | options.commandByte !== CommandType.DP_REFRESH) { 435 | // Add 3.4 header 436 | // check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2) 437 | const buffer = Buffer.alloc(payload.length + 15); 438 | Buffer.from('3.4').copy(buffer, 0); 439 | payload.copy(buffer, 15); 440 | payload = buffer; 441 | } 442 | 443 | // ? if (payload.length > 0) { // is null messages need padding - PING work without 444 | const padding = 0x10 - (payload.length & 0xF); 445 | const buf34 = Buffer.alloc((payload.length + padding), padding); 446 | payload.copy(buf34); 447 | payload = buf34; 448 | // } 449 | 450 | payload = this.cipher.encrypt({ 451 | data: payload 452 | }); 453 | 454 | payload = Buffer.from(payload); 455 | 456 | // Allocate buffer with room for payload + 24 bytes for 457 | // prefix, sequence, command, length, crc, and suffix 458 | const buffer = Buffer.alloc(payload.length + 52); 459 | 460 | // Add prefix, command, and length 461 | buffer.writeUInt32BE(0x000055AA, 0); 462 | buffer.writeUInt32BE(options.commandByte, 8); 463 | buffer.writeUInt32BE(payload.length + 0x24, 12); 464 | 465 | if (options.sequenceN) { 466 | buffer.writeUInt32BE(options.sequenceN, 4); 467 | } 468 | 469 | // Add payload, crc, and suffix 470 | payload.copy(buffer, 16); 471 | const calculatedCrc = this.cipher.hmac(buffer.slice(0, payload.length + 16));// & 0xFFFFFFFF; 472 | calculatedCrc.copy(buffer, payload.length + 16); 473 | 474 | buffer.writeUInt32BE(0x0000AA55, payload.length + 48); 475 | return buffer; 476 | } 477 | 478 | /** 479 | * Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.5 480 | * @param {Object} options Options for encoding 481 | * @param {Buffer|String|Object} options.data data to encode 482 | * @param {Boolean} options.encrypted whether or not to encrypt the data 483 | * @param {Number} options.commandByte 484 | * command byte of packet (use CommandType definitions) 485 | * @param {Number} [options.sequenceN] optional, sequence number 486 | * @returns {Buffer} Encoded Buffer 487 | */ 488 | _encode35(options) { 489 | let payload = options.data; 490 | 491 | if (options.commandByte !== CommandType.DP_QUERY && 492 | options.commandByte !== CommandType.HEART_BEAT && 493 | options.commandByte !== CommandType.DP_QUERY_NEW && 494 | options.commandByte !== CommandType.SESS_KEY_NEG_START && 495 | options.commandByte !== CommandType.SESS_KEY_NEG_FINISH && 496 | options.commandByte !== CommandType.DP_REFRESH) { 497 | // Add 3.5 header 498 | const buffer = Buffer.alloc(payload.length + 15); 499 | Buffer.from('3.5').copy(buffer, 0); 500 | payload.copy(buffer, 15); 501 | payload = buffer; 502 | // OO options.data = '3.5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + options.data; 503 | } 504 | 505 | // Allocate buffer for prefix, unknown, sequence, command, length 506 | let buffer = Buffer.alloc(18); 507 | 508 | // Add prefix, command, and length 509 | buffer.writeUInt32BE(0x00006699, 0); // Prefix 510 | buffer.writeUInt16BE(0x0, 4); // Unknown 511 | buffer.writeUInt32BE(options.sequenceN, 6); // Sequence 512 | buffer.writeUInt32BE(options.commandByte, 10); // Command 513 | buffer.writeUInt32BE(payload.length + 28 /* 0x1c */, 14); // Length 514 | 515 | const encrypted = this.cipher.encrypt({ 516 | data: payload, 517 | aad: buffer.slice(4, 18) 518 | }); 519 | 520 | buffer = Buffer.concat([buffer, encrypted]); 521 | 522 | return buffer; 523 | } 524 | } 525 | 526 | module.exports = {MessageParser, CommandType}; 527 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks a given input string. 3 | * @private 4 | * @param {String} input input string 5 | * @returns {Boolean} 6 | * `true` if is string and length != 0, `false` otherwise. 7 | */ 8 | function isValidString(input) { 9 | return typeof input === 'string' && input.length > 0; 10 | } 11 | 12 | module.exports = {isValidString}; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuyapi", 3 | "version": "7.7.1", 4 | "description": "An easy-to-use API for devices that use Tuya's cloud services", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "files": [ 8 | "lib/**/*", 9 | "index.js", 10 | "index.d.ts" 11 | ], 12 | "scripts": { 13 | "lint": "xo", 14 | "test": "npx ava --concurrency 1 # Unfortunately have to do this so we don't try to bind to the same port multiple times", 15 | "coverage": "nyc npm test && nyc report --reporter=lcov", 16 | "document": "documentation build index.js -f html -o docs --config documentation.yml", 17 | "prepublishOnly": "npm test", 18 | "preversion": "npm test" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/codetheweb/tuyapi.git" 23 | }, 24 | "keywords": [ 25 | "tuya", 26 | "iot", 27 | "plug", 28 | "jinvoo", 29 | "switch", 30 | "api", 31 | "socket", 32 | "protocol" 33 | ], 34 | "author": "Max Isom (https://maxisom.me)", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/codetheweb/tuyapi/issues" 38 | }, 39 | "homepage": "https://github.com/codetheweb/tuyapi#readme", 40 | "dependencies": { 41 | "debug": "^4.4.0", 42 | "p-queue": "6.6.2", 43 | "p-retry": "4.6.2", 44 | "p-timeout": "3.2.0" 45 | }, 46 | "devDependencies": { 47 | "@tuyapi/stub": "0.3.0", 48 | "ava": "2.4.0", 49 | "clone": "2.1.2", 50 | "coveralls": "3.1.1", 51 | "delay": "4.4.1", 52 | "documentation": "^14.0.3", 53 | "nyc": "15.1.0", 54 | "xo": "0.25.4" 55 | }, 56 | "xo": { 57 | "space": true, 58 | "ignores": [ 59 | "docs" 60 | ], 61 | "rules": { 62 | "max-len": "off", 63 | "indent": [ 64 | "error", 65 | 2, 66 | { 67 | "ObjectExpression": "first", 68 | "ArrayExpression": "first" 69 | } 70 | ] 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "baseBranches": ["master"] 6 | } 7 | -------------------------------------------------------------------------------- /test/arguments.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | const TuyAPI = require('..'); 4 | 5 | test('constructor throws error if both ID and IP are missing from device', t => { 6 | t.throws(() => { 7 | // eslint-disable-next-line no-new 8 | new TuyAPI(); 9 | }); 10 | }); 11 | 12 | test('constructor throws error if key is invalid', t => { 13 | t.throws(() => { 14 | // Key is 15 characters instead of 16 15 | // eslint-disable-next-line no-new 16 | new TuyAPI({id: '22325186db4a2217dc8e', 17 | key: '4226aa407d5c1e2'}); 18 | }); 19 | }); 20 | 21 | test('set throws error if no arguments are passed', t => { 22 | t.throws(() => { 23 | const device = new TuyAPI({id: '22325186db4a2217dc8e', 24 | key: '4226aa407d5c1e2b'}); 25 | 26 | device.set(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/cipher.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | const Cipher = require('../lib/cipher'); 4 | 5 | test('decrypt message with header and base64 encoding', t => { 6 | const message = '3.133ed3d4a21effe90zrA8OK3r3JMiUXpXDWauNppY4Am2c8rZ6sb4Yf15MjM8n5ByDx+QWeCZtcrPqddxLrhm906bSKbQAFtT1uCp+zP5AxlqJf5d0Pp2OxyXyjg='; 7 | const equals = {devId: '002004265ccf7fb1b659', 8 | dps: {1: false, 2: 0}, 9 | t: 1529442366, 10 | s: 8}; 11 | const cipher = new Cipher({key: 'bbe88b3f4106d354', version: 3.1}); 12 | 13 | const result = cipher.decrypt(message); 14 | 15 | t.deepEqual(result, equals); 16 | }); 17 | 18 | test('decrypt message without header and not base64 encoded', t => { 19 | const message = 'zrA8OK3r3JMiUXpXDWauNppY4Am2c8rZ6sb4Yf15MjM8n5ByDx+QWeCZtcrPqddxLrhm906bSKbQAFtT1uCp+zP5AxlqJf5d0Pp2OxyXyjg='; 20 | const decoded = Buffer.from(message, 'base64'); 21 | const data = {devId: '002004265ccf7fb1b659', 22 | dps: {1: false, 2: 0}, 23 | t: 1529442366, 24 | s: 8}; 25 | const cipher = new Cipher({key: 'bbe88b3f4106d354', version: 3.1}); 26 | 27 | const result = cipher.decrypt(decoded); 28 | 29 | t.deepEqual(result, data); 30 | }); 31 | 32 | test('encrypt message as a buffer', t => { 33 | const message = 'zrA8OK3r3JMiUXpXDWauNppY4Am2c8rZ6sb4Yf15MjM8n5ByDx+QWeCZtcrPqddxLrhm906bSKbQAFtT1uCp+zP5AxlqJf5d0Pp2OxyXyjg='; 34 | const buffer = Buffer.from(message, 'base64'); 35 | 36 | const data = {devId: '002004265ccf7fb1b659', 37 | dps: {1: false, 2: 0}, 38 | t: 1529442366, 39 | s: 8}; 40 | const cipher = new Cipher({key: 'bbe88b3f4106d354', version: 3.1}); 41 | const result = cipher.encrypt({data: JSON.stringify(data), base64: false}); 42 | 43 | t.deepEqual(buffer, result); 44 | }); 45 | 46 | test('decrypt message where payload is not a JSON object', t => { 47 | const message = '3.133ed3d4a21effe90rt1hJFzMJPF3x9UhPTCiXw=='; 48 | const equals = 'gw id invalid'; 49 | const cipher = new Cipher({key: 'bbe88b3f4106d354', version: 3.1}); 50 | 51 | const result = cipher.decrypt(message); 52 | 53 | t.deepEqual(result, equals); 54 | }); 55 | -------------------------------------------------------------------------------- /test/find.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import TuyaStub from '@tuyapi/stub'; 3 | import clone from 'clone'; 4 | import delay from 'delay'; 5 | 6 | const TuyAPI = require('..'); 7 | 8 | const stub = new TuyaStub({id: '22325186db4a2217dc8e', 9 | key: '4226aa407d5c1e2b', 10 | state: {1: false, 2: true}}); 11 | 12 | // You may notice that at the end of each test 13 | // there's a delay() before the function exits. 14 | // This is to prevent race conditions that can 15 | // occur in which a UDP broadcast lags after the 16 | // server is torn down and is captured by the 17 | // following test, skewing the results. 18 | 19 | test.serial('find device on network using deprecated resolveId', async t => { 20 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 21 | key: '4226aa407d5c1e2b'}); 22 | const thisStub = clone(stub); 23 | thisStub.startServer(); 24 | 25 | thisStub.startUDPBroadcast({interval: 1}); 26 | 27 | await stubDevice.resolveId(); 28 | 29 | stubDevice.disconnect(); 30 | thisStub.shutdown(); 31 | 32 | await delay(100); 33 | 34 | t.not(stubDevice.device.ip, undefined); 35 | }); 36 | 37 | test.serial('find device on network by ID', async t => { 38 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 39 | key: '4226aa407d5c1e2b'}); 40 | const thisStub = clone(stub); 41 | thisStub.startServer(); 42 | 43 | thisStub.startUDPBroadcast({interval: 1}); 44 | 45 | await stubDevice.find(); 46 | 47 | stubDevice.disconnect(); 48 | thisStub.shutdown(); 49 | 50 | await delay(100); 51 | 52 | t.not(stubDevice.device.ip, undefined); 53 | }); 54 | 55 | test.serial('find device on network by IP', async t => { 56 | const stubDevice = new TuyAPI({ip: 'localhost', 57 | key: '4226aa407d5c1e2b'}); 58 | const thisStub = clone(stub); 59 | thisStub.startServer(); 60 | 61 | thisStub.startUDPBroadcast({interval: 1}); 62 | 63 | await stubDevice.find(); 64 | 65 | stubDevice.disconnect(); 66 | thisStub.shutdown(); 67 | 68 | await delay(100); 69 | 70 | t.not(stubDevice.device.id, undefined); 71 | }); 72 | 73 | test.serial('find returns if both ID and IP are already set', async t => { 74 | const stubDevice = new TuyAPI({ip: 'localhost', 75 | id: '22325186db4a2217dc8e', 76 | key: '4226aa407d5c1e2b'}); 77 | const thisStub = clone(stub); 78 | thisStub.startServer(); 79 | 80 | thisStub.startUDPBroadcast({interval: 1}); 81 | 82 | const result = await stubDevice.find(); 83 | 84 | stubDevice.disconnect(); 85 | thisStub.shutdown(); 86 | 87 | await delay(100); 88 | 89 | t.is(true, result); 90 | }); 91 | 92 | test.serial('find throws timeout error', async t => { 93 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 94 | key: '4226aa407d5c1e2b'}); 95 | 96 | const thisStub = clone(stub); 97 | thisStub.startServer(); 98 | 99 | await t.throwsAsync(() => { 100 | return stubDevice.find({timeout: 0.1}).catch(async error => { 101 | stubDevice.disconnect(); 102 | thisStub.shutdown(); 103 | 104 | await delay(100); 105 | 106 | throw error; 107 | }); 108 | }); 109 | }); 110 | 111 | test.serial('find with option all', async t => { 112 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 113 | key: '4226aa407d5c1e2b'}); 114 | const thisStub = clone(stub); 115 | thisStub.startServer(); 116 | 117 | thisStub.startUDPBroadcast({interval: 1}); 118 | 119 | const foundDevices = await stubDevice.find({all: true, timeout: 2}); 120 | 121 | stubDevice.disconnect(); 122 | thisStub.shutdown(); 123 | 124 | await delay(100); 125 | 126 | t.truthy(foundDevices.length); 127 | }); 128 | -------------------------------------------------------------------------------- /test/parser.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | const {MessageParser, CommandType} = require('../lib/message-parser'); 4 | 5 | test('encode and decode message', t => { 6 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 7 | 8 | const parser = new MessageParser(); 9 | const encoded = parser.encode({ 10 | data: payload, 11 | commandByte: CommandType.DP_QUERY, 12 | sequenceN: 2 13 | }); 14 | 15 | const parsed = parser.parse(encoded)[0]; 16 | 17 | t.deepEqual(parsed.payload, payload); 18 | t.deepEqual(parsed.commandByte, CommandType.DP_QUERY); 19 | t.is(parsed.sequenceN, 2); 20 | }); 21 | 22 | test('encode and decode get message with protocol 3.3', t => { 23 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 24 | 25 | const parser = new MessageParser({key: 'bbe88b3f4106d354', version: '3.3'}); 26 | const encoded = parser.encode({ 27 | data: payload, 28 | commandByte: CommandType.DP_QUERY, 29 | sequenceN: 2 30 | }); 31 | 32 | const parsed = parser.parse(encoded)[0]; 33 | 34 | t.deepEqual(parsed.payload, payload); 35 | t.deepEqual(parsed.commandByte, CommandType.DP_QUERY); 36 | t.is(parsed.sequenceN, 2); 37 | }); 38 | 39 | test('encode and decode set message with protocol 3.3', t => { 40 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 41 | 42 | const parser = new MessageParser({key: 'bbe88b3f4106d354', version: '3.3'}); 43 | const encoded = parser.encode({data: payload, commandByte: CommandType.CONTROL}); 44 | 45 | const parsed = parser.parse(encoded)[0]; 46 | 47 | t.deepEqual(parsed.payload, payload); 48 | t.deepEqual(parsed.commandByte, CommandType.CONTROL); 49 | }); 50 | 51 | test('constructor throws with incorrect key length', t => { 52 | t.throws(() => { 53 | // eslint-disable-next-line no-new 54 | new MessageParser({key: 'bbe88b3f4106d35'}); 55 | }); 56 | }); 57 | 58 | test('decode empty message', t => { 59 | const payload = ''; 60 | 61 | const parser = new MessageParser(); 62 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 63 | 64 | const parsed = parser.parse(encoded)[0]; 65 | t.falsy(parsed.payload); 66 | }); 67 | 68 | test('decode message where payload is not a JSON object', t => { 69 | const payload = 'gw id invalid'; 70 | 71 | const parser = new MessageParser(); 72 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 73 | 74 | const parsed = parser.parse(encoded)[0]; 75 | 76 | t.deepEqual(payload, parsed.payload); 77 | }); 78 | 79 | test('decode message where payload is not a JSON object 2', t => { 80 | const payload = 'gw id invalid'; 81 | 82 | const parser = new MessageParser(); 83 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 84 | 85 | const parsed = parser.parse(encoded)[0]; 86 | t.deepEqual(payload, parsed.payload); 87 | }); 88 | 89 | test('decode corrupt (shortened) message', t => { 90 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 91 | 92 | const parser = new MessageParser(); 93 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 94 | 95 | t.throws(() => { 96 | parser.parse(encoded.slice(0, -10)); 97 | }); 98 | }); 99 | 100 | test('decode corrupt (shorter than possible) message', t => { 101 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 102 | 103 | const parser = new MessageParser(); 104 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 105 | 106 | t.throws(() => { 107 | parser.parse(encoded.slice(0, 23)); 108 | }); 109 | }); 110 | 111 | test('decode corrupt (prefix mismatch) message', t => { 112 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 113 | 114 | const parser = new MessageParser(); 115 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 116 | encoded.writeUInt32BE(0xDEADBEEF, 0); 117 | 118 | t.throws(() => { 119 | parser.parse(encoded); 120 | }); 121 | }); 122 | 123 | test('decode corrupt (suffix mismatch) message', t => { 124 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 125 | 126 | const parser = new MessageParser(); 127 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 128 | encoded.writeUInt32BE(0xDEADBEEF, encoded.length - 4); 129 | 130 | t.throws(() => { 131 | parser.parse(encoded); 132 | }); 133 | }); 134 | 135 | test('decode corrupt (crc mismatch) message', t => { 136 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 137 | 138 | const parser = new MessageParser(); 139 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 140 | encoded.writeUInt32BE(0xDEADBEEF, encoded.length - 8); 141 | 142 | t.throws(() => { 143 | parser.parse(encoded); 144 | }); 145 | }); 146 | 147 | test('decode message with two packets', t => { 148 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 149 | 150 | const parser = new MessageParser(); 151 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 152 | 153 | const parsed = parser.parse(Buffer.concat([encoded, encoded]))[0]; 154 | 155 | t.deepEqual(parsed.payload, payload); 156 | t.is(parsed.commandByte, 10); 157 | }); 158 | 159 | test('throw when called with invalid command byte', t => { 160 | const parser = new MessageParser(); 161 | 162 | t.throws(() => { 163 | parser.encode({data: {}, commandByte: 1000}); 164 | }); 165 | }); 166 | 167 | test('decode corrupt (shorter than possible) message 2', t => { 168 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 169 | 170 | const parser = new MessageParser(); 171 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 172 | 173 | t.throws(() => { 174 | parser.parse(encoded.slice(0, 23)); 175 | }); 176 | }); 177 | 178 | test('decode corrupt (prefix mismatch) message 2', t => { 179 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 180 | 181 | const parser = new MessageParser(); 182 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 183 | encoded.writeUInt32BE(0xDEADBEEF, 0); 184 | 185 | t.throws(() => { 186 | parser.parse(encoded); 187 | }); 188 | }); 189 | 190 | test('decode corrupt (suffix mismatch) message 2', t => { 191 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 192 | 193 | const parser = new MessageParser(); 194 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 195 | encoded.writeUInt32BE(0xDEADBEEF, encoded.length - 4); 196 | 197 | t.throws(() => { 198 | parser.parse(encoded); 199 | }); 200 | }); 201 | 202 | test('decode message with two packets 2', t => { 203 | const payload = {devId: '002004265ccf7fb1b659', dps: {1: true, 2: 0}}; 204 | 205 | const parser = new MessageParser(); 206 | const encoded = parser.encode({data: payload, commandByte: CommandType.DP_QUERY}); 207 | 208 | const parsed = parser.parse(Buffer.concat([encoded, encoded]))[0]; 209 | t.deepEqual(parsed.payload, payload); 210 | t.is(parsed.commandByte, 10); 211 | }); 212 | -------------------------------------------------------------------------------- /test/stub.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import TuyaStub from '@tuyapi/stub'; 3 | import clone from 'clone'; 4 | import pRetry from 'p-retry'; 5 | import delay from 'delay'; 6 | 7 | const TuyAPI = require('..'); 8 | 9 | const stub = new TuyaStub({id: '22325186db4a2217dc8e', 10 | key: '4226aa407d5c1e2b', 11 | state: {1: false, 2: true}}); 12 | 13 | test.serial('get property of device', async t => { 14 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 15 | key: '4226aa407d5c1e2b', 16 | ip: 'localhost'}); 17 | const thisStub = clone(stub); 18 | thisStub.startServer(); 19 | await stubDevice.connect(); 20 | 21 | // Get status 3 different ways 22 | const status = await stubDevice.get(); 23 | 24 | const schema = await stubDevice.get({schema: true}); 25 | 26 | const specificDPS = await stubDevice.get({dps: '1'}); 27 | 28 | // Shutdown stub server before continuing 29 | stubDevice.disconnect(); 30 | thisStub.shutdown(); 31 | 32 | // Check responses 33 | t.is(status, thisStub.getProperty('1')); 34 | 35 | t.is(schema.dps['1'], thisStub.getProperty('1')); 36 | 37 | t.is(specificDPS, thisStub.getProperty('1')); 38 | }); 39 | 40 | test.serial('set property of device', async t => { 41 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 42 | key: '4226aa407d5c1e2b', 43 | ip: 'localhost'}); 44 | const thisStub = clone(stub); 45 | thisStub.startServer(); 46 | await stubDevice.connect(); 47 | 48 | await stubDevice.set({set: true}); 49 | 50 | stubDevice.disconnect(); 51 | thisStub.shutdown(); 52 | 53 | t.is(true, thisStub.getProperty('1')); 54 | }); 55 | 56 | test.serial('set multiple properties at once', async t => { 57 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 58 | key: '4226aa407d5c1e2b', 59 | ip: 'localhost'}); 60 | const thisStub = clone(stub); 61 | thisStub.startServer(); 62 | await stubDevice.connect(); 63 | 64 | await stubDevice.set({multiple: true, data: {1: true, 2: false}}); 65 | 66 | stubDevice.disconnect(); 67 | thisStub.shutdown(); 68 | 69 | t.deepEqual({1: true, 2: false}, thisStub.getState()); 70 | }); 71 | 72 | test.serial('catch data event when property changes', async t => { 73 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 74 | key: '4226aa407d5c1e2b', 75 | ip: 'localhost'}); 76 | 77 | const thisStub = clone(stub); 78 | thisStub.startServer(); 79 | 80 | await new Promise((resolve, reject) => { 81 | stubDevice.on('data', data => { 82 | t.is(data.dps['1'], thisStub.getProperty('1')); 83 | resolve(); 84 | }); 85 | 86 | stubDevice.on('connected', () => { 87 | thisStub.setProperty('1', true); 88 | }); 89 | 90 | stubDevice.on('error', error => reject(error)); 91 | 92 | stubDevice.connect(); 93 | }); 94 | 95 | stubDevice.disconnect(); 96 | thisStub.shutdown(); 97 | 98 | t.pass(); 99 | }); 100 | 101 | test.serial('toggle property of device', async t => { 102 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 103 | key: '4226aa407d5c1e2b', 104 | ip: 'localhost'}); 105 | const thisStub = clone(stub); 106 | thisStub.startServer(); 107 | await stubDevice.connect(); 108 | 109 | await stubDevice.toggle(); 110 | 111 | stubDevice.disconnect(); 112 | thisStub.shutdown(); 113 | 114 | t.is(true, thisStub.getProperty('1')); 115 | }); 116 | 117 | test.serial('heartbeat event is fired', async t => { 118 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 119 | key: '4226aa407d5c1e2b', 120 | ip: 'localhost'}); 121 | 122 | const thisStub = clone(stub); 123 | thisStub.startServer(); 124 | 125 | stubDevice._pingPongPeriod = 0.5; 126 | 127 | await new Promise((resolve, reject) => { 128 | // One heartbeat must be in 1s as each one has 0.5s between 129 | const toleranceTimeout = setTimeout(() => reject(), 1000); 130 | 131 | stubDevice.on('heartbeat', () => { 132 | clearTimeout(toleranceTimeout); 133 | resolve(); 134 | }); 135 | 136 | stubDevice.on('error', error => reject(error)); 137 | 138 | stubDevice.connect(); 139 | }); 140 | 141 | stubDevice.disconnect(); 142 | thisStub.shutdown(); 143 | 144 | t.pass(); 145 | }); 146 | 147 | test.serial('disconnected event is fired when heartbeat times out', async t => { 148 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 149 | key: '4226aa407d5c1e2b', 150 | ip: 'localhost'}); 151 | 152 | const thisStub = clone(stub); 153 | thisStub.respondToHeartbeat = false; 154 | thisStub.startServer(); 155 | 156 | stubDevice._pingPongPeriod = 0.5; 157 | 158 | await stubDevice.connect(); 159 | 160 | await new Promise(resolve => { 161 | stubDevice.on('disconnected', () => resolve()); 162 | }); 163 | 164 | stubDevice.disconnect(); 165 | thisStub.shutdown(); 166 | 167 | t.pass(); 168 | }); 169 | 170 | test('can reconnect if device goes offline', async t => { 171 | const stubDevice = new TuyAPI({id: '22325186db4a2217dc8e', 172 | key: '4226aa407d5c1e2b', 173 | ip: 'localhost'}); 174 | 175 | const thisStub = clone(stub); 176 | thisStub.startServer(); 177 | 178 | stubDevice.on('error', () => {}); 179 | 180 | await stubDevice.connect(); 181 | 182 | thisStub.shutdown(); 183 | 184 | await delay(500); 185 | 186 | thisStub.startServer(); 187 | 188 | // Attempt to reconnect 189 | await pRetry(async () => { 190 | await stubDevice.connect(); 191 | }, {retries: 3}); 192 | 193 | t.pass(); 194 | }); 195 | --------------------------------------------------------------------------------