├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .jsdoc.json ├── LICENSE ├── README.md ├── examples ├── directFlight.js ├── doAFlip.js ├── package-lock.json ├── package.json └── toggleAutoTakeOff.js ├── index.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── ARDiscoveryError.js ├── CharacteristicEnums.js ├── CommandParser.js ├── DroneCommand.js ├── DroneCommandArgument.js ├── DroneConnection.js ├── InvalidCommandError.js ├── index.js └── util │ ├── Enum.js │ └── reflection.js └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_file_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "globals": { 8 | "global": false, 9 | "require": false, 10 | "module": true, 11 | "process": false, 12 | "Buffer": false, 13 | 14 | "VERSION": false, 15 | "LICENSE": false 16 | }, 17 | "rules": { 18 | "comma-dangle": [1,"always-multiline"], 19 | "no-cond-assign": [1,"always"], 20 | "no-console": [1, {"allow": ["warn", "error"]}], 21 | "no-constant-condition": 1, 22 | "no-case-declarations": 0, 23 | "no-control-regex": 1, 24 | "no-debugger": 2, 25 | "no-dupe-args": 2, 26 | "no-dupe-keys": 2, 27 | "no-duplicate-case": 1, 28 | "no-empty-character-class": 1, 29 | "no-empty": 2, 30 | "no-ex-assign": 2, 31 | "no-extra-parens": [1,"all"], 32 | "no-extra-semi": 2, 33 | "no-func-assign": 1, 34 | "no-invalid-regexp": 2, 35 | "no-irregular-whitespace": 1, 36 | "no-negated-in-lhs": 1, 37 | "no-obj-calls": 1, 38 | "no-regex-spaces": 1, 39 | "no-sparse-arrays": 1, 40 | "no-unreachable": 1, 41 | "use-isnan": 2, 42 | "valid-jsdoc": 1, 43 | "valid-typeof": 1, 44 | "no-unexpected-multiline": 1, 45 | "consistent-return": 1, 46 | "curly": [1,"all"], 47 | "dot-location": [2,"property"], 48 | "eqeqeq": [2,"smart"], 49 | "no-alert": 1, 50 | "no-caller": 2, 51 | "no-else-return": 1, 52 | "no-eval": 2, 53 | "no-extend-native": 1, 54 | "no-extra-bind": 1, 55 | "no-fallthrough": 1, 56 | "no-floating-decimal": 1, 57 | "no-implicit-coercion": 2, 58 | "no-implied-eval": 2, 59 | "no-iterator": 1, 60 | "no-lone-blocks": 1, 61 | "no-loop-func": 1, 62 | "no-multi-spaces": 1, 63 | "no-native-reassign": 1, 64 | "no-new-wrappers": 1, 65 | "no-octal-escape": 1, 66 | "no-process-env": 0, 67 | "no-proto": 1, 68 | "no-redeclare": 1, 69 | "no-return-assign": [2,"except-parens"], 70 | "no-script-url": 1, 71 | "no-self-compare": 1, 72 | "no-sequences": 2, 73 | "no-throw-literal": 2, 74 | "no-unused-expressions": 1, 75 | "no-useless-call": 1, 76 | "no-void": 2, 77 | "yoda": 1, 78 | "strict": [1,"never"], 79 | "no-catch-shadow": 1, 80 | "no-delete-var": 1, 81 | "no-label-var": 1, 82 | "no-undef": 1, 83 | "no-undefined": 1, 84 | "no-unused-vars": 1, 85 | "array-bracket-spacing": [1,"never",{}], 86 | "brace-style": [2,"1tbs",{}], 87 | "camelcase": [2,{"properties":"always"}], 88 | "comma-spacing": 1, 89 | "comma-style": 1, 90 | "eol-last": 2, 91 | "indent": [1, 2, {"SwitchCase": 1}], 92 | "key-spacing": [2,{"afterColon":true}], 93 | "linebreak-style": [1,"unix"], 94 | "new-cap": [2,{"newIsCap":true,"capIsNew":true}], 95 | "new-parens": 1, 96 | "newline-after-var": [1,"always"], 97 | "no-array-constructor": 1, 98 | "no-lonely-if": 1, 99 | "no-mixed-spaces-and-tabs": [2], 100 | "no-multiple-empty-lines": [1,{"max":2}], 101 | "no-nested-ternary": 1, 102 | "no-spaced-func": 2, 103 | "no-trailing-spaces": [1,{"skipBlankLines":false}], 104 | "no-unneeded-ternary": 1, 105 | "quotes": [1,"single","avoid-escape"], 106 | "semi": [2,"always"], 107 | "space-before-blocks": [2,"always"], 108 | "space-in-parens": [1,"never"], 109 | "space-infix-ops": [1,{"int32Hint":true}], 110 | "space-unary-ops": [2,{"words":true,"nonwords":false}], 111 | "spaced-comment": [2,"always",{}], 112 | "constructor-super": 1, 113 | "no-const-assign": 2, 114 | "no-this-before-super": 2, 115 | "no-var": 1, 116 | "prefer-const": 1, 117 | "prefer-spread": 1, 118 | "require-yield": 1, 119 | 120 | "keyword-spacing": [1, { 121 | "after": true 122 | }] 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | node-version: [16, 18, 20] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Cache node modules 20 | id: cache-npm 21 | uses: actions/cache@v3 22 | env: 23 | cache-name: cache-node-modules 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node-${{ matrix.node-version }}-build-${{ env.cache-name }}- 29 | ${{ runner.os }}-node-${{ matrix.node-version }}-build- 30 | ${{ runner.os }}-node-${{ matrix.node-version }}- 31 | - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} 32 | name: List the state of node modules 33 | continue-on-error: true 34 | run: npm list 35 | - name: Install Dependencies 36 | run: npm ci 37 | - name: Lint 38 | run: npx eslint src 39 | 40 | - name: Generate Documentation 41 | if: matrix.node-version == 18 42 | run: npm run docs 43 | - name: Archive Documentation 44 | if: matrix.node-version == 18 45 | uses: actions/upload-artifact@v3 46 | with: 47 | name: documentation 48 | path: docs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore dist and build folders 2 | /dist/ 3 | /build/ 4 | /docs/ 5 | !/src/ 6 | /local/ 7 | 8 | vendor/ 9 | 10 | # Maps4news api storage file 11 | .m4n 12 | 13 | # Test files during dev work 14 | /test*.html 15 | 16 | # Config 17 | .m4n_token 18 | 19 | # Environment file 20 | /.env 21 | 22 | # Ignore test html 23 | /*.html 24 | 25 | ### JetBrains ### 26 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 27 | 28 | /.idea 29 | 30 | # CMake 31 | cmake-build-debug/ 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | /out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Crashlytics plugin (for Android Studio and IntelliJ) 45 | com_crashlytics_export_strings.xml 46 | crashlytics.properties 47 | crashlytics-build.properties 48 | fabric.properties 49 | 50 | ### JetBrains Patch ### 51 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 52 | 53 | # *.iml 54 | # modules.xml 55 | # .idea/misc.xml 56 | # *.ipr 57 | 58 | # Sonarlint plugin 59 | .idea/sonarlint 60 | 61 | ### Linux ### 62 | *~ 63 | 64 | # temporary files which can be created if a process still has a handle open of a deleted file 65 | .fuse_hidden* 66 | 67 | # KDE directory preferences 68 | .directory 69 | 70 | # Linux trash folder which might appear on any partition or disk 71 | .Trash-* 72 | 73 | # .nfs files are created when an open file is removed but is still being accessed 74 | .nfs* 75 | 76 | ### macOS ### 77 | *.DS_Store 78 | .AppleDouble 79 | .LSOverride 80 | 81 | # Icon must end with two \r 82 | Icon 83 | 84 | # Thumbnails 85 | ._* 86 | 87 | # Files that might appear in the root of a volume 88 | .DocumentRevisions-V100 89 | .fseventsd 90 | .Spotlight-V100 91 | .TemporaryItems 92 | .Trashes 93 | .VolumeIcon.icns 94 | .com.apple.timemachine.donotpresent 95 | 96 | # Directories potentially created on remote AFP share 97 | .AppleDB 98 | .AppleDesktop 99 | Network Trash Folder 100 | Temporary Items 101 | .apdisk 102 | 103 | ### Node ### 104 | # Logs 105 | logs 106 | *.log 107 | npm-debug.log* 108 | yarn-debug.log* 109 | yarn-error.log* 110 | 111 | # Runtime data 112 | pids 113 | *.pid 114 | *.seed 115 | *.pid.lock 116 | 117 | # Directory for instrumented libs generated by jscoverage/JSCover 118 | lib-cov 119 | 120 | # Coverage directory used by tools like istanbul 121 | /coverage/ 122 | 123 | # nyc test coverage 124 | /.nyc_output/ 125 | 126 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 127 | .grunt 128 | 129 | # Bower dependency directory (https://bower.io/) 130 | bower_components 131 | 132 | # node-waf configuration 133 | .lock-wscript 134 | 135 | # Compiled binary addons (http://nodejs.org/api/addons.html) 136 | build/Release 137 | 138 | # Dependency directories 139 | node_modules/ 140 | jspm_packages/ 141 | 142 | # Typescript v1 declaration files 143 | typings/ 144 | 145 | # Optional npm cache directory 146 | .npm 147 | 148 | # Optional eslint cache 149 | .eslintcache 150 | 151 | # Optional REPL history 152 | .node_repl_history 153 | 154 | # Output of 'npm pack' 155 | *.tgz 156 | 157 | # Yarn Integrity file 158 | .yarn-integrity 159 | 160 | # dotenv environment variables file 161 | .env 162 | 163 | 164 | ### Windows ### 165 | # Windows thumbnail cache files 166 | Thumbs.db 167 | ehthumbs.db 168 | ehthumbs_vista.db 169 | 170 | # Folder config file 171 | Desktop.ini 172 | 173 | # Recycle Bin used on file shares 174 | $RECYCLE.BIN/ 175 | 176 | # Windows Installer files 177 | *.cab 178 | *.msi 179 | *.msm 180 | *.msp 181 | 182 | # Windows shortcuts 183 | *.lnk 184 | 185 | # End of https://www.gitignore.io/api/node,linux,macos,windows,jetbrains 186 | -------------------------------------------------------------------------------- /.jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true, 4 | "dictionaries": ["jsdoc"] 5 | 6 | }, 7 | "source": { 8 | "include": ["src", "package.json", "README.md"], 9 | "includePattern": ".js$", 10 | "excludePattern": "(node_modules/|docs)" 11 | }, 12 | "plugins": [ 13 | "plugins/markdown" 14 | ], 15 | "templates": { 16 | "cleverLinks": false, 17 | "monospaceLinks": true, 18 | "useLongnameInNav": false, 19 | "showInheritedInNav": true 20 | }, 21 | "opts": { 22 | "destination": "./build/docs", 23 | "encoding": "utf8", 24 | "private": false, 25 | "recurse": true 26 | // "template": "./node_modules/minami-compat/" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2018 Mechazawa 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Minidrone-js [![](https://badge.fury.io/js/minidrone-js.svg)](https://badge.fury.io/js/minidrone-js) [![](https://api.codeclimate.com/v1/badges/fc937ad532e4160ea2f0/maintainability)](https://codeclimate.com/github/Mechazawa/minidrone-js/maintainability) [![](https://travis-ci.org/Mechazawa/minidrone-js.svg?branch=master)](https://travis-ci.org/Mechazawa/minidrone-js) 2 | --------------- 3 | 4 | Minidrone-js is an easy to use drone library for the Parrot 5 | Minidrones. In theory it supports many different Parrot drones 6 | besides Minidrones but this is untested. 7 | 8 | This library is loosely based on the work by [fetherston] for 9 | [npm-parrot-minidrone] and [amymcgovern] for [pymambo]. 10 | 11 | [amymcgovern]: https://github.com/amymcgovern 12 | [pymambo]: https://github.com/amymcgovern/pymambo 13 | [fetherston]: https://github.com/fetherston 14 | [npm-parrot-minidrone]: https://github.com/fetherston/npm-parrot-minidrone 15 | 16 | ## Functionality 17 | This library is designed to support the two-way command communication 18 | protocol used by Parrot drones. It supports receiving sensor updates 19 | and sending commands based on the [xml specification]. 20 | 21 | [xml specification]: https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/ 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm install minidrone-js --save 27 | ``` 28 | 29 | ## Example 30 | 31 | This example will make the drone take-off, do a flip and then land again. 32 | 33 | ```js 34 | const {DroneConnection, CommandParser} = require('minidrone-js'); 35 | 36 | const parser = new CommandParser(); 37 | const drone = new DroneConnection(); 38 | 39 | 40 | /* 41 | * Commands are easily found by reading the xml specification 42 | * https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/ 43 | */ 44 | const takeoff = parser.getCommand('minidrone', 'Piloting', 'TakeOff'); 45 | const landing = parser.getCommand('minidrone', 'Piloting', 'Landing'); 46 | const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'}); 47 | 48 | /** Helper function */ 49 | function sleep(ms) { 50 | return new Promise(a => setTimeout(a, ms)); 51 | } 52 | 53 | void async function() { 54 | await new Promise(resolve => drone.once('connected', resolve)); 55 | 56 | // Makes the code a bit clearer 57 | const runCommand = x => drone.runCommand(x); 58 | 59 | await runCommand(takeoff); 60 | 61 | await sleep(2000); 62 | await runCommand(backFlip); 63 | 64 | await sleep(2000); 65 | await runCommand(landing); 66 | 67 | await sleep(5000); 68 | process.exit(); 69 | }(); 70 | ``` 71 | 72 | ## Troubleshooting 73 | 74 | #### MacOS won't connect to the drone 75 | - First turn off Bluetooth 76 | - Run the following code in your shell 77 | 78 | ```sh 79 | rm -v ~/Library/Preferences/ByHost/com.apple.Bluetooth.*.plist 80 | sudo rm /Library/Preferences/com.apple.Bluetooth.plist 81 | ``` 82 | 83 | - Turn Bluetooth back on 84 | 85 | Or alternativly using [blueutil]: 86 | 87 | ```sh 88 | blueutil off 89 | rm -v ~/Library/Preferences/ByHost/com.apple.Bluetooth.*.plist 90 | sudo rm /Library/Preferences/com.apple.Bluetooth.plist 91 | blueutil on 92 | ``` 93 | 94 | [blueutil]: http://www.frederikseiffert.de/blueutil/ 95 | 96 | ## License 97 | 98 | MIT License 99 | 100 | Copyright 2018 Mechazawa 101 | 102 | Permission is hereby granted, free of charge, to any person obtaining a copy 103 | of this software and associated documentation files (the "Software"), to deal 104 | in the Software without restriction, including without limitation the rights 105 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 106 | copies of the Software, and to permit persons to whom the Software is 107 | furnished to do so, subject to the following conditions: 108 | 109 | The above copyright notice and this permission notice shall be included in all 110 | copies or substantial portions of the Software. 111 | 112 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 113 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 114 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 115 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 116 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 117 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 118 | SOFTWARE. 119 | -------------------------------------------------------------------------------- /examples/directFlight.js: -------------------------------------------------------------------------------- 1 | const dualShock = require('dualshock-controller'); 2 | const {DroneConnection, CommandParser} = require('minidrone-js'); 3 | 4 | const controller = dualShock({config: 'dualShock4-alternate-driver'}); 5 | const parser = new CommandParser(); 6 | const drone = new DroneConnection(); 7 | const takeoff = parser.getCommand('minidrone', 'Piloting', 'TakeOff'); 8 | const landing = parser.getCommand('minidrone', 'Piloting', 'Landing'); 9 | const flatTrim = parser.getCommand('minidrone', 'Piloting', 'FlatTrim'); 10 | const takePicture = parser.getCommand('minidrone', 'MediaRecord', 'PictureV2'); 11 | const fireGun = parser.getCommand('minidrone', 'UsbAccessory', 'GunControl', {id: 0, action: 'FIRE'}); 12 | const clawOpen = parser.getCommand('minidrone', 'UsbAccessory', 'ClawControl', {id: 0, action: 'OPEN'}); 13 | const clawClose = parser.getCommand('minidrone', 'UsbAccessory', 'ClawControl', {id: 0, action: 'CLOSE'}); 14 | const allState = parser.getCommand('common', 'Common', 'AllStates'); 15 | const autoTakeOff = parser.getCommand('minidrone', 'Piloting', 'AutoTakeOffMode', {state: 1}); 16 | 17 | 18 | function paramsChanged(a, b) { 19 | for (const key of Object.keys(a)) { 20 | if (b[key] !== a[key]) { 21 | return true; 22 | } 23 | } 24 | 25 | return false; 26 | } 27 | 28 | let oldParams = {}; 29 | let flightParams = { 30 | roll: 0, pitch: 0, yaw: 0, gaz: 0, flag: true, 31 | }; 32 | 33 | function setFlightParams(data) { 34 | oldParams = flightParams; 35 | flightParams = Object.assign({}, flightParams, data); 36 | } 37 | 38 | let startTime; 39 | function writeFlightParams() { 40 | if(typeof startTime === 'undefined') { 41 | startTime = Date.now(); 42 | } 43 | 44 | const params = Object.assign({}, flightParams, { 45 | timestamp: Date.now() - startTime 46 | }); 47 | 48 | const command = parser.getCommand('minidrone', 'Piloting', 'PCMD', params); 49 | drone.runCommand(command); 50 | } 51 | 52 | function joyToFlightParam(value) { 53 | const deadZone = 10; // both ways 54 | const center = 255 / 2; 55 | 56 | if (value > center - deadZone && value < center + deadZone) { 57 | return 0; 58 | } 59 | 60 | return (value / center) * 100 - 100; 61 | } 62 | 63 | drone.on('connected', () => { 64 | console.log('Registering controller'); 65 | 66 | setInterval(writeFlightParams, 100); // Event loop 67 | 68 | // Bind controls 69 | controller.on('connected', () => console.log('Controller connected!')); 70 | controller.on('disconnecting', () => { 71 | console.log('Controller disconnected!'); 72 | setFlightParams({ 73 | roll: 0, pitch: 0, yaw: 0, gaz: -10, 74 | }); 75 | }); 76 | 77 | controller.on('circle:press', () => { 78 | console.log(Object.values(drone._sensorStore).map(x=>x.toString()).join('\n')); 79 | drone.runCommand(allState); 80 | }); 81 | controller.on('x:press', () => drone.runCommand(takeoff)); 82 | controller.on('square:press', () => drone.runCommand(landing)); 83 | controller.on('triangle:press', () => drone.runCommand(autoTakeOff)); 84 | 85 | controller.on('right:move', data => setFlightParams({yaw: joyToFlightParam(data.x), gaz: -joyToFlightParam(data.y)})); 86 | controller.on('left:move', data => setFlightParams({roll: joyToFlightParam(data.x), pitch: -joyToFlightParam(data.y)})); 87 | }); 88 | -------------------------------------------------------------------------------- /examples/doAFlip.js: -------------------------------------------------------------------------------- 1 | const {DroneConnection, CommandParser} = require('minidrone-js'); 2 | 3 | const parser = new CommandParser(); 4 | const drone = new DroneConnection(); 5 | const takeoff = parser.getCommand('minidrone', 'Piloting', 'TakeOff'); 6 | const landing = parser.getCommand('minidrone', 'Piloting', 'Landing'); 7 | const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'}); 8 | 9 | 10 | drone.on('connected', () => { 11 | console.log('Connected!') 12 | 13 | // Makes the code a bit clearer 14 | const runCommand = x => drone.runCommand(x); 15 | 16 | // runCommand(takeoff); 17 | 18 | // setTimeout(runCommand, 2000, backFlip); 19 | // setTimeout(runCommand, 2000, landing); 20 | setTimeout(process.exit, 5000); 21 | }); 22 | -------------------------------------------------------------------------------- /examples/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "direct-flight", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "direct-flight", 8 | "license": "MIT", 9 | "dependencies": { 10 | "minidrone-js": "../" 11 | } 12 | }, 13 | "..": { 14 | "version": "0.6.0", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@abandonware/noble": "^1.9.2-23", 18 | "arsdk-xml": "1.0.0", 19 | "case": "^1.5.4", 20 | "events": "^3.0.0", 21 | "node-gyp": "^10.0.0", 22 | "resolve": "^1.6.0", 23 | "winston": "^3.0.0", 24 | "xml2js": "^0.6.2" 25 | }, 26 | "devDependencies": { 27 | "ava": "5.3.1", 28 | "eslint": "^8.54.0", 29 | "jsdoc": "^4.0.2" 30 | } 31 | }, 32 | "node_modules/minidrone-js": { 33 | "resolved": "..", 34 | "link": true 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "license": "MIT", 4 | "dependencies": { 5 | "minidrone-js": "../" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/toggleAutoTakeOff.js: -------------------------------------------------------------------------------- 1 | const { DroneConnection, CommandParser } = require('minidrone-js'); 2 | const Logger = require('winston'); 3 | 4 | Logger.level = 'debug'; 5 | 6 | const parser = new CommandParser(); 7 | const drone = new DroneConnection(); 8 | 9 | const autoTakeOffOn = parser.getCommand('minidrone', 'Piloting', 'AutoTakeOffMode', { state: 1 }); 10 | const autoTakeOffOff = parser.getCommand('minidrone', 'Piloting', 'AutoTakeOffMode', { state: 0 }); 11 | 12 | function sleep(ms) { 13 | return new Promise(a => setTimeout(a, ms)); 14 | } 15 | 16 | drone.on('connected', async () => { 17 | await drone.runCommand(autoTakeOffOff); 18 | Logger.debug('Command got ACK\'d'); 19 | 20 | await sleep(2000); 21 | 22 | await drone.runCommand(autoTakeOffOn); 23 | Logger.debug('Command got ACK\'d'); 24 | 25 | await sleep(2000); 26 | 27 | await drone.runCommand(autoTakeOffOff); 28 | Logger.debug('Command got ACK\'d'); 29 | 30 | process.exit(); 31 | }); 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minidrone-js", 3 | "version": "0.6.1", 4 | "description": "Parrot Minidrone library", 5 | "main": "index.js", 6 | "repository": "https://github.com/Mechazawa/minidrone-js", 7 | "author": "Bas 'Mechazawa' ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "@abandonware/noble": "^1.9.2-23", 11 | "arsdk-xml": "1.0.0", 12 | "case": "^1.5.4", 13 | "events": "^3.0.0", 14 | "node-gyp": "^10.0.0", 15 | "resolve": "^1.6.0", 16 | "winston": "^3.0.0", 17 | "xml2js": "^0.6.2" 18 | }, 19 | "devDependencies": { 20 | "ava": "5.3.1", 21 | "eslint": "^8.54.0", 22 | "jsdoc": "^4.0.2" 23 | }, 24 | "scripts": { 25 | "test": "echo No tests defined", 26 | "docs": "npx jsdoc --configure .jsdoc.json --verbose; mkdir -p docs; rm -r docs; mv build/docs/minidrone-js/* docs; rm -r build/docs", 27 | "lint": "npx eslint src --fix --cache" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/ARDiscoveryError.js: -------------------------------------------------------------------------------- 1 | const Enum = require('./util/Enum'); 2 | 3 | /** 4 | * @type Enum 5 | * 6 | * @property {number} OK - No error 7 | * @property {number} ERROR - Unknown generic error 8 | * @property {number} ERROR_SIMPLE_POLL - Avahi failed to create simple poll object 9 | * @property {number} ERROR_BUILD_NAME - Avahi failed to create simple poll object 10 | * @property {number} ERROR_CLIENT - Avahi failed to create client 11 | * @property {number} ERROR_CREATE_CONFIG - Failed to create config file 12 | * @property {number} ERROR_DELETE_CONFIG - Failed to delete config file 13 | * @property {number} ERROR_ENTRY_GROUP - Avahi failed to create entry group 14 | * @property {number} ERROR_ADD_SERVICE - Avahi failed to add service 15 | * @property {number} ERROR_GROUP_COMMIT - Avahi failed to commit group 16 | * @property {number} ERROR_BROWSER_ALLOC - Avahi failed to allocate desired number of browsers 17 | * @property {number} ERROR_BROWSER_NEW - Avahi failed to create one browser 18 | * @property {number} ERROR_ALLOC - Failed to allocate connection resources 19 | * @property {number} ERROR_INIT - Wrong type to connect as 20 | * @property {number} ERROR_SOCKET_CREATION - Socket creation error 21 | * @property {number} ERROR_SOCKET_PERMISSION_DENIED - Socket access permission denied 22 | * @property {number} ERROR_SOCKET_ALREADY_CONNECTED - Socket is already connected 23 | * @property {number} ERROR_ACCEPT - Socket accept failed 24 | * @property {number} ERROR_SEND - Failed to write frame to socket 25 | * @property {number} ERROR_READ - Failed to read frame from socket 26 | * @property {number} ERROR_SELECT - Failed to select sets 27 | * @property {number} ERROR_TIMEOUT - timeout error 28 | * @property {number} ERROR_ABORT - Aborted by the user 29 | * @property {number} ERROR_PIPE_INIT - Failed to intitialize a pipe 30 | * @property {number} ERROR_BAD_PARAMETER - Bad parameters 31 | * @property {number} ERROR_BUSY - discovery is busy 32 | * @property {number} ERROR_SOCKET_UNREACHABLE - host or net is not reachable 33 | * @property {number} ERROR_OUTPUT_LENGTH - the length of the output is to small 34 | * @property {number} ERROR_JNI - JNI error 35 | * @property {number} ERROR_JNI_VM - JNI virtual machine, not initialized 36 | * @property {number} ERROR_JNI_ENV - null JNI environment 37 | * @property {number} ERROR_JNI_CALLBACK_LISTENER - null jni callback listener 38 | * @property {number} ERROR_CONNECTION - Connection error 39 | * @property {number} ERROR_CONNECTION_BUSY - Product already connected 40 | * @property {number} ERROR_CONNECTION_NOT_READY - Product not ready to connect 41 | * @property {number} ERROR_CONNECTION_BAD_ID - It is not the good Product 42 | * @property {number} ERROR_DEVICE - Device generic error 43 | * @property {number} ERROR_DEVICE_OPERATION_NOT_SUPPORTED - The current device does not support this operation 44 | * @property {number} ERROR_JSON - Json generic error 45 | * @property {number} ERROR_JSON_PARSSING - Json parssing error 46 | * @property {number} ERROR_JSON_BUFFER_SIZE - The size of the buffer storing the Json is too small 47 | * 48 | * @see https://github.com/Parrot-Developers/libARDiscovery/blob/master/Includes/libARDiscovery/ARDISCOVERY_Error.h 49 | */ 50 | const ARDiscoveryError = module.exports = new Enum({ 51 | // Do not change these values, they are sent by the device in the Json of connection. 52 | OK: 0, 53 | ERROR: -1, 54 | // End of values sent by the device in the Json of connection. 55 | 56 | ERROR_SIMPLE_POLL: -1000, 57 | ERROR_BUILD_NAME: -999, 58 | ERROR_CLIENT: -998, 59 | ERROR_CREATE_CONFIG: -997, 60 | ERROR_DELETE_CONFIG: -996, 61 | ERROR_ENTRY_GROUP: -995, 62 | ERROR_ADD_SERVICE: -994, 63 | ERROR_GROUP_COMMIT: -993, 64 | ERROR_BROWSER_ALLOC: -992, 65 | ERROR_BROWSER_NEW: -991, 66 | 67 | ERROR_ALLOC: -2000, 68 | ERROR_INIT: -1999, 69 | ERROR_SOCKET_CREATION: -1998, 70 | ERROR_SOCKET_PERMISSION_DENIED: -1997, 71 | ERROR_SOCKET_ALREADY_CONNECTED: -1996, 72 | ERROR_ACCEPT: -1995, 73 | ERROR_SEND: -1994, 74 | ERROR_READ: -1993, 75 | ERROR_SELECT: -1992, 76 | ERROR_TIMEOUT: -1991, 77 | ERROR_ABORT: -1990, 78 | ERROR_PIPE_INIT: -1989, 79 | ERROR_BAD_PARAMETER: -1988, 80 | ERROR_BUSY: -1987, 81 | ERROR_SOCKET_UNREACHABLE: -1986, 82 | ERROR_OUTPUT_LENGTH: -1985, 83 | 84 | ERROR_JNI: -3000, 85 | ERROR_JNI_VM: -2999, 86 | ERROR_JNI_ENV: -2998, 87 | ERROR_JNI_CALLBACK_LISTENER: -2997, 88 | 89 | // Do not change these values, they are sent by the device in the Json of connection. 90 | ERROR_CONNECTION: -4000, 91 | ERROR_CONNECTION_BUSY: -3999, 92 | ERROR_CONNECTION_NOT_READY: -3998, 93 | ERROR_CONNECTION_BAD_ID: -3997, 94 | // End of values sent by the device in the Json of connection. 95 | 96 | ERROR_DEVICE: -5000, 97 | ERROR_DEVICE_OPERATION_NOT_SUPPORTED: -499, 98 | 99 | ERROR_JSON: -6000, 100 | ERROR_JSON_PARSSING: -5999, 101 | ERROR_JSON_BUFFER_SIZE: -5998, 102 | }); 103 | -------------------------------------------------------------------------------- /src/CharacteristicEnums.js: -------------------------------------------------------------------------------- 1 | const Enum = require('./util/Enum'); 2 | 3 | // the following characteristic UUID segments come from the documentation at 4 | // http://forum.developer.parrot.com/t/minidrone-characteristics-uuid/4686/3 5 | // the 4th bytes are used to identify the characteristic 6 | // the usage of the channels are also documented here 7 | // http://forum.developer.parrot.com/t/ble-characteristics-of-minidrones/5912/2 8 | 9 | /** 10 | * Send characteristsic UUIDs 11 | * 12 | * @property {string} SEND_NO_ACK - not-ack commands (PCMD only) 13 | * @property {string} SEND_WITH_ACK - ack commands (all piloting commands) 14 | * @property {string} SEND_HIGH_PRIORITY - emergency commands 15 | * @property {string} ACK_COMMAND - ack for data sent on 0e 16 | * 17 | * @type {Enum} 18 | */ 19 | const sendUuids = new Enum({ 20 | SEND_NO_ACK: '0a', // not-ack commands (PCMD only) 21 | SEND_WITH_ACK: '0b', // ack commands (all piloting commands) 22 | SEND_HIGH_PRIORITY: '0c', // emergency commands 23 | ACK_COMMAND: '1e', // ack for data sent on 0e 24 | }); 25 | 26 | /** 27 | * Receive characteristsic UUIDs 28 | * 29 | * @property {string} ACK_DRONE_DATA - drone data that needs an ack (needs to be ack on 1e) 30 | * @property {string} NO_ACK_DRONE_DATA - data from drone (including battery and others), no ack 31 | * @property {string} ACK_COMMAND_SENT - ack 0b channel, SEND_WITH_ACK 32 | * @property {string} ACK_HIGH_PRIORITY - ack 0c channel, SEND_HIGH_PRIORITY 33 | * 34 | * @type {Enum} 35 | */ 36 | const receiveUuids = new Enum({ 37 | ACK_DRONE_DATA: '0e', // drone data that needs an ack (needs to be ack on 1e) 38 | NO_ACK_DRONE_DATA: '0f', // data from drone (including battery and others), no ack 39 | ACK_COMMAND_SENT: '1b', // ack 0b channel, SEND_WITH_ACK 40 | ACK_HIGH_PRIORITY: '1c', // ack 0c channel, SEND_HIGH_PRIORITY 41 | }); 42 | 43 | /** 44 | * @see http://forum.developer.parrot.com/t/minidrone-characteristics-uuid/4686/3 45 | */ 46 | const serviceUuids = new Enum({ 47 | ARCOMMAND_SENDING_SERVICE: 'fa', 48 | ARCOMMAND_RECEIVING_SERVICE: 'fb', 49 | PERFORMANCE_COUNTER_SERVICE: 'fc', 50 | NORMAL_BLE_FTP_SERVICE: 'fd21', 51 | UPDATE_BLE_FTP: 'fd51', 52 | UPDATE_RFCOMM_SERVICE: 'fe00', 53 | DeviceInfo: '1800', 54 | unknown: '1801', 55 | }); 56 | 57 | /** 58 | * @see http://forum.developer.parrot.com/t/minidrone-characteristics-uuid/4686/3 59 | */ 60 | const handshakeUuids = [ 61 | 'fb0f', 'fb0e', 'fb1b', 'fb1c', 62 | 'fd22', 'fd23', 'fd24', 'fd52', 63 | 'fd53', 'fd54', 64 | ]; 65 | 66 | module.exports = { 67 | sendUuids, 68 | receiveUuids, 69 | serviceUuids, 70 | handshakeUuids, 71 | }; 72 | -------------------------------------------------------------------------------- /src/CommandParser.js: -------------------------------------------------------------------------------- 1 | const { parseString } = require('xml2js'); 2 | const DroneCommand = require('./DroneCommand'); 3 | const Logger = require('winston'); 4 | const InvalidCommandError = require('./InvalidCommandError'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const resolve = require('resolve'); 8 | 9 | /** 10 | * Command parser used for looking up commands in the xml definition 11 | */ 12 | class CommandParser { 13 | /** 14 | * CommandParser constructor 15 | */ 16 | constructor() { 17 | if (typeof CommandParser._fileCache === 'undefined') { 18 | CommandParser._fileCache = {}; 19 | } 20 | 21 | this._commandCache = {}; 22 | } 23 | 24 | /** 25 | * Get an xml file and convert it to json 26 | * @param {string} name - Project name 27 | * @returns {Object} - Parsed Xml data using xml2js 28 | * @private 29 | */ 30 | _getJson(name) { 31 | const file = this._getXml(name); 32 | 33 | if (typeof file === 'undefined') { 34 | throw new Error(`Xml file ${name} could not be found`); 35 | } 36 | 37 | if (typeof CommandParser._fileCache[name] === 'undefined') { 38 | CommandParser._fileCache[name] = null; 39 | 40 | parseString(file, { async: false }, (e, result) => { 41 | CommandParser._fileCache[name] = result; 42 | }); 43 | 44 | return this._getJson(name); 45 | } else if (CommandParser._fileCache[name] === null) { 46 | // Fuck javascript async hipster shit 47 | return this._getJson(name); 48 | } 49 | 50 | return CommandParser._fileCache[name]; 51 | } 52 | 53 | /** 54 | * Get a command based on it's path in the xml definition 55 | * @param {string} projectName - The xml file name (project name) 56 | * @param {string} className - The command class name 57 | * @param {string} commandName - The command name 58 | * @param {Object?} commandArguments - Optional command arguments 59 | * @returns {DroneCommand} - Target command 60 | * @throws InvalidCommandError 61 | * @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/} 62 | * @example 63 | * const parser = new CommandParser(); 64 | * const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'}); 65 | */ 66 | getCommand(projectName, className, commandName, commandArguments = {}) { 67 | const cacheToken = [projectName, className, commandName].join('-'); 68 | 69 | if (typeof this._commandCache[cacheToken] === 'undefined') { 70 | // Find project 71 | const project = this._getJson(projectName).project; 72 | 73 | this._assertElementExists(project, 'project', projectName); 74 | 75 | const context = [projectName]; 76 | 77 | // Find class 78 | const targetClass = project.class.find(v => v.$.name === className); 79 | 80 | this._assertElementExists(targetClass, 'class', className); 81 | 82 | context.push(className); 83 | 84 | // Find command 85 | const targetCommand = targetClass.cmd.find(v => v.$.name === commandName); 86 | 87 | this._assertElementExists(targetCommand, 'command', commandName); 88 | 89 | const result = new DroneCommand(project, targetClass, targetCommand); 90 | 91 | this._commandCache[cacheToken] = result; 92 | 93 | if (result.deprecated) { 94 | Logger.warn(`${result.toString()} has been deprecated`); 95 | } 96 | } 97 | 98 | const target = this._commandCache[cacheToken].clone(); 99 | 100 | for (const arg of Object.keys(commandArguments)) { 101 | if (target.hasArgument(arg)) { 102 | target[arg] = commandArguments[arg]; 103 | } 104 | } 105 | 106 | return target; 107 | } 108 | 109 | /** 110 | * Gets the command by analysing the buffer 111 | * @param {Buffer} buffer - Command buffer without leading 2 bytes 112 | * @returns {DroneCommand} - Buffer's related DroneCommand 113 | * @private 114 | */ 115 | _getCommandFromBuffer(buffer) { 116 | // https://github.com/algolia/pdrone/commit/43cc0c4150297dab97d0f0bc119b8bd551da268f#comments 117 | buffer = buffer.readUInt8(0) > 0x80 ? buffer.slice(1) : buffer; 118 | 119 | const projectId = buffer.readUInt8(0); 120 | const classId = buffer.readUInt8(1); 121 | const commandId = buffer.readUInt8(2); 122 | 123 | const cacheToken = [projectId, classId, commandId].join('-'); 124 | 125 | // Build command if needed 126 | if (typeof this._commandCache[cacheToken] === 'undefined') { 127 | // Find project 128 | const project = CommandParser._files 129 | .map(x => this._getJson(x).project) 130 | .filter(x => typeof x !== 'undefined') 131 | .find(x => Number(x.$.id) === projectId); 132 | 133 | this._assertElementExists(project, 'project', projectId); 134 | 135 | // find class 136 | const targetClass = project.class.find(x => Number(x.$.id) === classId); 137 | 138 | const context = [project.$.name]; 139 | 140 | this._assertElementExists(targetClass, 'class', classId, context); 141 | 142 | // find command 143 | const targetCommand = targetClass.cmd.find(x => Number(x.$.id) === commandId); 144 | 145 | context.push(targetClass.$.name); 146 | 147 | this._assertElementExists(targetCommand, 'command', commandId, context); 148 | 149 | // Build command and store it 150 | this._commandCache[cacheToken] = new DroneCommand(project, targetClass, targetCommand); 151 | } 152 | 153 | return this._commandCache[cacheToken].clone(); 154 | } 155 | 156 | /** 157 | * Parse the input buffer and get the correct command with parameters 158 | * Used internally to parse sensor data 159 | * @param {Buffer} buffer - The command buffer without the first two bytes 160 | * @returns {DroneCommand} - Parsed drone command 161 | * @throws InvalidCommandError 162 | * @throws TypeError 163 | */ 164 | parseBuffer(buffer) { 165 | const command = this._getCommandFromBuffer(buffer); 166 | 167 | let bufferOffset = 4; 168 | 169 | for (const arg of command.arguments) { 170 | let valueSize = arg.getValueSize(); 171 | let value = 0; 172 | 173 | switch (arg.type) { 174 | case 'u8': 175 | case 'u16': 176 | case 'u32': 177 | case 'u64': 178 | value = buffer.readUIntLE(bufferOffset, valueSize); 179 | break; 180 | case 'i8': 181 | case 'i16': 182 | case 'i32': 183 | case 'i64': 184 | value = buffer.readIntLE(bufferOffset, valueSize); 185 | break; 186 | case 'enum': 187 | // @todo figure out why I have to do this 188 | value = buffer.readIntLE(bufferOffset + 1, valueSize - 1); 189 | break; 190 | // eslint-disable-next-line no-case-declarations 191 | case 'string': 192 | value = ''; 193 | let c = ''; // Last character 194 | 195 | for (valueSize = 0; valueSize < buffer.length && c !== '\0'; valueSize++) { 196 | c = String.fromCharCode(buffer[bufferOffset]); 197 | 198 | value += c; 199 | } 200 | break; 201 | case 'float': 202 | value = buffer.readFloatLE(bufferOffset); 203 | break; 204 | case 'double': 205 | value = buffer.readDoubleLE(bufferOffset); 206 | break; 207 | default: 208 | throw new TypeError(`Can't parse buffer: unknown data type "${arg.type}" for argument "${arg.name}" in ${command.getToken()}`); 209 | } 210 | 211 | arg.value = value; 212 | 213 | bufferOffset += valueSize; 214 | } 215 | 216 | return command; 217 | } 218 | 219 | /** 220 | * Warn up the parser by pre-fetching the xml files 221 | * @param {string[]} files - List of files to load in defaults to {@link CommandParser._files} 222 | * @returns {void} 223 | * 224 | */ 225 | warmup(files = this.constructor._files) { 226 | for (const file of files) { 227 | this._getJson(file); 228 | } 229 | } 230 | 231 | /** 232 | * Mapping of known xml files 233 | * @type {string[]} - known xml files 234 | * @private 235 | */ 236 | static get _files() { 237 | if (typeof this.__files === 'undefined') { 238 | const arsdkXmlPath = CommandParser._arsdkXmlPath; 239 | 240 | const isFile = filePath => fs.lstatSync(filePath).isFile(); 241 | 242 | this.__files = fs 243 | .readdirSync(arsdkXmlPath) 244 | .map(String) 245 | .filter(file => file.endsWith('.xml')) 246 | .filter(file => isFile(path.join(arsdkXmlPath, file))) 247 | .map(file => file.replace('.xml', '')); 248 | 249 | Logger.debug(`_files list found ${this._files.length} items`); 250 | } 251 | 252 | return this.__files; 253 | } 254 | 255 | /** 256 | * helper method 257 | * @param {Object|undefined} value - Xml node value 258 | * @param {string} type - Xml node type 259 | * @param {string|number} target - Xml node value 260 | * @param {Array} context - Parser context 261 | * @private 262 | * @throws InvalidCommandError 263 | * @returns {void} 264 | */ 265 | _assertElementExists(value, type, target, context = []) { 266 | if (typeof value === 'undefined') { 267 | throw new InvalidCommandError(value, type, target, context); 268 | } 269 | } 270 | 271 | /** 272 | * Reads xml file from ArSDK synchronously without a cache 273 | * @param {string} name - Xml file name 274 | * @returns {string} - File contents 275 | * @private 276 | */ 277 | _getXml(name) { 278 | const arsdkXmlPath = CommandParser._arsdkXmlPath; 279 | const filePath = `${arsdkXmlPath}/${name}.xml`; 280 | 281 | return fs.readFileSync(filePath); 282 | } 283 | 284 | /** 285 | * Path of the ArSDK xml directory 286 | * @returns {string} - Path 287 | * @private 288 | */ 289 | static get _arsdkXmlPath() { 290 | if (typeof this.__arsdkPath === 'undefined') { 291 | // common.xml is a file we know exists so we can use it to find the xml directory 292 | this.__arsdkPath = path.dirname(resolve.sync('arsdk-xml/xml/common.xml')); 293 | } 294 | 295 | return this.__arsdkPath; 296 | } 297 | } 298 | 299 | module.exports = CommandParser; 300 | -------------------------------------------------------------------------------- /src/DroneCommand.js: -------------------------------------------------------------------------------- 1 | const DroneCommandArgument = require('./DroneCommandArgument'); 2 | const Enum = require('./util/Enum'); 3 | const { sendUuids, serviceUuids} = require('./CharacteristicEnums'); 4 | 5 | /** 6 | * Buffer types 7 | * 8 | * @property {number} ACK - Acknowledgment of previously received data 9 | * @property {number} DATA - Normal data (no ack requested) 10 | * @property {number} NON_ACK - Same as DATA 11 | * @property {number} HIGH_PRIO - Not sure about this one could be LLD 12 | * @property {number} LOW_LATENCY_DATA - Treated as normal data on the network, but are given higher priority internally 13 | * @property {number} DATA_WITH_ACK - Data requesting an ack. The receiver must send an ack for this data unit! 14 | * 15 | * @type {Enum} 16 | */ 17 | const bufferType = new Enum({ 18 | ACK: 0x02, 19 | DATA: 0x02, 20 | NON_ACK: 0x02, 21 | HIGH_PRIO: 0x02, 22 | LOW_LATENCY_DATA: 0x03, 23 | DATA_WITH_ACK: 0x04, 24 | }); 25 | 26 | const bufferCharTranslationMap = new Enum({ 27 | ACK: 'ACK_COMMAND', 28 | DATA: 'SEND_NO_ACK', 29 | NON_ACK: 'SEND_NO_ACK', 30 | HIGH_PRIO: 'SEND_HIGH_PRIORITY', 31 | LOW_LATENCY_DATA: 'SEND_NO_ACK', 32 | DATA_WITH_ACK: 'SEND_WITH_ACK', 33 | }); 34 | 35 | /** 36 | * Drone command 37 | * 38 | * Used for building commands to be sent to the drone. It 39 | * is also used for the sensor readings. 40 | * 41 | * Arguments are automatically mapped on the object. This 42 | * means that it is easy to set command arguments. Default 43 | * arguments values are 0 or their enum equivalent by default. 44 | * 45 | * @example 46 | * const parser = new CommandParser(); 47 | * const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'}); 48 | * const frontFlip = backFlip.clone(); 49 | * 50 | * backFlip.direction = 'front'; 51 | * 52 | * drone.runCommand(backFlip); 53 | */ 54 | class DroneCommand { 55 | /** 56 | * Creates a new DroneCommand instance 57 | * @param {object} project - Project node from the xml spec 58 | * @param {object} class_ - Class node from the xml spec 59 | * @param {object} command - Command node from the xml spec 60 | */ 61 | constructor(project, class_, command) { 62 | this._project = project; 63 | this._projectId = Number(project.$.id); 64 | this._projectName = String(project.$.name); 65 | 66 | this._class = class_; 67 | this._classId = Number(class_.$.id); 68 | this._className = String(class_.$.name); 69 | 70 | this._command = command; 71 | this._commandId = Number(command.$.id); 72 | this._commandName = String(command.$.name); 73 | 74 | this._deprecated = command.$.deprecated === 'true'; 75 | this._description = String(command._).trim(); 76 | this._arguments = (command.arg || []).map(x => new DroneCommandArgument(x)); 77 | 78 | // NON_ACK, ACK or HIGH_PRIO. Defaults to ACK 79 | this._buffer = command.$.buffer || 'DATA_WITH_ACK'; 80 | this._timeout = command.$.timeout || 'POP'; 81 | 82 | this._mapArguments(); 83 | } 84 | 85 | /** 86 | * The project id 87 | * @returns {number} - project id 88 | */ 89 | get projectId() { 90 | return this._projectId; 91 | } 92 | 93 | /** 94 | * The project name (minidrone, common, etc) 95 | * @returns {string} - project name 96 | */ 97 | get projectName() { 98 | return this._projectName; 99 | } 100 | 101 | /** 102 | * The class id 103 | * @returns {number} - class id 104 | */ 105 | get classId() { 106 | return this._classId; 107 | } 108 | 109 | /** 110 | * The class name 111 | * @returns {string} - class name 112 | */ 113 | get className() { 114 | return this._className; 115 | } 116 | 117 | /** 118 | * The command id 119 | * @returns {number} - command id 120 | */ 121 | get commandId() { 122 | return this._commandId; 123 | } 124 | 125 | /** 126 | * The command name 127 | * @returns {string} - command name 128 | */ 129 | get commandName() { 130 | return this._commandName; 131 | } 132 | 133 | /** 134 | * Array containing the drone arguments 135 | * @returns {DroneCommandArgument[]} - arguments 136 | */ 137 | get arguments() { 138 | return this._arguments; 139 | } 140 | 141 | /** 142 | * Returns if the command has any arguments 143 | * @returns {boolean} - command has any arguments 144 | */ 145 | hasArguments() { 146 | return this.arguments.length > 0; 147 | } 148 | 149 | /** 150 | * Get the argument names. These names are also mapped to the instance 151 | * @returns {string[]} - argument names 152 | */ 153 | get argumentNames() { 154 | return this.arguments.map(x => x.name); 155 | } 156 | 157 | /** 158 | * Get the command description 159 | * @returns {string} - command description 160 | */ 161 | get description() { 162 | return this._description; 163 | } 164 | 165 | /** 166 | * Get if the command has been deprecated 167 | * @returns {boolean} - deprecated 168 | */ 169 | get deprecated() { 170 | return this._deprecated; 171 | } 172 | 173 | /** 174 | * Get the send characteristic uuid based on the buffer type 175 | * @returns {string} - uuid as a string 176 | */ 177 | get sendCharacteristicUuid() { 178 | const t = bufferCharTranslationMap[this.bufferType] || 'SEND_WITH_ACK'; 179 | 180 | return serviceUuids.ARCOMMAND_SENDING_SERVICE + sendUuids[t]; 181 | } 182 | 183 | /** 184 | * If the command should be acknowledged upon receiving 185 | * @returns {boolean} - Should ack 186 | */ 187 | get shouldAck() { 188 | return ['DATA_WITH_ACK', 'SEND_WITH_ACK', 'SEND_HIGH_PRIORITY'].includes(this.bufferType); 189 | } 190 | 191 | /** 192 | * Checks if the command has a certain argument 193 | * @param {string} key - Argument name 194 | * @returns {boolean} - If the argument exists 195 | */ 196 | hasArgument(key) { 197 | return this.arguments.findIndex(x => x.name === key) !== -1; 198 | } 199 | 200 | /** 201 | * Clones the instance 202 | * @returns {DroneCommand} - Cloned instance 203 | */ 204 | clone() { 205 | const command = new this.constructor(this._project, this._class, this._command); 206 | 207 | for (let i = 0; i < this.arguments.length; i++) { 208 | command.arguments[i].value = this.arguments[i].value; 209 | } 210 | 211 | return command; 212 | } 213 | 214 | /** 215 | * Converts the command to it's buffer representation 216 | * @returns {Buffer} - Command buffer 217 | * @throws TypeError 218 | */ 219 | toBuffer() { 220 | const bufferLength = 6 + this.arguments.reduce((acc, val) => val.getValueSize() + acc, 0); 221 | const buffer = Buffer.alloc(bufferLength, 0); 222 | 223 | buffer.writeUInt16LE(this.bufferFlag, 0); 224 | 225 | // Skip command counter (offset 1) because it's set in DroneConnection::runCommand 226 | 227 | buffer.writeUInt16LE(this.projectId, 2); 228 | buffer.writeUInt16LE(this.classId, 3); 229 | buffer.writeUInt16LE(this.commandId, 4); // two bytes 230 | 231 | let bufferOffset = 6; 232 | 233 | for (const arg of this.arguments) { 234 | const valueSize = arg.getValueSize(); 235 | 236 | switch (arg.type) { 237 | case 'u8': 238 | case 'u16': 239 | case 'u32': 240 | case 'u64': 241 | buffer.writeUIntLE(Math.floor(arg.value), bufferOffset, valueSize); 242 | break; 243 | case 'i8': 244 | case 'i16': 245 | case 'i32': 246 | case 'i64': 247 | case 'enum': 248 | buffer.writeIntLE(Math.floor(arg.value), bufferOffset, valueSize); 249 | break; 250 | case 'string': 251 | buffer.write(arg.value, bufferOffset, valueSize, 'ascii'); 252 | break; 253 | case 'float': 254 | buffer.writeFloatLE(arg.value, bufferOffset); 255 | break; 256 | case 'double': 257 | buffer.writeDoubleLE(arg.value, bufferOffset); 258 | break; 259 | default: 260 | throw new TypeError(`Can't encode buffer: unknown data type "${arg.type}" for argument "${arg.name}" in ${this.getToken()}`); 261 | } 262 | 263 | bufferOffset += valueSize; 264 | } 265 | 266 | return buffer; 267 | } 268 | 269 | /** 270 | * Maps the arguments to the class 271 | * @returns {void} 272 | * @private 273 | */ 274 | _mapArguments() { 275 | for (const arg of this.arguments) { 276 | const init = { 277 | enumerable: false, 278 | get: () => arg, 279 | set: v => { 280 | arg.value = v; 281 | }, 282 | }; 283 | 284 | Object.defineProperty(this, arg.name, init); 285 | } 286 | } 287 | 288 | /** 289 | * Returns a string representation of a DroneCommand 290 | * @param {boolean} debug - If extra debug information should be shown 291 | * @returns {string} - String representation if the instance 292 | * @example 293 | * const str = command.toString(); 294 | * 295 | * str === 'minidrone PilotingSettingsState PreferredPilotingModeChanged mode="medium"(1)'; 296 | * @example 297 | * const str = command.toString(true); 298 | * 299 | * str === 'minidrone PilotingSettingsState PreferredPilotingModeChanged (enum)mode="medium"(1)'; 300 | */ 301 | toString(debug = false) { 302 | const argStr = this.arguments.map(x => x.toString(debug)).join(' ').trim(); 303 | 304 | return `${this.getToken()} ${argStr}`.trim(); 305 | } 306 | 307 | /** 308 | * Get the command buffer type 309 | * @returns {string} - Buffer type 310 | */ 311 | get bufferType() { 312 | return this._buffer.toUpperCase(); 313 | } 314 | 315 | /** 316 | * Get the command buffer flag based on it's type 317 | * @returns {number} - Buffer flag 318 | */ 319 | get bufferFlag() { 320 | return bufferType[this.bufferType]; 321 | } 322 | 323 | /** 324 | * Indicates the required action to be taken in case the command times out 325 | * The value of this attribute can be either POP, RETRY or FLUSH, defaulting to POP 326 | * @returns {string} - Action name 327 | */ 328 | get timeoutAction() { 329 | return this._timeout; 330 | } 331 | 332 | /** 333 | * Get the token representation of the command. This 334 | * is useful for registering sensors for example 335 | * @returns {string} - Command token 336 | * @example 337 | * const backFlip = parser.getCommand('minidrone', 'Animations', 'Flip', {direction: 'back'}); 338 | * 339 | * backFlip.getToken() === 'minidrone-Animations-Flip'; 340 | */ 341 | getToken() { 342 | return [this.projectName, this.className, this.commandName].join('-'); 343 | } 344 | } 345 | 346 | module.exports = DroneCommand; 347 | -------------------------------------------------------------------------------- /src/DroneCommandArgument.js: -------------------------------------------------------------------------------- 1 | const Enum = require('./util/Enum'); 2 | 3 | /** 4 | * Drone Command Argument class 5 | * 6 | * Used for storing command arguments 7 | * 8 | * @property {Enum|undefined} enum - Enum store containing possible enum values if `this.type === 'enum'`. If set then `this.hasEnumProperty === true`. 9 | * @todo allow boolean values for u8 and i8 params 10 | */ 11 | class DroneCommandArgument { 12 | /** 13 | * Command argument constructor 14 | * @param {object} raw - Raw command argument data from the xml specification 15 | */ 16 | constructor(raw) { 17 | this._name = raw.$.name; 18 | this._description = String(raw._).trim(); 19 | this._type = raw.$.type; 20 | this._value = this.type === 'string' ? '' : 0; 21 | 22 | // Parse enum if needed 23 | if (this.type === 'enum') { 24 | const enumData = {}; 25 | let enumValue = 0; 26 | 27 | for (const {$: {name}} of raw.enum) { 28 | enumData[name] = enumValue++; 29 | } 30 | 31 | this._enum = new Enum(enumData); 32 | 33 | Object.defineProperty(this, 'enum', { 34 | enumerable: false, 35 | get: () => this._enum, 36 | }); 37 | } 38 | } 39 | 40 | /** 41 | * Parameter name 42 | * @returns {string} - name 43 | */ 44 | get name() { 45 | return this._name; 46 | } 47 | 48 | /** 49 | * Parameter description 50 | * @returns {string} - description 51 | */ 52 | get description() { 53 | return this._description; 54 | } 55 | 56 | /** 57 | * Parameter type 58 | * @returns {string} - type 59 | */ 60 | get type() { 61 | return this._type; 62 | } 63 | 64 | /** 65 | * Get the parameter value 66 | * @returns {number|string} - value 67 | * @see DroneCommandArgument#type 68 | */ 69 | get value() { 70 | if (this.type === 'string' && !this._value.endsWith('\0')) { 71 | return this._value + '\0'; 72 | } else if (this.type === 'float') { 73 | return Math.fround(this._value); 74 | 75 | /** 76 | * Javascript uses doubles by default not fixed 77 | * precision or decimals. This means that we can 78 | * just return the value without rounding it. 79 | */ 80 | } 81 | 82 | return this._value; 83 | } 84 | 85 | /** 86 | * Set the parameter value 87 | * @param {number|string} value - Parameter value 88 | * @throws TypeError 89 | */ 90 | set value(value) { 91 | if (Object.is(value, -0)) { 92 | value = 0; 93 | } 94 | 95 | this._value = this._parseValue(value); 96 | } 97 | 98 | /** 99 | * If it has the enum property set 100 | * @returns {boolean} - has enum 101 | */ 102 | get hasEnumProperty() { 103 | return typeof this.enum !== 'undefined'; 104 | } 105 | 106 | /** 107 | * Parses the value before setting it 108 | * @param {number|string} value - Target value 109 | * @returns {number|string} - parsed value 110 | * @private 111 | * @throws TypeError 112 | */ 113 | _parseValue(value) { 114 | switch (this.type) { 115 | case 'enum': 116 | if (this.enum.hasKey(value)) { 117 | return this.enum[value]; 118 | } else if (this.enum.hasValue(value)) { 119 | return value; 120 | // } else if (value === 256) { 121 | // // This is some BS value I sometimes get from the drone 122 | // // Pretty much just means "unavailable" 123 | // return value; 124 | } 125 | 126 | throw new TypeError(`Value ${value} could not be interpreted as an enum value for ${this.name}. Available options are ${this.enum.toString()}`); 127 | case 'string': 128 | return String(value); 129 | default: 130 | return Number(value); 131 | } 132 | } 133 | 134 | /** 135 | * Gets the byte size of the value. 136 | * @returns {number} - value size in bytes 137 | */ 138 | getValueSize() { 139 | switch (this.type) { 140 | case 'string': 141 | return this.value.length; 142 | case 'u8': 143 | case 'i8': 144 | return 1; 145 | case 'u16': 146 | case 'i16': 147 | return 2; 148 | case 'u32': 149 | case 'i32': 150 | return 4; 151 | case 'u64': 152 | case 'i64': 153 | return 8; 154 | case 'float': 155 | return 4; 156 | case 'double': 157 | return 8; 158 | case 'enum': 159 | return 4; 160 | default: 161 | return 0; 162 | } 163 | } 164 | 165 | /** 166 | * Returns a string representation of the DroneCommandArgument instance 167 | * @param {boolean} debug - If extra debug info should be shown. 168 | * @param {number} precision - Amount of precision for numerical values 169 | * @returns {string} - string representation 170 | */ 171 | toString(debug = false, precision = 3) { 172 | let value; 173 | 174 | switch (this.type) { 175 | case 'string': 176 | value = this.value; 177 | 178 | while (value.endsWith('\0')) { 179 | value = value.substring(0, value.length - 1); 180 | } 181 | 182 | value = `"${value}"`; 183 | break; 184 | case 'u8': 185 | case 'i8': 186 | case 'u16': 187 | case 'i16': 188 | case 'u32': 189 | case 'i32': 190 | case 'u64': 191 | case 'i64': 192 | value = this.value; 193 | break; 194 | case 'float': 195 | case 'double': 196 | // This will provide a reasonable estimate of the 197 | // floating point value for debugging purposes. 198 | value = this.value.toFixed(precision).replace(/0+$/, ''); 199 | break; 200 | case 'enum': 201 | value = `"${this.enum.findForValue(this.value)}"[${this.value}]`; 202 | break; 203 | default: 204 | value = this.value; 205 | } 206 | 207 | if (!debug) { 208 | return `${this.name}=${value}`; 209 | } 210 | 211 | return `(${this.type})${this.name}=${value}`; 212 | } 213 | } 214 | 215 | module.exports = DroneCommandArgument; 216 | -------------------------------------------------------------------------------- /src/DroneConnection.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const Logger = require('winston'); 3 | const CommandParser = require('./CommandParser'); 4 | const { sendUuids, receiveUuids, serviceUuids, handshakeUuids} = require('./CharacteristicEnums'); 5 | 6 | const MANUFACTURER_SERIALS = [ 7 | 0x4300cf1900090100, 8 | 0x4300cf1909090100, 9 | 0x4300cf1907090100, 10 | ]; 11 | 12 | const DRONE_PREFIXES = [ 13 | 'RS_', 14 | 'Mars_', 15 | 'Travis_', 16 | 'Maclan_', 17 | 'Mambo_', 18 | 'Blaze_', 19 | 'NewZ_', 20 | ]; 21 | 22 | /** 23 | * Drone connection class 24 | * 25 | * Exposes an api for controlling the drone 26 | * 27 | * @fires DroneCommand#connected 28 | * @fires DroneCommand#disconnected 29 | * @fires DroneCommand#sensor: 30 | * @property {CommandParser} parser - {@link CommandParser} instance 31 | */ 32 | class DroneConnection extends EventEmitter { 33 | /** 34 | * Creates a new DroneConnection instance 35 | * @param {string} [droneFilter=] - The drone name leave blank for no filter 36 | * @param {boolean} [warmup=true] - Warmup the command parser 37 | */ 38 | constructor(droneFilter = '', warmup = true) { 39 | super(); 40 | 41 | this.characteristics = []; 42 | 43 | this._characteristicLookupCache = {}; 44 | this._commandCallback = {}; 45 | this._sensorStore = {}; 46 | this._stepStore = {}; 47 | 48 | this.droneFilter = droneFilter; 49 | 50 | this.noble = require('@abandonware/noble'); 51 | this.parser = new CommandParser(); 52 | 53 | if (warmup) { 54 | // We'll do it for you so you don't have to 55 | this.parser.warmup(); 56 | } 57 | 58 | // bind noble event handlers 59 | this.noble.on('stateChange', state => this._onNobleStateChange(state)); 60 | this.noble.on('discover', peripheral => this._onPeripheralDiscovery(peripheral)); 61 | } 62 | 63 | /** 64 | * Event handler for when noble broadcasts a state change 65 | * @param {String} state a string describing noble's state 66 | * @return {undefined} 67 | * @private 68 | */ 69 | async _onNobleStateChange(state) { 70 | Logger.debug(`Noble state changed to ${state}`); 71 | 72 | while (state === 'poweredOn' && !this._peripheral) { 73 | const result = await this.noble.startScanningAsync(); 74 | 75 | if (typeof result === 'object') { 76 | this._onPeripheralDiscovery(result); 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Event handler for when noble discovers a peripheral 83 | * Validates it is a drone and attempts to connect. 84 | * 85 | * @param {Peripheral} peripheral a noble peripheral class 86 | * @return {undefined} 87 | * @private 88 | */ 89 | async _onPeripheralDiscovery(peripheral) { 90 | if (this._peripheral || !this._validatePeripheral(peripheral)) { 91 | return; 92 | } 93 | 94 | Logger.info(`Peripheral found ${peripheral.advertisement.localName}`); 95 | 96 | this._peripheral = peripheral; 97 | 98 | if (['disconnecting', 'disconnected', 'error'].includes(peripheral.state)) { 99 | Logger.info(`Connecting to peripheral`); 100 | 101 | await peripheral.connectAsync(); 102 | } 103 | 104 | if (['connecting', 'connected'].includes(peripheral.state)) { 105 | await this.noble.stopScanningAsync(); 106 | } else { 107 | Logger.info("Something went wrong: " + peripheral.state) 108 | 109 | this._peripheral = null; 110 | } 111 | 112 | this._setupPeripheral(); 113 | } 114 | 115 | /** 116 | * Validates a noble Peripheral class is a Parrot MiniDrone 117 | * @param {Peripheral} peripheral a noble peripheral object class 118 | * @return {boolean} If the peripheral is a drone 119 | * @private 120 | */ 121 | _validatePeripheral(peripheral) { 122 | if (typeof peripheral !== 'object') { 123 | return false; 124 | } 125 | 126 | const localName = peripheral.advertisement?.localName; 127 | const manufacturer = peripheral.advertisement?.manufacturerData; 128 | const matchesFilter = this.droneFilter ? localName === this.droneFilter : false; 129 | 130 | const localNameMatch = matchesFilter || DRONE_PREFIXES.some((prefix) => localName && localName.indexOf(prefix) >= 0); 131 | const manufacturerMatch = manufacturer && MANUFACTURER_SERIALS.indexOf(manufacturer) >= 0; 132 | 133 | // Is TRUE according to droneFilter or if empty, for EITHER an "RS_" name OR manufacturer code. 134 | return localNameMatch || manufacturerMatch; 135 | } 136 | 137 | /** 138 | * Sets up a peripheral and finds all of it's services and characteristics 139 | * @return {undefined} 140 | */ 141 | _setupPeripheral() { 142 | this.peripheral.discoverAllServicesAndCharacteristics((err, services, characteristics) => { 143 | if (err) { 144 | throw err; 145 | } 146 | 147 | this.characteristics = characteristics; 148 | 149 | if (Logger.level === 'debug') { 150 | Logger.debug('Found the following characteristics:'); 151 | 152 | // Get uuids 153 | const characteristicUuids = this.characteristics.map(x => x.uuid.substr(4, 4).toLowerCase()); 154 | 155 | characteristicUuids.sort(); 156 | 157 | characteristicUuids.join(', ').replace(/([^\n]{40,}?), /g, '$1|').split('|').map(s => Logger.debug(s)); 158 | } 159 | 160 | Logger.debug('Preforming handshake'); 161 | for (const uuid of handshakeUuids) { 162 | const target = this.getCharacteristic(uuid); 163 | 164 | target.subscribe(); 165 | } 166 | 167 | Logger.debug('Adding listeners (fb uuid prefix)'); 168 | for (const uuid of receiveUuids.values()) { 169 | const target = this.getCharacteristic(serviceUuids.ARCOMMAND_RECEIVING_SERVICE + uuid); 170 | 171 | target.subscribe(); 172 | target.on('data', data => this._handleIncoming(uuid, data)); 173 | } 174 | 175 | Logger.info(`Device connected ${this.peripheral.advertisement.localName}`); 176 | 177 | // Register some event handlers 178 | /** 179 | * Drone disconnected event 180 | * Fired when the bluetooth connection has been disconnected 181 | * 182 | * @event DroneCommand#disconnected 183 | */ 184 | this.noble.on('disconnect', () => this.emit('disconnected')); 185 | 186 | setTimeout(() => { 187 | /** 188 | * Drone connected event 189 | * You can control the drone once this event has been triggered. 190 | * 191 | * @event DroneCommand#connected 192 | */ 193 | this.emit('connected'); 194 | }, 200); 195 | }); 196 | } 197 | 198 | /** 199 | * @returns {Peripheral} a noble peripheral object class 200 | */ 201 | get peripheral() { 202 | return this._peripheral; 203 | } 204 | 205 | /** 206 | * @returns {boolean} If the drone is connected 207 | */ 208 | get connected() { 209 | return this.characteristics.length > 0; 210 | } 211 | 212 | /** 213 | * Finds a Noble Characteristic class for the given characteristic UUID 214 | * @param {String} uuid The characteristics UUID 215 | * @return {Characteristic} The Noble Characteristic corresponding to that UUID 216 | */ 217 | getCharacteristic(uuid) { 218 | uuid = uuid.toLowerCase(); 219 | 220 | if (typeof this._characteristicLookupCache[uuid] === 'undefined') { 221 | this._characteristicLookupCache[uuid] = this.characteristics.find(x => x.uuid.substr(4, 4).toLowerCase() === uuid); 222 | } 223 | 224 | return this._characteristicLookupCache[uuid]; 225 | } 226 | 227 | /** 228 | * Send a command to the drone and execute it 229 | * @param {DroneCommand} command - Command instance to be ran 230 | * @returns {Promise} - Resolves when the command has been received (if ack is required) 231 | * @async 232 | */ 233 | runCommand(command) { 234 | const buffer = command.toBuffer(); 235 | const packetId = this._getStep(command.bufferType); 236 | 237 | buffer.writeUIntLE(packetId, 1, 1); 238 | 239 | return new Promise(accept => { 240 | Logger.debug(`SEND ${command.bufferType}[${packetId}]: `, command.toString()); 241 | 242 | this.getCharacteristic(command.sendCharacteristicUuid).write(buffer, true); 243 | 244 | switch (command.bufferType) { 245 | case 'DATA_WITH_ACK': 246 | case 'SEND_WITH_ACK': 247 | if (!this._commandCallback['ACK_COMMAND_SENT']) { 248 | this._commandCallback['ACK_COMMAND_SENT'] = []; 249 | } 250 | 251 | this._commandCallback['ACK_COMMAND_SENT'][packetId] = accept; 252 | break; 253 | case 'SEND_HIGH_PRIORITY': 254 | if (!this._commandCallback['ACK_HIGH_PRIORITY']) { 255 | this._commandCallback['ACK_HIGH_PRIORITY'] = []; 256 | } 257 | 258 | this._commandCallback['ACK_HIGH_PRIORITY'][packetId] = accept; 259 | break; 260 | default: 261 | accept(); 262 | break; 263 | } 264 | }); 265 | } 266 | 267 | /** 268 | * Handles incoming data from the drone 269 | * @param {string} channelUuid - The channel uuid 270 | * @param {Buffer} buffer - The packet data 271 | * @private 272 | * @returns {void} 273 | */ 274 | _handleIncoming(channelUuid, buffer) { 275 | const channel = receiveUuids.findForValue(channelUuid); 276 | let callback; 277 | 278 | switch (channel) { 279 | case 'ACK_DRONE_DATA': 280 | // We need to response with an ack 281 | this._updateSensors(buffer, true); 282 | break; 283 | case 'NO_ACK_DRONE_DATA': 284 | this._updateSensors(buffer); 285 | break; 286 | case 'ACK_COMMAND_SENT': 287 | case 'ACK_HIGH_PRIORITY': 288 | const packetId = buffer.readUInt8(2); 289 | 290 | callback = (this._commandCallback[channel] || {})[packetId]; 291 | 292 | if (callback) { 293 | delete this._commandCallback[channel][packetId]; 294 | } 295 | 296 | if (typeof callback === 'function') { 297 | Logger.debug(`${channel}: packet id ${packetId}`); 298 | callback(); 299 | } else { 300 | Logger.debug(`${channel}: packet id ${packetId}, no callback :(`); 301 | } 302 | 303 | break; 304 | default: 305 | Logger.warn(`Got data on an unknown channel ${channel}(${channelUuid}) (wtf!?)`); 306 | break; 307 | } 308 | } 309 | 310 | /** 311 | * Update the sensor 312 | * 313 | * @param {Buffer} buffer - Command buffer 314 | * @param {boolean} ack - If an acknowledgement for receiving the data should be sent 315 | * @private 316 | * @fires DroneConnection#sensor: 317 | * @returns {void} 318 | */ 319 | _updateSensors(buffer, ack = false) { 320 | if (buffer[2] === 0) { 321 | return; 322 | } 323 | 324 | try { 325 | const command = this.parser.parseBuffer(buffer.slice(2)); 326 | const token = [command.projectName, command.className, command.commandName].join('-'); 327 | 328 | this._sensorStore[token] = command; 329 | 330 | Logger.debug('RECV:', command.toString()); 331 | 332 | /** 333 | * Fires when a new sensor reading has been received 334 | * 335 | * @event DroneConnection#sensor: 336 | * @type {DroneCommand} - The sensor reading 337 | * @example 338 | * connection.on('sensor:minidrone-UsbAccessoryState-GunState', function(sensor) { 339 | * if (sensor.state.value === sensor.state.enum.READY) { 340 | * console.log('The gun is ready to fire!'); 341 | * } 342 | * }); 343 | */ 344 | this.emit('sensor:' + token, command); 345 | this.emit('sensor:*', command); 346 | } catch (e) { 347 | Logger.warn('Unable to parse packet:', buffer); 348 | Logger.warn(e); 349 | } 350 | 351 | if (ack) { 352 | const packetId = buffer.readUInt8(1); 353 | 354 | this.ack(packetId); 355 | } 356 | } 357 | 358 | /** 359 | * Get the most recent sensor reading 360 | * 361 | * @param {string} project - Project name 362 | * @param {string} class_ - Class name 363 | * @param {string} command - Command name 364 | * @returns {DroneCommand|undefined} - {@link DroneCommand} instance or {@link undefined} if no sensor reading could be found 365 | * @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/} 366 | */ 367 | getSensor(project, class_, command) { 368 | const token = [project, class_, command].join('-'); 369 | 370 | return this.getSensorFromToken(token); 371 | } 372 | 373 | /** 374 | * Get the most recent sensor reading using the sensor token 375 | * 376 | * @param {string} token - Command token 377 | * @returns {DroneCommand|undefined} - {@link DroneCommand} instance or {@link undefined} if no sensor reading could be found 378 | * @see {@link https://github.com/Parrot-Developers/arsdk-xml/blob/master/xml/} 379 | * @see {@link DroneCommand.getToken} 380 | */ 381 | getSensorFromToken(token) { 382 | let command = this._sensorStore[token]; 383 | 384 | if (command) { 385 | command = command.copy(); 386 | } 387 | 388 | return command; 389 | } 390 | 391 | /** 392 | * Get the logger level 393 | * @returns {string|number} - logger level 394 | * @see {@link https://github.com/winstonjs/winston} 395 | */ 396 | get logLevel() { 397 | return Logger.level; 398 | } 399 | 400 | /** 401 | * Set the logger level 402 | * @param {string|number} value - logger level 403 | * @see {@link https://github.com/winstonjs/winston} 404 | */ 405 | set logLevel(value) { 406 | Logger.level = typeof value === 'number' ? value : value.toString(); 407 | } 408 | 409 | /** 410 | * used to count the drone command steps 411 | * @param {string} id - Step store id 412 | * @returns {number} - step number 413 | */ 414 | _getStep(id) { 415 | if (typeof this._stepStore[id] === 'undefined') { 416 | this._stepStore[id] = 0; 417 | } 418 | 419 | const out = this._stepStore[id]; 420 | 421 | this._stepStore[id]++; 422 | this._stepStore[id] &= 0xFF; 423 | 424 | return out; 425 | } 426 | 427 | /** 428 | * Acknowledge a packet 429 | * @param {number} packetId - ID of the packet to ack 430 | * @returns {void} 431 | */ 432 | ack(packetId) { 433 | Logger.debug('ACK: packet id ' + packetId); 434 | 435 | const characteristic = sendUuids.ACK_COMMAND; 436 | const buffer = Buffer.alloc(3); 437 | 438 | buffer.writeUIntLE(Number.parseInt('0x' + characteristic), 0, 1); 439 | buffer.writeUIntLE(this._getStep(characteristic), 1, 1); 440 | buffer.writeUIntLE(packetId, 2, 1); 441 | 442 | this.getCharacteristic(serviceUuids.ARCOMMAND_SENDING_SERVICE + characteristic).write(buffer, true); 443 | } 444 | } 445 | 446 | module.exports = DroneConnection; 447 | -------------------------------------------------------------------------------- /src/InvalidCommandError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Decimal to hex helper function 3 | * @param {number} d - input 4 | * @returns {string} - output 5 | * @private 6 | */ 7 | function d2h(d) { 8 | const h = Number(d).toString(16); 9 | 10 | return h.length === 1 ? '0' + h : h; 11 | } 12 | 13 | /** 14 | * Thrown when an invalid command is requested or received 15 | */ 16 | class InvalidCommandError extends Error { 17 | constructor(value, type, target, context = []) { 18 | let message; 19 | 20 | if (typeof target === 'number') { 21 | message = 'with the value ' + d2h(target); 22 | } else { 23 | message = `called "${target}"`; 24 | } 25 | 26 | message = `Can't find ${type} ${message}`; 27 | 28 | if (context.length > 0) { 29 | message += ' (' + context.join(', ') + ')'; 30 | } 31 | 32 | super(message); 33 | } 34 | } 35 | 36 | module.exports = InvalidCommandError; 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | InvalidCommandError: require('./InvalidCommandError'), 3 | CommandParser: require('./CommandParser'), 4 | DroneConnection: require('./DroneConnection'), 5 | ARDiscoveryError: require('./ARDiscoveryError'), 6 | }; 7 | -------------------------------------------------------------------------------- /src/util/Enum.js: -------------------------------------------------------------------------------- 1 | const { constant: constantCase } = require('case'); 2 | const { getTypeName } = require('./reflection'); 3 | 4 | /** 5 | * Base enum class 6 | * @example 7 | * const Colors = new Enum(['RED', 'BLACK', 'GREEN', 'WHITE', 'BLUE']); 8 | * 9 | * const Answers = new Enum({ 10 | * YES: true, 11 | * NO: false, 12 | * // Passing functions as values will turn them into getters 13 | * // Getter results will appear in ::values 14 | * MAYBE: () => Math.random() >= 0.5, 15 | * }); 16 | * 17 | * const FontStyles = new Enum(['italic', 'bold', 'underline', 'regular'], true); 18 | * FontStyles.ITALIC === 'italic' 19 | * FontStyles.BOLD === 'bold' 20 | * 21 | * // etc... 22 | */ 23 | class Enum { 24 | /** 25 | * @param {Object|Array} enums - Data to build the enum from 26 | * @param {boolean} auto - Auto generate enum from data making assumptions about 27 | * the data, requires enums to be of type array. 28 | */ 29 | constructor(enums, auto = false) { 30 | const isArray = enums instanceof Array; 31 | 32 | if (auto && !isArray) { 33 | throw new TypeError(`Expected enums to be of type "Array" got "${getTypeName(enums)}"`); 34 | } 35 | 36 | if (isArray && auto) { 37 | for (const row of enums) { 38 | const key = constantCase(row); 39 | 40 | Object.defineProperty(this, key, { 41 | enumerable: true, 42 | value: row, 43 | }); 44 | } 45 | } else if (isArray) { 46 | for (const key of enums) { 47 | Object.defineProperty(this, key, { 48 | enumerable: true, 49 | value: Enum._iota, 50 | }); 51 | } 52 | } else { 53 | for (const key of Object.keys(enums)) { 54 | const init = { enumerable: true }; 55 | 56 | if (typeof enums[key] === 'function') { 57 | init.get = enums[key]; 58 | } else { 59 | init.value = enums[key]; 60 | } 61 | 62 | Object.defineProperty(this, key, init); 63 | } 64 | } 65 | 66 | Object.freeze(this); 67 | } 68 | 69 | /** 70 | * List enum keys 71 | * @returns {Array} - Enum keys 72 | */ 73 | keys() { 74 | return Object.keys(this); 75 | } 76 | 77 | /** 78 | * List enum values 79 | * @returns {Array<*>} - Enum values 80 | */ 81 | values() { 82 | return this.keys() 83 | .map(key => this[key]) 84 | .filter((v, i, s) => s.indexOf(v) === i); 85 | } 86 | 87 | /** 88 | * Find if a key exists 89 | * @param {string|number|*} name - Enum value name 90 | * @returns {boolean} - key exists 91 | */ 92 | hasKey(name) { 93 | return this.keys().includes(name); 94 | } 95 | 96 | /** 97 | * Find if a key exists 98 | * @param {string|number|*} value - Enum value 99 | * @returns {boolean} - value exists 100 | */ 101 | hasValue(value) { 102 | return this.values().includes(value); 103 | } 104 | 105 | /** 106 | * Find key name for value 107 | * @param {string|number|*} value - Enum value 108 | * @returns {string} - key name 109 | */ 110 | findForValue(value) { 111 | const index = this.keys().map(key => this[key]).findIndex(x => x === value); 112 | 113 | return this.keys()[index]; 114 | } 115 | 116 | /** 117 | * Auto incrementing integer 118 | * @returns {number} - enum value 119 | * @private 120 | */ 121 | static get _iota() { 122 | if (!Enum.__iota) { 123 | Enum.__iota = 0; 124 | } 125 | 126 | return Enum.__iota++; 127 | } 128 | 129 | /** 130 | * Get a string representation of the enum 131 | * @returns {string} - String representation of the enum 132 | */ 133 | toString() { 134 | return this.keys().map(key => key + '=' + this[key]).join(', '); 135 | } 136 | } 137 | 138 | module.exports = Enum; 139 | -------------------------------------------------------------------------------- /src/util/reflection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the name of the value type 3 | * @param {*} value - Any value 4 | * @private 5 | * @returns {string} - Value type name 6 | */ 7 | module.exports.getTypeName = function getTypeName(value) { 8 | value = typeof value === 'function' ? value : value.constructor; 9 | 10 | return value.name; 11 | }; 12 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | require('winston').level = 'debug'; 2 | 3 | const {CommandParser} = require('./index'); 4 | const parser = new CommandParser(); 5 | 6 | parser.warmup(); 7 | 8 | const testData = [ 9 | [0x02, 0x0c, 0x02, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04], 10 | [0x02, 0x0d, 0x02, 0x0f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04], 11 | [0x02, 0x0e, 0x02, 0x0f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04], 12 | [0x02, 0x0f, 0x02, 0x13, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00], 13 | [0x02, 0x10, 0x02, 0x03, 0x03, 0x00, 0x00], 14 | [0x02, 0x11, 0x00, 0x05, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], 15 | [0x02, 0x05, 0x02, 0x12, 0x00, 0x00, 0xef, 0x11, 0x02, 0x3f, 0x33, 0xd2, 0x7d, 0xbf, 0xb4, 0xff, 0x00, 0x00, 0x00, 0x00], 16 | [0x02, 0x12, 0x00, 0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01], 17 | [0x02, 0x06, 0x02, 0x12, 0x01, 0x41, 0x55, 0xeb, 0x85, 0xbc, 0x10, 0xe7, 0x32, 0x3c, 0x2a, 0x9b, 0x91, 0xbd, 0xff, 0x53], 18 | ]; 19 | 20 | 21 | let command; 22 | for (const row of testData) { 23 | const buffer = Buffer.from(row.splice(2)); // Remove device id and message counter 24 | command = parser.parseBuffer(buffer); 25 | 26 | console.log(command.toString(true)); 27 | } 28 | 29 | console.log(Math.fround(13.37), command.speed_x.value) 30 | --------------------------------------------------------------------------------