├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples └── 01-read-write.json ├── gulpfile.js ├── images ├── ble1.png ├── ble2.png ├── ble3.png ├── ble4.png ├── ble5.png ├── gatttool-001.jpg ├── gatttool-002.jpg ├── gatttool-003.jpg ├── gatttool-004.jpg ├── gatttool-005.jpg ├── gatttool-006.jpg ├── gatttool-007.jpg └── gatttool-008.jpg ├── package-lock.json ├── package.json ├── src ├── generic-ble.html ├── generic-ble.js ├── locales │ └── en-US │ │ ├── generic-ble.html │ │ └── generic-ble.yml └── noble │ ├── index.js │ └── lib │ └── bluez │ └── bindings.js └── tests ├── generic-ble.test.js └── noble └── lib └── bluez ├── bindings.test.js └── index.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "env": { 13 | "test": { 14 | "plugins": [ 15 | "@babel/plugin-transform-modules-commonjs" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: false, 5 | node: true, 6 | es6: true, 7 | mocha: true, 8 | jest: true 9 | }, 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | sourceType: 'module', 13 | ecmaVersion: 9 14 | }, 15 | extends: [ 16 | 'plugin:prettier/recommended', 17 | 'prettier', 18 | 'eslint:recommended', 19 | ], 20 | plugins: [ 21 | 'prettier' 22 | ], 23 | rules: { 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.map 3 | dist 4 | !gulpfile.js 5 | *.tgz 6 | *.log 7 | .node-version 8 | .python-version -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .node-red 3 | node_modules 4 | images 5 | src 6 | tests 7 | .gitignore 8 | .npmignore 9 | .jshintrc 10 | .travis.yml 11 | gulpfile.js 12 | *.tgz 13 | *.zip 14 | *.log 15 | .dockerignore 16 | .node-version 17 | *.test.js 18 | *.test.js.map 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | 6 | addons: 7 | apt: 8 | sources: 9 | - ubuntu-toolchain-r-test 10 | packages: 11 | - g++-4.8 12 | - bluez 13 | 14 | sudo: require 15 | 16 | install: 17 | - if [[ $TRAVIS_OS_NAME == "linux" ]]; then export CXX=g++-4.8; fi 18 | - $CXX --version 19 | - npm install --unsafe-perm 20 | - npm install 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision History 2 | 3 | * 4.0.3 4 | - Always show BLE error info 5 | - Update dependencies 6 | 7 | * 4.0.2 8 | - Fix Module Loading Error on macOS 9 | 10 | * 4.0.1 11 | - Fix Module Loading Error 12 | 13 | * 4.0.0 14 | - The node category is now `Network` rather than `Input` and `Output`. 15 | - Improve stability on Linux by introducing BlueZ 5 D-Bus API 16 | - On Linux, this node is no longer dependent on the HCI socket library, which has lots of problematic issues that caused inconsistent results with old BlueZ CLI tools. 17 | - Note that when Node-RED process is run by non-root user, add the user to `bluetooth` group so to access BlueZ D-Bus API. For example, run `sudo usermod -G bluetooth -a pi` prior to starting the process if it's run by `pi` user. 18 | - BlueZ's BLE scanning seems to detect devices having `random` address type. But not sure if such devices work with this node properly. 19 | - Tested on Raspbian (4.19.97-v7l+) and Raspberry Pi 3/4. 20 | - With the following packages 21 | - bluez 5.50-1.2~deb10u1 22 | - bluez-firmware 1.2-4+rpt2 23 | - Improve BLE Device Scanning UI/UX 24 | - Check `BLE Scanning` in order to start scanning, and `Scan Result` select box will be fullfilled automatically whenever devices are found. The update will be performed every 10 seconds. The first scan result will appear after 5 seconds since the scanning starts. See the updated README document for detail. 25 | - `GENERIC_BLE_TRACE` environment variable is no longer working. Use `DEBUG` environment variable instead. 26 | - `DEBUG=node-red-contrib-generic-ble:index` is compatible with `GENERIC_BLE_TRACE=true`. 27 | - `DEBUG=node-red-contrib-generic-ble:*` will output all trace logs within the project. 28 | - `DEBUG=node-red-contrib-generic-ble:index:api` will output all API endpoint access logs. 29 | - `DEBUG=node-red-contrib-generic-ble:noble:*` will output all trace logs under `src/noble` modules. 30 | 31 | * 3.1.0 32 | - Support Node.js v10.x LTS (Fix #14 and #17) 33 | 34 | * 3.0.0 35 | - Refactor entire architecture 36 | - Peripheral connections are retained until it disconnects 37 | - Characteristic subscriptions are retained while the ongoing flows are running (will be unsubscribed on stopping them though) 38 | - The max number of concurrent BLE connections is 5 or 6 according to [this document](https://github.com/noble/noble#maximum-simultaneous-connections) 39 | 40 | * 2.0.4 41 | - Fix an issue where this node don't work with noble@1.9.x 42 | 43 | * 2.0.3 44 | - Fix an issue where noble looses a reference to a peripheral after it is disconnected 45 | 46 | * 2.0.2 47 | - Fix an issue where Write operation cannot be performed properly (#4) 48 | 49 | * 2.0.1 50 | - Fix an issue where `Select from scan result` failed to list characteristics 51 | 52 | * 2.0.0 53 | - Add `Poll Notify Events` message support so that Generic BLE out node can start to subscribe the given characteristic events 54 | - Support characteristic query by one or more uuids 55 | - Add `Mute Notify Events` to `Generic BLE` config node for this node to avoid unnecessary device connection for event subscription 56 | - Replace `RED.log` functions with node logging functions as possible to offer precise logging control via UI 57 | - Add `Operation Timeout` to `Generic BLE` config node to set the waiting time for Read/Write/Notify response **per characteristic** rather than per device 58 | - `GENERIC_BLE_OPERATION_WAIT_MS` is introduced for default `Operation Timeout` value 59 | - Remove `Listening Period` from `Generic BLE` config node 60 | - `GENERIC_BLE_NOTIFY_WAIT_MS` is removed 61 | 62 | * 1.0.2 63 | - Improve README 64 | - Add an example flow file available from the editor UI 65 | 66 | * 1.0.1 67 | - Fix an issue where custom characteristics cannot be listed on the Generic BLE config node dialog 68 | 69 | * 1.0.0 70 | - Fix an issue where some devices cannot be discovered within a specific time window even after they can be connected 71 | - Fix an issue where the Scan Result select widget didn't show the same item as the stored device info 72 | - Update Scan Result option list whenever Local Name is resolved 73 | - Improve stability by fixing minor bugs 74 | 75 | * 0.1.0 76 | - Initial Release (alpha) 77 | - `node-red` keyword is not yet added 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-red-contrib-generic-ble 2 | === 3 | 4 | [![GitHub release](https://img.shields.io/github/release/CANDY-LINE/node-red-contrib-generic-ble.svg)](https://github.com/CANDY-LINE/node-red-contrib-generic-ble/releases/latest) 5 | [![master Build Status](https://travis-ci.org/CANDY-LINE/node-red-contrib-generic-ble.svg?branch=master)](https://travis-ci.org/CANDY-LINE/node-red-contrib-generic-ble/) 6 | 7 | A Node-RED node set for providing access to generic BLE **peripheral** GATT characteristics. 8 | 9 | As of v4.0.0, this node is optmized for Linux with BlueZ 5 D-Bus API (HCI socket is no longer used on Linux). 10 | The node should still work on macOS and Windows as nothing is modified for these platforms. 11 | 12 | Supported operations are as follows: 13 | 14 | - Start BLE Scanning 15 | - Stop BLE Scanning 16 | - Restart BLE Scanning (Stop then start BLE Scanning again) 17 | - Connect to a peripheral device 18 | - Disonnect from a peripheral device 19 | - Read 20 | - Write 21 | - Write without Response 22 | - Notify (Subscribing the Notify events) 23 | 24 | The node status modes are as follows: 25 | 26 | - `missing` the configured BLE peripheral device is missing. When the device is discovered, the state transitions to `disconnected`. The `disconnected` device may transiton to `missing` again when RSSI is invalidated (Linux only) 27 | - `disconnected` when the configured BLE peripheral device is found but not conncted 28 | - `connecting` when the configured BLE peripheral device is being connected 29 | - `connected` when the configured BLE peripheral device is connected 30 | - `disconnecting` when the configured BLE peripheral device is being disconnected 31 | - `error` when unexpected error occurs 32 | 33 | Known issues for Linux BlueZ D-Bus API: 34 | 35 | - Unlike the older version, **you must set the process owner's permission properly and manually**. Non-root user's Node-RED process will fail to get this node working. Read [`Installation Note (Linux)` below](#installation-note-linux). 36 | - It seems the local name in advertisement packet isn't transferred to `LocalName` property in org.bluez.Device1 BlueZ D-Bus API. With the HCI socket implementaion, the local name was resolved. So the local name can be resolved on macOS and Windows. 37 | - `Bluetooth: hci0: hardware error 0x03` error sometimes occurs (and logged in syslog). When it's observed, all devices are disconnected and cahches are gone. The node tries to power on the BLE adapter again. 38 | 39 | # How to use 40 | 41 | ## How to configure a new BLE peripheral device 42 | 43 | At first, drag either a `Generic BLE in` node or a `Generic BLE out` node to the workspace from the node palette and double-click the node. And you can find the following dialog. Here, click the pencil icon (`1`) to add a new BLE peripheral or edit the existing one. 44 | 45 | ![ble out node](images/ble1.png) 46 | 47 | Then the new config node dialog appears as shown below. 48 | 49 | The `BLE Scanning` shows whether or not BLE scanning is going on. In order to start BLE scanning, check it (`2`). 50 | 51 | ![ble config node](images/ble2.png) 52 | 53 | As soon as you check it, `Scan Result` select box and `Apply` button appear. The scan results are automatically fufilled in the select box. The content will be refreshed every 10 seconds. 54 | 55 | ![ble config node](images/ble3.png) 56 | 57 | Chosoe one of the listed devices and then click `Apply` to populate `Local Name`, `MAC` and `UUID` input text boxes. Clicking `Apply` button also triggers GATT characteristics discovery as well. 58 | 59 | The following picure shows the `Apply` button clicking results. `GATT Characteristics` has a characteristic list of the selected device. When you see `(not available)` message in the box, check if the device is NOT sleeping (a sleeping device fails to respond to a connect request) and click `Apply` again. 60 | 61 | `GATT Characteristics` must be populated as the node uses the list to verify if a given characteristic UUID is valid on performing `Read`, `Write` and `Subscribe` requests. 62 | 63 | Click `Add` (`3`) to save the information when everything is OK. 64 | 65 | ![ble config node](images/ble4.png) 66 | 67 | Now back to `Generic BLE out` node. 68 | Click `Done` (`4`) to finish the `Generic BLE out` node settings. 69 | 70 | ![ble config node](images/ble5.png) 71 | 72 | You can also import an example flow from the menu icon(`三`) > Import > Examples > node-red-contrib-generic-ble > 01-read-write for learning more about this node. 73 | 74 | ## How to translate gatttool command into flow 75 | 76 | In this example, we show how to describe `gatttool` commands for characteristic value write and read with Generic BLE nodes. 77 | 78 | **NOTICE: As of BlueZ 5, gatttool is deprecated. gatttool will be removed in the future relesase.** 79 | 80 | ### Characteristics Value Write 81 | 82 | The following simple command line just issues a characteristic write request to the handle `0x0027`, which the BLE peripheral associates with the characteristic uuid `f000aa02-0451-4000-b000-000000000000`(uuids and handles can be listed by `gatttool -b 88:99:00:00:FF:FF --characteristics command`). 83 | 84 | ``` 85 | $ gatttool -b 88:99:00:00:FF:FF --char-write-req --handle=0x0027 --value=ca 86 | Characteristic value was written successfully 87 | ``` 88 | 89 | In this tutorial, we translate the above command into Node-RED flow. 90 | 91 | First of all, we use the following nodes. 92 | 93 | 1. `inject` node to trigger a write request 94 | 1. `Generic BLE out` node to perform the write request 95 | 96 | ![gatttool](images/gatttool-001.jpg) 97 | 98 | So the first step to create a flow is to place the above nodes on the workspace and connect them as shown above. 99 | 100 | Next, open the `inject` dialog so that you can provide the write request parameters, the characteristic uuid and the value. 101 | 102 | **Important!) Unlike `gatttool`, Generic BLE nodes NEVER use `handles`. Always use `uuid`s instead.** 103 | 104 | ![gatttool](images/gatttool-002.jpg) 105 | 106 | In this dialog, choose `JSON` at Payload input item since `Generic BLE out` node accepts a JSON object as its input value. See `Inputs` in the node description shown in the `info` tab for detail. 107 | 108 | ![gatttool](images/gatttool-003.jpg) 109 | 110 | Click/tap `...` to launch JSON value editor and populate the following JSON text. 111 | 112 | ``` 113 | { 114 | "f000aa0204514000b000000000000000": "ca" 115 | } 116 | ``` 117 | 118 | The property `f000aa0204514000b000000000000000` is a characteristic `uuid`. However, unlike `gatttool`, you must strip hyphens from the original uuid value. `Generic BLE` nodes doesn't accept `gatttool` style uuid format. 119 | 120 | The value `ca` is a hex string to be written, which is identical to the above command line. 121 | 122 | So you'll see the following image. 123 | 124 | ![gatttool](images/gatttool-004.jpg) 125 | 126 | Close the dialog by clicking `Done` button after entering the JSON text. 127 | 128 | Configure `Generic BLE out` node for your BLE peripheral (This step is already introduced above so we don't describe here. See `How to configure a new BLE peripheral`). 129 | 130 | Now you're ready to issue a characteristic write request to your BLE peripheral. Click `Deploy` and click `inject` node to issue a characteristic write request. 131 | 132 | ![gatttool](images/gatttool-005.jpg) 133 | 134 | Node-RED shows the notification message after your write request is performed successfully. 135 | 136 | Here in this tutorial, we use `inject` node to create characteristic write request parameters. However, this isn't the only way to do so. You can use other nodes than `inject` node. All you need is to prepare a valid JSON object for `Generic BLE out` node and provide it to the node. 137 | 138 | In order to retrieve the written value from your BLE peripheral, go to the next step. 139 | 140 | ### Characteristics Value Read 141 | 142 | The both commands perform characteristic value read commands and return the same result, the characteristic value of the uuid `f000aa02-0451-4000-b000-000000000000`. 143 | 144 | ``` 145 | $ gatttool -b 88:99:00:00:FF:FF --char-read -u f000aa02-0451-4000-b000-000000000000 146 | handle: 0x0027 value: ca 147 | 148 | $ gatttool -b 88:99:00:00:FF:FF --char-read --handle=0x0027 149 | Characteristic value/descriptor: ca 150 | ``` 151 | 152 | In this tutorial, we translate the above commands into Node-RED flow. 153 | 154 | We use the following nodes this time. 155 | 156 | 1. `inject` node to trigger a read command 157 | 1. `Generic BLE in` node to perform the read command 158 | 1. `debug` node to show the read value 159 | 160 | ![gatttool](images/gatttool-006.jpg) 161 | 162 | 163 | Put the above nodes onto your workspace and add connectors like above. 164 | 165 | Open `inject` node dialog and enter the characteristic `uuid` at Topic input box. Leave default values other than Topic since `Generic BLE in` sees only the topic value. 166 | 167 | You can also leave Topic empty when you want to retrieve all characteristics values. 168 | 169 | ![gatttool](images/gatttool-007.jpg) 170 | 171 | Click `Done` after entering the uuid to close the dialog. You need to configure `Generic BLE in` node to use your BLE peripheral but we skip to mention here as the instruction is described above (See `How to configure a new BLE peripheral` for detail). 172 | 173 | Click `Deploy` to function the flow. 174 | 175 | ![gatttool](images/gatttool-008.jpg) 176 | 177 | Let's read the characteristic value by clicking `inject` node pedal. The read result will be displayed on the debug tab. 178 | 179 | ## BLE in and out nodes 180 | 181 | See `info` tab for detail on the editor UI. 182 | 183 | # Example Flow 184 | 185 | You can import [the example flow](examples/01-read-write.json) on Node-RED UI. 186 | 187 | # Installation Note (Linux) 188 | 189 | The Node-RED process owner must belong to `bluetooth` group in order to access BlueZ D-Bus API, otherwise this node doesn't work at all because of bluetoothd permission issue. 190 | For example, if you're going to run the process by `pi` user, run the following command. 191 | 192 | ``` 193 | sudo usermod -G bluetooth -a pi 194 | ``` 195 | 196 | Then reboot the OS so that the policy changes take effect. 197 | 198 | ``` 199 | sudo reboot 200 | ``` 201 | 202 | ## Node-RED users 203 | 204 | Run the following commands: 205 | ``` 206 | cd ~/.node-red 207 | npm install node-red-contrib-generic-ble 208 | ``` 209 | 210 | Then restart Node-RED process. Again, for Linux users, read the above chapter `Installation Note (Linux)` to get this node working. 211 | 212 | ## CANDY RED users 213 | 214 | Run the following commands: 215 | ``` 216 | cd $(npm -g root)/candy-red 217 | sudo npm install --unsafe-perm node-red-contrib-generic-ble 218 | ``` 219 | 220 | Then restart `candy-red` service. 221 | 222 | ``` 223 | sudo systemctl restart candy-red 224 | ``` 225 | 226 | # Appendix 227 | 228 | ## How to build 229 | 230 | ``` 231 | # build 232 | $ NODE_ENV=development npm run build 233 | # package 234 | $ NODE_ENV=development npm pack 235 | ``` 236 | -------------------------------------------------------------------------------- /examples/01-read-write.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "8e787ad.6aa1688", 4 | "type": "tab", 5 | "label": "Generic BLE Example", 6 | "disabled": false, 7 | "info": "This flow shows BLE Read/Write example.\n\nTI's CC2650 user's guide is abailable at https://processors.wiki.ti.com/index.php/CC2650_SensorTag_User%27s_Guide#Use_of_buttons\n\nCC2650's base UUID is f0000000-0451-4000-b000-000000000000. Replace last 0000 in f0000000 with the sensor's UUID in order to find the 128-bit characteristic UUID." 8 | }, 9 | { 10 | "id": "4f0acd3.30d5234", 11 | "type": "Generic BLE in", 12 | "z": "8e787ad.6aa1688", 13 | "name": "", 14 | "genericBle": "22ee5c9e.9aa894", 15 | "useString": false, 16 | "notification": true, 17 | "x": 570, 18 | "y": 360, 19 | "wires": [ 20 | [ 21 | "feeba80a.a3064" 22 | ] 23 | ] 24 | }, 25 | { 26 | "id": "94a959c7.4c4c7", 27 | "type": "inject", 28 | "z": "8e787ad.6aa1688", 29 | "name": "Get Temperature (AA01)", 30 | "topic": "f000aa0104514000b000000000000000", 31 | "payload": "", 32 | "payloadType": "date", 33 | "repeat": "", 34 | "crontab": "", 35 | "once": false, 36 | "onceDelay": "", 37 | "x": 180, 38 | "y": 300, 39 | "wires": [ 40 | [ 41 | "4f0acd3.30d5234" 42 | ] 43 | ] 44 | }, 45 | { 46 | "id": "feeba80a.a3064", 47 | "type": "debug", 48 | "z": "8e787ad.6aa1688", 49 | "name": "", 50 | "active": true, 51 | "console": "false", 52 | "complete": "false", 53 | "x": 770, 54 | "y": 360, 55 | "wires": [] 56 | }, 57 | { 58 | "id": "7efff245.e4d254", 59 | "type": "Generic BLE out", 60 | "z": "8e787ad.6aa1688", 61 | "name": "", 62 | "genericBle": "22ee5c9e.9aa894", 63 | "x": 790, 64 | "y": 180, 65 | "wires": [] 66 | }, 67 | { 68 | "id": "769a559a.dc9a4c", 69 | "type": "inject", 70 | "z": "8e787ad.6aa1688", 71 | "name": "Start Temperature Measurement", 72 | "topic": "", 73 | "payload": "{\"f000aa0204514000b000000000000000\":\"01\"}", 74 | "payloadType": "json", 75 | "repeat": "", 76 | "crontab": "", 77 | "once": false, 78 | "onceDelay": "", 79 | "x": 510, 80 | "y": 120, 81 | "wires": [ 82 | [ 83 | "7efff245.e4d254" 84 | ] 85 | ] 86 | }, 87 | { 88 | "id": "174514e3.b64d63", 89 | "type": "inject", 90 | "z": "8e787ad.6aa1688", 91 | "name": "Get All Readable Attributes", 92 | "topic": "", 93 | "payload": "", 94 | "payloadType": "date", 95 | "repeat": "", 96 | "crontab": "", 97 | "once": false, 98 | "x": 190, 99 | "y": 200, 100 | "wires": [ 101 | [ 102 | "4f0acd3.30d5234" 103 | ] 104 | ] 105 | }, 106 | { 107 | "id": "e4653fa6.1df4f8", 108 | "type": "inject", 109 | "z": "8e787ad.6aa1688", 110 | "name": "Start Temperature Notify Events for 5 seconds", 111 | "topic": "f000aa0104514000b000000000000000", 112 | "payload": "{\"notify\":true,\"period\":5000}", 113 | "payloadType": "json", 114 | "repeat": "", 115 | "crontab": "", 116 | "once": false, 117 | "onceDelay": "", 118 | "x": 250, 119 | "y": 340, 120 | "wires": [ 121 | [ 122 | "4f0acd3.30d5234" 123 | ] 124 | ] 125 | }, 126 | { 127 | "id": "81b85e32.1b8c68", 128 | "type": "inject", 129 | "z": "8e787ad.6aa1688", 130 | "name": "Connect a CC2650 SensorTag", 131 | "topic": "connect", 132 | "payload": "", 133 | "payloadType": "date", 134 | "repeat": "", 135 | "crontab": "", 136 | "once": false, 137 | "onceDelay": "", 138 | "x": 500, 139 | "y": 80, 140 | "wires": [ 141 | [ 142 | "7efff245.e4d254" 143 | ] 144 | ] 145 | }, 146 | { 147 | "id": "ea53fb2c.8f1cb8", 148 | "type": "inject", 149 | "z": "8e787ad.6aa1688", 150 | "name": "Disonnect a CC2650 SensorTag", 151 | "topic": "disconnect", 152 | "payload": "", 153 | "payloadType": "date", 154 | "repeat": "", 155 | "crontab": "", 156 | "once": false, 157 | "onceDelay": "", 158 | "x": 210, 159 | "y": 440, 160 | "wires": [ 161 | [ 162 | "4f0acd3.30d5234" 163 | ] 164 | ] 165 | }, 166 | { 167 | "id": "fca7f32.52ff19", 168 | "type": "comment", 169 | "z": "8e787ad.6aa1688", 170 | "name": "Perform after connected", 171 | "info": "", 172 | "x": 190, 173 | "y": 160, 174 | "wires": [] 175 | }, 176 | { 177 | "id": "42bd68d3.b93bc8", 178 | "type": "comment", 179 | "z": "8e787ad.6aa1688", 180 | "name": "Perform after Temperature Measurement Started", 181 | "info": "", 182 | "x": 260, 183 | "y": 260, 184 | "wires": [] 185 | }, 186 | { 187 | "id": "39802692.3d633a", 188 | "type": "comment", 189 | "z": "8e787ad.6aa1688", 190 | "name": "Disconnect a peripheral device", 191 | "info": "", 192 | "x": 210, 193 | "y": 400, 194 | "wires": [] 195 | }, 196 | { 197 | "id": "d3cf629c.017a98", 198 | "type": "comment", 199 | "z": "8e787ad.6aa1688", 200 | "name": "Set up a peripheral device", 201 | "info": "", 202 | "x": 490, 203 | "y": 40, 204 | "wires": [] 205 | }, 206 | { 207 | "id": "22ee5c9e.9aa894", 208 | "type": "Generic BLE", 209 | "z": "", 210 | "localName": "CC2650 SensorTag", 211 | "address": "11:22:33:aa:bb:cc", 212 | "uuid": "112233aabbcc", 213 | "characteristics": [ 214 | { 215 | "uuid": "2a50", 216 | "name": "PnP ID", 217 | "type": "org.bluetooth.characteristic.pnp_id", 218 | "notifiable": false, 219 | "readable": true, 220 | "writable": false, 221 | "writeWithoutResponse": false 222 | }, 223 | { 224 | "uuid": "2a2a", 225 | "name": "IEEE 11073-20601 Regulatory Certification Data List", 226 | "type": "org.bluetooth.characteristic.ieee_11073-20601_regulatory_certification_data_list", 227 | "notifiable": false, 228 | "readable": true, 229 | "writable": false, 230 | "writeWithoutResponse": false 231 | }, 232 | { 233 | "uuid": "2a29", 234 | "name": "Manufacturer Name String", 235 | "type": "org.bluetooth.characteristic.manufacturer_name_string", 236 | "notifiable": false, 237 | "readable": true, 238 | "writable": false, 239 | "writeWithoutResponse": false 240 | }, 241 | { 242 | "uuid": "2a28", 243 | "name": "Software Revision String", 244 | "type": "org.bluetooth.characteristic.software_revision_string", 245 | "notifiable": false, 246 | "readable": true, 247 | "writable": false, 248 | "writeWithoutResponse": false 249 | }, 250 | { 251 | "uuid": "2a27", 252 | "name": "Hardware Revision String", 253 | "type": "org.bluetooth.characteristic.hardware_revision_string", 254 | "notifiable": false, 255 | "readable": true, 256 | "writable": false, 257 | "writeWithoutResponse": false 258 | }, 259 | { 260 | "uuid": "2a26", 261 | "name": "Firmware Revision String", 262 | "type": "org.bluetooth.characteristic.firmware_revision_string", 263 | "notifiable": false, 264 | "readable": true, 265 | "writable": false, 266 | "writeWithoutResponse": false 267 | }, 268 | { 269 | "uuid": "2a25", 270 | "name": "Serial Number String", 271 | "type": "org.bluetooth.characteristic.serial_number_string", 272 | "notifiable": false, 273 | "readable": true, 274 | "writable": false, 275 | "writeWithoutResponse": false 276 | }, 277 | { 278 | "uuid": "2a24", 279 | "name": "Model Number String", 280 | "type": "org.bluetooth.characteristic.model_number_string", 281 | "notifiable": false, 282 | "readable": true, 283 | "writable": false, 284 | "writeWithoutResponse": false 285 | }, 286 | { 287 | "uuid": "2a23", 288 | "name": "System ID", 289 | "type": "org.bluetooth.characteristic.system_id", 290 | "notifiable": false, 291 | "readable": true, 292 | "writable": false, 293 | "writeWithoutResponse": false 294 | }, 295 | { 296 | "uuid": "ffe1", 297 | "name": "", 298 | "type": "(Custom Type)", 299 | "notifiable": true, 300 | "readable": false, 301 | "writable": false, 302 | "writeWithoutResponse": false 303 | }, 304 | { 305 | "uuid": "f000aa0304514000b000000000000000", 306 | "name": "", 307 | "type": "(Custom Type)", 308 | "notifiable": false, 309 | "readable": true, 310 | "writable": true, 311 | "writeWithoutResponse": false 312 | }, 313 | { 314 | "uuid": "f000aa0204514000b000000000000000", 315 | "name": "", 316 | "type": "(Custom Type)", 317 | "notifiable": false, 318 | "readable": true, 319 | "writable": true, 320 | "writeWithoutResponse": false 321 | }, 322 | { 323 | "uuid": "f000aa0104514000b000000000000000", 324 | "name": "", 325 | "type": "(Custom Type)", 326 | "notifiable": true, 327 | "readable": true, 328 | "writable": false, 329 | "writeWithoutResponse": false 330 | }, 331 | { 332 | "uuid": "f000aa2304514000b000000000000000", 333 | "name": "", 334 | "type": "(Custom Type)", 335 | "notifiable": false, 336 | "readable": true, 337 | "writable": true, 338 | "writeWithoutResponse": false 339 | }, 340 | { 341 | "uuid": "f000aa2204514000b000000000000000", 342 | "name": "", 343 | "type": "(Custom Type)", 344 | "notifiable": false, 345 | "readable": true, 346 | "writable": true, 347 | "writeWithoutResponse": false 348 | }, 349 | { 350 | "uuid": "f000aa2104514000b000000000000000", 351 | "name": "", 352 | "type": "(Custom Type)", 353 | "notifiable": true, 354 | "readable": true, 355 | "writable": false, 356 | "writeWithoutResponse": false 357 | }, 358 | { 359 | "uuid": "f000aa4404514000b000000000000000", 360 | "name": "", 361 | "type": "(Custom Type)", 362 | "notifiable": false, 363 | "readable": true, 364 | "writable": true, 365 | "writeWithoutResponse": false 366 | }, 367 | { 368 | "uuid": "f000aa4204514000b000000000000000", 369 | "name": "", 370 | "type": "(Custom Type)", 371 | "notifiable": false, 372 | "readable": true, 373 | "writable": true, 374 | "writeWithoutResponse": false 375 | }, 376 | { 377 | "uuid": "f000aa4104514000b000000000000000", 378 | "name": "", 379 | "type": "(Custom Type)", 380 | "notifiable": true, 381 | "readable": true, 382 | "writable": false, 383 | "writeWithoutResponse": false 384 | }, 385 | { 386 | "uuid": "f000aa6604514000b000000000000000", 387 | "name": "", 388 | "type": "(Custom Type)", 389 | "notifiable": false, 390 | "readable": true, 391 | "writable": true, 392 | "writeWithoutResponse": false 393 | }, 394 | { 395 | "uuid": "f000aa6504514000b000000000000000", 396 | "name": "", 397 | "type": "(Custom Type)", 398 | "notifiable": false, 399 | "readable": true, 400 | "writable": true, 401 | "writeWithoutResponse": false 402 | }, 403 | { 404 | "uuid": "f000aa7304514000b000000000000000", 405 | "name": "", 406 | "type": "(Custom Type)", 407 | "notifiable": false, 408 | "readable": true, 409 | "writable": true, 410 | "writeWithoutResponse": false 411 | }, 412 | { 413 | "uuid": "f000aa7204514000b000000000000000", 414 | "name": "", 415 | "type": "(Custom Type)", 416 | "notifiable": false, 417 | "readable": true, 418 | "writable": true, 419 | "writeWithoutResponse": false 420 | }, 421 | { 422 | "uuid": "f000aa7104514000b000000000000000", 423 | "name": "", 424 | "type": "(Custom Type)", 425 | "notifiable": true, 426 | "readable": true, 427 | "writable": false, 428 | "writeWithoutResponse": false 429 | }, 430 | { 431 | "uuid": "f000aa8304514000b000000000000000", 432 | "name": "", 433 | "type": "(Custom Type)", 434 | "notifiable": false, 435 | "readable": true, 436 | "writable": true, 437 | "writeWithoutResponse": false 438 | }, 439 | { 440 | "uuid": "f000aa8204514000b000000000000000", 441 | "name": "", 442 | "type": "(Custom Type)", 443 | "notifiable": false, 444 | "readable": true, 445 | "writable": true, 446 | "writeWithoutResponse": false 447 | }, 448 | { 449 | "uuid": "f000aa8104514000b000000000000000", 450 | "name": "", 451 | "type": "(Custom Type)", 452 | "notifiable": true, 453 | "readable": true, 454 | "writable": false, 455 | "writeWithoutResponse": false 456 | }, 457 | { 458 | "uuid": "f000ac0304514000b000000000000000", 459 | "name": "", 460 | "type": "(Custom Type)", 461 | "notifiable": false, 462 | "readable": true, 463 | "writable": true, 464 | "writeWithoutResponse": false 465 | }, 466 | { 467 | "uuid": "f000ac0204514000b000000000000000", 468 | "name": "", 469 | "type": "(Custom Type)", 470 | "notifiable": false, 471 | "readable": true, 472 | "writable": true, 473 | "writeWithoutResponse": false 474 | }, 475 | { 476 | "uuid": "f000ac0104514000b000000000000000", 477 | "name": "", 478 | "type": "(Custom Type)", 479 | "notifiable": false, 480 | "readable": true, 481 | "writable": true, 482 | "writeWithoutResponse": false 483 | }, 484 | { 485 | "uuid": "f000ccc304514000b000000000000000", 486 | "name": "", 487 | "type": "(Custom Type)", 488 | "notifiable": false, 489 | "readable": false, 490 | "writable": true, 491 | "writeWithoutResponse": false 492 | }, 493 | { 494 | "uuid": "f000ccc204514000b000000000000000", 495 | "name": "", 496 | "type": "(Custom Type)", 497 | "notifiable": false, 498 | "readable": false, 499 | "writable": true, 500 | "writeWithoutResponse": false 501 | }, 502 | { 503 | "uuid": "f000ccc104514000b000000000000000", 504 | "name": "", 505 | "type": "(Custom Type)", 506 | "notifiable": true, 507 | "readable": true, 508 | "writable": false, 509 | "writeWithoutResponse": false 510 | }, 511 | { 512 | "uuid": "f000ffc404514000b000000000000000", 513 | "name": "", 514 | "type": "(Custom Type)", 515 | "notifiable": true, 516 | "readable": true, 517 | "writable": false, 518 | "writeWithoutResponse": false 519 | }, 520 | { 521 | "uuid": "f000ffc304514000b000000000000000", 522 | "name": "", 523 | "type": "(Custom Type)", 524 | "notifiable": false, 525 | "readable": false, 526 | "writable": true, 527 | "writeWithoutResponse": false 528 | }, 529 | { 530 | "uuid": "f000ffc204514000b000000000000000", 531 | "name": "", 532 | "type": "(Custom Type)", 533 | "notifiable": true, 534 | "readable": false, 535 | "writable": true, 536 | "writeWithoutResponse": false 537 | }, 538 | { 539 | "uuid": "f000ffc104514000b000000000000000", 540 | "name": "", 541 | "type": "(Custom Type)", 542 | "notifiable": true, 543 | "readable": false, 544 | "writable": true, 545 | "writeWithoutResponse": false 546 | } 547 | ] 548 | } 549 | ] -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2017 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | const gulp = require('gulp'); 19 | const babel = require('gulp-babel'); 20 | const uglify = require('gulp-uglify-es').default; 21 | const del = require('del'); 22 | const eslint = require('gulp-eslint'); 23 | const jest = require('gulp-jest').default; 24 | const sourcemaps = require('gulp-sourcemaps'); 25 | const gulpIf = require('gulp-if'); 26 | const htmlmin = require('gulp-htmlmin'); 27 | const cleancss = require('gulp-clean-css'); 28 | const less = require('gulp-less'); 29 | const yaml = require('gulp-yaml'); 30 | const prettier = require('gulp-prettier'); 31 | 32 | gulp.task('lintSrcs', () => { 33 | return gulp.src(['./src/**/*.js']) 34 | .pipe( 35 | eslint({ 36 | useEslintrc: true, 37 | fix: true, 38 | }) 39 | ) 40 | .pipe(eslint.format()) 41 | .pipe(prettier()) 42 | .pipe( 43 | gulpIf((file) => { 44 | return file.eslint != null && file.eslint.fixed; 45 | }, gulp.dest('./src')) 46 | ) 47 | .pipe(eslint.failAfterError()); 48 | }); 49 | 50 | gulp.task('lintTests', () => { 51 | return gulp.src(['./tests/**/*.js']) 52 | .pipe( 53 | eslint({ 54 | useEslintrc: true, 55 | fix: true, 56 | }) 57 | ) 58 | .pipe(eslint.format()) 59 | .pipe(prettier()) 60 | .pipe( 61 | gulpIf((file) => { 62 | return file.eslint != null && file.eslint.fixed; 63 | }, gulp.dest('./tests')) 64 | ) 65 | .pipe(eslint.failAfterError()); 66 | }); 67 | 68 | gulp.task('lint', gulp.series('lintSrcs', 'lintTests')); 69 | 70 | gulp.task('clean', () => { 71 | return del([ 72 | 'dist/*', 73 | './dist', 74 | '!node_modules/**/*', 75 | './*.tgz', 76 | ]); 77 | }); 78 | 79 | gulp.task('cleanTestJs', () => { 80 | return del([ 81 | 'dist/**/*.test.js', 82 | ]); 83 | }); 84 | 85 | gulp.task('i18n', () => { 86 | return gulp.src([ 87 | './src/locales/**/*.{yaml,yml}' 88 | ]) 89 | .pipe(yaml({ safe: true })) 90 | .pipe(gulp.dest('./dist/locales')); 91 | }); 92 | 93 | gulp.task('assets', gulp.series('i18n', () => { 94 | return gulp.src([ 95 | './src/**/*.{less,ico,png,json,yaml,yml}', 96 | '!./src/locales/**/*.{yaml,yml}' 97 | ]) 98 | .pipe(gulp.dest('./dist')); 99 | })); 100 | 101 | gulp.task('js', gulp.series('assets', () => { 102 | return gulp.src('./src/**/*.js') 103 | .pipe(sourcemaps.init()) 104 | .pipe( 105 | babel({ 106 | minified: true, 107 | compact: true, 108 | configFile: './.babelrc', 109 | }) 110 | ) 111 | .pipe(uglify({ 112 | mangle: true, 113 | output: { 114 | comments: 'some', 115 | }, 116 | compress: { 117 | dead_code: true, 118 | drop_debugger: true, 119 | properties: true, 120 | unused: true, 121 | toplevel: true, 122 | if_return: true, 123 | drop_console: true, 124 | conditionals: true, 125 | unsafe_math: true, 126 | unsafe: true 127 | }, 128 | })) 129 | .pipe(sourcemaps.write('.')) 130 | .pipe(gulp.dest('./dist')); 131 | })); 132 | 133 | gulp.task('less', () => { 134 | return gulp.src('./src/**/*.less') 135 | .pipe(sourcemaps.init()) 136 | .pipe(less()) 137 | .pipe(cleancss({compatibility: 'ie8'})) 138 | .pipe(sourcemaps.write('.')) 139 | .pipe(gulp.dest('./dist')); 140 | }); 141 | 142 | gulp.task('html', () => { 143 | return gulp.src([ 144 | './src/**/*.html', 145 | '!./src/nodes/*/node_modules/**/*.html', 146 | ]) 147 | .pipe(htmlmin({ 148 | collapseWhitespace: true, 149 | conservativeCollapse: true, 150 | minifyJS: true, minifyCSS: true, 151 | removeComments: true 152 | })) 153 | .pipe(gulp.dest('./dist')); 154 | }); 155 | 156 | gulp.task('build', gulp.series('lint', 'js', 'less', 'html', 'assets')); 157 | 158 | gulp.task('testAssets', () => { 159 | return gulp.src('./tests/**/*.{css,less,ico,png,html,json,yaml,yml}') 160 | .pipe(gulp.dest('./dist')); 161 | }); 162 | 163 | gulp.task('test', gulp.series('build', 'testAssets', done => { 164 | process.env.NODE_ENV = 'test'; 165 | return gulp.src('tests').pipe(jest({ 166 | modulePaths: [ 167 | '/src' 168 | ], 169 | preprocessorIgnorePatterns: [ 170 | '/dist/', 171 | '/node_modules/' 172 | ], 173 | verbose: true, 174 | automock: false 175 | })) 176 | .once('error', () => { done();process.exit(1); }) 177 | .once('end', () => { done();process.exit(); }) 178 | })); 179 | 180 | gulp.task('default', gulp.series('build')); 181 | -------------------------------------------------------------------------------- /images/ble1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/ble1.png -------------------------------------------------------------------------------- /images/ble2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/ble2.png -------------------------------------------------------------------------------- /images/ble3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/ble3.png -------------------------------------------------------------------------------- /images/ble4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/ble4.png -------------------------------------------------------------------------------- /images/ble5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/ble5.png -------------------------------------------------------------------------------- /images/gatttool-001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-001.jpg -------------------------------------------------------------------------------- /images/gatttool-002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-002.jpg -------------------------------------------------------------------------------- /images/gatttool-003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-003.jpg -------------------------------------------------------------------------------- /images/gatttool-004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-004.jpg -------------------------------------------------------------------------------- /images/gatttool-005.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-005.jpg -------------------------------------------------------------------------------- /images/gatttool-006.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-006.jpg -------------------------------------------------------------------------------- /images/gatttool-007.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-007.jpg -------------------------------------------------------------------------------- /images/gatttool-008.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CANDY-LINE/node-red-contrib-generic-ble/6c976faeab5e09526259c3de686d26b9340c8fcf/images/gatttool-008.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-generic-ble", 3 | "version": "4.0.3", 4 | "description": "Node-RED nodes for generic BLE devices", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/CANDY-LINE/node-red-contrib-generic-ble.git" 9 | }, 10 | "author": "Daisuke Baba ", 11 | "bugs": { 12 | "url": "https://github.com/CANDY-LINE/node-red-contrib-generic-ble/issues" 13 | }, 14 | "scripts": { 15 | "build": "gulp build", 16 | "test": "DEBUG=node-red-contrib-generic-ble:* gulp test", 17 | "clean": "gulp clean", 18 | "prepublish": "gulp build" 19 | }, 20 | "homepage": "https://github.com/CANDY-LINE/node-red-contrib-generic-ble#readme", 21 | "keywords": [ 22 | "node-red", 23 | "bluetooth", 24 | "BLE", 25 | "bluetooth low energy", 26 | "bluetooth smart", 27 | "CANDY RED", 28 | "CANDY EGG" 29 | ], 30 | "devDependencies": { 31 | "@babel/core": "^7.12.3", 32 | "@babel/plugin-transform-modules-commonjs": "^7.12.1", 33 | "@babel/preset-env": "^7.12.1", 34 | "del": "^2.2.2", 35 | "eslint": "^6.8.0", 36 | "eslint-config-prettier": "^6.15.0", 37 | "eslint-plugin-prettier": "^3.1.4", 38 | "gulp": "^4.0.2", 39 | "gulp-babel": "^8.0.0", 40 | "gulp-clean-css": "^2.4.0", 41 | "gulp-cli": "^2.3.0", 42 | "gulp-eslint": "^6.0.0", 43 | "gulp-header": "^1.8.8", 44 | "gulp-htmlmin": "^3.0.0", 45 | "gulp-if": "^3.0.0", 46 | "gulp-jest": "^4.0.3", 47 | "gulp-less": "^4.0.1", 48 | "gulp-prettier": "^3.0.0", 49 | "gulp-resources": "^0.5.0", 50 | "gulp-sourcemaps": "^2.6.5", 51 | "gulp-uglify-es": "^2.0.0", 52 | "gulp-yaml": "^1.0.1", 53 | "jest": "^24.9.0", 54 | "jest-cli": "^24.9.0", 55 | "natives": "^1.1.6", 56 | "prettier": "^2.1.2", 57 | "sinon": "^2.1.0", 58 | "supertest": "^1.1.0" 59 | }, 60 | "dependencies": { 61 | "@abandonware/noble": "^1.9.2-10", 62 | "dbus-next": "^0.8.2", 63 | "debug": "^4.2.0", 64 | "source-map-support": "^0.5.19" 65 | }, 66 | "node-red": { 67 | "nodes": { 68 | "generic-ble": "dist/generic-ble.js" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/generic-ble.html: -------------------------------------------------------------------------------- 1 | 26 | 27 | 56 | 57 | 72 | 73 | 101 | 102 | 146 | 147 | 420 | -------------------------------------------------------------------------------- /src/generic-ble.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2017 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import noble from './noble'; 19 | import debugLogger from 'debug'; 20 | 21 | const debug = debugLogger('node-red-contrib-generic-ble:index'); 22 | const debugIn = debugLogger( 23 | 'node-red-contrib-generic-ble:index:generic-ble-in' 24 | ); 25 | const debugOut = debugLogger( 26 | 'node-red-contrib-generic-ble:index:generic-ble-out' 27 | ); 28 | const debugCfg = debugLogger('node-red-contrib-generic-ble:index:generic-ble'); 29 | const debugApi = debugLogger('node-red-contrib-generic-ble:index:api'); 30 | 31 | // Workaround for a Jest Issue 32 | // https://github.com/kulshekhar/ts-jest/issues/727#issuecomment-422747294 33 | if (process.env.NODE_ENV !== 'test') { 34 | debug('Requiring "source-map-support/register"...'); 35 | require('source-map-support/register'); 36 | } 37 | const configBleDevices = {}; 38 | const genericBleState = { 39 | scanning: false, 40 | }; 41 | const handlers = { 42 | // global event handlers 43 | }; 44 | 45 | function getAddressOrUUID(peripheral) { 46 | if (!peripheral) { 47 | return null; 48 | } 49 | if (!peripheral.address || peripheral.address === 'unknown') { 50 | return peripheral.uuid; 51 | } 52 | return peripheral.address; 53 | } 54 | 55 | function valToBuffer(hexOrIntArray, len = 1) { 56 | if (Buffer.isBuffer(hexOrIntArray)) { 57 | return hexOrIntArray; 58 | } 59 | if (typeof hexOrIntArray === 'number') { 60 | let rawHex = parseInt(hexOrIntArray).toString(16); 61 | if (rawHex.length < len * 2) { 62 | rawHex = Array(len * 2 - rawHex.length + 1).join('0') + rawHex; 63 | } 64 | if (rawHex.length % 2 === 1) { 65 | rawHex = '0' + rawHex; 66 | } 67 | return Buffer.from(rawHex, 'hex'); 68 | } 69 | if (typeof hexOrIntArray === 'string') { 70 | if (hexOrIntArray.length < len * 2) { 71 | hexOrIntArray = 72 | Array(len * 2 - hexOrIntArray.length + 1).join('0') + hexOrIntArray; 73 | } 74 | if (hexOrIntArray.length % 2 === 1) { 75 | hexOrIntArray = '0' + hexOrIntArray; 76 | } 77 | return Buffer.from(hexOrIntArray, 'hex'); 78 | } 79 | if (Array.isArray(hexOrIntArray)) { 80 | for (let i = 0; i < len - hexOrIntArray.length; i++) { 81 | hexOrIntArray.splice(0, 0, 0); 82 | } 83 | return Buffer.from(hexOrIntArray); 84 | } 85 | return Buffer.alloc(0); 86 | } 87 | 88 | function onDiscoverFunc(RED) { 89 | return (peripheral) => { 90 | const addressOrUUID = getAddressOrUUID(peripheral); 91 | if (!addressOrUUID) { 92 | return; 93 | } else if (peripheral.connectable) { 94 | debug( 95 | `[GenericBLE:DISCOVER] <${addressOrUUID}> ${peripheral.advertisement.localName}` 96 | ); 97 | RED.nodes.eachNode((node) => { 98 | if (node.type === 'Generic BLE' && peripheral.uuid === node.uuid) { 99 | RED.nodes.getNode(node.id).discovered(); 100 | } 101 | }); 102 | } 103 | }; 104 | } 105 | 106 | function onMissFunc(RED) { 107 | return (peripheral) => { 108 | const addressOrUUID = getAddressOrUUID(peripheral); 109 | debug( 110 | `[GenericBLE:MISS] <${addressOrUUID}> ${peripheral.advertisement.localName}` 111 | ); 112 | RED.nodes.eachNode((node) => { 113 | if (node.type === 'Generic BLE' && node.uuid === peripheral.uuid) { 114 | RED.nodes.getNode(node.id).missed(); 115 | } 116 | }); 117 | }; 118 | } 119 | 120 | function onStateChangeFunc(RED) { 121 | return (state) => { 122 | if (state === 'poweredOn') { 123 | if (!genericBleState.scanning) { 124 | RED.log.info(`[GenericBLE] Start BLE scanning`); 125 | noble.startScanning([], true); 126 | genericBleState.scanning = true; 127 | } 128 | } else if (genericBleState.scanning) { 129 | RED.log.info(`[GenericBLE] Stop BLE scanning`); 130 | noble.stopScanning(); 131 | genericBleState.scanning = false; 132 | } 133 | }; 134 | } 135 | 136 | function onErrorFunc(RED) { 137 | return (err) => { 138 | const message = `[GenericBLE:ERROR] ${err.message}, ${err.stack}`; 139 | debug(message); 140 | RED.log.error(message); 141 | if (!noble.initialized) { 142 | RED.log.error( 143 | `The error seems to be a BlueZ Permission Error. See 'Installation Note' in README at https://flows.nodered.org/node/node-red-contrib-generic-ble for addressing the issue.` 144 | ); 145 | } 146 | Object.values(configBleDevices).forEach((node) => node.emit('error')); 147 | }; 148 | } 149 | 150 | function stopBLEScanning(RED) { 151 | if (!genericBleState.scanning) { 152 | return; 153 | } 154 | RED.log.info(`[GenericBLE] Stop BLE scanning`); 155 | noble.stopScanning(); 156 | genericBleState.scanning = false; 157 | } 158 | 159 | function startBLEScanning(RED) { 160 | if (genericBleState.scanning) { 161 | return; 162 | } 163 | if (!handlers.onDiscover) { 164 | handlers.onDiscover = onDiscoverFunc(RED); 165 | } 166 | if (!handlers.onMiss) { 167 | handlers.onMiss = onMissFunc(RED); 168 | } 169 | if (!handlers.onStateChange) { 170 | handlers.onStateChange = onStateChangeFunc(RED); 171 | } 172 | if (!handlers.onError) { 173 | handlers.onError = onErrorFunc(RED); 174 | } 175 | 176 | noble.removeListener('discover', handlers.onDiscover); 177 | noble.removeListener('miss', handlers.onMiss); 178 | noble.removeListener('stateChange', handlers.onStateChange); 179 | noble.removeListener('error', handlers.onError); 180 | 181 | noble.addListener('discover', handlers.onDiscover); 182 | noble.addListener('miss', handlers.onMiss); 183 | noble.addListener('stateChange', handlers.onStateChange); 184 | noble.addListener('error', handlers.onError); 185 | 186 | if (noble.state === 'poweredOn') { 187 | RED.log.info(`[GenericBLE] Start BLE scanning`); 188 | noble.startScanning([], true); 189 | genericBleState.scanning = true; 190 | } else { 191 | debug(`noble.state=>${noble.state}`); 192 | } 193 | } 194 | 195 | async function toApiObject(peripheral) { 196 | if (!peripheral) { 197 | return null; 198 | } 199 | return { 200 | localName: peripheral.advertisement.localName, 201 | address: peripheral.address === 'unknown' ? '' : peripheral.address, 202 | uuid: peripheral.uuid, 203 | rssi: peripheral.rssi, 204 | }; 205 | } 206 | 207 | async function toDetailedObject(peripheral, RED) { 208 | const obj = await toApiObject(peripheral); 209 | switch (peripheral.state) { 210 | case 'disconnected': { 211 | await new Promise((resolve, reject) => { 212 | peripheral.once('connect', (err) => { 213 | if (err) { 214 | return reject(err); 215 | } 216 | const discoveryInterrupted = () => { 217 | return reject(new Error(`Missing Peripheral Device`)); 218 | }; 219 | peripheral.once('disconnect', discoveryInterrupted); 220 | peripheral.discoverAllServicesAndCharacteristics((err, services) => { 221 | debug( 222 | ` Callback OK!` 223 | ); 224 | peripheral.removeListener('disconnect', discoveryInterrupted); 225 | if (err) { 226 | return reject(err); 227 | } 228 | let resolved = false; 229 | obj.characteristics = services 230 | .reduce((prev, curr) => { 231 | return prev.concat(curr.characteristics); 232 | }, []) 233 | .map((c) => { 234 | const characteristic = { 235 | uuid: c.uuid, 236 | name: c.name || RED._('generic-ble.label.unnamedChr'), 237 | type: c.type || RED._('generic-ble.label.customType'), 238 | notifiable: c.properties.indexOf('notify') >= 0, 239 | readable: c.properties.indexOf('read') >= 0, 240 | writable: c.properties.indexOf('write') >= 0, 241 | writeWithoutResponse: 242 | c.properties.indexOf('writeWithoutResponse') >= 0, 243 | }; 244 | if ( 245 | peripheral.state === 'connected' && 246 | c.type === 'org.bluetooth.characteristic.gap.device_name' 247 | ) { 248 | resolved = true; 249 | c.read((err, data) => { 250 | if (err) { 251 | return resolve(); 252 | } 253 | obj.localName = data.toString(); 254 | peripheral.advertisement.localName = obj.localName; 255 | return resolve(); 256 | }); 257 | } 258 | return characteristic; 259 | }); 260 | if (!resolved) { 261 | return resolve(); 262 | } 263 | }); 264 | }); 265 | peripheral.connect(); // peripheral.state => connecting 266 | }); 267 | break; 268 | } 269 | case 'connected': { 270 | obj.characteristics = []; 271 | let deviceNameCharacteristic; 272 | peripheral.services.map((s) => { 273 | obj.characteristics = obj.characteristics.concat( 274 | (s.characteristics || []).map((c) => { 275 | if (c.type === 'org.bluetooth.characteristic.gap.device_name') { 276 | deviceNameCharacteristic = c; 277 | } 278 | return { 279 | uuid: c.uuid, 280 | name: c.name || RED._('generic-ble.label.unnamedChr'), 281 | type: c.type || RED._('generic-ble.label.customType'), 282 | notifiable: c.properties.indexOf('notify') >= 0, 283 | readable: c.properties.indexOf('read') >= 0, 284 | writable: c.properties.indexOf('write') >= 0, 285 | writeWithoutResponse: 286 | c.properties.indexOf('writeWithoutResponse') >= 0, 287 | }; 288 | }) 289 | ); 290 | }); 291 | if ( 292 | deviceNameCharacteristic && 293 | !peripheral.advertisement.localName && 294 | peripheral.state === 'connected' 295 | ) { 296 | await new Promise((resolve) => { 297 | deviceNameCharacteristic.read((err, data) => { 298 | if (err) { 299 | return resolve(); 300 | } 301 | obj.localName = data.toString(); 302 | peripheral.advertisement.localName = obj.localName; 303 | return resolve(); 304 | }); 305 | }); 306 | } 307 | break; 308 | } 309 | case 'disconnecting': 310 | case 'connecting': { 311 | return; 312 | } 313 | default: { 314 | return; 315 | } 316 | } 317 | return obj; 318 | } 319 | 320 | module.exports = function (RED) { 321 | function toCharacteristic(c) { 322 | const self = { 323 | uuid: c.uuid, 324 | name: c.name || RED._('generic-ble.label.unnamedChr'), 325 | type: c.type || RED._('generic-ble.label.customType'), 326 | notifiable: c.properties.indexOf('notify') >= 0, 327 | readable: c.properties.indexOf('read') >= 0, 328 | writable: c.properties.indexOf('write') >= 0, 329 | writeWithoutResponse: c.properties.indexOf('writeWithoutResponse') >= 0, 330 | object: c, 331 | addDataListener: (func) => { 332 | if (self.dataListener) { 333 | return false; 334 | } 335 | self.dataListener = func; 336 | self.object.removeAllListeners('data'); 337 | self.object.on('data', func); 338 | return true; 339 | }, 340 | unsubscribe: () => { 341 | return new Promise((resolve) => { 342 | const peripheral = noble._peripherals[self._peripheralId]; 343 | if ( 344 | self.notifiable && 345 | peripheral && 346 | peripheral.state === 'connected' 347 | ) { 348 | delete self.dataListener; 349 | self.object.unsubscribe(resolve); 350 | } else { 351 | return resolve(); 352 | } 353 | }); 354 | }, 355 | }; 356 | return self; 357 | } 358 | 359 | class GenericBLENode { 360 | constructor(n) { 361 | RED.nodes.createNode(this, n); 362 | this.localName = n.localName; 363 | this.address = n.address; 364 | this.uuid = n.uuid; 365 | this.characteristics = []; 366 | const key = getAddressOrUUID(n); 367 | if (key) { 368 | configBleDevices[key] = this; 369 | } 370 | this.nodes = {}; 371 | [ 372 | 'connected', 373 | 'disconnected', 374 | 'error', 375 | 'connecting', 376 | 'disconnecting', 377 | 'missing', 378 | ].forEach((ev) => { 379 | this.on(ev, () => { 380 | try { 381 | Object.keys(this.nodes).forEach((id) => { 382 | this.nodes[id].emit(ev); 383 | }); 384 | } catch (e) { 385 | this.error(e); 386 | } 387 | }); 388 | }); 389 | this.on('close', (done) => { 390 | if (genericBleState.scanning) { 391 | stopBLEScanning(); 392 | } 393 | Object.keys(configBleDevices).forEach( 394 | (k) => delete configBleDevices[k] 395 | ); 396 | this.removeAllListeners('ble-notify'); 397 | this.shutdown().then(done).catch(done); 398 | }); 399 | process.nextTick(() => { 400 | if (noble.initialized) { 401 | this.emit('missing'); 402 | } 403 | }); 404 | } 405 | async discovered() { 406 | debugCfg( 407 | ` noble._peripherals=>${Object.keys( 408 | noble._peripherals 409 | )}` 410 | ); 411 | const peripheral = noble._peripherals[this.uuid]; 412 | if (peripheral) { 413 | this.emit(peripheral.state || 'disconnected'); 414 | } 415 | } 416 | async missed() { 417 | debugCfg(``); 418 | this.emit('missing'); 419 | } 420 | async connectPeripheral() { 421 | debugCfg( 422 | ` noble._peripherals=>${Object.keys( 423 | noble._peripherals 424 | )}` 425 | ); 426 | const peripheral = noble._peripherals[this.uuid]; 427 | if (!peripheral) { 428 | this.emit('missing'); 429 | return; 430 | } 431 | debug( 432 | ` peripheral.state=>${peripheral.state}` 433 | ); 434 | switch (peripheral.state) { 435 | case 'disconnected': { 436 | this.emit('disconnected'); 437 | if (!peripheral._disconnectedHandlerSet) { 438 | peripheral._disconnectedHandlerSet = true; 439 | peripheral.once('disconnect', () => { 440 | this.emit('disconnected'); 441 | peripheral._disconnectedHandlerSet = false; 442 | }); 443 | } 444 | if (!peripheral._connectHandlerSet) { 445 | peripheral._connectHandlerSet = true; 446 | peripheral.once('connect', (err) => { 447 | if (err) { 448 | this.log(` error:${err.message}`); 449 | this.emit('disconnected'); 450 | return; 451 | } 452 | this.emit('connected'); 453 | peripheral._connectHandlerSet = false; 454 | peripheral.discoverAllServicesAndCharacteristics( 455 | (err, services) => { 456 | debug( 457 | ` Callback OK!` 458 | ); 459 | if (err) { 460 | this.log( 461 | ` error:${err.message}` 462 | ); 463 | return; 464 | } 465 | this.characteristics = services 466 | .reduce((prev, curr) => { 467 | return prev.concat(curr.characteristics); 468 | }, []) 469 | .map((c) => toCharacteristic(c)); 470 | } 471 | ); 472 | }); 473 | peripheral.connect(); // peripheral.state => connecting 474 | this.emit('connecting'); 475 | } 476 | break; 477 | } 478 | case 'connected': { 479 | if (peripheral.services) { 480 | this.characteristics = peripheral.services 481 | .reduce((prev, curr) => { 482 | return prev.concat(curr.characteristics); 483 | }, []) 484 | .map((c) => toCharacteristic(c)); 485 | } 486 | if (!peripheral._disconnectedHandlerSet) { 487 | peripheral._disconnectedHandlerSet = true; 488 | peripheral.once('disconnect', () => { 489 | this.emit('disconnected'); 490 | peripheral._disconnectedHandlerSet = false; 491 | }); 492 | } 493 | this.emit('connected'); 494 | break; 495 | } 496 | case 'disconnecting': 497 | case 'connecting': { 498 | this.emit(peripheral.state); 499 | break; 500 | } 501 | default: { 502 | break; 503 | } 504 | } 505 | return peripheral.state; 506 | } 507 | async disconnectPeripheral() { 508 | debugCfg( 509 | ` noble._peripherals=>${Object.keys( 510 | noble._peripherals 511 | )}` 512 | ); 513 | const peripheral = noble._peripherals[this.uuid]; 514 | if (!peripheral) { 515 | debugCfg( 516 | ` peripheral is already gone.` 517 | ); 518 | this.emit('missing'); 519 | return; 520 | } 521 | if (peripheral.state === 'disconnected') { 522 | debugCfg( 523 | ` peripheral is already disconnected.` 524 | ); 525 | this.emit('disconnected'); 526 | return; 527 | } 528 | if (!peripheral._disconnectedHandlerSet) { 529 | peripheral._disconnectedHandlerSet = true; 530 | peripheral.once('disconnect', () => { 531 | this.emit('disconnected'); 532 | peripheral._disconnectedHandlerSet = false; 533 | }); 534 | } 535 | peripheral.disconnect(); 536 | this.emit('disconnecting'); 537 | } 538 | async shutdown() { 539 | await Promise.all(this.characteristics.map((c) => c.unsubscribe())); 540 | } 541 | register(node) { 542 | this.nodes[node.id] = node; 543 | } 544 | remove(node) { 545 | delete this.nodes[node.id]; 546 | } 547 | // dataObj = { 548 | // 'uuid-to-write-1': Buffer(), 549 | // 'uuid-to-write-2': Buffer(), 550 | // : 551 | // } 552 | async write(dataObj) { 553 | if (!dataObj) { 554 | throw new Error(`Nothing to write`); 555 | } 556 | const state = await this.connectPeripheral(); 557 | if (state !== 'connected') { 558 | debugCfg( 559 | `[write] Peripheral:${this.uuid} is NOT ready. state=>${state}` 560 | ); 561 | throw new Error(`Not yet connected.`); 562 | } 563 | let writables = this.characteristics.filter( 564 | (c) => c.writable || c.writeWithoutResponse 565 | ); 566 | debugCfg( 567 | `characteristics => ${JSON.stringify( 568 | this.characteristics.map((c) => { 569 | return { 570 | uuid: c.uuid, 571 | notifiable: c.notifiable, 572 | readable: c.readable, 573 | writable: c.writable, 574 | writeWithoutResponse: c.writeWithoutResponse, 575 | }; 576 | }) 577 | )}` 578 | ); 579 | debugCfg(`writables.length => ${writables.length}`); 580 | if (writables.length === 0) { 581 | return; 582 | } 583 | const uuidList = Object.keys(dataObj); 584 | writables = writables.filter((c) => uuidList.indexOf(c.uuid) >= 0); 585 | debugCfg(`UUIDs to write => ${uuidList}`); 586 | debugCfg(`writables.length => ${writables.length}`); 587 | if (writables.length === 0) { 588 | return; 589 | } 590 | // perform write here right now 591 | await Promise.all( 592 | writables.map((w) => { 593 | // {uuid:'characteristic-uuid-to-write', data:Buffer()} 594 | return new Promise((resolve, reject) => { 595 | const buf = valToBuffer(dataObj[w.uuid]); 596 | debugCfg( 597 | ` uuid => ${w.uuid}, data => ${buf.toString( 598 | 'hex' 599 | )}, writeWithoutResponse => ${w.writeWithoutResponse}` 600 | ); 601 | w.object.write(buf, w.writeWithoutResponse, (err) => { 602 | if (err) { 603 | debugCfg(` ${w.uuid} => FAIL`); 604 | return reject(err); 605 | } 606 | debugCfg(` ${w.uuid} => OK`); 607 | resolve(true); 608 | }); 609 | }); 610 | }) 611 | ); 612 | } 613 | async read(uuids = '') { 614 | const state = await this.connectPeripheral(); 615 | if (state !== 'connected') { 616 | debugCfg( 617 | `[read] Peripheral:${this.uuid} is NOT ready. state=>${state}` 618 | ); 619 | throw new Error(`Not yet connected.`); 620 | } 621 | uuids = uuids 622 | .split(',') 623 | .map((uuid) => uuid.trim()) 624 | .filter((uuid) => uuid); 625 | const readables = this.characteristics.filter((c) => { 626 | if (c.readable) { 627 | if (uuids.length === 0) { 628 | return true; 629 | } 630 | return uuids.indexOf(c.uuid) >= 0; 631 | } 632 | }); 633 | debugCfg( 634 | `characteristics => ${JSON.stringify( 635 | this.characteristics.map((c) => { 636 | return { 637 | uuid: c.uuid, 638 | notifiable: c.notifiable, 639 | readable: c.readable, 640 | writable: c.writable, 641 | writeWithoutResponse: c.writeWithoutResponse, 642 | }; 643 | }) 644 | )}` 645 | ); 646 | debugCfg(`readables.length => ${readables.length}`); 647 | if (readables.length === 0) { 648 | return null; 649 | } 650 | const notifiables = this.characteristics.filter((c) => { 651 | if (c.notifiable) { 652 | if (uuids.length === 0) { 653 | return true; 654 | } 655 | return uuids.indexOf(c.uuid) >= 0; 656 | } 657 | }); 658 | // perform read here right now 659 | const readObj = {}; 660 | // unsubscribe all notifiable characteristics 661 | await Promise.all(notifiables.map((n) => n.unsubscribe())); 662 | // read all readable characteristics 663 | await Promise.all( 664 | readables.map((r) => { 665 | // {uuid:'characteristic-uuid-to-read'} 666 | return new Promise((resolve, reject) => { 667 | r.object.read((err, data) => { 668 | if (err) { 669 | debug(` ${r.uuid} => FAIL`); 670 | return reject(err); 671 | } 672 | debugCfg(` ${r.uuid} => ${JSON.stringify(data)}`); 673 | readObj[r.uuid] = data; 674 | resolve(); 675 | }); 676 | }); 677 | }) 678 | ); 679 | return Object.keys(readObj).length > 0 ? readObj : null; 680 | } 681 | async subscribe(uuids = '', period = 0) { 682 | const state = await this.connectPeripheral(); 683 | if (state !== 'connected') { 684 | this.log( 685 | `[subscribe] Peripheral:${this.uuid} is NOT ready. state=>${state}` 686 | ); 687 | throw new Error(`Not yet connected.`); 688 | } 689 | uuids = uuids 690 | .split(',') 691 | .map((uuid) => uuid.trim()) 692 | .filter((uuid) => uuid); 693 | const notifiables = this.characteristics.filter((c) => { 694 | if (c.notifiable) { 695 | if (uuids.length === 0) { 696 | return true; 697 | } 698 | return uuids.indexOf(c.uuid) >= 0; 699 | } 700 | }); 701 | debugCfg( 702 | `characteristics => ${JSON.stringify( 703 | this.characteristics.map((c) => { 704 | return { 705 | uuid: c.uuid, 706 | notifiable: c.notifiable, 707 | readable: c.readable, 708 | writable: c.writable, 709 | writeWithoutResponse: c.writeWithoutResponse, 710 | }; 711 | }) 712 | )}` 713 | ); 714 | debugCfg(`notifiables.length => ${notifiables.length}`); 715 | if (notifiables.length === 0) { 716 | return; 717 | } 718 | await Promise.all( 719 | notifiables.map(async (r) => { 720 | r.addDataListener((data, isNotification) => { 721 | if (isNotification) { 722 | let readObj = { 723 | notification: true, 724 | }; 725 | readObj[r.uuid] = data; 726 | this.emit('ble-notify', this.uuid, readObj); 727 | } 728 | }); 729 | r.object.subscribe((err) => { 730 | if (err) { 731 | this.emit('error', err); 732 | this.log(`subscription error: ${err.message}`); 733 | } 734 | }); 735 | if (period > 0) { 736 | setTimeout(() => { 737 | r.object.unsubscribe((err) => { 738 | if (err) { 739 | this.emit('error', err); 740 | this.log(`unsubscription error: ${err.message}`); 741 | } else { 742 | const peripheral = noble._peripherals[this.uuid]; 743 | if (peripheral) { 744 | this.emit(peripheral.state); 745 | } else { 746 | this.emit('missing'); 747 | } 748 | } 749 | }); 750 | }, 5000); 751 | } 752 | }) 753 | ); 754 | } 755 | } 756 | RED.nodes.registerType('Generic BLE', GenericBLENode); 757 | 758 | class GenericBLEInNode { 759 | constructor(n) { 760 | RED.nodes.createNode(this, n); 761 | this.useString = n.useString; 762 | this.notification = n.notification; 763 | this.genericBleNodeId = n.genericBle; 764 | this.genericBleNode = RED.nodes.getNode(this.genericBleNodeId); 765 | if (this.genericBleNode) { 766 | if (this.notification) { 767 | this.genericBleNode.on('ble-notify', this.onBleNotify.bind(this)); 768 | } 769 | this.on('connected', () => { 770 | this.status({ 771 | fill: 'green', 772 | shape: 'dot', 773 | text: `generic-ble.status.connected`, 774 | }); 775 | }); 776 | ['disconnected', 'error', 'missing'].forEach((ev) => { 777 | this.on(ev, () => { 778 | this.status({ 779 | fill: 'red', 780 | shape: 'ring', 781 | text: `generic-ble.status.${ev}`, 782 | }); 783 | }); 784 | }); 785 | ['connecting', 'disconnecting'].forEach((ev) => { 786 | this.on(ev, () => { 787 | this.status({ 788 | fill: 'grey', 789 | shape: 'ring', 790 | text: `generic-ble.status.${ev}`, 791 | }); 792 | }); 793 | }); 794 | this.genericBleNode.register(this); 795 | 796 | this.on('input', async (msg, send) => { 797 | debugIn(`input arrived! msg=>${JSON.stringify(msg)}`); 798 | let obj = msg.payload || {}; 799 | try { 800 | if (typeof obj === 'string') { 801 | obj = JSON.parse(msg.payload); 802 | } 803 | } catch (_) { 804 | // ignore 805 | } 806 | try { 807 | if (msg.topic === 'scanStart') { 808 | startBLEScanning(RED); 809 | return; 810 | } else if (msg.topic === 'scanStop') { 811 | stopBLEScanning(RED); 812 | return; 813 | } else if (msg.topic === 'scanRestart') { 814 | stopBLEScanning(RED); 815 | setTimeout(() => { 816 | startBLEScanning(RED); 817 | }, 1000); 818 | return; 819 | } else if (msg.topic === 'connect') { 820 | await this.genericBleNode.connectPeripheral(); 821 | } else if (msg.topic === 'disconnect') { 822 | await this.genericBleNode.disconnectPeripheral(); 823 | } else if (obj.notify) { 824 | await this.genericBleNode.subscribe(msg.topic, obj.period); 825 | debugIn(`<${this.genericBleNode.uuid}> subscribe: OK`); 826 | } else { 827 | const readObj = await this.genericBleNode.read(msg.topic); 828 | debugIn(`<${this.genericBleNode.uuid}> read: OK`); 829 | if (!readObj) { 830 | this.warn( 831 | `<${this.genericBleNode.uuid}> tpoic[${msg.topic}]: (no data)` 832 | ); 833 | return; 834 | } 835 | let payload = { 836 | uuid: this.genericBleNode.uuid, 837 | characteristics: readObj, 838 | }; 839 | if (this.useString) { 840 | payload = JSON.stringify(payload); 841 | } 842 | const node = this; 843 | send = 844 | send || 845 | function () { 846 | node.send.apply(node, arguments); 847 | }; 848 | send({ 849 | payload, 850 | }); 851 | } 852 | } catch (err) { 853 | debugIn( 854 | `<${this.genericBleNode.uuid}> tpoic[${msg.topic}]: (err:${err}, stack:${err.stack})` 855 | ); 856 | this.error( 857 | `<${this.genericBleNode.uuid}> tpoic[${msg.topic}]: (err:${err}, stack:${err.stack})` 858 | ); 859 | } 860 | }); 861 | this.on('close', () => { 862 | if (this.genericBleNode) { 863 | this.genericBleNode.remove(this); 864 | } 865 | }); 866 | } 867 | this.name = n.name; 868 | } 869 | onBleNotify(uuid, readObj, err) { 870 | if (err) { 871 | this.error(`<${uuid}> notify: (err:${err}, stack:${err.stack})`); 872 | return; 873 | } 874 | let payload = { 875 | uuid: uuid, 876 | characteristics: readObj, 877 | }; 878 | if (this.useString) { 879 | try { 880 | payload = JSON.stringify(payload); 881 | } catch (err) { 882 | this.warn(`<${uuid}> notify: (err:${err}, stack:${err.stack})`); 883 | return; 884 | } 885 | } 886 | this.send({ 887 | payload, 888 | }); 889 | } 890 | } 891 | RED.nodes.registerType('Generic BLE in', GenericBLEInNode); 892 | 893 | class GenericBLEOutNode { 894 | constructor(n) { 895 | RED.nodes.createNode(this, n); 896 | this.genericBleNodeId = n.genericBle; 897 | this.genericBleNode = RED.nodes.getNode(this.genericBleNodeId); 898 | if (this.genericBleNode) { 899 | this.on('connected', () => { 900 | this.status({ 901 | fill: 'green', 902 | shape: 'dot', 903 | text: `generic-ble.status.connected`, 904 | }); 905 | }); 906 | ['disconnected', 'error', 'missing'].forEach((ev) => { 907 | this.on(ev, () => { 908 | this.status({ 909 | fill: 'red', 910 | shape: 'ring', 911 | text: `generic-ble.status.${ev}`, 912 | }); 913 | }); 914 | }); 915 | ['connecting', 'disconnecting'].forEach((ev) => { 916 | this.on(ev, () => { 917 | this.status({ 918 | fill: 'grey', 919 | shape: 'ring', 920 | text: `generic-ble.status.${ev}`, 921 | }); 922 | }); 923 | }); 924 | this.genericBleNode.register(this); 925 | this.on('input', async (msg) => { 926 | debugOut(`input arrived! msg=>${JSON.stringify(msg)}`); 927 | try { 928 | if (msg.topic === 'connect') { 929 | await this.genericBleNode.connectPeripheral(); 930 | } else if (msg.topic === 'disconnect') { 931 | await this.genericBleNode.disconnectPeripheral(); 932 | } else { 933 | await this.genericBleNode.write(msg.payload); 934 | debugOut(`<${this.genericBleNode.uuid}> write: OK`); 935 | } 936 | } catch (err) { 937 | debugOut(`<${this.genericBleNode.uuid}> write: (err:${err})`); 938 | this.error(err); 939 | } 940 | }); 941 | this.on('close', () => { 942 | if (this.genericBleNode) { 943 | this.genericBleNode.remove(this); 944 | } 945 | }); 946 | } 947 | this.name = n.name; 948 | } 949 | } 950 | RED.nodes.registerType('Generic BLE out', GenericBLEOutNode); 951 | 952 | RED.events.on('runtime-event', (ev) => { 953 | debugApi(`[GenericBLE] ${JSON.stringify(ev)}`); 954 | if (ev.id === 'runtime-state' && Object.keys(configBleDevices).length > 0) { 955 | stopBLEScanning(RED); 956 | startBLEScanning(RED); 957 | } 958 | }); 959 | 960 | // __blestate endpoint 961 | RED.httpAdmin.get( 962 | '/__blestate', 963 | RED.auth.needsPermission('generic-ble.read'), 964 | async (req, res) => { 965 | debugApi(`${req.method}:${req.originalUrl}`); 966 | return res.status(200).send(genericBleState).end(); 967 | } 968 | ); 969 | 970 | // __blescan/:sw endpoint 971 | RED.httpAdmin.post( 972 | '/__blescan/:sw', 973 | RED.auth.needsPermission('generic-ble.write'), 974 | async (req, res) => { 975 | debugApi( 976 | `${req.method}:${req.originalUrl}, genericBleState.scanning:${genericBleState.scanning}` 977 | ); 978 | const { sw } = req.params; 979 | if (sw === 'start') { 980 | startBLEScanning(RED); 981 | return res 982 | .status(200) 983 | .send({ status: 200, message: 'startScanning' }) 984 | .end(); 985 | } else { 986 | stopBLEScanning(RED); 987 | return res 988 | .status(200) 989 | .send({ status: 200, message: 'stopScanning' }) 990 | .end(); 991 | } 992 | } 993 | ); 994 | 995 | // __bledevlist endpoint 996 | RED.httpAdmin.get( 997 | '/__bledevlist', 998 | RED.auth.needsPermission('generic-ble.read'), 999 | async (req, res) => { 1000 | debugApi(`${req.method}:${req.originalUrl}`); 1001 | try { 1002 | const body = ( 1003 | await Promise.all( 1004 | Object.keys(noble._peripherals).map((uuid) => { 1005 | // load the live object for invoking functions 1006 | // as cached object is disconnected from noble context 1007 | const apiObject = toApiObject(noble._peripherals[uuid]); 1008 | if (apiObject) { 1009 | return apiObject; 1010 | } 1011 | }) 1012 | ) 1013 | ).filter((obj) => obj); 1014 | debugApi('/__bledevlist', JSON.stringify(body, null, 2)); 1015 | res.json(body); 1016 | } catch (err) { 1017 | RED.log.error( 1018 | `/__bledevlist err:${err}\n=>${err.stack || err.message}` 1019 | ); 1020 | if (!res._headerSent) { 1021 | return res 1022 | .status(500) 1023 | .send({ status: 500, message: err.message || err }) 1024 | .end(); 1025 | } 1026 | } 1027 | } 1028 | ); 1029 | // __bledev endpoint 1030 | RED.httpAdmin.get( 1031 | '/__bledev/:uuid', 1032 | RED.auth.needsPermission('generic-ble.read'), 1033 | async (req, res) => { 1034 | debugApi(`${req.method}:${req.originalUrl}`); 1035 | const { uuid } = req.params; 1036 | if (!uuid) { 1037 | return res 1038 | .status(404) 1039 | .send({ status: 404, message: 'missing peripheral' }) 1040 | .end(); 1041 | } 1042 | // load the live object for invoking functions 1043 | // as cached object is disconnected from noble context 1044 | const peripheral = noble._peripherals[uuid]; 1045 | if (!peripheral) { 1046 | return res 1047 | .status(404) 1048 | .send({ status: 404, message: 'missing peripheral' }) 1049 | .end(); 1050 | } 1051 | 1052 | try { 1053 | const bleDevice = await toDetailedObject(peripheral, RED); 1054 | debugApi( 1055 | `/__bledev/${uuid} OUTPUT`, 1056 | JSON.stringify(bleDevice, null, 2) 1057 | ); 1058 | return res.json(bleDevice); 1059 | } catch (err) { 1060 | RED.log.error( 1061 | `/__bledev/${uuid} err:${err}\n=>${err.stack || err.message}` 1062 | ); 1063 | if (!res._headerSent) { 1064 | return res 1065 | .status(500) 1066 | .send({ status: 500, message: err.message || err }) 1067 | .end(); 1068 | } 1069 | } 1070 | } 1071 | ); 1072 | }; 1073 | -------------------------------------------------------------------------------- /src/locales/en-US/generic-ble.html: -------------------------------------------------------------------------------- 1 | 75 | 76 | 123 | 124 | 145 | -------------------------------------------------------------------------------- /src/locales/en-US/generic-ble.yml: -------------------------------------------------------------------------------- 1 | # Message Resources for English (fallback language) 2 | generic-ble: 3 | label: 4 | genericBle: BLE 5 | useString: Stringify in payload 6 | bleDevices: Scan Result 7 | localName: Local Name 8 | unnamed: (Unnamed) 9 | address: MAC 10 | uuid: UUID 11 | characteristics: GATT Characteristics 12 | notAvailable: (not available) 13 | notification: Emit notify events 14 | customType: (Custom Type) 15 | unnamedChr: 16 | scanning: BLE Scanning 17 | applyBleDevice: Apply 18 | noSignal: no signal 19 | placeholder: 20 | localName: BLE device name 21 | address: aa:11:bb:22:cc:33 (case insensitive) 22 | uuid: (optional, e.g. 46788f85-c8a9-4f57-a9b1-f1df906c1ad6) 23 | status: 24 | connected: connected 25 | disconnected: disconnected 26 | error: error 27 | connecting: connecting 28 | disconnecting: disconnecting 29 | missing: missing 30 | -------------------------------------------------------------------------------- /src/noble/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2020 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import Noble from '@abandonware/noble/lib/noble'; 19 | import os from 'os'; 20 | import debugLogger from 'debug'; 21 | 22 | const debug = debugLogger('node-red-contrib-generic-ble:noble'); 23 | 24 | const platform = os.platform(); 25 | debug(`Detected Platform => [${platform}]`); 26 | 27 | let bindings; 28 | if (platform === 'linux') { 29 | bindings = require('./lib/bluez/bindings').default; 30 | } 31 | if (!bindings) { 32 | debug(`Loading the default resolve-bindings module in @abandonware/noble.`); 33 | bindings = require('@abandonware/noble/lib/resolve-bindings')(); 34 | } 35 | 36 | class PeripheralRemovableNoble extends Noble { 37 | constructor(bindings) { 38 | super(bindings); 39 | bindings.on('miss', this.onMiss.bind(this)); 40 | bindings.on('error', this.onError.bind(this)); 41 | } 42 | onMiss(uuid) { 43 | debug(` this.initialized => ${this.initialized}`); 44 | if (this._peripherals[uuid]) { 45 | const peripheral = this._peripherals[uuid]; 46 | debug(` peripheral.state => ${peripheral.state}`); 47 | if (peripheral.state === 'connected') { 48 | peripheral.once('disconnect', () => { 49 | debug( 50 | ` peripheral disconnected.` 51 | ); 52 | this.onMiss(uuid); 53 | }); 54 | peripheral.disconnect(); 55 | return; 56 | } 57 | delete this._peripherals[uuid]; 58 | delete this._services[uuid]; 59 | delete this._characteristics[uuid]; 60 | delete this._descriptors[uuid]; 61 | const previouslyDiscoverdIndex = this._discoveredPeripheralUUids.indexOf( 62 | uuid 63 | ); 64 | if (previouslyDiscoverdIndex >= 0) { 65 | this._discoveredPeripheralUUids.splice(previouslyDiscoverdIndex, 1); 66 | } 67 | debug(`Peripheral(uuid:${uuid}) has gone.`); 68 | this.emit('miss', peripheral); 69 | } 70 | } 71 | onError(err) { 72 | if (err.type === 'org.freedesktop.DBus.Error.AccessDenied') { 73 | this.initialized = false; 74 | } 75 | this.emit('error', err); 76 | } 77 | } 78 | 79 | module.exports = new PeripheralRemovableNoble(bindings); 80 | -------------------------------------------------------------------------------- /src/noble/lib/bluez/bindings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2020 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import EventEmitter from 'events'; 19 | import debugLogger from 'debug'; 20 | import dbus from 'dbus-next'; 21 | 22 | const debug = debugLogger('node-red-contrib-generic-ble:noble:bluez'); 23 | 24 | // Workaround for a Jest Issue 25 | // https://github.com/kulshekhar/ts-jest/issues/727#issuecomment-422747294 26 | if (process.env.NODE_ENV !== 'test') { 27 | debug('Requiring "source-map-support/register"...'); 28 | require('source-map-support/register'); 29 | } 30 | 31 | class BluezBindings extends EventEmitter { 32 | constructor() { 33 | super(); 34 | 35 | this.bus = dbus.systemBus(); 36 | 37 | this._scanFilterDuplicates = null; 38 | this._scanning = false; 39 | this.hciObjectPath = `/org/bluez/${process.env.HCIDEVICE || 'hci0'}`; 40 | 41 | // Remove entry on onDevicesServicesCharacteristicsMissed() 42 | this.objectStore = { 43 | // key: objectPath, value: any 44 | }; 45 | 46 | debug('BluezBindings instance created!'); 47 | } 48 | 49 | _addDashes(uuid) { 50 | if (!uuid || typeof uuid !== 'string') { 51 | return uuid; 52 | } 53 | uuid = this._to128bitUuid(uuid); 54 | if (uuid.length === 32) { 55 | uuid = `${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring( 56 | 12, 57 | 16 58 | )}-${uuid.substring(16, 20)}-${uuid.substring(20)}`; 59 | } 60 | return uuid.toLowerCase(); 61 | } 62 | 63 | _stripDashes(uuid) { 64 | if (typeof uuid === 'string') { 65 | uuid = uuid.split('-').join('').toLowerCase(); 66 | } 67 | return this._to16bitUuid(uuid); 68 | } 69 | 70 | _to128bitUuid(uuid) { 71 | // Bluetooth Base UUID(00000000-0000-1000-8000-00805F9B34FB) 72 | // Device Name (w/o dashes) : 2a00 => 00002a0000001000800000805f9b34fb 73 | if (uuid.length === 4) { 74 | uuid = `0000${uuid}-0000-1000-8000-00805f9b34fb`; 75 | } 76 | return uuid; 77 | } 78 | 79 | _to16bitUuid(uuid) { 80 | // Bluetooth Base UUID(00000000-0000-1000-8000-00805F9B34FB) 81 | // Device Name (w/o dashes) : 00002a0000001000800000805f9b34fb => 2a00 82 | if ( 83 | uuid.indexOf('0000') === 0 && 84 | uuid.indexOf('00001000800000805f9b34fb') === 8 85 | ) { 86 | return uuid.substring(4, 8); 87 | } 88 | return uuid; 89 | } 90 | 91 | async _startDiscovery() { 92 | try { 93 | const powered = (await this.hciProps.Get('org.bluez.Adapter1', 'Powered')) 94 | .value; 95 | if (!powered) { 96 | debug(`[_startDiscovery] Turning the adapter on...`); 97 | await this.hciProps.Set( 98 | 'org.bluez.Adapter1', 99 | 'Powered', 100 | new dbus.Variant('b', true) 101 | ); 102 | } 103 | debug(`[_startDiscovery] Setting discovery filter...`); 104 | await this.hciAdapter.SetDiscoveryFilter({ 105 | DuplicateData: new dbus.Variant('b', !this._scanFilterDuplicates), 106 | }); 107 | debug(`[_startDiscovery] Start Scanning...`); 108 | await this.hciAdapter.StartDiscovery(); 109 | } catch (err) { 110 | debug( 111 | `[ERROR] _startDiscovery => err.message:${ 112 | err.message 113 | }, err.toString:${err.toString()}` 114 | ); 115 | if (!this._scanning) { 116 | // failed to power on 117 | this.emit('stateChange', 'poweredOff'); 118 | } 119 | } 120 | } 121 | 122 | async startScanning(/* never used */ serviceUuids, allowDuplicates) { 123 | if (this._initialized) { 124 | this._scanFilterDuplicates = !allowDuplicates; 125 | if (this._scanning) { 126 | debug(`[startScanning] Scan already ongoing...`); 127 | } else { 128 | await this._startDiscovery(); 129 | } 130 | } else { 131 | this.once('poweredOn', () => { 132 | debug( 133 | `[startScanning] Trigger startScanning again as initialization done.` 134 | ); 135 | this.startScanning(serviceUuids, allowDuplicates); 136 | }); 137 | } 138 | } 139 | 140 | async stopScanning() { 141 | if (this._initialized) { 142 | debug(`[startScanning] Stop Scanning...`); 143 | try { 144 | await this.hciAdapter.StopDiscovery(); 145 | } catch (err) { 146 | debug( 147 | `[ERROR] stopScanning => err.message:${ 148 | err.message 149 | }, err.toString:${err.toString()}` 150 | ); 151 | } 152 | } 153 | } 154 | 155 | async init() { 156 | if (this._initialized) { 157 | debug(`init: => already initialzied. Skip!`); 158 | return; 159 | } 160 | debug(`initializing....`); 161 | 162 | this.onSigIntBinded = this.onSigInt.bind(this); 163 | /* Add exit handlers after `init()` has completed. If no adaptor 164 | is present it can throw an exception - in which case we don't 165 | want to try and clear up afterwards (issue #502) */ 166 | process.on('SIGINT', this.onSigIntBinded); 167 | process.on('exit', this.onExit.bind(this)); 168 | 169 | try { 170 | this.bluezService = await this.bus.getProxyObject('org.bluez', '/'); 171 | this.bluezObjectManager = this.bluezService.getInterface( 172 | 'org.freedesktop.DBus.ObjectManager' 173 | ); 174 | const bluezObjects = await this.bluezObjectManager.GetManagedObjects(); 175 | debug(`Detected Object Paths:${Object.keys(bluezObjects)}`); 176 | if (!bluezObjects[this.hciObjectPath]) { 177 | debug( 178 | `Missing Bluetooth Object, Path:${ 179 | this.hciObjectPath 180 | }, Valid Paths:${Object.keys(bluezObjects)}}` 181 | ); 182 | throw new Error( 183 | `Missing Bluetooth Object, Path:${ 184 | this.hciObjectPath 185 | }, Valid Paths:${Object.keys(bluezObjects)}}` 186 | ); 187 | } 188 | this.hciObject = await this.bus.getProxyObject( 189 | 'org.bluez', 190 | this.hciObjectPath 191 | ); 192 | this.hciProps = this.hciObject.getInterface( 193 | 'org.freedesktop.DBus.Properties' 194 | ); 195 | this.hciAdapter = this.hciObject.getInterface('org.bluez.Adapter1'); 196 | this._scanning = ( 197 | await this.hciProps.Get('org.bluez.Adapter1', 'Discovering') 198 | ).value; 199 | if (this._scanning) { 200 | this.onScanStarted(); 201 | } 202 | 203 | // Devices/Services/Characteristics Discovered/Missed 204 | this.bluezObjectManager.on( 205 | 'InterfacesAdded', 206 | this.onDevicesServicesCharacteristicsDiscovered.bind(this) 207 | ); 208 | this.bluezObjectManager.on( 209 | 'InterfacesRemoved', 210 | this.onDevicesServicesCharacteristicsMissed.bind(this) 211 | ); 212 | 213 | // Adapter Properties Change Listener 214 | this.hciProps.on( 215 | 'PropertiesChanged', 216 | this.onAdapterPropertiesChanged.bind(this) 217 | ); 218 | 219 | // init finished 220 | this._initialized = true; 221 | debug(`async init() => done`); 222 | this.emit('stateChange', 'poweredOn'); 223 | } catch (err) { 224 | debug( 225 | `async init() => error { message:${err.message}, type: ${err.type} }` 226 | ); 227 | this.emit('stateChange', 'error'); 228 | this.emit('error', err); 229 | } 230 | } 231 | 232 | _option(proxy, prop, defaultValue = null) { 233 | if (proxy[prop]) { 234 | return proxy[prop].value; 235 | } 236 | return defaultValue; 237 | } 238 | 239 | // /org/bluez/hci0/dev_11_22_33_DD_EE_FF => 112233ddeeff 240 | _toUuid(objectPath) { 241 | return objectPath 242 | .split('/')[4] 243 | .substring(4) 244 | .replace(/_/g, '') 245 | .toLowerCase(); 246 | } 247 | 248 | _toObjectPath(peripheralUuid) { 249 | // 112233ddeeff => /org/bluez/hci0/dev_11_22_33_DD_EE_FF 250 | const uuid = peripheralUuid.toUpperCase(); 251 | return `/org/bluez/hci0/dev_${uuid[0]}${uuid[1]}_${uuid[2]}${uuid[3]}_${uuid[4]}${uuid[5]}_${uuid[6]}${uuid[7]}_${uuid[8]}${uuid[9]}_${uuid[10]}${uuid[11]}`; 252 | } 253 | 254 | async _getProxyObject(objectPath) { 255 | return this.bus.getProxyObject('org.bluez', objectPath); 256 | } 257 | 258 | async _getDeviceInterface(objectPath) { 259 | return (await this._getProxyObject(objectPath)).getInterface( 260 | 'org.bluez.Device1' 261 | ); 262 | } 263 | 264 | async _getPropertiesInterface(objectPath) { 265 | return (await this._getProxyObject(objectPath)).getInterface( 266 | 'org.freedesktop.DBus.Properties' 267 | ); 268 | } 269 | 270 | async _getCharacteristicInterface(objectPath) { 271 | return (await this._getProxyObject(objectPath)).getInterface( 272 | 'org.bluez.GattCharacteristic1' 273 | ); 274 | } 275 | 276 | async connect(deviceUuid) { 277 | debug(`connect:deviceUuid=>${deviceUuid}`); 278 | const objectPath = this._toObjectPath(deviceUuid); 279 | const deviceInterface = await this._getDeviceInterface(objectPath); 280 | try { 281 | await deviceInterface.Connect(); 282 | } catch (err) { 283 | debug( 284 | `[ERROR] connect:deviceUuid=>${deviceUuid} => err.message:${ 285 | err.message 286 | }, err.toString:${err.toString()}` 287 | ); 288 | this.emit('connect', deviceUuid, err); 289 | try { 290 | await this.hciAdapter.RemoveDevice(objectPath); 291 | } catch (err) { 292 | debug( 293 | `[${deviceUuid}] Error while removing the device: ${err.message}, ${err.type}` 294 | ); 295 | } 296 | } 297 | } 298 | 299 | async disconnect(deviceUuid) { 300 | debug(`disconnect:deviceUuid=>${deviceUuid}`); 301 | const objectPath = this._toObjectPath(deviceUuid); 302 | const deviceInterface = await this._getDeviceInterface(objectPath); 303 | try { 304 | await deviceInterface.Disconnect(); 305 | } catch (err) { 306 | debug( 307 | `[ERROR] disconnect:deviceUuid=>${deviceUuid} => err.message:${ 308 | err.message 309 | }, err.toString:${err.toString()}` 310 | ); 311 | this.emit('disconnect', deviceUuid); // swallow err 312 | } 313 | } 314 | 315 | async discoverServices(deviceUuid, uuids) { 316 | debug(`discoverServices:deviceUuid=>${deviceUuid},uuids=>${uuids}`); 317 | const objectPath = this._toObjectPath(deviceUuid); 318 | const props = await this._getPropertiesInterface(objectPath); 319 | const servicesResolved = ( 320 | await props.Get('org.bluez.Device1', 'ServicesResolved') 321 | ).value; 322 | if (servicesResolved) { 323 | debug( 324 | `discoverServices:deviceUuid=>${deviceUuid}, servicesResolved=>${servicesResolved}` 325 | ); 326 | this.onServicesResolved(deviceUuid, props); 327 | } 328 | } 329 | 330 | async _listCharacteristics(deviceUuid, serviceUuid, characteristicUuids) { 331 | debug( 332 | `[${deviceUuid}] Collecting characteristsics for the service ${serviceUuid}` 333 | ); 334 | const dashedCharacteristicUuids = (characteristicUuids || []).map( 335 | this._addDashes.bind(this) 336 | ); 337 | const objectPath = this._toObjectPath(deviceUuid); 338 | const objectPathPrefix = `${objectPath}/service`; 339 | const bluezObjects = await this.bluezObjectManager.GetManagedObjects(); 340 | const serviceObjectPaths = Object.keys(bluezObjects).filter( 341 | (serviceObjectPath) => serviceObjectPath.indexOf(objectPathPrefix) === 0 342 | ); 343 | if (serviceObjectPaths.length === 0) { 344 | return null; 345 | } 346 | const serviceObjectPath = serviceObjectPaths.filter((serviceObjectPath) => { 347 | const serviceObject = bluezObjects[serviceObjectPath]; 348 | return ( 349 | serviceObject['org.bluez.GattService1'] && 350 | serviceObject['org.bluez.GattService1'].UUID.value === serviceUuid 351 | ); 352 | })[0]; 353 | if (!serviceObjectPath) { 354 | return null; 355 | } 356 | const characteristicPathPrefix = `${serviceObjectPath}/char`; 357 | const discoveredCharacteristics = {}; 358 | serviceObjectPaths 359 | .filter( 360 | (serviceObjectPath) => 361 | serviceObjectPath.indexOf(characteristicPathPrefix) === 0 362 | ) 363 | .forEach((characteristicObjectPath) => { 364 | const chr = 365 | bluezObjects[characteristicObjectPath][ 366 | 'org.bluez.GattCharacteristic1' 367 | ]; 368 | if (!chr) { 369 | // org.bluez.GattDescriptor1 370 | return; 371 | } 372 | if ( 373 | dashedCharacteristicUuids.length > 0 && 374 | !dashedCharacteristicUuids.includes(chr.UUID.value) 375 | ) { 376 | return; 377 | } 378 | discoveredCharacteristics[characteristicObjectPath] = chr; 379 | }); 380 | return discoveredCharacteristics; 381 | } 382 | 383 | async discoverCharacteristics(deviceUuid, serviceUuid, characteristicUuids) { 384 | debug( 385 | `discoverCharacteristics:deviceUuid=>${deviceUuid},serviceUuid=>${serviceUuid},characteristicUuids=>${characteristicUuids}` 386 | ); 387 | const discoveredCharacteristics = await this._listCharacteristics( 388 | deviceUuid, 389 | serviceUuid, 390 | characteristicUuids 391 | ); 392 | const resultChrs = Object.values(discoveredCharacteristics || {}).map( 393 | (chr) => { 394 | return { 395 | uuid: this._stripDashes(chr.UUID.value), 396 | properties: chr.Flags.value, 397 | }; 398 | } 399 | ); 400 | debug(`resultChrs => ${JSON.stringify(resultChrs)}`); 401 | try { 402 | this.emit('characteristicsDiscover', deviceUuid, serviceUuid, resultChrs); 403 | debug( 404 | `[${deviceUuid}] OK. Found ${resultChrs.length} Characteristics. characteristicsDiscover event` 405 | ); 406 | } catch (err) { 407 | debug( 408 | `Failed to emit 'characteristicsDiscover' event. message:${err.message}` 409 | ); 410 | } 411 | } 412 | 413 | async read(deviceUuid, serviceUuid, characteristicUuid) { 414 | const dashedCharacteristicUuid = this._addDashes(characteristicUuid); 415 | debug( 416 | `read:deviceUuid=>${deviceUuid},serviceUuid=>${serviceUuid},dashedCharacteristicUuid=>${dashedCharacteristicUuid}` 417 | ); 418 | const discoveredCharacteristics = await this._listCharacteristics( 419 | deviceUuid, 420 | serviceUuid, 421 | [dashedCharacteristicUuid] 422 | ); 423 | let data = null; // Buffer object 424 | const characteristicObjectPath = Object.keys(discoveredCharacteristics)[0]; 425 | if (characteristicObjectPath) { 426 | const chracteristic = await this._getCharacteristicInterface( 427 | characteristicObjectPath 428 | ); 429 | data = Buffer.from(await chracteristic.ReadValue({})); 430 | } 431 | debug( 432 | `read:characteristicObjectPath=>${characteristicObjectPath}, data=>${JSON.stringify( 433 | data 434 | )}` 435 | ); 436 | this.emit('read', deviceUuid, serviceUuid, characteristicUuid, data, false); 437 | } 438 | 439 | async write( 440 | deviceUuid, 441 | serviceUuid, 442 | characteristicUuid, 443 | data, // Buffer object 444 | withoutResponse 445 | ) { 446 | const dashedCharacteristicUuid = this._addDashes(characteristicUuid); 447 | debug( 448 | `write:deviceUuid=>${deviceUuid},serviceUuid=>${serviceUuid},dashedCharacteristicUuid=>${dashedCharacteristicUuid},data=>${data},withoutResponse=>${withoutResponse}` 449 | ); 450 | const discoveredCharacteristics = await this._listCharacteristics( 451 | deviceUuid, 452 | serviceUuid, 453 | [dashedCharacteristicUuid] 454 | ); 455 | const characteristicObjectPath = Object.keys(discoveredCharacteristics)[0]; 456 | if (characteristicObjectPath) { 457 | const chracteristic = await this._getCharacteristicInterface( 458 | characteristicObjectPath 459 | ); 460 | data = data.toJSON().data; 461 | const type = withoutResponse ? 'command' : 'request'; 462 | await chracteristic.WriteValue(data, { 463 | type: new dbus.Variant('s', type), 464 | }); 465 | } 466 | debug( 467 | `write:characteristicObjectPath=>${characteristicObjectPath}, data=>${JSON.stringify( 468 | data 469 | )}, withoutResponse=>${withoutResponse}` 470 | ); 471 | this.emit( 472 | 'write', 473 | deviceUuid, 474 | serviceUuid, 475 | characteristicUuid, 476 | data, 477 | withoutResponse 478 | ); 479 | } 480 | 481 | async notify(deviceUuid, serviceUuid, characteristicUuid, subscribe) { 482 | const dashedCharacteristicUuid = this._addDashes(characteristicUuid); 483 | debug( 484 | `notify:deviceUuid=>${deviceUuid},serviceUuid=>${serviceUuid},dashedCharacteristicUuid=>${dashedCharacteristicUuid},subscribe?=>${subscribe}` 485 | ); 486 | const discoveredCharacteristics = await this._listCharacteristics( 487 | deviceUuid, 488 | serviceUuid, 489 | [dashedCharacteristicUuid] 490 | ); 491 | const characteristicObjectPath = Object.keys(discoveredCharacteristics)[0]; 492 | if (characteristicObjectPath) { 493 | const chracteristic = await this._getCharacteristicInterface( 494 | characteristicObjectPath 495 | ); 496 | 497 | // GattCharacteristic1 Properties Change Listener 498 | const props = await this._getPropertiesInterface( 499 | characteristicObjectPath 500 | ); 501 | if (!this.objectStore[characteristicObjectPath]) { 502 | this.objectStore[characteristicObjectPath] = {}; 503 | } 504 | const objectStore = this.objectStore[characteristicObjectPath] || {}; 505 | this.objectStore[characteristicObjectPath] = objectStore; 506 | if (!objectStore.notificationHandeler) { 507 | debug(`Setting objectStore.notificationHandeler`); 508 | objectStore.notificationHandeler = async ( 509 | /*string*/ interfaceName, 510 | /*obj*/ changedProps, 511 | /*string[]*/ invalidatedProps 512 | ) => { 513 | debug( 514 | `[${characteristicObjectPath}] interfaceName:${interfaceName}, changedProps:${Object.keys( 515 | changedProps 516 | )}, invalidatedProps:${JSON.stringify(invalidatedProps)}` 517 | ); 518 | if (interfaceName === 'org.bluez.GattCharacteristic1') { 519 | if (changedProps.Value) { 520 | this.emit( 521 | 'read', 522 | deviceUuid, 523 | serviceUuid, 524 | characteristicUuid, 525 | Buffer.from(changedProps.Value.value), 526 | objectStore.notifying 527 | ); 528 | } 529 | debug( 530 | `[${characteristicObjectPath}] GattCharacteristic1 changedProps=>${JSON.stringify( 531 | changedProps 532 | )}` 533 | ); 534 | } 535 | }; 536 | props.on('PropertiesChanged', objectStore.notificationHandeler); 537 | } 538 | const notifying = ( 539 | await props.Get('org.bluez.GattCharacteristic1', 'Notifying') 540 | ).value; 541 | debug( 542 | `${deviceUuid}, subscribing(${characteristicUuid})? => ${notifying}` 543 | ); 544 | if (subscribe) { 545 | await chracteristic.StartNotify(); 546 | objectStore.notifying = true; 547 | debug( 548 | `${deviceUuid}, START subscribing(${characteristicUuid}) Notify events` 549 | ); 550 | } else { 551 | await chracteristic.StopNotify(); 552 | objectStore.notifying = false; 553 | debug( 554 | `${deviceUuid}, STOP subscribing(${characteristicUuid}) Notify events` 555 | ); 556 | } 557 | this.emit( 558 | 'notify', 559 | deviceUuid, 560 | serviceUuid, 561 | characteristicUuid, 562 | subscribe 563 | ); 564 | } 565 | debug( 566 | `notify:characteristicObjectPath=>${characteristicObjectPath}, subscribe?=>${subscribe}` 567 | ); 568 | this.emit('notify', deviceUuid, serviceUuid, characteristicUuid, subscribe); 569 | } 570 | 571 | // Methods not implemented: 572 | // updateRssi(deviceUuid) 573 | // discoverIncludedServices(deviceUuid, serviceUuid, serviceUuids) 574 | // broadcast(deviceUuid, serviceUuid, characteristicUuid, broadcast) 575 | // discoverDescriptors(deviceUuid, serviceUuid, characteristicUuid) 576 | // readValue(deviceUuid, serviceUuid, characteristicUuid, descriptorUuid) 577 | // writeValue(deviceUuid, serviceUuid, characteristicUuid, descriptorUuid, data) 578 | // readHandle(deviceUuid, handle) 579 | // writeHandle(deviceUuid, handle, data, withoutResponse) 580 | 581 | async onDevicesServicesCharacteristicsDiscovered( 582 | objectPath, 583 | /*Object>*/ interfacesAndProps 584 | ) { 585 | const interfaces = Object.keys(interfacesAndProps); 586 | if (interfaces.includes('org.bluez.Device1')) { 587 | const device = interfacesAndProps['org.bluez.Device1']; 588 | this.onDeviceDiscovered(objectPath, device); 589 | } else { 590 | debug( 591 | ` objectPath:${objectPath}, interfaces:${JSON.stringify( 592 | interfaces 593 | )}` 594 | ); 595 | } 596 | } 597 | 598 | async onDeviceDiscovered(objectPath, device) { 599 | debug( 600 | ` objectPath:${objectPath}, alias:${ 601 | device.Alias.value || 'n/a' 602 | }, device: ${JSON.stringify(device)}` 603 | ); 604 | const peripheralUuid = this._toUuid(objectPath); 605 | 606 | // Device Properties Change Listener 607 | const props = await this._getPropertiesInterface(objectPath); 608 | props.on('PropertiesChanged', async ( 609 | /*string*/ interfaceName, 610 | /*obj*/ changedProps, 611 | /*string[]*/ invalidatedProps 612 | ) => { 613 | debug( 614 | `[${peripheralUuid}] interfaceName:${interfaceName}, changedProps:${Object.keys( 615 | changedProps 616 | )}, invalidatedProps:${JSON.stringify(invalidatedProps)}` 617 | ); 618 | if (interfaceName === 'org.bluez.Device1') { 619 | if (changedProps.Connected) { 620 | if (changedProps.Connected.value) { 621 | this.emit('connect', peripheralUuid); 622 | } else { 623 | this.emit('disconnect', peripheralUuid); 624 | } 625 | } 626 | if ( 627 | changedProps.ServicesResolved && 628 | changedProps.ServicesResolved.value 629 | ) { 630 | this.onServicesResolved(peripheralUuid, props); 631 | } 632 | if (changedProps.RSSI) { 633 | this.emit('rssiUpdate', peripheralUuid, changedProps.RSSI.value); 634 | } 635 | if (invalidatedProps.includes('RSSI')) { 636 | debug( 637 | `[${peripheralUuid}] RSSI is invalidated. Removing the device.` 638 | ); 639 | try { 640 | await this.hciAdapter.RemoveDevice(objectPath); 641 | } catch (err) { 642 | if (err.type !== 'org.bluez.Error.DoesNotExist') { 643 | debug( 644 | `[${peripheralUuid}] Error while removing the device: ${err.message}, ${err.type}` 645 | ); 646 | } 647 | } 648 | } 649 | } 650 | }); 651 | 652 | const rssi = this._option(device, 'RSSI'); 653 | const address = (device.Address.value || '').toLowerCase(); 654 | const addressType = device.AddressType.value; 655 | const connectable = !device.Blocked.value; 656 | const manufacturerData = device.ManufacturerData 657 | ? Object.values(device.ManufacturerData.value)[0].value 658 | : null; 659 | if (manufacturerData) { 660 | // Prepend Manufacturer ID 661 | manufacturerData.unshift(Object.keys(device.ManufacturerData.value)[0]); 662 | } 663 | const serviceData = device.ServiceData 664 | ? Object.keys(device.ServiceData.value).map((uuid) => { 665 | return { 666 | uuid, 667 | data: Buffer.from(device.ServiceData.value[uuid].value), 668 | }; 669 | }) 670 | : null; 671 | const advertisement = { 672 | localName: this._option(device, 'Alias'), 673 | txPowerLevel: this._option(device, 'TxPower'), 674 | serviceUuids: this._option(device, 'UUIDs', []), 675 | manufacturerData: manufacturerData ? Buffer.from(manufacturerData) : null, 676 | serviceData, 677 | }; 678 | 679 | this.emit( 680 | 'discover', 681 | peripheralUuid, 682 | address, 683 | addressType, 684 | connectable, 685 | advertisement, 686 | rssi 687 | ); 688 | } 689 | 690 | async onServicesResolved( 691 | peripheralUuid, 692 | /*_getPropertiesInterface()*/ props 693 | ) { 694 | const serviceUuids = (await props.Get('org.bluez.Device1', 'UUIDs')).value; 695 | this.emit('servicesDiscover', peripheralUuid, serviceUuids); 696 | } 697 | 698 | async onDevicesServicesCharacteristicsMissed( 699 | objectPath, 700 | /*String[]*/ interfaces 701 | ) { 702 | debug( 703 | ` objectPath:${objectPath}, interfaces:${JSON.stringify( 704 | interfaces 705 | )}` 706 | ); 707 | delete this.objectStore[objectPath]; 708 | if (interfaces.includes('org.bluez.Device1')) { 709 | const peripheralUuid = this._toUuid(objectPath); 710 | this.onDeviceMissed(peripheralUuid); 711 | } 712 | } 713 | 714 | async onDeviceMissed(peripheralUuid) { 715 | debug(` peripheralUuid:${peripheralUuid}`); 716 | this.emit('miss', peripheralUuid); 717 | } 718 | 719 | async onAdapterPropertiesChanged( 720 | /*string*/ interfaceName, 721 | /*obj*/ changedProps, 722 | /*string[]*/ invalidatedProps 723 | ) { 724 | debug( 725 | ` interfaceName:${interfaceName}, changedProps:${Object.keys( 726 | changedProps 727 | )}, invalidatedProps:${JSON.stringify(invalidatedProps)}` 728 | ); 729 | if (interfaceName === 'org.bluez.Adapter1') { 730 | if (changedProps.Discovering) { 731 | debug(`Discovering=>${changedProps.Discovering.value}`); 732 | if (changedProps.Discovering.value) { 733 | this.onScanStarted(); 734 | } else { 735 | this.onScanStopepd(); 736 | } 737 | } 738 | if (changedProps.Powered) { 739 | debug(`Powered=>${changedProps.Powered.value}`); 740 | if (!changedProps.Powered.value) { 741 | this._scanning = false; 742 | setTimeout(async () => { 743 | try { 744 | await this._startDiscovery(); 745 | } catch (err) { 746 | debug( 747 | `Error while turning on the adapter. err.message:${err.message}, type:${err.type}` 748 | ); 749 | } 750 | }, 5 * 1000); 751 | } 752 | } 753 | // Skip to show other props 754 | } 755 | } 756 | 757 | async onScanStarted() { 758 | debug(` fired`); 759 | this._scanning = true; 760 | const bluezObjects = await this.bluezObjectManager.GetManagedObjects(); 761 | // Invoke DevicesDiscovered event listerner if devices already exists 762 | const deviceObjectPathPrefix = `${this.hciObjectPath}/dev_`; 763 | let count = 0; 764 | Object.keys(bluezObjects) 765 | .filter( 766 | (objectPath) => 767 | objectPath.indexOf(deviceObjectPathPrefix) === 0 && 768 | /*Exclude Service/Characteristic Paths*/ objectPath.length === 769 | 37 /*=> '/org/bluez/hci0/dev_11_22_33_44_55_66'.length*/ 770 | ) 771 | .forEach( 772 | /*deviceUuid*/ (objectPath) => { 773 | debug(` ${count++}:${objectPath} Device Found`); 774 | const interfacesAndProps = bluezObjects[objectPath]; 775 | this.onDevicesServicesCharacteristicsDiscovered( 776 | objectPath, 777 | /*Object>*/ interfacesAndProps 778 | ); 779 | } 780 | ); 781 | this.emit('scanStart', this._scanFilterDuplicates); 782 | } 783 | 784 | onScanStopepd() { 785 | debug(`[onScanStopepd] fired`); 786 | this._scanning = false; 787 | this.emit('scanStop'); 788 | } 789 | 790 | onSigInt() { 791 | const sigIntListeners = process.listeners('SIGINT'); 792 | 793 | if (sigIntListeners[sigIntListeners.length - 1] === this.onSigIntBinded) { 794 | // we are the last listener, so exit 795 | // this will trigger onExit, and clean up 796 | process.exit(1); 797 | } 798 | } 799 | 800 | onExit() { 801 | this.stopScanning(); 802 | } 803 | 804 | get bluez() { 805 | return true; 806 | } 807 | } 808 | 809 | export default new BluezBindings(); 810 | -------------------------------------------------------------------------------- /tests/generic-ble.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2017 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import * as sinon from 'sinon'; 19 | import genericBLEModule from 'generic-ble'; 20 | import EventEmitter from 'events'; 21 | 22 | const RED = {}; 23 | 24 | describe('generic-ble node', () => { 25 | RED.debug = true; 26 | let sandbox; 27 | beforeEach(() => { 28 | sandbox = sinon.sandbox.create(); 29 | RED._ = sinon.spy(); 30 | RED.events = sandbox.stub(new EventEmitter()); 31 | RED.nodes = sandbox.stub({ 32 | registerType: () => {}, 33 | }); 34 | RED.log = sandbox.stub({ 35 | debug: () => {}, 36 | info: () => {}, 37 | warn: () => {}, 38 | error: () => {}, 39 | }); 40 | RED.httpAdmin = sandbox.stub({ 41 | get: () => {}, 42 | post: () => {}, 43 | }); 44 | RED.auth = sandbox.stub({ 45 | needsPermission: () => {}, 46 | }); 47 | }); 48 | afterEach(() => { 49 | sandbox = sandbox.restore(); 50 | }); 51 | describe('generic-ble module', () => { 52 | test('should have valid Node-RED plugin classes', () => { 53 | expect(RED).not.toBeNull(); 54 | genericBLEModule(RED); 55 | expect( 56 | RED.nodes.registerType.withArgs('Generic BLE', sinon.match.any) 57 | .calledOnce 58 | ).toBeTruthy(); 59 | expect( 60 | RED.nodes.registerType.withArgs('Generic BLE in', sinon.match.any) 61 | .calledOnce 62 | ).toBeTruthy(); 63 | expect( 64 | RED.nodes.registerType.withArgs('Generic BLE out', sinon.match.any) 65 | .calledOnce 66 | ).toBeTruthy(); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/noble/lib/bluez/bindings.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2020 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | describe('bindings', () => { 19 | const bindings = require('noble/lib/bluez/bindings').default; 20 | test('object is an instance of NobleBindings class', () => { 21 | expect(bindings).not.toBeNull(); 22 | expect(bindings.bluez).toBeTruthy(); 23 | }); 24 | test('#_addDashes adds - to 128bit/16bit UUID', () => { 25 | expect(bindings._addDashes('00002a0000001000800000805f9b34fb')).toBe( 26 | '00002a00-0000-1000-8000-00805f9b34fb' 27 | ); 28 | expect(bindings._addDashes('2a00')).toBe( 29 | '00002a00-0000-1000-8000-00805f9b34fb' 30 | ); 31 | }); 32 | test('#_stripDashes strips - from 128bit/16bit UUID', () => { 33 | expect(bindings._stripDashes('00002a00-0000-1000-8000-00805f9b34fb')).toBe( 34 | '2a00' 35 | ); 36 | expect(bindings._stripDashes('00000000-0000-1000-8000-00805F9B34FB')).toBe( 37 | '0000' 38 | ); 39 | expect(bindings._stripDashes('f000aa44-0451-4000-b000-000000000000')).toBe( 40 | 'f000aa4404514000b000000000000000' 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/noble/lib/bluez/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2020 CANDY LINE INC. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | describe('index', () => { 19 | test('PeripheralRemovableNoble instance is returned', () => { 20 | const noble = require('noble'); 21 | expect(noble.listeners('miss').length).not.toBe(1); 22 | expect(noble.listeners('connect').length).not.toBe(1); 23 | expect(noble.onMiss).toBeDefined(); 24 | }); 25 | }); 26 | --------------------------------------------------------------------------------