├── .devcontainer ├── .env ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.json ├── espruinohub.code-workspace ├── index.js ├── lib ├── attributes.js ├── config.js ├── connect.js ├── devices.js ├── discovery.js ├── history.js ├── homeassistant.js ├── http.js ├── mqttclient.js ├── parsers │ ├── atc.js │ ├── qingping.js │ └── xiaomi.js ├── service.js ├── status.js └── util.js ├── log └── .gitkeep ├── needs-bleno.js ├── package-lock.json ├── package.json ├── start.sh ├── systemd-EspruinoHub.service └── www ├── ide.html ├── index.html ├── mqtt.html ├── paho-mqtt.js ├── rssi.html ├── tinydash.css ├── tinydash.js └── tinydash_mqtt.js /.devcontainer/.env: -------------------------------------------------------------------------------- 1 | NOBLE_HCI_DEVICE_ID=0 -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | # x64 or armv6l 4 | ARG arch=x64 5 | ARG nodev=v8.11.1 6 | ARG nodefile=node-${nodev}-linux-${arch} 7 | 8 | USER root 9 | 10 | # Install dependencies 11 | RUN apt-get -yqq update && \ 12 | apt-get -yqq --no-install-recommends install git build-essential bluetooth bluez libbluetooth-dev libudev-dev bluez-tools rfkill && \ 13 | apt-get -yqq --no-install-recommends install wget libcap2-bin python mosquitto && \ 14 | #if [ "${arch}" == "armv6l"]; then \ 15 | # apt-get -yqq --no-install-recommends install python-rpi.gpio; \ 16 | #fi && \ 17 | apt-get -yqq autoremove && \ 18 | apt-get -yqq clean && \ 19 | rm -rf /var/lib/apt/lists/* /var/cache/* /tmp/* /var/tmp/* 20 | 21 | RUN cd / && \ 22 | wget --no-check-certificate --quiet http://nodejs.org/dist/${nodev}/${nodefile}.tar.gz && \ 23 | tar -xzf ${nodefile}.tar.gz && \ 24 | cd ${nodefile}/ && \ 25 | cp -R * /usr/local/ && \ 26 | cd / && \ 27 | rm -r ${nodefile} && \ 28 | rm ${nodefile}.tar.gz && \ 29 | export PATH=$PATH:/usr/local/bin 30 | 31 | 32 | RUN usermod -a -G bluetooth root && \ 33 | setcap cap_net_raw+eip /usr/local/bin/node 34 | 35 | EXPOSE 1888 1883 36 | 37 | RUN mkdir /workspaces 38 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.128.0/containers/javascript-node-10 3 | { 4 | "name": "EspruinoHub", 5 | "dockerFile": "Dockerfile", 6 | "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined","--device=/dev","--network=host","--privileged","--env-file=./.devcontainer/.env"], 7 | "settings": { 8 | "terminal.integrated.shell.linux": "/bin/bash" 9 | }, 10 | "extensions": [ 11 | "dbaeumer.vscode-eslint" 12 | ], 13 | "postCreateCommand": "npm install", 14 | } -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Create Docker Images 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | env: 10 | NODE_LATEST: 18 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node: [ 16, 18, 20 ] 16 | 17 | steps: 18 | - name: 1-checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - name: 2-setup-image-setting 23 | id: setting 24 | run: | 25 | TAGS="ghcr.io/${{ github.repository }}:${{ matrix.node }}" 26 | if [ "${{ matrix.node }}" == "${{ env.NODE_LATEST }}" ]; then 27 | TAGS="$TAGS,ghcr.io/${{ github.repository }}:latest" 28 | fi 29 | 30 | echo "current tags $TAGS" 31 | echo "TAGS=$TAGS" >> $GITHUB_ENV 32 | 33 | - name: 3-setup-tags-toLowerCase 34 | uses: actions/github-script@v6 35 | id: tags-tolowercase 36 | with: 37 | script: return `${process.env.TAGS.toLowerCase()}` 38 | result-encoding: string 39 | - name: 4-setup-qemu-action 40 | uses: docker/setup-qemu-action@v2 41 | - name: 5-login-action 42 | uses: docker/login-action@v2 43 | with: 44 | registry: ghcr.io 45 | username: ${{ github.repository_owner }} 46 | password: ${{ secrets.GITHUB_TOKEN }} 47 | - name: 6-setup-buildx-action 48 | uses: docker/setup-buildx-action@v2 49 | - name: 7-build-push-action 50 | uses: docker/build-push-action@v3 51 | continue-on-error: true 52 | with: 53 | context: ./ 54 | file: ./Dockerfile 55 | platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6 56 | push: true 57 | build-args: | 58 | NODE_VERSION=${{ matrix.node }} 59 | tags: | 60 | ${{ steps.tags-tolowercase.outputs.result }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules 3 | *.diff 4 | log/* 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=18 2 | 3 | FROM node:${NODE_VERSION}-alpine AS build 4 | 5 | COPY / /app 6 | 7 | RUN set -x \ 8 | && apk add --no-cache --virtual .build-deps \ 9 | build-base \ 10 | linux-headers \ 11 | eudev-dev \ 12 | python3 \ 13 | git \ 14 | && cd /app \ 15 | && npm i --production --verbose \ 16 | && apk del .build-deps 17 | 18 | FROM node:${NODE_VERSION}-alpine 19 | 20 | COPY --from=build /app /app 21 | 22 | RUN set -x \ 23 | && apk add --no-cache tzdata libcap \ 24 | && mkdir -p /data \ 25 | && cp /app/config.json /data/config.json \ 26 | # support port 80/443 27 | && setcap 'cap_net_bind_service=+ep' `which node` 28 | 29 | WORKDIR /app 30 | 31 | CMD [ "node", "index.js", "-c", "/data/config.json" ] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All files in this package are Copyright 2013 Gordon Williams, Pur3 Ltd unless 2 | otherwise noted. 3 | 4 | ------------------------------------------------------------------------------- 5 | 6 | Mozilla Public License Version 2.0 7 | ================================== 8 | 9 | 1. Definitions 10 | -------------- 11 | 12 | 1.1. "Contributor" 13 | means each individual or legal entity that creates, contributes to 14 | the creation of, or owns Covered Software. 15 | 16 | 1.2. "Contributor Version" 17 | means the combination of the Contributions of others (if any) used 18 | by a Contributor and that particular Contributor's Contribution. 19 | 20 | 1.3. "Contribution" 21 | means Covered Software of a particular Contributor. 22 | 23 | 1.4. "Covered Software" 24 | means Source Code Form to which the initial Contributor has attached 25 | the notice in Exhibit A, the Executable Form of such Source Code 26 | Form, and Modifications of such Source Code Form, in each case 27 | including portions thereof. 28 | 29 | 1.5. "Incompatible With Secondary Licenses" 30 | means 31 | 32 | (a) that the initial Contributor has attached the notice described 33 | in Exhibit B to the Covered Software; or 34 | 35 | (b) that the Covered Software was made available under the terms of 36 | version 1.1 or earlier of the License, but not also under the 37 | terms of a Secondary License. 38 | 39 | 1.6. "Executable Form" 40 | means any form of the work other than Source Code Form. 41 | 42 | 1.7. "Larger Work" 43 | means a work that combines Covered Software with other material, in 44 | a separate file or files, that is not Covered Software. 45 | 46 | 1.8. "License" 47 | means this document. 48 | 49 | 1.9. "Licensable" 50 | means having the right to grant, to the maximum extent possible, 51 | whether at the time of the initial grant or subsequently, any and 52 | all of the rights conveyed by this License. 53 | 54 | 1.10. "Modifications" 55 | means any of the following: 56 | 57 | (a) any file in Source Code Form that results from an addition to, 58 | deletion from, or modification of the contents of Covered 59 | Software; or 60 | 61 | (b) any new file in Source Code Form that contains any Covered 62 | Software. 63 | 64 | 1.11. "Patent Claims" of a Contributor 65 | means any patent claim(s), including without limitation, method, 66 | process, and apparatus claims, in any patent Licensable by such 67 | Contributor that would be infringed, but for the grant of the 68 | License, by the making, using, selling, offering for sale, having 69 | made, import, or transfer of either its Contributions or its 70 | Contributor Version. 71 | 72 | 1.12. "Secondary License" 73 | means either the GNU General Public License, Version 2.0, the GNU 74 | Lesser General Public License, Version 2.1, the GNU Affero General 75 | Public License, Version 3.0, or any later versions of those 76 | licenses. 77 | 78 | 1.13. "Source Code Form" 79 | means the form of the work preferred for making modifications. 80 | 81 | 1.14. "You" (or "Your") 82 | means an individual or a legal entity exercising rights under this 83 | License. For legal entities, "You" includes any entity that 84 | controls, is controlled by, or is under common control with You. For 85 | purposes of this definition, "control" means (a) the power, direct 86 | or indirect, to cause the direction or management of such entity, 87 | whether by contract or otherwise, or (b) ownership of more than 88 | fifty percent (50%) of the outstanding shares or beneficial 89 | ownership of such entity. 90 | 91 | 2. License Grants and Conditions 92 | -------------------------------- 93 | 94 | 2.1. Grants 95 | 96 | Each Contributor hereby grants You a world-wide, royalty-free, 97 | non-exclusive license: 98 | 99 | (a) under intellectual property rights (other than patent or trademark) 100 | Licensable by such Contributor to use, reproduce, make available, 101 | modify, display, perform, distribute, and otherwise exploit its 102 | Contributions, either on an unmodified basis, with Modifications, or 103 | as part of a Larger Work; and 104 | 105 | (b) under Patent Claims of such Contributor to make, use, sell, offer 106 | for sale, have made, import, and otherwise transfer either its 107 | Contributions or its Contributor Version. 108 | 109 | 2.2. Effective Date 110 | 111 | The licenses granted in Section 2.1 with respect to any Contribution 112 | become effective for each Contribution on the date the Contributor first 113 | distributes such Contribution. 114 | 115 | 2.3. Limitations on Grant Scope 116 | 117 | The licenses granted in this Section 2 are the only rights granted under 118 | this License. No additional rights or licenses will be implied from the 119 | distribution or licensing of Covered Software under this License. 120 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 121 | Contributor: 122 | 123 | (a) for any code that a Contributor has removed from Covered Software; 124 | or 125 | 126 | (b) for infringements caused by: (i) Your and any other third party's 127 | modifications of Covered Software, or (ii) the combination of its 128 | Contributions with other software (except as part of its Contributor 129 | Version); or 130 | 131 | (c) under Patent Claims infringed by Covered Software in the absence of 132 | its Contributions. 133 | 134 | This License does not grant any rights in the trademarks, service marks, 135 | or logos of any Contributor (except as may be necessary to comply with 136 | the notice requirements in Section 3.4). 137 | 138 | 2.4. Subsequent Licenses 139 | 140 | No Contributor makes additional grants as a result of Your choice to 141 | distribute the Covered Software under a subsequent version of this 142 | License (see Section 10.2) or under the terms of a Secondary License (if 143 | permitted under the terms of Section 3.3). 144 | 145 | 2.5. Representation 146 | 147 | Each Contributor represents that the Contributor believes its 148 | Contributions are its original creation(s) or it has sufficient rights 149 | to grant the rights to its Contributions conveyed by this License. 150 | 151 | 2.6. Fair Use 152 | 153 | This License is not intended to limit any rights You have under 154 | applicable copyright doctrines of fair use, fair dealing, or other 155 | equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 160 | in Section 2.1. 161 | 162 | 3. Responsibilities 163 | ------------------- 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under 169 | the terms of this License. You must inform recipients that the Source 170 | Code Form of the Covered Software is governed by the terms of this 171 | License, and how they can obtain a copy of this License. You may not 172 | attempt to alter or restrict the recipients' rights in the Source Code 173 | Form. 174 | 175 | 3.2. Distribution of Executable Form 176 | 177 | If You distribute Covered Software in Executable Form then: 178 | 179 | (a) such Covered Software must also be made available in Source Code 180 | Form, as described in Section 3.1, and You must inform recipients of 181 | the Executable Form how they can obtain a copy of such Source Code 182 | Form by reasonable means in a timely manner, at a charge no more 183 | than the cost of distribution to the recipient; and 184 | 185 | (b) You may distribute such Executable Form under the terms of this 186 | License, or sublicense it under different terms, provided that the 187 | license for the Executable Form does not attempt to limit or alter 188 | the recipients' rights in the Source Code Form under this License. 189 | 190 | 3.3. Distribution of a Larger Work 191 | 192 | You may create and distribute a Larger Work under terms of Your choice, 193 | provided that You also comply with the requirements of this License for 194 | the Covered Software. If the Larger Work is a combination of Covered 195 | Software with a work governed by one or more Secondary Licenses, and the 196 | Covered Software is not Incompatible With Secondary Licenses, this 197 | License permits You to additionally distribute such Covered Software 198 | under the terms of such Secondary License(s), so that the recipient of 199 | the Larger Work may, at their option, further distribute the Covered 200 | Software under the terms of either this License or such Secondary 201 | License(s). 202 | 203 | 3.4. Notices 204 | 205 | You may not remove or alter the substance of any license notices 206 | (including copyright notices, patent notices, disclaimers of warranty, 207 | or limitations of liability) contained within the Source Code Form of 208 | the Covered Software, except that You may alter any license notices to 209 | the extent required to remedy known factual inaccuracies. 210 | 211 | 3.5. Application of Additional Terms 212 | 213 | You may choose to offer, and to charge a fee for, warranty, support, 214 | indemnity or liability obligations to one or more recipients of Covered 215 | Software. However, You may do so only on Your own behalf, and not on 216 | behalf of any Contributor. You must make it absolutely clear that any 217 | such warranty, support, indemnity, or liability obligation is offered by 218 | You alone, and You hereby agree to indemnify every Contributor for any 219 | liability incurred by such Contributor as a result of warranty, support, 220 | indemnity or liability terms You offer. You may include additional 221 | disclaimers of warranty and limitations of liability specific to any 222 | jurisdiction. 223 | 224 | 4. Inability to Comply Due to Statute or Regulation 225 | --------------------------------------------------- 226 | 227 | If it is impossible for You to comply with any of the terms of this 228 | License with respect to some or all of the Covered Software due to 229 | statute, judicial order, or regulation then You must: (a) comply with 230 | the terms of this License to the maximum extent possible; and (b) 231 | describe the limitations and the code they affect. Such description must 232 | be placed in a text file included with all distributions of the Covered 233 | Software under this License. Except to the extent prohibited by statute 234 | or regulation, such description must be sufficiently detailed for a 235 | recipient of ordinary skill to be able to understand it. 236 | 237 | 5. Termination 238 | -------------- 239 | 240 | 5.1. The rights granted under this License will terminate automatically 241 | if You fail to comply with any of its terms. However, if You become 242 | compliant, then the rights granted under this License from a particular 243 | Contributor are reinstated (a) provisionally, unless and until such 244 | Contributor explicitly and finally terminates Your grants, and (b) on an 245 | ongoing basis, if such Contributor fails to notify You of the 246 | non-compliance by some reasonable means prior to 60 days after You have 247 | come back into compliance. Moreover, Your grants from a particular 248 | Contributor are reinstated on an ongoing basis if such Contributor 249 | notifies You of the non-compliance by some reasonable means, this is the 250 | first time You have received notice of non-compliance with this License 251 | from such Contributor, and You become compliant prior to 30 days after 252 | Your receipt of the notice. 253 | 254 | 5.2. If You initiate litigation against any entity by asserting a patent 255 | infringement claim (excluding declaratory judgment actions, 256 | counter-claims, and cross-claims) alleging that a Contributor Version 257 | directly or indirectly infringes any patent, then the rights granted to 258 | You by any and all Contributors for the Covered Software under Section 259 | 2.1 of this License shall terminate. 260 | 261 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 262 | end user license agreements (excluding distributors and resellers) which 263 | have been validly granted by You or Your distributors under this License 264 | prior to termination shall survive termination. 265 | 266 | ************************************************************************ 267 | * * 268 | * 6. Disclaimer of Warranty * 269 | * ------------------------- * 270 | * * 271 | * Covered Software is provided under this License on an "as is" * 272 | * basis, without warranty of any kind, either expressed, implied, or * 273 | * statutory, including, without limitation, warranties that the * 274 | * Covered Software is free of defects, merchantable, fit for a * 275 | * particular purpose or non-infringing. The entire risk as to the * 276 | * quality and performance of the Covered Software is with You. * 277 | * Should any Covered Software prove defective in any respect, You * 278 | * (not any Contributor) assume the cost of any necessary servicing, * 279 | * repair, or correction. This disclaimer of warranty constitutes an * 280 | * essential part of this License. No use of any Covered Software is * 281 | * authorized under this License except under this disclaimer. * 282 | * * 283 | ************************************************************************ 284 | 285 | ************************************************************************ 286 | * * 287 | * 7. Limitation of Liability * 288 | * -------------------------- * 289 | * * 290 | * Under no circumstances and under no legal theory, whether tort * 291 | * (including negligence), contract, or otherwise, shall any * 292 | * Contributor, or anyone who distributes Covered Software as * 293 | * permitted above, be liable to You for any direct, indirect, * 294 | * special, incidental, or consequential damages of any character * 295 | * including, without limitation, damages for lost profits, loss of * 296 | * goodwill, work stoppage, computer failure or malfunction, or any * 297 | * and all other commercial damages or losses, even if such party * 298 | * shall have been informed of the possibility of such damages. This * 299 | * limitation of liability shall not apply to liability for death or * 300 | * personal injury resulting from such party's negligence to the * 301 | * extent applicable law prohibits such limitation. Some * 302 | * jurisdictions do not allow the exclusion or limitation of * 303 | * incidental or consequential damages, so this exclusion and * 304 | * limitation may not apply to You. * 305 | * * 306 | ************************************************************************ 307 | 308 | 8. Litigation 309 | ------------- 310 | 311 | Any litigation relating to this License may be brought only in the 312 | courts of a jurisdiction where the defendant maintains its principal 313 | place of business and such litigation shall be governed by laws of that 314 | jurisdiction, without reference to its conflict-of-law provisions. 315 | Nothing in this Section shall prevent a party's ability to bring 316 | cross-claims or counter-claims. 317 | 318 | 9. Miscellaneous 319 | ---------------- 320 | 321 | This License represents the complete agreement concerning the subject 322 | matter hereof. If any provision of this License is held to be 323 | unenforceable, such provision shall be reformed only to the extent 324 | necessary to make it enforceable. Any law or regulation which provides 325 | that the language of a contract shall be construed against the drafter 326 | shall not be used to construe this License against a Contributor. 327 | 328 | 10. Versions of the License 329 | --------------------------- 330 | 331 | 10.1. New Versions 332 | 333 | Mozilla Foundation is the license steward. Except as provided in Section 334 | 10.3, no one other than the license steward has the right to modify or 335 | publish new versions of this License. Each version will be given a 336 | distinguishing version number. 337 | 338 | 10.2. Effect of New Versions 339 | 340 | You may distribute the Covered Software under the terms of the version 341 | of the License under which You originally received the Covered Software, 342 | or under the terms of any subsequent version published by the license 343 | steward. 344 | 345 | 10.3. Modified Versions 346 | 347 | If you create software not governed by this License, and you want to 348 | create a new license for such software, you may create and use a 349 | modified version of this License if you rename the license and remove 350 | any references to the name of the license steward (except to note that 351 | such modified license differs from this License). 352 | 353 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 354 | Licenses 355 | 356 | If You choose to distribute Source Code Form that is Incompatible With 357 | Secondary Licenses under the terms of this version of the License, the 358 | notice described in Exhibit B of this License must be attached. 359 | 360 | Exhibit A - Source Code Form License Notice 361 | ------------------------------------------- 362 | 363 | This Source Code Form is subject to the terms of the Mozilla Public 364 | License, v. 2.0. If a copy of the MPL was not distributed with this 365 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 366 | 367 | If it is not possible or desirable to put the notice in a particular 368 | file, then You may include the notice in a location (such as a LICENSE 369 | file in a relevant directory) where a recipient would be likely to look 370 | for such a notice. 371 | 372 | You may add additional accurate notices of copyright ownership. 373 | 374 | Exhibit B - "Incompatible With Secondary Licenses" Notice 375 | --------------------------------------------------------- 376 | 377 | This Source Code Form is "Incompatible With Secondary Licenses", as 378 | defined by the Mozilla Public License, v. 2.0. 379 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | EspruinoHub 2 | =========== 3 | 4 | A BLE -> MQTT bridge for Raspberry Pi and other Embedded devices for [Espruino](http://www.espruino.com/) and [Puck.js](http://www.puck-js.com/) 5 | 6 | Setting up 7 | ---------- 8 | 9 | Ideally use a Raspberry Pi 3 or Zero W, as these have Bluetooth LE on them already. However the BLE USB dongles [mentioned in the Puck.js Quick Start guide](http://www.espruino.com/Puck.js+Quick+Start#requirements) should work. 10 | 11 | ### Get Raspbian running on your Raspberry Pi 12 | 13 | * Download Raspbian Lite from https://www.raspberrypi.org/downloads/raspbian/ 14 | * Copy it to an SD card with `sudo dd if=2017-11-29-raspbian-stretch-lite.img of=/dev/sdc status=progress bs=1M` on Linux (or see the instructions on the Raspbian download page above for your platform) 15 | * Unplug and re-plug the SD card and add a file called `ssh` to the `boot` drive - this will enable SSH access to the Pi 16 | * If you're using WiFi rather than Ethernet, see [this post on setting up WiFi via the SD card](https://raspberrypi.stackexchange.com/questions/10251/prepare-sd-card-for-wifi-on-headless-pi) 17 | * Now put the SD card in the Pi, apply power, and wait a minute 18 | * `ssh pi@raspberrypi.local` (or use PuTTY on Windows) and use the password `raspberry` 19 | * Run `sudo raspi-config` and set the Pi up as you want (eg. hostname, password) 20 | 21 | ### Installation of everything (EspruinoHub, Node-RED, Web IDE) 22 | 23 | These instructions install up to date Node.js and Node-RED - however it can take a while! If you just want EspruinoHub and the IDE, see the next item. 24 | 25 | ``` 26 | sudo apt-get update 27 | # OPTIONAL: Update everything to latest versions 28 | sudo apt-get upgrade -y 29 | # Get required packages 30 | sudo apt-get install -y build-essential python-rpi.gpio nodejs nodered git-core 31 | # OPTIONAL: Install a modern version of nodejs and nodered 32 | # Not recommended - The Pi's supplied Node.js version is more than good enough 33 | # bash <(curl -sL https://raw.githubusercontent.com/node-red/raspbian-deb-package/master/resources/update-nodejs-and-nodered) 34 | # Get dependencies 35 | sudo apt-get install -y mosquitto mosquitto-clients bluetooth bluez libbluetooth-dev libudev-dev 36 | # Auto start Node-RED 37 | sudo systemctl enable nodered.service 38 | # Start nodered manually this one time (this creates ~/.node-red) 39 | sudo systemctl start nodered.service 40 | # wait for the ~/.node-red directory to get created... 41 | # Install the Node-RED UI 42 | cd ~/.node-red && npm install node-red-contrib-ui 43 | # Now get EspruinoHub 44 | cd ~/ 45 | git clone https://github.com/espruino/EspruinoHub 46 | # Install EspruinoHub's required Node libraries 47 | cd EspruinoHub 48 | npm install 49 | 50 | # Give Node.js access to Bluetooth 51 | sudo setcap cap_net_raw+eip $(eval readlink -f `which node`) 52 | 53 | # You may need to run the setcap line above again if you update Node.js 54 | ``` 55 | 56 | You can now type `./start.sh` to run EspruinoHub, but it's worth checking out the `Auto Start` section to see how to get it to run at boot. 57 | 58 | ### Installation of EspruinoHub and Web IDE 59 | 60 | ``` 61 | # Install Node, Bluetooth, etc 62 | sudo apt-get update 63 | # OPTIONAL: Update everything to latest versions 64 | sudo apt-get upgrade -y 65 | # Get required packages 66 | sudo apt-get install -y git-core nodejs npm build-essential mosquitto mosquitto-clients bluetooth bluez libbluetooth-dev libudev-dev 67 | # Now get EspruinoHub 68 | git clone https://github.com/espruino/EspruinoHub 69 | # Install EspruinoHub's required Node libraries 70 | cd EspruinoHub 71 | npm install 72 | 73 | # Give Node.js access to Bluetooth 74 | sudo setcap cap_net_raw+eip $(eval readlink -f `which node`) 75 | 76 | # You may need to run the setcap line above again if you update Node.js 77 | ``` 78 | 79 | You can now type `./start.sh` to run EspruinoHub, but it's worth checking out the `Auto Start` section to see how to get it to run at boot. 80 | 81 | ### Auto Start 82 | 83 | There are a 2 main ways to run EspruinoHub on the Raspberry Pi. 84 | 85 | #### Headless Startup 86 | 87 | This is the normal way of running services - to configure them as a system start-up job using `systemd`:** 88 | 89 | ``` 90 | sudo cp systemd-EspruinoHub.service /etc/systemd/system/EspruinoHub.service 91 | ``` 92 | 93 | and edit it as necessary to match your installation directory and user configuration. Then, to start it for testing: 94 | 95 | ``` 96 | sudo systemctl start EspruinoHub.service && sudo journalctl -f -u EspruinoHub 97 | ``` 98 | 99 | If it works, Ctrl-C to break out and enable it to start on login: 100 | 101 | ``` 102 | sudo systemctl enable EspruinoHub.service 103 | ``` 104 | 105 | 106 | #### Console Startup 107 | 108 | If you have a video output on your Pi then you can run EspruinoHub at boot - on the main display - so that you can see what it's reporting. 109 | 110 | * Edit `.bashrc` and add the following right at the bottom: 111 | 112 | ``` 113 | if [ $(tty) == /dev/tty1 ]; then 114 | while true; do 115 | EspruinoHub/start.sh 116 | sleep 1s 117 | done 118 | fi 119 | ``` 120 | 121 | * Now run `sudo raspi-config`, choose `Boot Options`, `Desktop / CLI`, and `Console Autologin` 122 | 123 | * Next time you reboot, the console will automatically run `EspruinoHub` 124 | 125 | ### Notes 126 | 127 | * On non-Raspberry Pi devices, Mosquitto (the MQTT server) may default to not allowing anonymous (un-authenticated) connections to MQTT. To fix this edit `/etc/mosquitto/conf.d/local.conf` and set `allow_anonymous` to `true`. 128 | * By default the HTTP server in EspruinoHub is enabled, however it can be disabled by setting `http_port` to `0` in `config.json` 129 | * The HTTP Proxy service is disabled by default and needs some configuration - see **HTTP Proxy** below 130 | * You used to need a local copy of the Espruino Web IDE, however now EspruinoHub just serves up an IFRAME which points to the online IDE, ensuring it is always up to date. 131 | 132 | ### Uninstalling 133 | 134 | Assuming you followed the steps above (including for 'Headless Startup') you can 135 | uninstall EspruinoHub using the following commands: 136 | 137 | ``` 138 | sudo systemctl stop EspruinoHub.service 139 | sudo systemctl disable EspruinoHub.service 140 | sudo rm /etc/systemd/system/EspruinoHub.service 141 | sudo rm -rf ~/EspruinoHub 142 | ``` 143 | 144 | Run with Docker 145 | --------------- 146 | 147 | More information how work Bluetooth in docker you can read in article "[How to run containerized Bluetooth applications with BlueZ](https://medium.com/omi-uulm/how-to-run-containerized-bluetooth-applications-with-bluez-dced9ab767f6)" by Thomas Huffert 148 | 149 | 150 | Currently, espruinohub has support for multiple architectures: 151 | - `amd64` : based on linux Alpine - for most desktop computer (e.g. x64, x86-64, x86_64) 152 | - `arm32v6` : based on linux Alpine - (i.e. Raspberry Pi 1 & Zero) 153 | - `arm32v7` : based on linux Alpine - (i.e. Raspberry Pi 2, 3, 4) 154 | - `arm64v8` : based on linux Alpine - (i.e. Pine64) 155 | 156 | Install: 157 | 158 | docker pull ghcr.io/espruino/espruinohub 159 | 160 | Run from the directory containing your `config.json`: 161 | 162 | docker run -d -v $PWD/config.json:/data/config.json:ro --restart=always --net=host --privileged --name espruinohub ghcr.io/espruino/espruinohub 163 | 164 | Example for `docker-compose.yml` 165 | 166 | espruinohub: 167 | image: ghcr.io/espruino/espruinohub 168 | hostname: espruinohub 169 | container_name: espruinohub 170 | privileged: true 171 | environment: 172 | - TZ=Europe/Amsterdam 173 | - NOBLE_HCI_DEVICE_ID=0 174 | network_mode: host 175 | volumes: 176 | - /home/twocolors/espruinohub:/data 177 | restart: unless-stopped 178 | 179 | Manual build: 180 | 181 | docker build -t espruino/espruinohub https://github.com/espruino/EspruinoHub.git 182 | 183 | Usage 184 | ----- 185 | 186 | Once started, you then have a few options... 187 | 188 | ### Status / Websocket MQTT / Espruino Web IDE 189 | 190 | By default EspruinoHub starts a web server at http://localhost:1888 that serves 191 | the contents of the `www` folder. **You can disable this by setting `http_port` 192 | to 0 in `config.json`**. 193 | 194 | With that server, you can: 195 | 196 | * See the Intro page 197 | * See the status and log messages at http://localhost:1888/status 198 | * Access the Espruino Web IDE at http://localhost:1888/ide. You 199 | can then connect to any Bluetooth LE device within range of EspruinoHub. 200 | * View real-time Signal Strength data via WebSockets at http://localhost:1888/rssi.html 201 | * View real-time MQTT data via WebSockets at http://localhost:1888/mqtt.html 202 | * View any of your own pages that are written into the `www` folder. For instance 203 | you could use [TinyDash](https://github.com/espruino/TinyDash) with the code 204 | from `www/mqtt.html` to display the latest BLE data that you have received. 205 | 206 | ### MQTT / Node-RED 207 | 208 | If set up, you can access Node-RED using `http://localhost:1880` 209 | 210 | Once you add UI elements and click `Deploy` they'll be visible at `http://localhost:1880/ui` 211 | 212 | The easiest way to get data is to add an MQTT listener node that requests 213 | `/ble/advertise/#` (`#` is a wildcard). This will output all information received 214 | via advertising (see 'Advertising Data' below). 215 | 216 | For more info on available MQTT commands see the 'MQTT Bridge' section below. 217 | 218 | Check out http://www.espruino.com/Puck.js+Node-RED for a proper introduction 219 | on using Node-RED. 220 | 221 | ### MQTT Command-line 222 | 223 | You can use the Mosquitto command-line tools to send and receive MQTT data 224 | that will make `EspruinoHub` do things: 225 | 226 | ``` 227 | # listen to all, verbose 228 | mosquitto_sub -h localhost -t "/#" -v 229 | 230 | # listen to any device advertising a 1809 temperature characteristic and 231 | # output *just* the temperature 232 | mosquitto_sub -h localhost -t "/ble/advertise/+/temp" 233 | 234 | # Test publish 235 | mosquitto_pub -h localhost -t test/topic -m "Hello world" 236 | ``` 237 | 238 | For more info on available MQTT commands see the 'MQTT Bridge' section below. 239 | 240 | 241 | MQTT bridge 242 | ----------- 243 | 244 | ### Advertising 245 | 246 | Data that is received via bluetooth advertising will be relayed over MQTT in the following format: 247 | 248 | * `/ble/presence/DEVICE` - 1 or 0 depending on whether device has been seen or not 249 | * `/ble/advertise/DEVICE` - JSON for device's broadcast name, rssi and manufacturer-specific data (if `mqtt_advertise=true` in `config.json` - the default) 250 | * `/ble/advertise/DEVICE/manufacturer/COMPANY` - Manufacturer-specific data (without leading company code) encoded in base16. To decode use `var data = Buffer.from(msg.payload, 'hex');` (if `mqtt_advertise_manufacturer_data=true` in `config.json` - the default) 251 | * `/ble/advertise/DEVICE/rssi` - Device signal strength 252 | * `/ble/advertise/DEVICE/SERVICE` - Raw service data (as a JSON Array of bytes) (if `mqtt_advertise_service_data=true` in `config.json`) 253 | * `/ble/advertise/DEVICE/PRETTY` or `/ble/PRETTY/DEVICE` - Decoded service data based on the decoding in `attributes.js` 254 | * `1809` decodes to `temp` (Temperature in C) 255 | * `180f` decodes to `battery` 256 | * `feaa` decodes to `url` (Eddystone) 257 | * `2a6d` decodes to `pressure` (Pressure in pa) 258 | * `2a6e` decodes to `temp` (Temperature in C) 259 | * `2a6f` decodes to `humidity` (Humidity in %) 260 | * `ffff` decodes to `data` (This is not a standard - however it's useful for debugging or quick tests) 261 | * `/ble/json/DEVICE/UUID` - Decoded service data (as above) as JSON, eg `/ble/json/DEVICE/1809 => {"temp":16.5}` (if `mqtt_format_json=true` in `config.json` - the default) 262 | * `/ble/advertise/DEVICE/espruino` - If manufacturer data is broadcast Espruino's manufacturer ID `0x0590` **and** it is valid JSON, it is rebroadcast. If an object like `{"a":1,"b":2}` is sent, `/ble/advertise/DEVICE/a` and `/ble/advertise/DEVICE/b` will also be sent. (A JSON5 parser is used, so the more compact `{a:1,b:2}` is also valid). 263 | 264 | You can take advantage of Espruino's manufacturer ID `0x0590` to relay JSON over 265 | Bluetooth LE advertising using the following code on an Espruino board: 266 | 267 | ``` 268 | var data = {a:1,b:2}; 269 | NRF.setAdvertising({},{ 270 | showName:false, 271 | manufacturer:0x0590, 272 | manufacturerData:E.toJS(data) 273 | }); 274 | // Note: JSON.stringify(data) can be used instead of 275 | // E.toJS(data) to produce 'standard' JSON like {"a":1,"b":2} 276 | // instead of E.toJS's more compact {a:1,b:2} 277 | ``` 278 | 279 | Assuming a device with an address of `ma:c_:_a:dd:re:ss` this will create the 280 | folling MQTT topics when `mqtt_advertise_manufacturer_data` is `true` in `config.json`: 281 | 282 | * `/ble/advertise/ma:c_:_a:dd:re:ss/espruino` -> `{"a":1,"b":2}` 283 | * `/ble/advertise/ma:c_:_a:dd:re:ss/a` -> `1` 284 | * `/ble/advertise/ma:c_:_a:dd:re:ss/b` -> `2` 285 | 286 | Note that **you only have 24 characters available for JSON**, so try to use 287 | the shortest field names possible and avoid floating point values that can 288 | be very long when converted to a String. 289 | 290 | 291 | ### Connections 292 | 293 | You can also connect to a device using MQTT packets: 294 | 295 | * `/ble/write/DEVICE/SERVICE/CHARACTERISTIC` connects and writes to the charactertistic 296 | * `/ble/read/DEVICE/SERVICE/CHARACTERISTIC` connects and reads from the charactertistic, sending the result back as a topic `/ble/data/DEVICE/SERVICE/CHARACTERISTIC` 297 | * `/ble/read/DEVICE` connects and reads an array of services and charactertistics 298 | * `/ble/notify/DEVICE/SERVICE/CHARACTERISTIC` connects and starts notifications on the characteristic, which 299 | send data back on `/ble/data/DEVICE/SERVICE/CHARACTERISTIC` 300 | * `/ble/ping/DEVICE` connects, or maintains a connection to the device, and sends `/ble/pong/DEVICE` on success 301 | * `/ble/disconnect/DEVICE` will force a disconnect and send `/ble/disconnected/DEVICE` on completion. This is not normally required as EspruinoHub will automatically disconnect after a period of inactivity (see `connection_timeout` and `connection_alive_on_notify` in the config file) 302 | 303 | `SERVICE` and `CHARACTERISTIC` are either known names from [attributes.js](https://github.com/espruino/EspruinoHub/blob/master/lib/attributes.js) 304 | such as `nus` and `nus_tx` or are of the form `6e400001b5a3f393e0a9e50e24dcca9e` for 128 bit uuids or `abcd` for 16 bit UUIDs. 305 | 306 | After connecting, EspruinoHub will stay connected for a few seconds unless there is 307 | any activity (eg a `write` or `ping`). So you can for instance evaluate something 308 | on a Puck.js BLE UART connection with: 309 | 310 | ``` 311 | => /ble/notify/c7:f9:36:dd:b0:ca/nus/nus_rx 312 | "\x10Bluetooth.println(E.getTemperature())\n" => /ble/write/c7:f9:36:dd:b0:ca/nus/nus_tx 313 | 314 | /ble/data/c7:f9:36:dd:b0:ca/nus/nus_rx => "23\r\n" 315 | ``` 316 | 317 | Once a `/ble/write/DEVICE/SERVICE/CHARACTERISTIC` has been executed, a `/ble/written/DEVICE/SERVICE/CHARACTERISTIC` packet will be sent in response. 318 | 319 | Payload can take the following values 320 | - **object as json** with type and data fields 321 | available values for `type` =` Buffer, buffer, hex` 322 | - *boolean* uint8 323 | - *integer* uint8 324 | - *array* will be loop-encoded in uint8 325 | - *string* will be loop-encoded in uint8 326 | 327 | ### History 328 | 329 | EspruinoHub contains code (`libs/history.js`) that subscribes to any MQTT data 330 | beginning with `/ble/` and that then stores logs of the average value 331 | every minute, 10 minutes, hour and day (see `config.js:history_times`). The 332 | averages are broadcast over MQTT as the occur, but can also be queried by sending 333 | messages to `/hist/request`. 334 | 335 | For example, an Espruino device with address `f5:47:c8:0b:49:04` may broadcast 336 | advertising data with UUID `1809` (Temperature) with the following code: 337 | 338 | ``` 339 | setInterval(function() { 340 | NRF.setAdvertising({ 341 | 0x1809 : [Math.round(E.getTemperature())] 342 | }); 343 | }, 30000); 344 | ``` 345 | 346 | This is decoded into `temp` by `attributes.js`, and it sends the following MQTT 347 | packets: 348 | 349 | ``` 350 | /ble/advertise/f5:47:c8:0b:49:04 {"rssi":-53,"name":"...","serviceUuids":["6e400001b5a3f393e0a9e50e24dcca9e"]} 351 | /ble/advertise/f5:47:c8:0b:49:04/rssi -53 352 | /ble/advertise/f5:47:c8:0b:49:04/1809 [22] 353 | /ble/advertise/f5:47:c8:0b:49:04/temp 22 354 | /ble/temp/f5:47:c8:0b:49:04 22 355 | ``` 356 | 357 | You can now subscribe with MQTT to `/hist/hour/ble/temp/f5:47:c8:0b:49:04` and 358 | every hour you will receive a packet containing the average temperature over 359 | that time. 360 | 361 | However, you can also request historical data by sending the JSON: 362 | 363 | ``` 364 | { 365 | "topic" : "/hist/hour/ble/temp/f5:47:c8:0b:49:04", 366 | "interval" : "minute", 367 | "age" : 6 368 | } 369 | ``` 370 | 371 | to `/hist/request/a_unique_id`. EspruinoHub will then send a packet to 372 | `/hist/response/a_unique_id` containing: 373 | 374 | ``` 375 | { 376 | "interval":"minute", 377 | "from":1531227216903, // unix timestamp (msecs since 1970) 378 | "to":1531234416903, // unix timestamp (msecs since 1970) 379 | "topic":"/hist/hour/ble/temp/f5:47:c8:0b:49:04", 380 | "times":[ array of unix timestamps ], 381 | "data":[ array of average data values ] 382 | } 383 | ``` 384 | 385 | Requests can be of the form: 386 | 387 | ``` 388 | { 389 | topic : "/ble/advertise/...", 390 | "interval" : "minute" / "tenminutes" / "hour" / "day" 391 | // Then time period is either: 392 | "age" : 1, // hours 393 | // or: 394 | "from" : "1 July 2018", 395 | "to" : "5 July 2018" (or anything that works in new Date(...)) 396 | } 397 | ``` 398 | 399 | For a full example of usage see `www/rssi.html`. 400 | 401 | 402 | HTTP Proxy 403 | ---------- 404 | 405 | EspruinoHub implements the [Bluetooth HTTP Proxy service](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.http_proxy.xml) 406 | 407 | The HTTP Proxy is disabled by default as it can give any Bluetooth LE device in range access to your network. To fix this, edit the `http_proxy` and `http_whitelist` entries in `config.json` to enable the proxy and whitelist devices based on address (which you can find from EspruinoHub's status of MQTT advertising packets). 408 | 409 | **NOTE:** Some Bluetooth adaptors (eg CSR / `0a12:0001`) will cause the error `Command Disallowed (0xc)` when attempting to connect to a device when `http_proxy` is enabled. 410 | 411 | To allow Bluetooth to advertise services (for the HTTP proxy) you also need: 412 | 413 | ``` 414 | # Stop the bluetooth service 415 | sudo service bluetooth stop 416 | # Start Bluetooth but without bluetoothd 417 | sudo hciconfig hci0 up 418 | ``` 419 | 420 | See https://github.com/sandeepmistry/bleno 421 | 422 | 423 | Home Assistant Integration 424 | -------------------------- 425 | 426 | Follow the instructions at https://www.home-assistant.io/integrations/mqtt/ to enable Home Assistant to use an external MQTT broker. Assuming you're running on the same device as EspruinoHub, use `localhost` as the IP address for the MQTT server. 427 | 428 | Ensure that `homeassistant` is set to `true` in EspruinoHub's `config.json`. It's currently the default. 429 | 430 | Now, in the Home Assistant main page you should see new Sensors and Binary sensors which match any devices that EspruinoHub has found! 431 | 432 | 433 | Troubleshooting 434 | --------------- 435 | 436 | ### When using the HTTP Proxy I get `BLOCKED` returned in the HTTP body 437 | 438 | Your BLE device isn't in the whitelist in `config.json` - because the HTTP Proxy 439 | exposes your internet connection to the world, only BLE devices with the addresses 440 | you have specified beforehand are allowed to connect. 441 | 442 | 443 | TODO 444 | ---- 445 | 446 | * Handle over-size reads and writes for HTTP Proxy 447 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "// Set this to true to only publish MQTT messages for known devices":0, 3 | "only_known_devices": false, 4 | 5 | "// If a device's address is here, it'll be given a human-readable name":0, 6 | "known_devices" : { 7 | "c0:52:3f:50:42:c9" : "office", 8 | "f4:af:01:22:33:44" : { 9 | "name" : "band", 10 | 11 | "// skip advertise with a smaller signal":0, 12 | "min_rssi" : -80, 13 | 14 | "// disable merging and sending cached data to json topic":0, 15 | "cache_state": false 16 | }, 17 | "a4:c1:55:66:77:88" : { 18 | "name" : "LYWSD03MMC", 19 | 20 | "// bind_key to decrypt the message":0, 21 | "bind_key" : "0B49A588B2B7122FDFB1661C323E52F1" 22 | }, 23 | "8d:3d:aa:bb:cc:xx" : { 24 | "name" : "pvvx", 25 | "// How many seconds to wait for emitting a presence event, after latest time polled":0, 26 | "// Default is global presence_timeout or 60 seconds":0, 27 | "presence_timeout" : 1, 28 | 29 | "model": "" 30 | } 31 | }, 32 | 33 | "// skip advertise with a smaller signal":0, 34 | "min_rssi" : -90, 35 | 36 | "// How many seconds to wait for a packet before considering BLE connection":0, 37 | "// broken and exiting. Higher values are useful with slowly advertising sensors.":0, 38 | "// Setting a value of 0 disables the exit/restart.":0, 39 | "ble_timeout": 20, 40 | 41 | "// How many seconds to wait for emitting a presence event, after latest time polled":0, 42 | "// Default is 60 seconds":0, 43 | "presence_timeout" : 30, 44 | 45 | "// Number of simultaneous bluetooth connection the device can handle (PI Zero=4)":0, 46 | "max_connections" : 4, 47 | 48 | "// How long to wait before we disconnect an inactive bluetooth device":0, 49 | "connection_timeout": 20, 50 | 51 | "// When a device sends data down a bluetooth 'notify' characteristic, should we keep the connection alive?":0, 52 | "connection_alive_on_notify": false, 53 | 54 | "// MQTT path for history requests and output. Default is Empty (to disable).":0, 55 | "//history_path": "/ble/hist/", 56 | 57 | "// We can add our own custom advertising UUIDs here with names to help decode them":0, 58 | "advertised_services" : { 59 | "ffff" : { 60 | "name" : "level" 61 | } 62 | }, 63 | 64 | "// Make this nonzero to enable the HTTP server on the given port.":0, 65 | "// See README.md for more info on what it does":0, 66 | "http_port" : 1888, 67 | 68 | "// Set this to enable the HTTP proxy - it's off by default for safety":0, 69 | "// since it would be possible to spoof MAC addresses and use your":0, 70 | "// connection":0, 71 | "// NOTE: Some Bluetooth adaptors will cause the error: Command Disallowed (0xc)":0, 72 | "// when trying to connect if http_proxyis enabled.":0, 73 | "http_proxy" : false, 74 | 75 | "// If there are any addresses here, they are given access to the HTTP proxy":0, 76 | "http_whitelist" : [ 77 | "e7:e0:57:ad:36:a2" 78 | ], 79 | "mqtt_host": "mqtt://localhost", 80 | "//mqtt_options": { 81 | "username": "user", 82 | "password": "pass", 83 | "clientId": "clientid" 84 | }, 85 | 86 | "// Define the topic prefix under which the MQTT data will be posted. Defaults to /ble which is not adviced. For new installation, please activate the option below.":0, 87 | "//mqtt_prefix": "ble", 88 | 89 | "// These are the types of MQTT topics that are created":0, 90 | 91 | "// Send /ble/advertise/ad:dr:es:ss JSON with raw advertising data, as well as /ble/advertise/ad:dr:es:ss/rssi":0, 92 | "// This is used by the localhost:1888/ide service to detect devices":0, 93 | "mqtt_advertise": true, 94 | "// Send /ble/advertise/ad:dr:es:ss/manufacturer/uuid raw manufacturer data as well as decoded /ble/advertise/ad:dr:es:ss/json_key for json-formatted 0x0590 advertising data":0, 95 | "mqtt_advertise_manufacturer_data": false, 96 | "// Send /ble/advertise/ad:dr:es:ss/uuid raw service data":0, 97 | "mqtt_advertise_service_data": false, 98 | "// Send /ble/json/ad:dr:es:ss/uuid for decoded service data - REQUIRED FOR HOMEASSISTANT":0, 99 | "mqtt_format_json": true, 100 | "// Send /ble/service_name/ad:dr:es:ss for decoded service data":0, 101 | "mqtt_format_decoded_key_topic": true, 102 | 103 | "// Whether to enable Home Assistant integration":0, 104 | "homeassistant": true 105 | } 106 | -------------------------------------------------------------------------------- /espruinohub.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | }, 9 | "tasks": { 10 | "version": "2.0.0", 11 | "tasks": [ 12 | { 13 | "label": "Start EspruinoHub", 14 | "type": "shell", 15 | "command": "./start.sh", 16 | } 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Entrypoint 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | require("./lib/status.js").init(); // Enable Status reporting to console 17 | require("./lib/config.js").init(); // Load configuration 18 | require("./lib/service.js").init(); // Enable HTTP Proxy Service 19 | require("./lib/discovery.js").init(); // Enable Advertising packet discovery 20 | require("./lib/http.js").init(); // Enable HTTP server for status 21 | require("./lib/history.js").init(); // Enable History/Logging 22 | -------------------------------------------------------------------------------- /lib/attributes.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Known Attributes and conversions for them 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | var config = require("./config"); 17 | const util = require("./util"); 18 | const miParser = require("./parsers/xiaomi").Parser; 19 | const qingping = require("./parsers/qingping").Parser; 20 | const {ParserAtc} = require("./parsers/atc"); 21 | 22 | exports.names = { 23 | // https://www.bluetooth.com/specifications/gatt/services/ 24 | "1801": "Generic Attribute", 25 | "1809": "Temperature", 26 | "180a": "Device Information", 27 | "180f": "Battery Service", 28 | // https://github.com/atc1441/ATC_MiThermometer#advertising-format-of-the-custom-firmware 29 | "181a": "ATC_MiThermometer", 30 | "181b": "Body Composition", 31 | "181c": "User Data", 32 | "181d": "Weight Scale", 33 | // https://www.bluetooth.com/specifications/gatt/characteristics/ 34 | "2a2b": "Current Time", 35 | "2a6d": "Pressure", 36 | "2a6e": "Temperature", 37 | "2a6f": "Humidity", 38 | "2af2": "Energy", 39 | // https://www.bluetooth.com/specifications/assigned-numbers/16-bit-uuids-for-members/ 40 | "fe0f": "Philips", 41 | "fe95": "Xiaomi", 42 | "fe9f": "Google", 43 | "feaa": "Google Eddystone", 44 | 45 | "6e400001b5a3f393e0a9e50e24dcca9e": "nus", 46 | "6e400002b5a3f393e0a9e50e24dcca9e": "nus_tx", 47 | "6e400003b5a3f393e0a9e50e24dcca9e": "nus_rx" 48 | }; 49 | 50 | exports.handlers = { 51 | "1809": function (a) { // Temperature 52 | var t = (a.length == 2) ? (((a[1] << 8) + a[0]) / 100) : a[0]; 53 | if (t >= 128) t -= 256; 54 | return {temp: t} 55 | }, 56 | "180f": function (a) { // Battery percent 57 | return { 58 | battery: a[0] 59 | } 60 | }, 61 | "181a": function (a, device) { // ATC_MiThermometer 62 | try { 63 | return new ParserAtc(a, device).parse(); 64 | } catch (e) { 65 | return {error: e.message, raw: a.toString("hex")}; 66 | } 67 | }, 68 | "181b": function (a) { // Xiaomi V2 Scale 69 | let unit; 70 | let weight = a.readUInt16LE(a.length - 2) / 100; 71 | if ((a[0] & (1 << 4)) !== 0) { // Chinese Catty 72 | unit = "jin"; 73 | } else if ((a[0] & 0x0F) === 0x03) { // Imperial pound 74 | unit = "lbs"; 75 | } else if ((a[0] & 0x0F) === 0x02) { // MKS kg 76 | unit = "kg"; 77 | weight = weight / 2; 78 | } else { 79 | unit = "???" 80 | } 81 | const state = { 82 | isStabilized: ((a[1] & (1 << 5)) !== 0), 83 | loadRemoved: ((a[1] & (1 << 7)) !== 0), 84 | impedanceMeasured: ((a[1] & (1 << 1)) !== 0) 85 | }; 86 | 87 | const measurements = { 88 | weight: util.toFixedFloat(weight, 2), 89 | unit, 90 | impedance: a.readUInt16LE(a.length - 4) 91 | }; 92 | return {...measurements, ...state}; 93 | }, 94 | "181d": function (a) { // Xiaomi V1 Scale 95 | let unit; 96 | let weight = a.readUInt16LE(1) * 0.01; 97 | // status byte: 98 | //- Bit 0: lbs unit 99 | //- Bit 1-3: unknown 100 | //- Bit 4: jin unit 101 | //- Bit 5: stabilized 102 | //- Bit 6: unknown 103 | //- Bit 7: weight removed 104 | let status = []; 105 | for (let i = 0; i <= 7; i++) { 106 | status.push(a[0] & (1 << i) ? 1 : 0) 107 | } 108 | 109 | if (status[0] === 1) { 110 | unit = "lbs"; 111 | } else if (status[4] === 1) { 112 | unit = "jin"; 113 | } else { 114 | unit = "kg"; 115 | weight = weight / 2; 116 | } 117 | 118 | const state = { 119 | isStabilized: (status[5] !== 0), 120 | loadRemoved: (status[7] !== 0) 121 | }; 122 | 123 | const d = { 124 | year: a.readUInt16LE(3), 125 | month: a.readUInt8(5), 126 | day: a.readUInt8(6), 127 | hour: a.readUInt8(7), 128 | minute: a.readUInt8(8), 129 | second: a.readUInt8(9) 130 | } 131 | let date = new Date(d.year, d.month - 1, d.day, d.hour, d.minute, d.second); 132 | return {weight: util.toFixedFloat(weight, 2), unit, ...state, date}; 133 | }, 134 | "fdcd": function(d) { 135 | try { 136 | return new qingping(d).parse(); 137 | } catch (e) { 138 | return {error: e.message, raw: d.toString("hex")}; 139 | } 140 | }, 141 | "fff9": function(d) { 142 | try { 143 | return new qingping(d).parse(); 144 | } catch (e) { 145 | return {error: e.message, raw: d.toString("hex")}; 146 | } 147 | }, 148 | "fe95": function (d, device) { 149 | try { 150 | const r = new miParser(d, device ? device.bind_key : null).parse(); 151 | return {...r.event, productName: r.productName}; 152 | } catch (e) { 153 | return {error: e.message, raw: d.toString("hex")}; 154 | } 155 | }, 156 | "fee0": function (d) { 157 | let r = {steps: (0xff & d[0] | (0xff & d[1]) << 8)}; 158 | if (d.length === 5) 159 | r.heartRate = d[4]; 160 | return r; 161 | }, 162 | "feaa": function (d) { // Eddystone 163 | if (d[0] == 0x10) { // URL 164 | var rssi = d[1]; 165 | if (rssi & 128) rssi -= 256; // signed number 166 | var urlType = d[2]; 167 | var URL_TYPES = [ 168 | "http://www.", 169 | "https://www.", 170 | "http://", 171 | "https://"]; 172 | var url = URL_TYPES[urlType] || ""; 173 | for (var i = 3; i < d.length; i++) 174 | url += String.fromCharCode(d[i]); 175 | return {url: url, "rssi@1m": rssi}; 176 | } 177 | }, 178 | "2a6d": function (a) { // Pressure in pa 179 | return {pressure: ((a[1] << 24) + (a[1] << 16) + (a[1] << 8) + a[0]) / 10} 180 | }, 181 | "2a6e": function (a) { // Temperature in C 182 | var t = ((a[1] << 8) + a[0]) / 100; 183 | if (t >= 128) t -= 256; 184 | return {temp: t} 185 | }, 186 | "2a6f": function (a) { // Humidity 187 | return {humidity: ((a[1] << 8) + a[0]) / 100} 188 | }, 189 | "2a06": function (a) { // org.bluetooth.characteristic.alert_level 190 | // probably not meant for advertising, but seems useful! 191 | return {alert: a[0]} 192 | }, 193 | "2a56": function (a) { // org.bluetooth.characteristic.digital 194 | // probably not meant for advertising, but seems useful! 195 | return {digital: a[0] != 0} 196 | }, 197 | "2a58": function (a) { // org.bluetooth.characteristic.analog 198 | // probably not meant for advertising, but seems useful! 199 | return {analog: a[0] | (a.length > 1 ? (a[1] << 8) : 0)} 200 | }, 201 | "2af2": function (a) { // Energy 202 | return { 203 | energy: a.readUInt32BE() 204 | } 205 | }, 206 | // org.bluetooth.characteristic.digital_output 0x2A57 ? 207 | "ffff": function (a) { // 0xffff isn't standard anything - just transmit it as 'data' 208 | if (a.length == 1) 209 | return {data: a[0]}; 210 | return {data: Array.prototype.slice.call(a, 0).join(",")} 211 | } 212 | }; 213 | 214 | exports.getReadableAttributeName = function (attr) { 215 | for (var i in exports.names) 216 | if (exports.names[i] == attr) return i; 217 | return attr; 218 | }; 219 | 220 | exports.decodeAttribute = function (name, value, device) { 221 | if (!(name in config.exclude_services)) { // @todo per device 222 | // built-in decoders 223 | if (name in exports.handlers) { 224 | var r = exports.handlers[name](value, device); 225 | return r ? r : value; 226 | } 227 | 228 | // use generic decoder for known services 229 | if (name in exports.names) { 230 | var obj = {}; 231 | obj[exports.names[name]] = value; 232 | return obj; 233 | } 234 | 235 | // look up decoders in config.json 236 | if (name in config.advertised_services) { 237 | var srv = config.advertised_services[name]; 238 | var obj = {}; 239 | obj[srv.name] = value[0]; 240 | return obj; 241 | } 242 | } 243 | // otherwise as-is 244 | return value; 245 | }; 246 | 247 | exports.lookup = function (attr) { 248 | for (var i in exports.names) 249 | if (exports.names[i] == attr) return i; 250 | return attr; 251 | }; 252 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Configuration file handling 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | var CONFIG_FILENAME = "config.json"; 17 | 18 | /** If the device is listed here, we use the human readable name 19 | when printing status and publishing on MQTT */ 20 | exports.known_devices = {}; 21 | 22 | /** List of device addresses that are allowed to access the HTTP proxy */ 23 | exports.http_whitelist = []; 24 | 25 | /** list of services that can be decoded */ 26 | exports.advertised_services = {}; 27 | 28 | exports.exclude_services = []; 29 | exports.exclude_attributes = []; 30 | 31 | /** switch indicating whether discovery should only accept known devices */ 32 | exports.only_known_devices = false; 33 | 34 | /* How many seconds to wait for a packet before considering BLE connection 35 | broken and exiting. Higher values are useful with slowly advertising sensors. 36 | Setting a value of 0 disables the exit/restart. */ 37 | exports.ble_timeout = 10; 38 | 39 | /** How many seconds to wait for emitting a presence event, after latest time polled */ 40 | exports.presence_timeout = 60; 41 | 42 | /** How long to wait before we disconnect an inactive bluetooth device */ 43 | exports.connection_timeout = 20; 44 | 45 | /** When a device sends data down a bluetooth 'notify' characteristic, should we keep the connection alive? */ 46 | exports.connection_alive_on_notify = false; 47 | 48 | exports.max_connections = 4; 49 | 50 | exports.min_rssi = -100; 51 | 52 | /* MQTT base path for history requests and output */ 53 | exports.history_path = ""; 54 | 55 | /* time periods used for history */ 56 | exports.history_times = { 57 | minute: 60 * 1000, 58 | tenminutes: 10 * 60 * 1000, 59 | hour: 60 * 60 * 1000, 60 | day: 24 * 60 * 60 * 1000 61 | }; 62 | 63 | /* Mqtt topic prefix. For legacy purposes this is configured with a leading 64 | slash. Please note that this is not adviced as it adds an unnecesary level. 65 | For new installation, please remove the slash from the configuration. */ 66 | exports.mqtt_prefix = "/ble"; 67 | 68 | exports.mqtt_advertise = true; 69 | exports.mqtt_advertise_manufacturer_data = true; 70 | exports.mqtt_advertise_service_data = true; 71 | exports.mqtt_format_json = true; 72 | exports.mqtt_format_decoded_key_topic = true; 73 | exports.homeassistant = false; 74 | 75 | function log(x) { 76 | console.log("[Config] " + x); 77 | } 78 | 79 | /// Load configuration 80 | exports.init = function () { 81 | var argv = require("minimist")(process.argv.slice(2), { 82 | alias: {c: "config"}, 83 | string: ["config"] 84 | }); 85 | var config_filename = argv.config || CONFIG_FILENAME; 86 | var fs = require("fs"); 87 | if (fs.existsSync(config_filename)) { 88 | var f = fs.readFileSync(config_filename).toString(); 89 | var json = {}; 90 | try { 91 | json = JSON.parse(f); 92 | } catch (e) { 93 | log("Error parsing " + config_filename + ": " + e); 94 | return; 95 | } 96 | if (json.only_known_devices) 97 | exports.only_known_devices = json.only_known_devices; 98 | if (json.ble_timeout) 99 | exports.ble_timeout = json.ble_timeout; 100 | if (json.presence_timeout) 101 | exports.presence_timeout = json.presence_timeout; 102 | if (json.hasOwnProperty("connection_timeout")) 103 | exports.connection_timeout = json.connection_timeout; 104 | if (json.hasOwnProperty("connection_alive_on_notify")) 105 | exports.connection_alive_on_notify = !!json.connection_alive_on_notify; 106 | if (json.max_connections) 107 | exports.max_connections = json.max_connections; 108 | if (json.history_path) 109 | exports.history_path = json.history_path; 110 | exports.mqtt_host = json.mqtt_host ? json.mqtt_host : "mqtt://localhost"; 111 | exports.mqtt_options = json.mqtt_options ? json.mqtt_options : {}; 112 | if (json.mqtt_prefix) 113 | exports.mqtt_prefix = json.mqtt_prefix; 114 | if (json.http_whitelist) 115 | exports.http_whitelist = json.http_whitelist; 116 | if (json.advertised_services) 117 | exports.advertised_services = json.advertised_services; 118 | if (json.http_proxy) 119 | exports.http_proxy = true; 120 | if (parseInt(json.http_port)) 121 | exports.http_port = parseInt(json.http_port); 122 | 123 | if (json.hasOwnProperty("mqtt_advertise")) 124 | exports.mqtt_advertise = json.mqtt_advertise; 125 | if (json.hasOwnProperty("mqtt_advertise_manufacturer_data")) 126 | exports.mqtt_advertise_manufacturer_data = json.mqtt_advertise_manufacturer_data; 127 | if (json.hasOwnProperty("mqtt_advertise_service_data")) 128 | exports.mqtt_advertise_service_data = json.mqtt_advertise_service_data; 129 | if (json.hasOwnProperty("mqtt_format_json")) 130 | exports.mqtt_format_json = json.mqtt_format_json; 131 | if (json.hasOwnProperty("mqtt_format_decoded_key_topic")) 132 | exports.mqtt_format_decoded_key_topic = json.mqtt_format_decoded_key_topic; 133 | if (json.hasOwnProperty("homeassistant")) 134 | exports.homeassistant = json.homeassistant; 135 | 136 | if (json.hasOwnProperty("min_rssi")) 137 | exports.min_rssi = json.min_rssi; 138 | 139 | if (json.hasOwnProperty("exclude_services")) 140 | exports.exclude_services = json.exclude_services; 141 | 142 | if (json.hasOwnProperty("exclude_attributes")) 143 | exports.exclude_attributes = json.exclude_attributes; 144 | 145 | 146 | // Load settings 147 | 148 | if (json.known_devices) { 149 | const devices = require('./devices'); 150 | Object.keys(json.known_devices).forEach(function (k) { 151 | devices.known(k, json.known_devices[k]); 152 | }); 153 | } 154 | log("Config " + config_filename + " loaded"); 155 | } else { 156 | log("No " + config_filename + " found"); 157 | } 158 | }; 159 | -------------------------------------------------------------------------------- /lib/connect.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Connect to BLE devices 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | var DEBUG = false;//true; 16 | 17 | var util = require("./util"); 18 | var discovery = require("./discovery"); 19 | var config = require("./config"); 20 | var queue = []; 21 | 22 | var connections = []; 23 | var isBusy = false; 24 | var busyTimeout = 0; 25 | /* characteristic.write/getservices may fail if disconnect happens during it. If so 26 | we should call it ourself when the device closes. */ 27 | var jobInProgress; 28 | 29 | function log(x) { 30 | console.log("[Connect] " + x); 31 | } 32 | 33 | function Connection(device, callback) { 34 | var connection = this; 35 | 36 | connection.secondsSinceUsed = 0; 37 | connection.device = device; 38 | connection.name = device.address; 39 | connection.services = {}; 40 | connection.isOpen = true; 41 | connections.push(this); 42 | 43 | log(connection.name + ": Connecting..."); 44 | device.connect(function (error) { 45 | if (error) { 46 | log(connection.name + ": Error Connecting: " + error.toString()); 47 | connection.device = undefined; 48 | connection.close(); 49 | callback("Error Connecting: " + error.toString()); 50 | } else { 51 | log("Connected."); 52 | device.once("disconnect", () => { 53 | log(connection.name + ": Disconnected by device"); 54 | connection.device = undefined; 55 | connection.close(); 56 | if (jobInProgress) { 57 | jobInProgress("DISCONNECTED"); 58 | jobInProgress = undefined; 59 | } 60 | if (!queue.length && !connections.length) // no open connections 61 | discovery.startScan(); 62 | }); 63 | callback(null, connection); 64 | } 65 | }); 66 | } 67 | 68 | Connection.prototype.getCharacteristic = function (serviceUUID, characteristicUUID, callback) { 69 | var connection = this; 70 | 71 | function getCharacteristicFromService(matchedService) { 72 | // do explicit search for known characteristic 73 | matchedService.discoverCharacteristics([characteristicUUID], function (error, characteristics) { 74 | if (error) { 75 | callback(error); 76 | } 77 | if (timeout) clearTimeout(timeout); 78 | if (characteristics != undefined && characteristics.length) { 79 | var matchedCharacteristic = characteristics[0]; 80 | connection.services[serviceUUID][characteristicUUID] = { 81 | characteristic: matchedCharacteristic, 82 | notifyCallback: undefined 83 | }; 84 | log(connection.name + ": found characteristic: " + matchedCharacteristic.uuid); 85 | callback(null, matchedCharacteristic); 86 | } else { 87 | callback("Characteristic " + characteristicUUID + " not found"); 88 | } 89 | }); 90 | } 91 | 92 | // look in cache 93 | if (connection.services[serviceUUID] && 94 | connection.services[serviceUUID][characteristicUUID]) 95 | return callback(null, connection.services[serviceUUID][characteristicUUID].characteristic); 96 | 97 | log(connection.name + ": Getting Service..."); 98 | var timeout = setTimeout(function () { 99 | timeout = undefined; 100 | log(connection.name + ": Timed out getting services."); 101 | callback("Timed out getting services for characteristic."); 102 | }, 4000); 103 | 104 | var called = false; 105 | if (connection.services[serviceUUID]) { 106 | getCharacteristicFromService(connection.services[serviceUUID].service); 107 | } else { 108 | this.device.discoverServices([serviceUUID], function (error, services) { // do explicit search for known service 109 | if (called) return; // double callbacks for some reason? 110 | called = true; 111 | if (services != undefined && services.length) { 112 | var matchedService = services[0]; 113 | log(connection.name + ": found service: " + matchedService.uuid, "getting Characteristic...."); 114 | if (!connection.services[serviceUUID]) 115 | connection.services[serviceUUID] = {service: matchedService}; 116 | getCharacteristicFromService(matchedService); 117 | } else { 118 | if (timeout) clearTimeout(timeout); 119 | callback("Service " + serviceUUID + " not found"); 120 | } 121 | }); 122 | } 123 | } 124 | 125 | Connection.prototype.getServices = function (callback) { 126 | var connection = this; 127 | 128 | function handleService(allServices, index) { 129 | matchedService = allServices[index] 130 | log(connection.name + ": found service: " + matchedService.uuid, "getting Characteristic...."); 131 | if (!connection.services[matchedService.uuid]) { 132 | connection.services[matchedService.uuid] = matchedService; 133 | } 134 | 135 | matchedService.discoverCharacteristics(null, function (error, characteristics) { // do search for all characteristics 136 | if (!error) { 137 | if (timeout) clearTimeout(timeout); 138 | if (characteristics != undefined && characteristics.length) { 139 | characteristics.forEach(function (matchedCharacteristic) { 140 | connection.services[matchedService.uuid][matchedCharacteristic.uuid] = { 141 | characteristic: matchedCharacteristic, 142 | notifyCallback: undefined 143 | }; 144 | log(connection.name + ": found characteristic: " + matchedCharacteristic.uuid); 145 | }); 146 | } 147 | if (index < allServices.length - 1) { // Last service in array? 148 | handleService(allServices, index + 1) // Handle next service 149 | } else { 150 | callback(null, connection.services) // Return connection's services 151 | } 152 | } else { 153 | callback("Failed to discover characteristics") 154 | } 155 | }); 156 | } 157 | 158 | // don't look in cache 159 | 160 | log(connection.name + ": Getting Services..."); 161 | var timeout = setTimeout(function () { 162 | timeout = undefined; 163 | log(connection.name + ": Timed out getting services."); 164 | callback("Timed out getting services."); 165 | }, 4000); 166 | 167 | 168 | this.device.discoverServices(null, function (error, services) { // do search for all services 169 | if (!error) { 170 | 171 | if (services != undefined && services.length) { 172 | handleService(services, 0) 173 | } else { 174 | callback(null, {}) 175 | } 176 | } else { 177 | callback("Failed to discover services"); 178 | } 179 | }); 180 | } 181 | 182 | Connection.prototype.close = function () { 183 | if (!this.isOpen) return; 184 | this.isOpen = false; 185 | if (this.device) { 186 | log(this.name + ": Disconnecting."); 187 | try { 188 | this.device.disconnect(); 189 | log(this.name + ": Disconnected"); 190 | } catch (e) { 191 | log(this.name + ": Disconnect error: " + e); 192 | } 193 | this.device = undefined; 194 | } 195 | // remove from connection list 196 | var i = connections.indexOf(this); 197 | if (i >= 0) connections.splice(i, 1); 198 | log("Connections remaining: " + JSON.stringify(connections.map(c => c.name))); 199 | // we'll just wait for the next idle to see if there's anything else in the queue 200 | }; 201 | 202 | Connection.prototype.setUsed = function () { 203 | this.secondsSinceUsed = 0; 204 | }; 205 | 206 | // ----------------------------------------------------------------------------- 207 | // ----------------------------------------------------------------------------- 208 | // ----------------------------------------------------------------------------- 209 | 210 | // Repeated write to characteristic 211 | function writeToCharacteristic(characteristic, message, callback) { // added function to write longer strings 212 | if (message.length) { 213 | var data = message.slice(0, 20); 214 | message = message.slice(20); 215 | jobInProgress = callback; // in case characteristic.write fails from disconnect 216 | characteristic.write(data, false, function () { 217 | jobInProgress = undefined; 218 | //log("wrote data: "+ JSON.stringify(data.toString())+ " " + data.length + " bytes"); 219 | writeToCharacteristic(characteristic, message, callback); 220 | }); 221 | } else if (callback) callback(); 222 | } 223 | 224 | // Utility getCharacteristic fn that always calls callback 225 | function getCharacteristic(connection, service, characteristic, callback) { 226 | jobInProgress = callback; // in case getCharacteristic fails from disconnect 227 | connection.getCharacteristic(util.uuid2noble(service), 228 | util.uuid2noble(characteristic), 229 | function (err, char) { 230 | jobInProgress = undefined; 231 | callback(err, char); 232 | }); 233 | } 234 | 235 | // Look up an existing connection 236 | function findConnectedDevice(device) { 237 | var found; 238 | connections.forEach(function (connection) { 239 | if (connection.device && connection.device.address == device.address) 240 | found = connection; 241 | }); 242 | return found; 243 | } 244 | 245 | // Look up an existing connection or make one 246 | function getConnectedDevice(device, callback) { 247 | var found = findConnectedDevice(device); 248 | if (found) { 249 | found.setUsed(); 250 | callback(null, found); 251 | } else { 252 | discovery.stopScan(); 253 | setTimeout(() => { new Connection(device,callback)},1000 ); 254 | } 255 | } 256 | 257 | function serviceQueue() { 258 | if (isBusy) return; 259 | if (queue.length) 260 | log("serviceQueue jobs " + queue.length); 261 | if (!queue.length) { 262 | if (connections.length == 0) // no open connections 263 | discovery.startScan(); 264 | return; 265 | } 266 | if (connections.length < config.max_connections) { 267 | var job = queue.shift(); 268 | discovery.stopScan(); 269 | console.log("Starting job from Queue"); 270 | setTimeout(job, 100); 271 | } 272 | } 273 | 274 | function getStack() { 275 | var err = new Error(); 276 | Error.captureStackTrace(err, getStack); 277 | var s = err.stack.toString().trim(); 278 | if (s.startsWith("Error")) 279 | s = s.substr(5).trim(); 280 | return s; 281 | } 282 | 283 | function setNotBusy(dontService) { 284 | //log("SET NOT BUSY " + getStack()); 285 | isBusy = false; 286 | busyTimeout = 0; 287 | if (!dontService) 288 | serviceQueue(); 289 | } 290 | 291 | // ----------------------------------------------------------------------------- 292 | // ----------------------------------------------------------------------------- 293 | // ----------------------------------------------------------------------------- 294 | 295 | 296 | /* Write to the given device */ 297 | exports.write = function (device, service, characteristic, data, callback) { 298 | if (isBusy) { 299 | queue.push(function () { 300 | exports.write(device, service, characteristic, data); 301 | }); 302 | return; 303 | } 304 | if (DEBUG) log("> write to " + device); 305 | isBusy = true; 306 | getConnectedDevice(device, function (err, connection) { 307 | if (err) return setNotBusy(); 308 | getCharacteristic(connection, service, characteristic, function (err, char) { 309 | if (err) return setNotBusy(); 310 | var dataBuf = util.obj2buf(data); 311 | writeToCharacteristic(char, dataBuf, function (err) { 312 | if (err) log(connection.name + ": Error " + err + " during write."); 313 | else log(connection.name + ": Written " + dataBuf.length + " bytes"); 314 | setNotBusy(err); 315 | if (callback) callback(); 316 | }); 317 | }); 318 | }); 319 | }; 320 | 321 | /* Read from the given device */ 322 | exports.read = function (device, service, characteristic, callback) { 323 | if (isBusy) { 324 | queue.push(function () { 325 | exports.read(device, service, characteristic, callback); 326 | }); 327 | return; 328 | } 329 | if (DEBUG) log("> read from " + device); 330 | isBusy = true; 331 | getConnectedDevice(device, function (err, connection) { 332 | if (err) return; 333 | getCharacteristic(connection, service, characteristic, function (err, char) { 334 | if (err) return setNotBusy(); 335 | char.read(function (err, data) { 336 | if (err) log(connection.name + ": Error " + err + " during read."); 337 | else log(connection.name + ": Read."); 338 | if (callback) callback(data.toString()); 339 | setNotBusy(err); 340 | }); 341 | }); 342 | }); 343 | }; 344 | 345 | /* Read services from the given device */ 346 | exports.readServices = function (device, callback) { 347 | if (isBusy) { 348 | queue.push(function () { 349 | exports.readServices(device, callback); 350 | }); 351 | return; 352 | } 353 | if (DEBUG) log("> readServices on " + device); 354 | isBusy = true; 355 | getConnectedDevice(device, function (err, connection) { 356 | if (err) { 357 | return; 358 | } 359 | connection.getServices(function (err, services) { 360 | if (err) { 361 | return setNotBusy(); 362 | } 363 | /* Extract UUIDs from the connection's services object. 364 | Output array format: 365 | [ 366 | { 367 | uuid:serviceUuid, 368 | characteristics: [ 369 | { 370 | uuid:characteristicUuid 371 | } 372 | ] 373 | } 374 | ] 375 | */ 376 | var output = [] 377 | for (service in services) { 378 | var item = { 379 | uuid: service, 380 | characteristics: [] 381 | } 382 | for (uuid in services[service].characteristics) { 383 | let c = services[service].characteristics[uuid]; 384 | if (c.hasOwnProperty("uuid")) { 385 | item.characteristics.push({uuid: c.uuid, properties: c.properties, name: c.name, type: c.type}) 386 | } 387 | } 388 | output.push(item) 389 | } 390 | // Stringifies array before sending 391 | callback(JSON.stringify(output)) 392 | setNotBusy(); 393 | }); 394 | }); 395 | }; 396 | 397 | /* Start notifications on the given device. callback(String) */ 398 | exports.notify = function (device, service, characteristic, callback) { 399 | if (isBusy) { 400 | queue.push(function () { 401 | exports.notify(device, service, characteristic, callback); 402 | }); 403 | return; 404 | } 405 | if (DEBUG) log("> notify for " + device); 406 | isBusy = true; 407 | getConnectedDevice(device, function (err, connection) { 408 | //if (DEBUG) log("> notify 1 "+(err||"success")+isBusy); 409 | if (err) return setNotBusy(err); 410 | 411 | getCharacteristic(connection, service, characteristic, function (err, char) { 412 | //if (DEBUG) log("> notify 2 "+(err||"success")+isBusy); 413 | if (err) return setNotBusy(err); 414 | var serviceUUID = util.uuid2noble(service); 415 | var characteristicUUID = util.uuid2noble(characteristic); 416 | 417 | if (connection.services[serviceUUID][characteristicUUID].notifyCallback) { 418 | if (DEBUG) log("> notifications already set up"); 419 | connection.services[serviceUUID][characteristicUUID].notifyCallback = callback; 420 | return setNotBusy(); // notifications were already set up 421 | } 422 | char.on("data", function (data) { 423 | if (DEBUG) log(connection.name + ": notification on " + JSON.stringify(data.toString("binary"))); 424 | if (connection.services[serviceUUID][characteristicUUID].notifyCallback) 425 | connection.services[serviceUUID][characteristicUUID].notifyCallback(data.toString("binary")); 426 | // If configured, reset 'secondsSinceUsed' on notifyCallback triggered. 427 | if (config.connection_alive_on_notify) 428 | connection.setUsed(); 429 | }); 430 | char.subscribe(function (err) { 431 | //if (DEBUG) log("> notify 3 "+(err||"success")+isBusy); 432 | connection.services[serviceUUID][characteristicUUID].notifyCallback = callback; 433 | log(connection.name + ": startNotifications complete"); 434 | setNotBusy(); 435 | }); 436 | }); 437 | }); 438 | }; 439 | 440 | /* Just try and connect. Will reset the timeout counter as well */ 441 | exports.ping = function (device, callback) { 442 | if (isBusy) { 443 | queue.push(function () { 444 | exports.ping(device, callback); 445 | }); 446 | return; 447 | } 448 | if (DEBUG) log("> ping " + device); 449 | isBusy = true; 450 | getConnectedDevice(device, function (err, connection) { 451 | if (err) return setNotBusy(); 452 | if (callback) callback(null); 453 | setNotBusy(); 454 | }); 455 | }; 456 | 457 | /* Get a line of status info to display on screen */ 458 | exports.getStatusText = function () { 459 | return "[CONNECT] Connections [" + connections.map(c => c.name) + "] " + (isBusy ? "BUSY" : "IDLE"); 460 | } 461 | 462 | // ----------------------------------------------------------------------------- 463 | // ----------------------------------------------------------------------------- 464 | // ----------------------------------------------------------------------------- 465 | 466 | 467 | setInterval(function () { 468 | if (isBusy) { 469 | busyTimeout++; 470 | if (busyTimeout > 10) { 471 | log("TIMEOUT! Busy for >10 secs, ignoring"); 472 | isBusy = false; 473 | } 474 | } 475 | for (var i = 0; i < connections.length; i++) { 476 | var connection = connections[i]; 477 | connection.secondsSinceUsed++; 478 | if (connection.secondsSinceUsed > config.connection_timeout) { 479 | log(connection.name + ": Disconnecting due to lack of use (after " + config.connection_timeout + " secs)"); 480 | connection.close(); 481 | i--; // connection automatically removes itself from list 482 | } 483 | } 484 | }, 1000); 485 | -------------------------------------------------------------------------------- /lib/devices.js: -------------------------------------------------------------------------------- 1 | var config = require("./config"); 2 | 3 | const defaultSettings = { 4 | "min_rssi": config.min_rssi, 5 | "presence_timeout": config.presence_timeout, 6 | "connection_timeout": config.connection_timeout, 7 | "exclude_attributes": config.exclude_attributes, 8 | "cache_state": config.mqtt_cache_state, 9 | "bind_key": null 10 | } 11 | 12 | const devices = {}; 13 | exports.list = devices; 14 | 15 | function createDevice(mac, name = "", settings) { 16 | let name_is_mac = false; 17 | mac = mac.toLowerCase(); 18 | if (name === "") { 19 | name = mac; 20 | name_is_mac = true; 21 | } 22 | let device = {mac, name, name_is_mac, ...defaultSettings, ...settings}; 23 | 24 | device.json_state_topic = config.mqtt_prefix + "/json/" + device.name; 25 | device.presence_topic = config.mqtt_prefix + "/presence/" + device.name; 26 | device.advertise_topic = config.mqtt_prefix + "/advertise/" + device.name; 27 | 28 | device.state = {}; 29 | device.getOrSetState = function (key, state) { 30 | if (device.state[key] !== undefined) { 31 | state = {...device.state[key], ...state}; 32 | } 33 | return device.state[key] = state; 34 | } 35 | device.filterAttributes = function (decoded) { 36 | device.exclude_attributes.map(function (a) { 37 | if (decoded.hasOwnProperty(a)) delete decoded[a]; 38 | }) 39 | } 40 | return device; 41 | } 42 | 43 | exports.known = function (mac, s) { 44 | mac = mac.toLowerCase(); 45 | let device = {}; 46 | if (typeof s === "string") { 47 | device = createDevice(mac, s); 48 | } else if (typeof s === "object") { 49 | device = createDevice(mac, s.name, s); 50 | } 51 | device.known = true; 52 | devices[mac] = device; 53 | } 54 | 55 | exports.getByMac = function (mac) { 56 | mac = mac.toLowerCase(); 57 | if (!(mac in devices)) { 58 | devices[mac] = createDevice(mac, mac); 59 | } 60 | return devices[mac]; 61 | } 62 | 63 | exports.getByName = function (name) { 64 | let found; 65 | Object.keys(devices).forEach(function (k) { 66 | if (devices[k].name === name) 67 | found = devices[k]; 68 | }); 69 | return found; 70 | } 71 | 72 | exports.deviceToAddr = function (id) { 73 | let addr = id.toLowerCase(); 74 | let device = exports.getByName(id); 75 | if (device !== undefined) { 76 | addr = device.mac; 77 | } 78 | return addr; 79 | } 80 | -------------------------------------------------------------------------------- /lib/discovery.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Converts BLE advertising packets to MQTT 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | var noble; 17 | try { 18 | noble = require("noble"); 19 | } catch (e) { 20 | noble = require("@abandonware/noble"); 21 | } 22 | var mqtt = require("./mqttclient"); 23 | var config = require("./config"); 24 | var attributes = require("./attributes"); 25 | const devices = require("./devices"); 26 | const homeassistant = require("./homeassistant"); 27 | 28 | // List of BLE devices that are currently in range 29 | var inRange = {}; 30 | var packetsReceived = 0; 31 | var scanStartTime = Date.now(); 32 | 33 | /* 34 | On some adapters you cannot scan and connect at same time. 35 | Tested on raspberry pi zero, the first startScan of an app, triggers both stop and start. 36 | All external stops and starts are received as 'stop' callbacks for every app that is not its self. 37 | This makes onStart reliable (for when it triggers itself), and onStop unreliable. 38 | However we can't assume it works this way for all adapters, so try to rely on states as little as possible. 39 | If Broken BLE restart tests seem best. 40 | */ 41 | var checkBrokenInterval = undefined; 42 | var wishToScan = false; 43 | 44 | 45 | function log(x) { 46 | console.log("[Discover] " + x); 47 | } 48 | 49 | // ---------------------------------------------------------------------- 50 | var powerOnTimer; 51 | if (config.ble_timeout > 0) 52 | powerOnTimer = setTimeout(function () { 53 | powerOnTimer = undefined; 54 | log("BLE broken? No Noble State Change to 'poweredOn' in " + config.ble_timeout + " seconds - restarting!"); 55 | process.exit(1); 56 | }, config.ble_timeout * 1000) 57 | 58 | function onStateChange(state) { 59 | log("Noble StateChange: " + state); 60 | if (state != "poweredOn") return; 61 | if (powerOnTimer) { 62 | clearTimeout(powerOnTimer); 63 | powerOnTimer = undefined; 64 | } 65 | // delay startup to allow Bleno to set discovery up 66 | setTimeout(function () { 67 | exports.startScan(); 68 | }, 1000); 69 | }; 70 | 71 | // ---------------------------------------------------------------------- 72 | async function onDiscovery(peripheral) { 73 | packetsReceived++; 74 | var addr = peripheral.address; 75 | var id = addr; 76 | let dev = await devices.getByMac(addr); 77 | if ((config.only_known_devices && !dev.known) || (peripheral.rssi < dev.min_rssi)) { 78 | return; 79 | } 80 | var entered = !inRange[addr]; 81 | 82 | if (entered) { 83 | inRange[addr] = { 84 | id: id, 85 | address: addr, 86 | peripheral: peripheral, 87 | name: "?", 88 | dev: dev, 89 | data: {} 90 | }; 91 | mqtt.send(dev.presence_topic, "1", {retain: true}); 92 | } 93 | var mqttData = { 94 | rssi: peripheral.rssi 95 | }; 96 | if (peripheral.advertisement.localName) { 97 | mqttData.name = peripheral.advertisement.localName; 98 | inRange[addr].name = peripheral.advertisement.localName; 99 | } 100 | if (peripheral.advertisement.serviceUuids) 101 | mqttData.serviceUuids = peripheral.advertisement.serviceUuids; 102 | 103 | inRange[addr].lastSeen = Date.now(); 104 | inRange[addr].rssi = peripheral.rssi; 105 | 106 | if (peripheral.advertisement.manufacturerData && config.mqtt_advertise_manufacturer_data) { 107 | var mdata = peripheral.advertisement.manufacturerData.toString("hex"); 108 | 109 | // Include the entire raw string, incl. manufacturer, as hex 110 | mqttData.manufacturerData = mdata; 111 | mqtt.send(dev.advertise_topic, JSON.stringify(mqttData)); 112 | 113 | // First two bytes is the manufacturer code (little-endian) 114 | // re: https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers 115 | var manu = mdata.slice(2, 4) + mdata.slice(0, 2); 116 | var rest = mdata.slice(4); 117 | 118 | // Split out the manufacturer specific data 119 | mqtt.send(dev.advertise_topic + "/manufacturer/" + manu, JSON.stringify(rest)); 120 | if (manu == "0590") { 121 | var str = ""; 122 | for (var i = 0; i < rest.length; i += 2) 123 | str += String.fromCharCode(parseInt(rest.substr(i, 2), 16)); 124 | var j; 125 | try { 126 | /* If we use normal JSON it'll complain about {a:1} because 127 | it's not {"a":1}. JSON5 won't do that */ 128 | j = require("json5").parse(str); 129 | mqtt.send(dev.advertise_topic + "/espruino", str); 130 | if ("object" == typeof j) 131 | for (var key in j) 132 | mqtt.send(dev.advertise_topic + "/" + key, JSON.stringify(j[key])); 133 | } catch (e) { 134 | // it's not valid JSON, leave it 135 | } 136 | } 137 | } else if (config.mqtt_advertise) { 138 | // No manufacturer specific data 139 | mqtt.send(dev.advertise_topic, JSON.stringify(mqttData)); 140 | } 141 | 142 | 143 | if(peripheral.advertisement.serviceData) { 144 | peripheral.advertisement.serviceData.forEach(function (d) { 145 | /* Don't keep sending the same old data on MQTT. Only send it if 146 | it's changed or >1 minute old. */ 147 | if (inRange[addr].data[d.uuid] && 148 | inRange[addr].data[d.uuid].payload.toString() == d.data.toString() && 149 | inRange[addr].data[d.uuid].time > Date.now() - 60000) 150 | return; 151 | 152 | if (config.mqtt_advertise_service_data) { 153 | // Send advertising data as a simple JSON array, eg. "[1,2,3]" 154 | var byteData = []; 155 | for (var i = 0; i < d.data.length; i++) 156 | byteData.push(d.data.readUInt8(i)); 157 | mqtt.send(dev.advertise_topic + "/" + d.uuid, JSON.stringify(byteData)); 158 | } 159 | 160 | inRange[addr].data[d.uuid] = {payload: d.data, time: Date.now()}; 161 | 162 | var decoded = attributes.decodeAttribute(d.uuid, d.data, dev); 163 | if (decoded !== d.data) { 164 | decoded.rssi = peripheral.rssi; 165 | dev.filterAttributes(decoded); 166 | 167 | if (config.homeassistant) homeassistant.configDiscovery(decoded, dev, peripheral, d.uuid); 168 | for (var k in decoded) { 169 | if (config.mqtt_advertise) mqtt.send(config.mqtt_prefix + "/advertise/" + dev.name + "/" + k, JSON.stringify(decoded[k])); 170 | if (config.mqtt_format_decoded_key_topic) mqtt.send(config.mqtt_prefix + "/" + k + "/" + dev.name, JSON.stringify(decoded[k])); 171 | } 172 | 173 | if (config.mqtt_format_json) { 174 | mqtt.send(dev.json_state_topic + "/" + d.uuid, JSON.stringify(dev.getOrSetState(d.uuid, decoded))); 175 | } 176 | } 177 | }); 178 | } 179 | } 180 | 181 | 182 | /** If a BLE device hasn't polled in for 60? seconds, emit a presence event */ 183 | function checkForPresence() { 184 | var timeout = Date.now() - config.presence_timeout * 1000; 185 | 186 | if (!wishToScan || scanStartTime > timeout) 187 | return; // don't check, as we're not scanning/haven't had time 188 | 189 | Object.keys(inRange).forEach(function (addr) { 190 | let timeout = Date.now() - inRange[addr].dev.presence_timeout * 1000; 191 | if (inRange[addr].lastSeen < timeout) { 192 | mqtt.send(inRange[addr].dev.presence_topic, "0", {retain: true}); 193 | delete inRange[addr]; 194 | } 195 | }); 196 | } 197 | 198 | function checkIfBroken() { 199 | // If no packets for ble_timeout seconds, restart 200 | if (packetsReceived == 0) { 201 | log("BLE broken? No advertising packets in " + config.ble_timeout + " seconds - restarting!"); 202 | process.exit(1); 203 | } 204 | packetsReceived = 0; 205 | } 206 | 207 | exports.init = function () { 208 | noble.on("stateChange", onStateChange); 209 | noble.on("discover", onDiscovery); 210 | noble.on("scanStart", function () { 211 | scanStartTime = Date.now(); 212 | log("Scanning started."); 213 | }); 214 | noble.on("scanStop", function () { 215 | //unreliable, because some adapters fire this when other processes start scanning 216 | log("unreliable scanStop()"); 217 | // if this was us stopping scan, wishToScan would be false 218 | if ( wishToScan ) { 219 | // Scanning is lower priority, only way to allow others to connect, drop fast 220 | process.exit(1); 221 | } 222 | }); 223 | setInterval(checkForPresence, 1000); 224 | }; 225 | 226 | exports.inRange = inRange; 227 | 228 | exports.startScan = function () { 229 | log("caller is " + exports.startScan.caller); 230 | wishToScan = true; 231 | if ( config.ble_timeout > 0 && checkBrokenInterval === undefined) { 232 | log("Spawning check-broken interval"); 233 | checkBrokenInterval = setInterval(checkIfBroken, config.ble_timeout * 1000); 234 | } 235 | // Other programs _could_ receive this signal as scanStopped 236 | noble.startScanning([],true); 237 | log("Starting Scan"); 238 | } 239 | 240 | exports.stopScan = function () { 241 | wishToScan = false; 242 | if (checkBrokenInterval) { 243 | clearInterval(checkBrokenInterval); 244 | checkBrokenInterval = undefined; 245 | } 246 | noble.stopScanning(); 247 | } 248 | 249 | /// Send up to date presence data for all known devices over MQTT (to be done when first connected to MQTT) 250 | exports.sendMQTTPresence = function () { 251 | log("Re-sending presence status of known devices"); 252 | for (let addr in inRange) { 253 | mqtt.send(inRange[addr].dev.presence_topic, "1", {retain: true}); 254 | } 255 | for (let mac in devices.list) { 256 | if (devices.list[mac].known) 257 | mqtt.send(devices.list[mac].presence_topic, (devices.list[mac].mac in inRange) ? "1" : "0", {retain: true}); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /lib/history.js: -------------------------------------------------------------------------------- 1 | var config = require("./config"); 2 | var fs = require("fs"); 3 | 4 | var pathToLog; 5 | var historyTopics = []; 6 | 7 | function log(x) { 8 | console.log("[History] " + x); 9 | } 10 | 11 | // ============================================================================= 12 | function getLogFileForDate(timespec, date) { 13 | if (!pathToLog) return; 14 | return pathToLog + "/" + timespec + "-" + date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); 15 | } 16 | 17 | function logWrite(timespec, topic, data) { 18 | if (!pathToLog) return; 19 | //log(" [LOG]"+timespec+" "+topic+" "+data); 20 | var file = getLogFileForDate(timespec, new Date()); 21 | fs.appendFileSync(file, Date.now() + " " + topic + " " + data + "\n"); 22 | }; 23 | 24 | function logReadTopic(interval, from, to, topic, callback) { 25 | var fromTime = from.getTime(); 26 | var toTime = to.getTime(); 27 | var time = fromTime; 28 | if (from.getFullYear() < 2018 || 29 | toTime > Date.now() + 1000 * 60 * 60 * 24) return; // invalid date range 30 | var files = []; 31 | while (time <= toTime) { 32 | var file = getLogFileForDate(interval, new Date(time)); 33 | if (fs.existsSync(file)) files.push(file); 34 | time += 1000 * 60 * 60 * 24; // one day 35 | } 36 | 37 | function readFiles(result, callback) { 38 | if (!files.length) return callback(result); 39 | var file = files.shift(); // take first file off 40 | const rl = require("readline").createInterface({ 41 | input: fs.createReadStream(file), 42 | crlfDelay: Infinity 43 | }); 44 | rl.on("line", (line) => { 45 | var topicIdx = line.indexOf(" "); 46 | var dataIdx = line.indexOf(" ", topicIdx + 1); 47 | var lTopic = line.substring(topicIdx + 1, dataIdx); 48 | if (lTopic == topic) { 49 | try { 50 | var t = parseInt(line.substr(0, topicIdx)); 51 | var d = JSON.parse(line.substr(dataIdx + 1)); 52 | if (t >= fromTime && t <= toTime) { 53 | result.times.push(t); 54 | result.data.push(d); 55 | } 56 | } catch (e) { 57 | log("Unable to parse log file, " + e.toString()); 58 | } 59 | } 60 | }); 61 | rl.on("close", (line) => { 62 | readFiles(result, callback); 63 | }); 64 | } 65 | 66 | readFiles({ 67 | interval: interval, 68 | from: from.getTime(), 69 | to: to.getTime(), 70 | topic: topic, 71 | times: [], 72 | data: [] 73 | }, function (result) { 74 | callback(result); 75 | }); 76 | }; 77 | 78 | // ============================================================================= 79 | function onMQTTMessage(topic, message) { 80 | var msg = message.toString(); 81 | if (topic.indexOf(" ") >= 0) { 82 | log("Topic ignored due to whitespace: " + topic); 83 | return; // ignore topics with spaces 84 | } 85 | if (topic.startsWith(config.history_path)) { 86 | handleCommand(topic, msg); 87 | } else { 88 | handleData(topic, msg); 89 | } 90 | } 91 | 92 | function handleData(topic, message) { 93 | var data = parseFloat(message); 94 | if (!isNaN(data)) { 95 | //log("MQTT>"+topic+" => "+data); 96 | if (topic in historyTopics) { 97 | historyTopics[topic].pushNumber(data); 98 | } else { 99 | historyTopics[topic] = new HistoryTopic(topic); 100 | historyTopics[topic].pushNumber(data); 101 | } 102 | } 103 | } 104 | 105 | function handleCommand(topic, message) { 106 | var cmdRequest = config.history_path + "request/"; 107 | //log("MQTT Command>"+topic+" => "+JSON.stringify(message)); 108 | if (topic.startsWith(cmdRequest)) { 109 | /* 110 | interval : "minute", 111 | //use age : 1, // hour 112 | //or from : "1 July 2018", to : "5 July 2018" (or anything that works in new Date(...)) 113 | topic : config.mqtt_prefix+"/advertise/..." 114 | */ 115 | var json; 116 | try { 117 | json = JSON.parse(message); 118 | } catch (e) { 119 | log("MQTT " + cmdRequest + " malformed JSON " + JSON.stringify(message)); 120 | return; 121 | } 122 | var tag = topic.substr(cmdRequest.length); 123 | log("REQUEST " + tag + " " + JSON.stringify(json)); 124 | // TODO: Validate request 125 | if (!json.topic) { 126 | log("MQTT " + cmdRequest + " no topic"); 127 | return; 128 | } 129 | if (!(json.interval in config.history_times)) { 130 | log("MQTT " + cmdRequest + " invalid interval"); 131 | return; 132 | } 133 | var dFrom, dTo; 134 | if (json.from) 135 | dFrom = new Date(json.from); 136 | if (json.age) 137 | dFrom = new Date(Date.now() - parseFloat(json.age * 1000 * 60 * 60)); 138 | if (json.to) 139 | dTo = new Date(json.to); 140 | else 141 | dTo = new Date(); 142 | if (!dFrom || isNaN(dFrom.getTime()) || 143 | !dTo || isNaN(dTo.getTime())) { 144 | log("MQTT " + cmdRequest + " invalid from/to or age"); 145 | return; 146 | } 147 | //log("HISTORY "+dFrom+" -> "+dTo); 148 | logReadTopic(json.interval, dFrom, dTo, json.topic, function (data) { 149 | log("RESPONSE " + tag + " (" + data.data.length + " items)"); 150 | require("./mqttclient.js").send(config.history_path + "response/" + tag, JSON.stringify(data)); 151 | }); 152 | } 153 | } 154 | 155 | // ============================================================================= 156 | function HistoryTopic(topic) { 157 | log("New History Topic for " + topic); 158 | this.topic = topic; 159 | this.times = {}; 160 | for (var i in config.history_times) 161 | this.times[i] = {num: 0, sum: 0, time: 0}; 162 | } 163 | 164 | HistoryTopic.prototype.pushNumber = function (n) { 165 | //log.write("all",this.topic,n); 166 | for (var i in config.history_times) { 167 | this.times[i].num++; 168 | this.times[i].sum += n; 169 | } 170 | }; 171 | 172 | HistoryTopic.prototype.tick = function (time) { 173 | for (var period in config.history_times) { 174 | var t = this.times[period]; 175 | t.time += time; 176 | if (t.time > config.history_times[period]) { 177 | if (t.num) { 178 | var avr = t.sum / t.num; 179 | logWrite(period, this.topic, avr); 180 | require("./mqttclient.js").send(config.history_path + period + this.topic, "" + avr); 181 | } 182 | this.times[period] = {num: 0, sum: 0, time: 0}; 183 | } 184 | } 185 | }; 186 | 187 | // ============================================================================= 188 | exports.init = function () { 189 | if (config.history_path == "") { 190 | log("history_path value is empty, thus not providing history."); 191 | } else { 192 | var mqtt = require("./mqttclient.js").client; 193 | // Link in to messages 194 | mqtt.on("connect", function () { 195 | // Subscribe to any BLE data 196 | mqtt.subscribe(config.mqtt_prefix + "/#"); 197 | // Subscribe to history requests 198 | mqtt.subscribe(config.history_path + "#"); 199 | }); 200 | mqtt.on("message", onMQTTMessage); 201 | // Check all history topics and write to log if needed 202 | // TODO: could be just do this when we receive our data? 203 | setInterval(function () { 204 | Object.keys(historyTopics).forEach(function (el) { 205 | historyTopics[el].tick(1000); 206 | }); 207 | }, 1000); 208 | 209 | // Handle log dir serving 210 | pathToLog = require("path").resolve(__dirname, "../log"); 211 | if (require("fs").existsSync(pathToLog)) { 212 | log("log directory found at " + pathToLog + ". Enabling logging."); 213 | } else { 214 | log("log directory not found. Not logging."); 215 | pathToLog = undefined; 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /lib/homeassistant.js: -------------------------------------------------------------------------------- 1 | const packageJSON = require("../package.json"); 2 | const mqtt = require("./mqttclient"); 3 | const config = require("./config"); 4 | const deviceTypes = { 5 | temp: { 6 | unit_of_measurement: "°C", 7 | device_class: "temperature" 8 | }, 9 | temperature: { 10 | unit_of_measurement: "°C", 11 | device_class: "temperature" 12 | }, 13 | humidity: { 14 | unit_of_measurement: "%", 15 | device_class: "humidity" 16 | }, 17 | pressure: { // pa 18 | unit_of_measurement: "hPa", 19 | device_class: "pressure" 20 | }, 21 | steps: { 22 | unit_of_measurement: "steps", 23 | icon: "mdi:walk" 24 | }, 25 | heartRate: { 26 | unit_of_measurement: "bpm", 27 | icon: "mdi:heart-pulse" 28 | }, 29 | weight: { 30 | unit_of_measurement: "kg", 31 | icon: "mdi:scale-bathroom" 32 | }, 33 | battery: { 34 | unit_of_measurement: "%", 35 | device_class: "battery" 36 | }, 37 | energy: { 38 | unit_of_measurement: "Wh", 39 | device_class: "energy" 40 | }, 41 | illuminance: { // lx 42 | unit_of_measurement: "lx", 43 | device_class: "illuminance" 44 | }, 45 | light: { 46 | device_class: "light" 47 | }, 48 | moisture: { // % 49 | unit_of_measurement: "%", 50 | icon: "mdi:water-percent" 51 | }, 52 | motion: { 53 | device_class: "motion", 54 | off_delay: 30 55 | }, 56 | conductivity: { // µS/cm 57 | unit_of_measurement: "µS/cm", 58 | icon: "mdi:flower" 59 | }, 60 | rssi: { 61 | unit_of_measurement: "dBm", 62 | device_class: "signal_strength" 63 | }, 64 | battery_voltage: { 65 | entity_category: "diagnostic", 66 | device_class: "voltage", 67 | state_class: "measurement" 68 | }, 69 | voltage: { 70 | entity_category: "diagnostic", 71 | device_class: "voltage", 72 | state_class: "measurement" 73 | }, 74 | digital: {}, 75 | analog: {}, 76 | level: {}, 77 | alert: {}, 78 | data: {} 79 | } 80 | const binaryDevices = ["digital", "motion", "light"]; 81 | 82 | let discoverySend = {}; 83 | 84 | function joinKey(arr) { 85 | return arr.join("_").replace("/", ""); 86 | } 87 | 88 | exports.configDiscovery = function (data, device, peripheral, serviceId) { 89 | let id = peripheral.address; 90 | let uuid = peripheral.uuid; 91 | for (let dataKey in data) { 92 | if (deviceTypes[dataKey] !== undefined && !discoverySend[id + serviceId + dataKey]) { 93 | let component, valueTemplate; 94 | if (binaryDevices.includes(dataKey)) { 95 | component = "binary_sensor"; 96 | valueTemplate = "{{ \"ON\" if float(value_json." + dataKey + ")!=0 else \"OFF\" }}"; 97 | } else { 98 | component = "sensor"; 99 | valueTemplate = "{{ value_json." + dataKey + " }}"; 100 | } 101 | let configTopic = `homeassistant/${component}/${uuid}/${serviceId}_${dataKey}/config` 102 | let payload = { 103 | ...deviceTypes[dataKey], 104 | "state_topic": device.json_state_topic + "/" + serviceId, 105 | "value_template": valueTemplate, 106 | "json_attributes_topic": device.json_state_topic + "/" + serviceId, 107 | "name": (device.name_is_mac ? uuid : device.name) + " " + joinKey([serviceId, dataKey]), 108 | "unique_id": joinKey([config.mqtt_prefix, uuid, serviceId, dataKey]), 109 | "device": { 110 | "identifiers": [id], 111 | "name": (device.name_is_mac ? id : device.name), 112 | "sw_version": packageJSON.name + " " + packageJSON.version, 113 | "model": (device.model ? device.model : "-"), 114 | "manufacturer": (device.manufacturer ? device.manufacturer : "-") 115 | }, 116 | "availability": [ 117 | { 118 | "topic": device.presence_topic, 119 | "payload_available": "1", 120 | "payload_not_available": "0" 121 | }, 122 | { 123 | "topic": config.mqtt_prefix + "/state" 124 | } 125 | ] 126 | }; 127 | if (device.name_is_mac) { 128 | if (peripheral.advertisement.localName) { 129 | payload.name = peripheral.advertisement.localName + " " + joinKey([serviceId, dataKey]); 130 | payload.device.name = peripheral.advertisement.localName; 131 | } 132 | } 133 | if (config.mqtt_options.clientId) { 134 | payload.unique_id += "_" + config.mqtt_options.clientId; 135 | payload.name += "_" + config.mqtt_options.clientId; 136 | payload.device.identifiers[0] += "_" + config.mqtt_options.clientId; 137 | } 138 | if (data["productName"]) { 139 | payload.device.model = data["productName"]; 140 | payload.device.manufacturer = "Xiaomi"; 141 | } 142 | 143 | mqtt.send(configTopic, JSON.stringify(payload), {retain: true}); 144 | discoverySend[id + serviceId + dataKey] = true; 145 | } 146 | } 147 | } 148 | 149 | mqtt.client.on("close", function () { 150 | discoverySend = {}; 151 | }) 152 | -------------------------------------------------------------------------------- /lib/http.js: -------------------------------------------------------------------------------- 1 | // HTTP server 2 | var webSocketServer = require("websocket").server; 3 | var http = require("http"); 4 | var config = require("./config"); 5 | var status = require("./status"); 6 | var discovery = require("./discovery"); 7 | 8 | var pathToWWW; 9 | 10 | function log(x) { 11 | console.log("[HTTP] " + x); 12 | } 13 | 14 | function pageHandlerStatus(req, res) { 15 | res.writeHead(200, {"Content-Type": "text/html"}); 16 | res.write(""); 17 | res.write(""); 18 | res.write("EspruinoHub Status"); 19 | res.write(""); 20 | res.write("
");
 21 |   res.write(status.getStatusText());
 22 |   res.write("
"); 23 | res.write(""); 24 | res.end(); 25 | } 26 | 27 | function resolvePath(base, url) { 28 | var path = require("path").resolve(base, "./" + url); 29 | if (path.substr(0, base.length) != base) { 30 | log("Hacking attempt? ", url); 31 | res.writeHead(404); 32 | res.end(); 33 | return; 34 | } 35 | log("Serving " + path); 36 | return path; 37 | } 38 | 39 | function handleMIME(path, res) { 40 | var mime; 41 | if (path.substr(-4) == ".css") mime = "text/css"; 42 | if (path.substr(-5) == ".html") mime = "text/html"; 43 | if (path.substr(-4) == ".png") mime = "image/png"; 44 | if (path.substr(-4) == ".js") mime = "text/javascript"; 45 | if (mime) res.setHeader("Content-Type", mime); 46 | } 47 | 48 | function pageHandlerWWW(req, res) { 49 | if (!pathToWWW) return false; // no WWW 50 | var url = require("url").parse(req.url).pathname; 51 | if (url == "/") url = "/index.html"; 52 | if (url.substr(0, 4) == "/ide") { 53 | if (url !== "/ide") { 54 | res.writeHead(302, { 55 | location: "/ide" 56 | }); 57 | res.end(); 58 | return true; 59 | } 60 | url = "/ide.html"; 61 | } 62 | // load filesystem file 63 | var path = resolvePath(pathToWWW, url); 64 | if (!path) return true; 65 | if (require("fs").existsSync(path)) { 66 | //console.log("Serving file ",path); 67 | require("fs").readFile(path, function (err, blob) { 68 | handleMIME(path, res); 69 | res.writeHead(200); 70 | res.end(blob); 71 | }); 72 | return true; 73 | } 74 | return false; 75 | } 76 | 77 | function pageHandler(req, res) { 78 | var url = req.url.toString(); 79 | if ((url == "/" && !pathToWWW) || 80 | (url == "/status")) { 81 | pageHandlerStatus(req, res); 82 | } else if (!pageHandlerWWW(req, res)) { 83 | res.writeHead(404, {"Content-Type": "text/plain"}); 84 | res.end("404: Page " + url + " not found"); 85 | } 86 | } 87 | 88 | // WebSocket to MQTT forwarder 89 | function mqttWebSocket(request) { 90 | if (request.requestedProtocols[0] != "mqttv3.1" && 91 | request.requestedProtocols[0] != "mqtt") return false; 92 | 93 | var wsconnection = request.accept(request.requestedProtocols[0], request.origin); 94 | log((new Date()) + " Connection accepted."); 95 | var socket = new require("net").Socket(); 96 | 97 | var mqttServer = require("url").parse(config.mqtt_host); 98 | if (!mqttServer.port) 99 | mqttServer.port = (mqttServer.protocol == "mqtts:") ? 8883 : 1883; 100 | 101 | socket.connect(mqttServer.port, mqttServer.hostname, function () { 102 | log("Websocket MQTT connected"); 103 | }); 104 | socket.on("data", function (d) { 105 | wsconnection.sendBytes(d); 106 | }); 107 | socket.on("close", function () { 108 | log("Websocket MQTT closed (MQTT)"); 109 | wsconnection.close(); 110 | }); 111 | socket.on("error", function (msg) { 112 | log("Websocket MQTT error: " + msg); 113 | wsconnection.close(); 114 | }); 115 | 116 | wsconnection.on("message", function (message) { 117 | if (message.type === "binary") { 118 | socket.write(message.binaryData); 119 | } 120 | }); 121 | wsconnection.on("close", function (reasonCode, description) { 122 | log("Websocket MQTT closed (WebSocket)"); 123 | socket.end(); 124 | }); 125 | return true; 126 | } 127 | 128 | exports.init = function () { 129 | var httpPort = config.http_port; 130 | if (!httpPort) { 131 | log("No http_port in config. Not enabling web server"); 132 | return; 133 | } 134 | 135 | var server = http.createServer(pageHandler); 136 | server.listen(httpPort); 137 | log("Server is listening on http://localhost:" + httpPort); 138 | /* Start the WebSocket relay - allows standard Websocket MQTT communications */ 139 | var wsServer = new webSocketServer({ 140 | httpServer: server, 141 | autoAcceptConnections: false 142 | }); 143 | wsServer.on("request", function (request) { 144 | if (mqttWebSocket(request)) return; 145 | request.reject(); 146 | log("Rejected unknown websocket type " + request.requestedProtocols[0]); 147 | }); 148 | 149 | // Handle WWW dir serving 150 | pathToWWW = require("path").resolve(__dirname, "../www"); 151 | if (require("fs").existsSync(pathToWWW)) { 152 | log("www directory found at " + pathToWWW + ". Web server at http://localhost:" + httpPort); 153 | } else { 154 | log("www directory not found. Not serving."); 155 | pathToWWW = undefined; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/mqttclient.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Handling the MQTT connection 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | var mqtt = require("mqtt"); 17 | var config = require("./config"); 18 | var connect = require("./connect"); 19 | var devices = require("./devices"); 20 | var discovery = require("./discovery"); 21 | var attributes = require("./attributes"); 22 | 23 | function log(x) { 24 | console.log("[MQTT] " + x); 25 | } 26 | 27 | log("Connecting..."); 28 | var client; 29 | try { 30 | let options = { 31 | ...config.mqtt_options, 32 | will: { 33 | topic: `${config.mqtt_prefix}/state`, 34 | payload: "offline", 35 | retain: true 36 | } 37 | } 38 | client = mqtt.connect(config.mqtt_host, options); 39 | } catch (e) { 40 | client = mqtt.connect("mqtt://" + config.mqtt_host); 41 | } 42 | 43 | var connected = false; 44 | var connectTimer = setTimeout(function () { 45 | connectTimer = undefined; 46 | log("NOT CONNECTED AFTER 10 SECONDS"); 47 | }, 10000); 48 | 49 | client.on("error", function (error) { 50 | log("Connection error:" + error); 51 | }); 52 | client.on("connect", function () { 53 | if (connectTimer) clearTimeout(connectTimer); 54 | log("Connected"); 55 | connected = true; 56 | // Subscribe to BLE read and write requests 57 | client.subscribe(config.mqtt_prefix + "/write/#"); 58 | client.subscribe(config.mqtt_prefix + "/read/#"); 59 | client.subscribe(config.mqtt_prefix + "/notify/#"); 60 | client.subscribe(config.mqtt_prefix + "/ping/#"); 61 | client.subscribe(config.mqtt_prefix + "/disconnect/#"); 62 | client.publish(`${config.mqtt_prefix}/state`, "online", {retain: true}); 63 | // Push out updated presence info 64 | discovery.sendMQTTPresence(); 65 | }); 66 | client.on("close", function () { 67 | if (connected) { 68 | log("Disconnected"); 69 | connected = false; 70 | } 71 | }); 72 | 73 | exports.client = client; 74 | 75 | exports.send = function (topic, message, options) { 76 | if (connected) client.publish(topic, message, options); 77 | }; 78 | 79 | function convertMessage(data) { 80 | data = data.toString(); 81 | try { // if it was JSON, try and parse it (eg arrays/numbers/quoted string,etc) 82 | data = JSON.parse(data); 83 | } catch (e) { 84 | // if it's not parseable, we just use the string as-is 85 | } 86 | return data; 87 | } 88 | 89 | client.on("message", function (topic, message) { 90 | if (topic.startsWith(config.mqtt_prefix + "/")) { 91 | //log(topic+" => "+JSON.stringify(message.toString())); 92 | var path = topic.substr(1).split("/"); 93 | if (path[1] === "write") { 94 | var id = devices.deviceToAddr(path[2]); 95 | if (discovery.inRange[id]) { 96 | if (path.length === 5) { 97 | var device = discovery.inRange[id].peripheral; 98 | var service = attributes.lookup(path[3].toLowerCase()); 99 | var charc = attributes.lookup(path[4].toLowerCase()); 100 | connect.write(device, service, charc, convertMessage(message), function () { 101 | client.publish(config.mqtt_prefix + "/written/" + path[2] + "/" + path[3] + "/" + path[4], message); 102 | }); 103 | } else { 104 | log("Invalid number of topic levels"); 105 | } 106 | } else { 107 | log("Write to " + id + " but not in range"); 108 | } 109 | } 110 | if (path[1] === "read") { 111 | var id = devices.deviceToAddr(path[2]); 112 | if (discovery.inRange[id]) { 113 | var device = discovery.inRange[id].peripheral; 114 | if (path.length === 5) { 115 | var service = attributes.lookup(path[3].toLowerCase()); 116 | var charc = attributes.lookup(path[4].toLowerCase()); 117 | connect.read(device, service, charc, function (data) { 118 | client.publish(config.mqtt_prefix + "/data/" + path[2] + "/" + path[3] + "/" + path[4], data); 119 | }); 120 | } else if (path.length === 3) { 121 | connect.readServices(device, function (data) { 122 | client.publish(config.mqtt_prefix + "/data/" + path[2], data); 123 | }); 124 | } else { 125 | log("Invalid number of topic levels"); 126 | } 127 | } else { 128 | log("Read from " + id + " but not in range"); 129 | } 130 | } 131 | if (path[1] === "notify") { // start notifications 132 | if (path.length !== 5) { 133 | log("Invalid 'notify' packet"); 134 | } else { 135 | var id = devices.deviceToAddr(path[2]); 136 | if (discovery.inRange[id]) { 137 | var device = discovery.inRange[id].peripheral; 138 | var service = attributes.lookup(path[3].toLowerCase()); 139 | var charc = attributes.lookup(path[4].toLowerCase()); 140 | connect.notify(device, service, charc, function (data) { 141 | // data is a String 142 | client.publish(config.mqtt_prefix + "/data/" + path[2] + "/" + path[3] + "/" + path[4], data); 143 | }); 144 | } else { 145 | log("Notify on " + id + " but not in range"); 146 | } 147 | } 148 | } 149 | if (path[1] === "ping") { // open or keep a connection to a device open 150 | var id = devices.deviceToAddr(path[2]); 151 | if (discovery.inRange[id]) { 152 | var device = discovery.inRange[id].peripheral; 153 | connect.ping(device, function () { 154 | client.publish(config.mqtt_prefix + "/pong/" + path[2], message); 155 | }); 156 | } else { 157 | log("Ping " + id + " but not in range"); 158 | } 159 | } 160 | if (path[1] === "disconnect") { // disconnect device 161 | var id = devices.deviceToAddr(path[2]); 162 | if (discovery.inRange[id]) { 163 | var device = discovery.inRange[id].peripheral; 164 | connect.disconnect(device, function () { 165 | client.publish(config.mqtt_prefix + "/disconnected/" + path[2], message); 166 | }); 167 | } else { 168 | log("Disconnect " + id + " but not in range"); 169 | } 170 | } 171 | } 172 | }); 173 | -------------------------------------------------------------------------------- /lib/parsers/atc.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | 3 | class ParserAtc { 4 | constructor(buffer, device) { 5 | this.buffer = buffer; 6 | this.device = device; 7 | } 8 | 9 | parse() { 10 | if (this.buffer.length === 15) { // pvvx 11 | let voltage = this.buffer.readInt16LE(10); 12 | return { 13 | temp: this.buffer.readInt16LE(6) / 100, 14 | humidity: this.buffer.readUInt16LE(8) / 100, 15 | battery_voltage: voltage > 1000 ? voltage / 1000 : voltage, 16 | battery: this.buffer.readUInt8(12), 17 | counter: this.buffer.readUInt8(13), 18 | switch: (this.buffer.readInt8(14) >> 1) & 1, 19 | opening: (this.buffer.readInt8(14) ^ 1) & 1, 20 | type: "PVVX (No encryption)" 21 | } 22 | } else if (this.buffer.length === 13) { 23 | return { 24 | temp: this.buffer.readInt16BE(6) / 10, 25 | humidity: this.buffer.readUInt8(8), 26 | battery: this.buffer.readUInt8(9), 27 | battery_voltage: this.buffer.readInt16BE(10) / 1000, 28 | type: "ATC (ATC1441)" 29 | } 30 | } else if (this.buffer.length === 11) { 31 | const decoded = this.decryptPayload(); 32 | const battery_voltage = 2.2 + (3.1 - 2.2) * (decoded.readInt8(3) / 100); 33 | return { 34 | temp: decoded.readInt16LE(0) / 100, 35 | humidity: decoded.readUInt16LE(2) / 100, 36 | battery_voltage, 37 | battery: decoded.readInt8(4), 38 | switch: (decoded.readInt8(5) >> 1) & 1, 39 | opening: (decoded.readInt8(5) ^ 1) & 1, 40 | type: "PVVX (encryption)" 41 | } 42 | } else if (this.buffer.length === 8) { 43 | const decoded = this.decryptPayload(); 44 | const battery = decoded.readInt8(2) & 0x7f; 45 | const battery_voltage = 2.2 + (3.1 - 2.2) * (battery / 100); 46 | const trigger = decoded.readInt8(2) >> 7; 47 | 48 | return { 49 | temp: decoded.readUInt8(0) / 2 - 40, 50 | humidity: decoded.readUInt8(1) / 2, 51 | battery, 52 | battery_voltage, 53 | switch: trigger, 54 | type: "ATC (Atc1441 encryption)" 55 | } 56 | } 57 | } 58 | 59 | decryptPayload() { 60 | if (this.device.bind_key == null) { 61 | throw Error("Sensor data is encrypted. Please configure a bind_key."); 62 | } 63 | const nonce = Buffer.concat([ 64 | Buffer.from(this.device.mac.replace(/:/ig, ""), "hex").reverse(), // reverse mac 65 | Uint8Array.from([ 66 | this.buffer.length + 3, // length advertising data ( type + uuid 16 + service data ) 67 | 0x16, // type 68 | 0x1a, // UUID 181a 69 | 0x18 70 | ]), 71 | this.buffer.slice(0, 1) // counter 72 | ]); 73 | 74 | const decipher = crypto.createDecipheriv( 75 | "aes-128-ccm", 76 | Buffer.from(this.device.bind_key, "hex"), //key 77 | nonce, //iv 78 | {authTagLength: 4} 79 | ); 80 | const ciphertext = this.buffer.slice(1, this.buffer.length - 4); 81 | 82 | decipher.setAuthTag(this.buffer.slice(-4)); 83 | decipher.setAAD(Buffer.from("11", "hex"), { 84 | plaintextLength: ciphertext.length 85 | }); 86 | 87 | const decoded = decipher.update(ciphertext); 88 | 89 | decipher.final(); 90 | return decoded; 91 | } 92 | } 93 | 94 | module.exports = { 95 | ParserAtc 96 | } 97 | -------------------------------------------------------------------------------- /lib/parsers/qingping.js: -------------------------------------------------------------------------------- 1 | const ProductName = { 2 | 0x01: "CGG1 Goose", 3 | 0x07: "CGG1", 4 | 0x09: "CGP1W", 5 | 0x0C: "CGD1", 6 | 0x12: "CGPR1", 7 | }; 8 | 9 | const EventTypes = { 10 | temperatureAndHumidity: { 11 | id: 0x01, 12 | size: 4, 13 | parser: (b, p) => { 14 | return { 15 | temperature: b.readInt16LE(p) / 10, 16 | humidity: b.readInt16LE(p + 2) / 10, 17 | } 18 | } 19 | }, 20 | battery: { 21 | id: 0x02, 22 | size: 1, 23 | parser: (b, p) => { 24 | return {battery: b.readUInt8(p)}; 25 | } 26 | }, 27 | pressure: { 28 | id: 0x07, 29 | size: 2, 30 | parser: (b, p) => { 31 | return {pressure: b.readInt16LE(p) / 100.0}; 32 | } 33 | }, 34 | motionWithIlluminance: { 35 | id: 0x08, 36 | size: 4, 37 | parser: (b, p) => { 38 | return { 39 | motion: b.readUInt8(p), 40 | illuminance: b.readUInt16LE(p + 1) + b.readUInt8(p + 3) * 256 41 | } 42 | } 43 | }, 44 | illuminance: { 45 | id: 0x09, 46 | size: 4, 47 | parser: (b, p) => { 48 | return {illuminance: b.readUInt32LE(p)}; 49 | } 50 | }, 51 | light: { 52 | id: 0x11, 53 | size: 1, 54 | parser: (b, p) => { 55 | return {light: b.readUInt8(p)}; 56 | } 57 | }, 58 | count: { 59 | id: 0x0F, 60 | size: 1, 61 | parser: (b, p) => { 62 | return {count: b.readUInt8(p)}; 63 | } 64 | }, 65 | }; 66 | 67 | class Parser { 68 | 69 | constructor(buffer) { 70 | this.baseByteLength = 2; 71 | this.minLength = 11; 72 | if (buffer == null) { 73 | throw new Error("A buffer must be provided."); 74 | } 75 | this.buffer = buffer; 76 | this.result = {}; 77 | if (buffer.length < this.minLength) { 78 | throw new Error( 79 | `Service data length must be >= ${this.minLength} bytes. ${this.toString()}` 80 | ); 81 | } 82 | } 83 | 84 | parse() { 85 | const msgLength = this.buffer.length; 86 | this.productId = this.parseProductId(); 87 | this.result.productName = ProductName[this.productId] || null; 88 | // this.macAddress = this.parseMacAddress(); 89 | 90 | let dataPoint = 10; 91 | if ((this.buffer.readUInt8(0) & 0x3f) === 0x08) { 92 | 93 | while (dataPoint < msgLength) { 94 | let dataId = this.buffer.readInt8(dataPoint - 2); 95 | let dataSize = this.buffer.readInt8(dataPoint - 1); 96 | if (dataPoint + dataSize <= msgLength) { 97 | let parsed = false; 98 | Object 99 | .keys(EventTypes) 100 | .forEach((type) => { 101 | if (!parsed && EventTypes[type].id === dataId && EventTypes[type].size === dataSize) { 102 | this.result = {...this.result, ...EventTypes[type].parser(this.buffer, dataPoint)}; 103 | parsed = true; 104 | } 105 | }) 106 | if (!parsed) 107 | this.result.raw[dataId] = this.buffer.slice(dataPoint, dataPoint + dataSize); 108 | 109 | } 110 | dataPoint = dataPoint + dataSize + 2 111 | } 112 | 113 | } else { 114 | this.result.raw = this.buffer; 115 | } 116 | return this.result; 117 | } 118 | 119 | parseProductId() { 120 | return this.buffer.readUInt8(1); 121 | } 122 | 123 | parseMacAddress() { 124 | const macBuffer = this.buffer.slice( 125 | this.baseByteLength, 126 | this.baseByteLength + 6 127 | ); 128 | return Buffer.from(macBuffer) 129 | .reverse() 130 | .toString("hex"); 131 | } 132 | 133 | } 134 | 135 | module.exports = { 136 | Parser, 137 | EventTypes 138 | }; 139 | -------------------------------------------------------------------------------- /lib/parsers/xiaomi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This parser was originally ported from: 3 | * 4 | * https://github.com/hannseman/homebridge-mi-hygrothermograph/blob/master/lib/parser.js 5 | */ 6 | const crypto = require("crypto"); 7 | 8 | const SERVICE_DATA_UUID = "fe95"; 9 | 10 | const FrameControlFlags = { 11 | isFactoryNew: 1 << 0, 12 | isConnected: 1 << 1, 13 | isCentral: 1 << 2, 14 | isEncrypted: 1 << 3, 15 | hasMacAddress: 1 << 4, 16 | hasCapabilities: 1 << 5, 17 | hasEvent: 1 << 6, 18 | hasCustomData: 1 << 7, 19 | hasSubtitle: 1 << 8, 20 | hasBinding: 1 << 9 21 | }; 22 | 23 | const CapabilityFlags = { 24 | connectable: 1 << 0, 25 | central: 1 << 1, 26 | secure: 1 << 2, 27 | io: (1 << 3) | (1 << 4) 28 | }; 29 | 30 | const EventTypes = { 31 | easyPairing: 0x0002, 32 | button: 0x1001, 33 | movingWithLight: 0x000F,//0x000F // Someone is moving (with light) 34 | //0x1017 // No one moves 35 | //0x1018 // Light intensity 36 | temperature: 4100, 37 | humidity: 4102, 38 | illuminance: 4103, 39 | moisture: 4104, 40 | fertility: 4105, 41 | battery: 4106, 42 | temperatureAndHumidity: 4109 43 | }; 44 | 45 | const xiaomiProductName = { 46 | 0x005d: "HHCCPOT002", 47 | 0x0098: "HHCCJCY01", 48 | 0x01d8: "Stratos", 49 | 0x0153: "YEE-RC", 50 | 0x02df: "JQJCY01YM", 51 | 0x03b6: "YLKG08YL", 52 | 0x03bc: "GCLS002", 53 | 0x040a: "WX08ZM", 54 | 0x045b: "LYWSD02", 55 | 0x055b: "LYWSD03MMC", 56 | 0x0576: "CGD1", 57 | 0x0347: "CGG1", 58 | 0x01aa: "LYWSDCGQ", 59 | 0x03dd: "MUE4094RT", 60 | 0x07f6: "MJYD02YLA", 61 | 0x0387: "MHOC401" 62 | }; 63 | 64 | class Parser { 65 | constructor(buffer, bindKey = null) { 66 | this.baseByteLength = 5; 67 | if (buffer == null) { 68 | throw new Error("A buffer must be provided."); 69 | } 70 | this.buffer = buffer; 71 | if (buffer.length < this.baseByteLength) { 72 | throw new Error( 73 | `Service data length must be >= 5 bytes. ${this.toString()}` 74 | ); 75 | } 76 | this.bindKey = bindKey; 77 | } 78 | 79 | parse() { 80 | this.frameControl = this.parseFrameControl(); 81 | this.version = this.parseVersion(); 82 | this.productId = this.parseProductId(); 83 | this.frameCounter = this.parseFrameCounter(); 84 | this.macAddress = this.parseMacAddress(); 85 | this.capabilities = this.parseCapabilities(); 86 | 87 | if (this.frameControl.isEncrypted) { 88 | if (this.version <= 3) { 89 | this.decryptLegacyPayload(); 90 | } else { 91 | this.decryptPayload(); 92 | } 93 | } 94 | 95 | this.eventType = this.parseEventType(); 96 | this.eventLength = this.parseEventLength(); 97 | this.event = this.parseEventData(); 98 | this.productName = xiaomiProductName[this.productId] || null; 99 | 100 | return { 101 | frameControl: this.frameControl, 102 | event: this.event, 103 | productId: this.productId, 104 | frameCounter: this.frameCounter, 105 | macAddress: this.macAddress, 106 | eventType: this.eventType, 107 | capabilities: this.capabilities, 108 | eventLength: this.eventLength, 109 | version: this.version 110 | }; 111 | } 112 | 113 | parseFrameControl() { 114 | const frameControl = this.buffer.readUInt16LE(0); 115 | return Object.keys(FrameControlFlags).reduce((map, flag) => { 116 | map[flag] = (frameControl & FrameControlFlags[flag]) !== 0; 117 | return map; 118 | }, {}); 119 | } 120 | 121 | parseVersion() { 122 | return this.buffer.readUInt8(1) >> 4; 123 | } 124 | 125 | parseProductId() { 126 | return this.buffer.readUInt16LE(2); 127 | } 128 | 129 | parseFrameCounter() { 130 | return this.buffer.readUInt8(4); 131 | } 132 | 133 | parseMacAddress() { 134 | if (!this.frameControl.hasMacAddress) { 135 | return null; 136 | } 137 | const macBuffer = this.buffer.slice( 138 | this.baseByteLength, 139 | this.baseByteLength + 6 140 | ); 141 | return Buffer.from(macBuffer) 142 | .reverse() 143 | .toString("hex"); 144 | } 145 | 146 | get capabilityOffset() { 147 | if (!this.frameControl.hasMacAddress) { 148 | return this.baseByteLength; 149 | } 150 | return 11; 151 | } 152 | 153 | parseCapabilities() { 154 | if (!this.frameControl.hasCapabilities) { 155 | return null; 156 | } 157 | const capabilities = this.buffer.readUInt8(this.capabilityOffset); 158 | return Object.keys(CapabilityFlags).reduce((map, flag) => { 159 | map[flag] = (capabilities & CapabilityFlags[flag]) !== 0; 160 | return map; 161 | }, {}); 162 | } 163 | 164 | get eventOffset() { 165 | let offset = this.baseByteLength; 166 | if (this.frameControl.hasMacAddress) { 167 | offset = 11; 168 | } 169 | if (this.frameControl.hasCapabilities) { 170 | offset += 1; 171 | } 172 | 173 | return offset; 174 | } 175 | 176 | parseEventType() { 177 | if (!this.frameControl.hasEvent) { 178 | return null; 179 | } 180 | return this.buffer.readUInt16LE(this.eventOffset); 181 | } 182 | 183 | parseEventLength() { 184 | if (!this.frameControl.hasEvent) { 185 | return null; 186 | } 187 | return this.buffer.readUInt8(this.eventOffset + 2); 188 | } 189 | 190 | decryptPayload() { 191 | const msgLength = this.buffer.length; 192 | const eventLength = msgLength - this.eventOffset; 193 | 194 | if (eventLength < 3) { 195 | return; 196 | } 197 | if (this.bindKey == null) { 198 | throw Error("Sensor data is encrypted. Please configure a bindKey."); 199 | } 200 | 201 | const encryptedPayload = this.buffer.slice(this.eventOffset, msgLength); 202 | 203 | const nonce = Buffer.concat([ 204 | this.buffer.slice(5, 11), //mac_reversed 205 | this.buffer.slice(2, 4), //device_type 206 | this.buffer.slice(4, 5), //frame_cnt 207 | encryptedPayload.slice(-7, -4) //ext_cnt 208 | ]); 209 | 210 | const decipher = crypto.createDecipheriv( 211 | "aes-128-ccm", 212 | Buffer.from(this.bindKey, "hex"), //key 213 | nonce, //iv 214 | {authTagLength: 4} 215 | ); 216 | 217 | const ciphertext = encryptedPayload.slice(0, -7); 218 | 219 | decipher.setAuthTag(encryptedPayload.slice(-4)); 220 | decipher.setAAD(Buffer.from("11", "hex"), { 221 | plaintextLength: ciphertext.length 222 | }); 223 | 224 | const receivedPlaintext = decipher.update(ciphertext); 225 | 226 | decipher.final(); 227 | 228 | this.buffer = Buffer.concat([ 229 | this.buffer.slice(0, this.eventOffset), 230 | receivedPlaintext 231 | ]); 232 | } 233 | 234 | decryptLegacyPayload() { 235 | const msgLength = this.buffer.length; 236 | const eventLength = msgLength - this.eventOffset; 237 | 238 | if (eventLength < 3) { 239 | return; 240 | } 241 | if (this.bindKey == null) { 242 | throw Error("Sensor data is encrypted. Please configure a bindKey."); 243 | } 244 | 245 | const encryptedPayload = this.buffer.slice(this.eventOffset, this.eventOffset + 6); 246 | 247 | const nonce = Buffer.concat([ 248 | Buffer.from("01", "hex"), 249 | this.buffer.slice(0, 5), 250 | this.buffer.slice(-4, -1), 251 | this.buffer.slice(5, 10), //mac_reversed 252 | Buffer.from("0001", "hex"), 253 | ]); 254 | 255 | const bindKeyBuffer = Buffer.from(this.bindKey, "hex"); 256 | const key = Buffer.concat([ 257 | bindKeyBuffer.slice(0, 6), 258 | Buffer.from("8d3d3c97", "hex"), 259 | bindKeyBuffer.slice(6) 260 | ]); 261 | 262 | const decipher = crypto.createCipheriv("aes-128-ctr", key, nonce); 263 | 264 | const receivedPlaintext = decipher.update(encryptedPayload); 265 | decipher.final(); 266 | 267 | this.buffer = Buffer.concat([ 268 | this.buffer.slice(0, this.eventOffset), 269 | receivedPlaintext 270 | ]); 271 | } 272 | 273 | parseEventData() { 274 | if (!this.frameControl.hasEvent) { 275 | return null; 276 | } 277 | switch (this.eventType) { 278 | case EventTypes.easyPairing: { 279 | return this.parseEasyPairing(); 280 | } 281 | case EventTypes.button: { 282 | return this.parseButton(); 283 | } 284 | case EventTypes.temperature: { 285 | return this.parseTemperatureEvent(); 286 | } 287 | case EventTypes.humidity: { 288 | return this.parseHumidityEvent(); 289 | } 290 | case EventTypes.battery: { 291 | return this.parseBatteryEvent(); 292 | } 293 | case EventTypes.temperatureAndHumidity: { 294 | return this.parseTemperatureAndHumidityEvent(); 295 | } 296 | case EventTypes.illuminance: { 297 | return this.parseIlluminanceEvent(); 298 | } 299 | case EventTypes.fertility: { 300 | return this.parseFertilityEvent(); 301 | } 302 | case EventTypes.moisture: { 303 | return this.parseMoistureEvent(); 304 | } 305 | case EventTypes.movingWithLight: { 306 | return this.parseMovingWithLightEvent(); 307 | } 308 | default: { 309 | throw new Error( 310 | `Unknown event type: ${this.eventType}. ${this.toString()} - ${this.buffer.slice(this.eventOffset)}` 311 | ); 312 | } 313 | } 314 | } 315 | 316 | parseTemperatureEvent() { 317 | return { 318 | temperature: this.buffer.readInt16LE(this.eventOffset + 3) / 10 319 | }; 320 | } 321 | 322 | parseHumidityEvent() { 323 | return { 324 | humidity: this.buffer.readUInt16LE(this.eventOffset + 3) / 10 325 | }; 326 | } 327 | 328 | parseBatteryEvent() { 329 | return { 330 | battery: this.buffer.readUInt8(this.eventOffset + 3) 331 | }; 332 | } 333 | 334 | parseTemperatureAndHumidityEvent() { 335 | const temperature = this.buffer.readInt16LE(this.eventOffset + 3) / 10; 336 | const humidity = this.buffer.readUInt16LE(this.eventOffset + 5) / 10; 337 | return {temperature, humidity}; 338 | } 339 | 340 | parseIlluminanceEvent() { 341 | return { 342 | illuminance: this.buffer.readUIntLE(this.eventOffset + 3, 3) 343 | }; 344 | } 345 | 346 | parseFertilityEvent() { 347 | return { 348 | fertility: this.buffer.readInt16LE(this.eventOffset + 3) 349 | }; 350 | } 351 | 352 | parseMoistureEvent() { 353 | return { 354 | moisture: this.buffer.readInt8(this.eventOffset + 3) 355 | }; 356 | } 357 | 358 | parseMovingWithLightEvent() { 359 | //@todo Qingping light 360 | return { 361 | motion: 1, 362 | illuminance: this.buffer.readUIntLE(this.eventOffset + 3, 3) 363 | }; 364 | } 365 | 366 | toString() { 367 | return this.buffer.toString("hex"); 368 | } 369 | 370 | parseButton() { 371 | const actions = Object.freeze({ 372 | 0x00: "single", 373 | 0x01: "double", 374 | 0x02: "long_press", 375 | 0x03: "triple" 376 | }); 377 | const button = this.buffer.readInt16LE(this.eventOffset + 3); 378 | const action = this.buffer.readInt8(this.eventOffset + 5); 379 | return { 380 | button, 381 | action: actions[action] || null 382 | }; 383 | } 384 | 385 | parseEasyPairing() { 386 | return {objectID: this.buffer.readInt16LE(this.eventOffset + 3)}; 387 | } 388 | } 389 | 390 | module.exports = { 391 | Parser, 392 | EventTypes, 393 | SERVICE_DATA_UUID 394 | }; 395 | -------------------------------------------------------------------------------- /lib/service.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * HTTP Proxy Service 13 | * https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.http_proxy.xml 14 | * ---------------------------------------------------------------------------- 15 | */ 16 | 17 | var config = require("./config"); 18 | 19 | function log(x) { 20 | console.log("[HTTPProxy] " + x); 21 | } 22 | 23 | if (!config.http_proxy) { 24 | log("config.http_proxy=false, not enabling Bleno/Proxy"); 25 | exports.init = function () { 26 | }; 27 | return; 28 | } 29 | 30 | var bleno; 31 | try { 32 | bleno = require("bleno"); 33 | } catch (e) { 34 | bleno = require("@abandonware/bleno"); 35 | } 36 | var discovery = require("./discovery"); 37 | 38 | if (Buffer.from === undefined) // Oh, thanks Node. 39 | Buffer.from = function (x) { 40 | return new Buffer(x); 41 | } 42 | 43 | 44 | var httpProxy = { 45 | whitelisted: false, 46 | uri: "", 47 | headers: "", 48 | body: "EMPTY" 49 | }; 50 | 51 | var httpStatusCode = new bleno.Characteristic({ // org.bluetooth.characteristic.http_status_code 52 | // 16 bit status code + Data Status 53 | uuid: "2AB8", 54 | properties: ["notify"] 55 | }); 56 | 57 | var httpProxyService = new bleno.PrimaryService({ 58 | uuid: "1823", 59 | characteristics: [ 60 | new bleno.Characteristic({ // org.bluetooth.characteristic.uri 61 | uuid: "2AB6", 62 | properties: ["write"], 63 | onWriteRequest: function (data, offset, withoutResponse, callback) { 64 | if (httpProxy.whitelisted) { 65 | httpProxy.uri = data.toString("utf8"); 66 | log("URI -> " + httpProxy.uri); 67 | } 68 | callback(bleno.Characteristic.RESULT_SUCCESS); 69 | } 70 | }), 71 | new bleno.Characteristic({ // org.bluetooth.characteristic.http_headers 72 | uuid: "2AB7", 73 | properties: ["read", "write"], 74 | onReadRequest: function (offset, callback) { 75 | callback(bleno.Characteristic.RESULT_SUCCESS, Buffer.from(httpProxy.headers, "utf8")); 76 | }, 77 | onWriteRequest: function (data, offset, withoutResponse, callback) { 78 | if (httpProxy.whitelisted) 79 | httpProxy.headers = data.toString("utf8"); 80 | callback(bleno.Characteristic.RESULT_SUCCESS); 81 | } 82 | }), 83 | new bleno.Characteristic({ // org.bluetooth.characteristic.http_entity_body 84 | uuid: "2AB9", 85 | properties: ["read", "write"], 86 | onReadRequest: function (offset, callback) { 87 | callback(bleno.Characteristic.RESULT_SUCCESS, Buffer.from(httpProxy.body, "utf8")); 88 | }, 89 | onWriteRequest: function (data, offset, withoutResponse, callback) { 90 | if (httpProxy.whitelisted) 91 | httpProxy.body = data.toString("utf8"); 92 | callback(bleno.Characteristic.RESULT_SUCCESS); 93 | } 94 | }), 95 | new bleno.Characteristic({ // org.bluetooth.characteristic.http_control_point 96 | uuid: "2ABA", 97 | properties: ["write"], 98 | onWriteRequest: function (data, offset, withoutResponse, callback) { 99 | if (httpProxy.whitelisted) 100 | httpStateHandler(data.readUInt8(0)); 101 | callback(bleno.Characteristic.RESULT_SUCCESS); 102 | } 103 | }), 104 | httpStatusCode, 105 | new bleno.Characteristic({ // org.bluetooth.characteristic.https_security 106 | uuid: "2ABB", 107 | properties: ["read"], 108 | onReadRequest: function (offset, callback) { 109 | callback(bleno.Characteristic.RESULT_SUCCESS, Buffer.from([0])); 110 | } 111 | }) 112 | ] 113 | }); 114 | 115 | 116 | HTTP_CONTROL = { 117 | GET: 1, // HTTP GET Request N/A Initiates an HTTP GET Request. 118 | HEAD: 2, // HTTP HEAD Request N/A Initiates an HTTP HEAD Request. 119 | POST: 3, // HTTP POST Request N/A Initiates an HTTP POST Request. 120 | PUT: 4, // HTTP PUT Request N/A Initiates an HTTP PUT Request. 121 | DELETE: 5, // HTTP DELETE Request N/A Initiates an HTTP DELETE Request. 122 | SGET: 6, // HTTPS GET Request N/A Initiates an HTTPS GET Reques.t 123 | SHEAD: 7, // HTTPS HEAD Request N/A Initiates an HTTPS HEAD Request. 124 | SPOST: 8, // HTTPS POST Request N/A Initiates an HTTPS POST Request. 125 | SPUT: 9, // HTTPS PUT Request N/A Initiates an HTTPS PUT Request. 126 | SDELETE: 10, // HTTPS DELETE Request N/A Initiates an HTTPS DELETE Request. 127 | CANCEL: 11 // HTTP Request Cancel N/A Terminates any executing HTTP Request from the HPS Client. 128 | }; 129 | 130 | HTTP_DATA_STATUS_BIT = { // 3rd byte of http_status_code 131 | HEADERS_RECEIVED: 1, // Headers Received 132 | // 0 The response-header and entity-header fields were not received in the HTTP response or stored in the HTTP Headers characteristic. 133 | // 1 The response-header and entity-header fields were received in the HTTP response and stored in the HTTP Headers characteristic for the Client to read. 134 | HEADERS_TRUNCATED: 2, // Headers Truncated 135 | // 0 Any received response-header and entity-header fields did not exceed 512 octets in length. 136 | // 1 The response-header and entity-header fields exceeded 512 octets in length and the first 512 octets were saved in the HTTP Headers characteristic. 137 | BODY_RECEIVED: 4, // Body Received 138 | // 0 The entity-body field was not received in the HTTP response or stored in the HTTP Entity Body characteristic. 139 | // 1 The entity-body field was received in the HTTP response and stored in the HTTP Entity Body characteristic for the Client to read. 140 | BODY_TRUNCATED: 8 // Body Truncated 141 | // 0 Any received entity-body field did not exceed 512 octets in length. 142 | // 1 The entity-body field exceeded 512 octets in length and the first 512 octets were saved in the HTTP Headers characteristic 143 | }; 144 | 145 | function httpSetStatusCode(httpCode, httpStatus) { 146 | log("Status code => " + httpCode + " " + httpStatus, httpProxy.body); 147 | var data = new Buffer(3); 148 | data.writeUInt16LE(httpCode, 0); 149 | data.writeUInt8(httpStatus, 2); 150 | if (httpStatusCode.updateValueCallback) 151 | httpStatusCode.updateValueCallback(data); 152 | } 153 | 154 | function httpStateHandler(state) { 155 | log("State => " + state, httpProxy); 156 | var method, protocol; 157 | switch (state) { 158 | case HTTP_CONTROL.GET : 159 | method = "GET"; 160 | protocol = "http:"; 161 | break; 162 | case HTTP_CONTROL.HEAD : 163 | method = "HEAD"; 164 | protocol = "http:"; 165 | break; 166 | case HTTP_CONTROL.POST : 167 | method = "POST"; 168 | protocol = "http:"; 169 | break; 170 | case HTTP_CONTROL.PUT : 171 | method = "PUT"; 172 | protocol = "http:"; 173 | break; 174 | case HTTP_CONTROL.DELETE : 175 | method = "DELETE"; 176 | protocol = "https:"; 177 | break; 178 | case HTTP_CONTROL.SGET : 179 | method = "GET"; 180 | protocol = "https:"; 181 | break; 182 | case HTTP_CONTROL.SHEAD : 183 | method = "HEAD"; 184 | protocol = "https:"; 185 | break; 186 | case HTTP_CONTROL.SPOST : 187 | method = "POST"; 188 | protocol = "https:"; 189 | break; 190 | case HTTP_CONTROL.SPUT : 191 | method = "PUT"; 192 | protocol = "https:"; 193 | break; 194 | case HTTP_CONTROL.SDELETE : 195 | method = "DELETE"; 196 | protocol = "https:"; 197 | break; 198 | } 199 | 200 | if (method && protocol) { 201 | var options = require("url").parse(protocol + "//" + httpProxy.uri); 202 | options.method = method; 203 | 204 | var handler = (protocol == "https:") ? require("https") : require("http"); 205 | var req = handler.request(options, function (res) { 206 | httpProxy.headers = ""; 207 | Object.keys(res.headers).forEach(function (k) { 208 | httpProxy.headers += k + ": " + res.headers[k] + "\r\n"; 209 | }); 210 | httpSetStatusCode(res.statusCode, HTTP_DATA_STATUS_BIT.HEADERS_RECEIVED); 211 | httpProxy.body = ""; 212 | res.on("data", function (d) { 213 | httpProxy.body += d.toString(); 214 | }); 215 | res.on("end", function () { 216 | httpSetStatusCode(res.statusCode, HTTP_DATA_STATUS_BIT.HEADERS_RECEIVED | HTTP_DATA_STATUS_BIT.BODY_RECEIVED); 217 | }); 218 | }); 219 | req.on("error", function (e) { 220 | log("Problem with request: " + e.message); 221 | }); 222 | req.end(); 223 | } 224 | } 225 | 226 | function onStateChange(state) { 227 | log("Bleno State " + state); 228 | if (state == "poweredOn") { 229 | bleno.startAdvertising("EspruinoHub", ["1823"], onAdvertisingStart); 230 | } 231 | } 232 | 233 | function onAdvertisingStart(error) { 234 | log("Bleno.startAdvertising " + (error ? error : "Success")); 235 | if (!error) { 236 | bleno.setServices([httpProxyService], function (error) { 237 | log("Bleno.setServices " + (error ? error : "Success")); 238 | }); 239 | } 240 | } 241 | 242 | /// When connection accepted 243 | function onAccept(address) { 244 | address = address.toLowerCase(); 245 | var whitelisted = config.http_proxy && 246 | config.http_whitelist.indexOf(address) >= 0; 247 | log(address + " connected (whitelisted: " + whitelisted + ")"); 248 | // Reset state on each new connection 249 | httpProxy = { 250 | whitelisted: whitelisted, 251 | uri: "", 252 | headers: whitelisted ? "" : "BLOCKED", 253 | body: whitelisted ? "" : "BLOCKED" 254 | } 255 | } 256 | 257 | function onDisconnect() { 258 | log("Disconnected"); 259 | discovery.startScan(); 260 | } 261 | 262 | exports.init = function () { 263 | bleno.on("stateChange", onStateChange); 264 | bleno.on("accept", onAccept); 265 | bleno.on("disconnect", onDisconnect); 266 | } 267 | -------------------------------------------------------------------------------- /lib/status.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Writes the current status to the console 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | var attributes = require("./attributes"); 17 | 18 | var logHistory = []; 19 | 20 | function getStatusText(maxHeight) { 21 | // if no height specified, dump everything 22 | if (maxHeight === undefined) 23 | maxHeight = 1000; 24 | 25 | var inRange = require("./discovery.js").inRange; 26 | var status = ""; 27 | //process.stdout.write('\x1B[2J\x1B[0f'); 28 | // ... 29 | status += logHistory.join("\n") + "\n\n"; 30 | 31 | status += (new Date()).toString() + "\n\n"; 32 | // sort by most recent 33 | var arr = []; 34 | for (var id in inRange) 35 | arr.push(inRange[id]); 36 | //arr.sort(function(a,b) { return a.rssi - b.rssi; }); 37 | arr.sort(function (a, b) { 38 | return a.id.localeCompare(b.id); 39 | }); 40 | // output 41 | var amt = 3; 42 | for (var i in arr) { 43 | var p = arr[i]; 44 | if (++amt > maxHeight) { 45 | status += "...\n"; 46 | return status; 47 | } 48 | status += p.id + " - " + p.name + " (RSSI " + p.rssi + ")\n"; 49 | for (var j in p.data) { 50 | if (++amt > maxHeight) { 51 | status += "...\n"; 52 | return status; 53 | } 54 | var n = attributes.getReadableAttributeName(j); 55 | var v = p.data[j].payload; 56 | var value = attributes.decodeAttribute(n, v, p.dev); 57 | p.dev.filterAttributes(value); 58 | if (value instanceof Buffer) // Buffer -> array... 59 | value = Array.prototype.slice.call(value, 0); 60 | status += " " + n + " => " + JSON.stringify(value) + "\n"; 61 | } 62 | } 63 | status += require("./connect.js").getStatusText(); 64 | return status; 65 | } 66 | 67 | function dumpStatus() { 68 | var status = "\033c"; // clear screen 69 | // get status text, and fit it to the screen 70 | status += getStatusText(process.stdout.getWindowSize()[1]); 71 | console._log(status); 72 | } 73 | 74 | // ----------------------------------------- 75 | 76 | exports.init = function () { 77 | console._log = console.log; 78 | /** Replace existing console.log with something that'll let us 79 | report status alongside evrything else */ 80 | console.log = function () { 81 | //var args = Array.from(arguments); 82 | var args = Array.prototype.slice.call(arguments); 83 | while (logHistory.length > 200) 84 | logHistory = logHistory.slice(-200); 85 | var msg = args.join("\t"); 86 | logHistory.push(msg); 87 | if (process.stdout.getWindowSize !== undefined) 88 | dumpStatus(); 89 | else 90 | console._log(msg); 91 | }; 92 | 93 | // if we have no proper console, don't output status to stdout 94 | if (process.stdout.getWindowSize !== undefined) 95 | setInterval(dumpStatus, 1000); 96 | }; 97 | exports.getStatusText = getStatusText; 98 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of EspruinoHub, a Bluetooth-MQTT bridge for 3 | * Puck.js/Espruino JavaScript Microcontrollers 4 | * 5 | * Copyright (C) 2016 Gordon Williams 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 10 | * 11 | * ---------------------------------------------------------------------------- 12 | * Utilities 13 | * ---------------------------------------------------------------------------- 14 | */ 15 | 16 | exports.str2buf = function (str) { 17 | var buf = new Buffer(str.length); 18 | for (var i = 0; i < buf.length; i++) { 19 | buf.writeUInt8(str.charCodeAt(i), i); 20 | } 21 | return buf; 22 | }; 23 | 24 | exports.obj2buf = function (o) { 25 | if ((typeof o) == "object" && (typeof o.type) === "string") { 26 | switch (o.type) { 27 | case "Buffer": 28 | case "buffer": 29 | return new Buffer(o.data); 30 | case "hex": 31 | return Buffer.from(o.data, "hex"); 32 | } 33 | } 34 | 35 | if ((typeof o) == "number" || (typeof o) == "boolean") { 36 | let buf = new Buffer(1); 37 | buf.writeUInt8(0 | o, 0); 38 | return buf; 39 | } 40 | 41 | // if it's not a string or array, convert to JSON and send that 42 | if (!((typeof o) == "string" || Array.isArray(o))) { 43 | return exports.obj2buf(JSON.stringify(o)); 44 | } 45 | 46 | let buf = new Buffer(o.length); 47 | for (var i = 0; i < buf.length; i++) { 48 | if ("string" == typeof o) 49 | buf.writeUInt8(o.charCodeAt(i), i); 50 | else 51 | buf.writeUInt8(o[i], i); 52 | } 53 | return buf; 54 | }; 55 | 56 | exports.uuid2noble = function (c) { 57 | return c.replace(/-/g, ""); 58 | }; 59 | 60 | exports.toFixedFloat = function (num, digits) { 61 | return parseFloat(num.toFixed(digits)); 62 | } 63 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espruino/EspruinoHub/2628c7522a7b3dcb7a56251586a542bcf07934f4/log/.gitkeep -------------------------------------------------------------------------------- /needs-bleno.js: -------------------------------------------------------------------------------- 1 | // Returns nonzero exit code if bleno isn't needed 2 | // Called by start.sh 3 | var config = require("./lib/config.js"); 4 | config.init(); // Load configuration 5 | if (config.http_proxy) { 6 | console.log("YES"); 7 | } else { 8 | console.log("NO"); 9 | process.exit(1); 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "EspruinoHub", 3 | "version": "0.0.2", 4 | "description": "Linux based hub software for Puck.js / Espruino", 5 | "main": "index.js", 6 | "scripts": { 7 | "Start-EspruinoHub": "./start.sh", 8 | "Start-Mosquitto": "mosquitto" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/Espruino/EspruinoHub.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/espruino/EspruinoHub/issues" 16 | }, 17 | "homepage": "https://github.com/espruino/EspruinoHub#readme", 18 | "keywords": [ 19 | "espruino" 20 | ], 21 | "author": "Gordon Williams (gw@pur3.co.uk)", 22 | "license": "MPL-2.0", 23 | "dependencies": { 24 | "@abandonware/bleno": "^0.6.1", 25 | "@abandonware/noble": "^1.9.2-8", 26 | "json5": "^2.2.2", 27 | "minimist": "^1.2.6", 28 | "mqtt": "^2.0.1", 29 | "tar": "^6.2.1", 30 | "websocket": "^1.0.24" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd `dirname $0` 3 | 4 | # Stop terminal screensaver 5 | setterm --blank 0 6 | 7 | sudo setcap cap_net_raw+eip $(eval readlink -f `which node`) 8 | 9 | if node needs-bleno.js > /dev/null; then 10 | echo "Starting with Bleno (GATT Server)" 11 | export BLENO_ADVERTISING_INTERVAL=300 12 | export NOBLE_MULTI_ROLE=1 13 | else 14 | echo "Starting without Bleno (GATT Server)" 15 | # Don't force multi-role if there's no HTTP proxy needed 16 | fi 17 | 18 | # start properly 19 | node index.js 20 | -------------------------------------------------------------------------------- /systemd-EspruinoHub.service: -------------------------------------------------------------------------------- 1 | # Copy this file to /etc/systemd/system/EspruinoHub.service and then 2 | # sudo systemctl start EspruinoHub.service 3 | # and to start on boot: 4 | # sudo systemctl enable EspruinoHub.service 5 | # To consult the log : sudo journalctl -u EspruinoHub 6 | 7 | [Unit] 8 | Description=EspruinoHub BLE -> MQTT bridge 9 | After=nodered.target 10 | Documentation=https://github.com/espruino/EspruinoHub 11 | 12 | [Service] 13 | ExecStart=/home/pi/EspruinoHub/start.sh 14 | WorkingDirectory=/home/pi/EspruinoHub 15 | User=pi 16 | Group=daemon 17 | Nice=10 18 | SyslogIdentifier=EspruinoHub 19 | StandardOutput=syslog 20 | Restart=on-failure 21 | KillSignal=SIGINT 22 | 23 | [Install] 24 | WantedBy=multi-user.target 25 | 26 | -------------------------------------------------------------------------------- /www/ide.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | EspruinoHub IDE 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | EspruinoHub 4 | 5 | 6 |

Welcome!

7 |

EspruinoHub is running... You can:

8 |
    9 |
  • See the current status at status
  • 10 |
  • Use the Web IDE (if installed) at ide
  • 11 |
  • View device Signal Strength data rssi.html
  • 12 |
  • View live MQTT packets via Websocket with mqtt.html
  • 13 |
  • View any pages stored in `EspruinoHub/www`
  • 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /www/mqtt.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | EspruinoHub 4 | 7 | 8 | 9 |

Live MQTT data

10 |
11 | 12 | 13 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /www/rssi.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | EspruinoHub RSSI 7 | 10 | 11 | 12 | 13 | 14 | 15 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /www/tinydash.css: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2017, Gordon Williams, MPLv2 License. https://github.com/espruino/TinyDash */ 2 | body { 3 | background-color: #000; 4 | } 5 | .td { 6 | position: absolute; 7 | /*border: 2px solid #555;*/ 8 | color: #fff; 9 | font: 12px sans-serif; 10 | background-color: #222; 11 | } 12 | .td span { 13 | color: #888; 14 | position: absolute; left: 8px; top: 8px; 15 | } 16 | 17 | .td_scrollable::-webkit-scrollbar-track { 18 | background-color: #222; 19 | } 20 | 21 | .td_scrollable::-webkit-scrollbar { 22 | width: 12px; 23 | background-color: #000; 24 | } 25 | 26 | .td_scrollable::-webkit-scrollbar-thumb { 27 | border-radius: 10px; 28 | background-color: #555; 29 | } 30 | 31 | .td_label { 32 | display: table; 33 | text-align: center; 34 | } 35 | .td_label span { 36 | display: table-cell; 37 | vertical-align: middle; 38 | font-size: 20px; 39 | } 40 | .td_btn { 41 | } 42 | .td_btn_a { 43 | margin-top: 20px; 44 | margin-left: auto; margin-right: auto; /* centered */ 45 | font-size: 30px; 46 | font-weight: bolder; 47 | color: #555; 48 | border: 2px solid; 49 | border-radius: 16px; 50 | width: 34px; 51 | height: 34px; 52 | padding: 10px; 53 | cursor: pointer; 54 | user-select: none; 55 | } 56 | .td_btn_a:hover { background-color: #036; } 57 | .td_btn[pressed="1"] .td_btn_a { color: #09F; } 58 | .td_btn[pressed="1"] .td_btn_a:hover { background-color: #06B; } 59 | .td_toggle_a { 60 | margin-top: 20px; 61 | margin-left: auto; margin-right: auto; /* centered */ 62 | border: 2px solid #fff; 63 | border-radius: 16px; 64 | width: 50px; 65 | height: 28px; 66 | } 67 | .td_toggle_a:hover { 68 | background-color: #06B; 69 | } 70 | .td_toggle_b { 71 | background-color: #fff; 72 | border: 2px solid #fff; 73 | border-radius: 8px; 74 | width: 16px; 75 | height: 16px; 76 | margin: 4px; 77 | } 78 | .td_toggle[pressed="1"] .td_toggle_b { 79 | margin-left: 26px; 80 | } 81 | .td_val { 82 | text-align: center; 83 | } 84 | .td_val div { 85 | display:inline-block; 86 | } 87 | .td_val_a { 88 | margin-top: 20px; 89 | font-size: 20px; 90 | color: #09F; 91 | } 92 | .td_val_b { 93 | margin-left: 10px; 94 | margin-right: 10px; 95 | font-size: 20px; 96 | color: #09F; 97 | cursor: pointer; 98 | user-select: none; 99 | } 100 | .td_val_b:hover { 101 | color:#fff; 102 | } 103 | 104 | 105 | .td_gauge { 106 | text-align: center; 107 | } 108 | .td_gauge canvas { 109 | width: 100%; 110 | height: 100%; 111 | } 112 | .td_gauge_a { 113 | position: absolute; 114 | left: 0px; right: 0px; top: 50%; 115 | font-size: 40px; 116 | color: #09F; 117 | } 118 | .td_graph { 119 | text-align: center; 120 | } 121 | .td_graph canvas { 122 | width: 100%; 123 | height: 100%; 124 | } 125 | .td_log_a { 126 | position: absolute; 127 | top: 24px; 128 | left: 4px; 129 | right: 4px; 130 | bottom: 4px; 131 | padding: 4px; 132 | background-color: #000; 133 | overflow: auto; 134 | } 135 | .td_modal { 136 | background-color: rgba(0,0,0,0.75); 137 | border: 2px solid white; 138 | cursor: pointer; 139 | user-select: none; 140 | } 141 | .td_modal span { 142 | left: 50%; top: 50%; 143 | transform: translate(-50%, -50%); 144 | font-size: 40px; 145 | } 146 | -------------------------------------------------------------------------------- /www/tinydash.js: -------------------------------------------------------------------------------- 1 | /* Copyright (c) 2017, Gordon Williams, MPLv2 License. https://github.com/espruino/TinyDash */ 2 | /* All elements have x/y/width/height,name 3 | 4 | Elements that can be changed can also have `onchanged` 5 | 6 | TODO: 7 | terminal 8 | dial 9 | scrollbar 10 | */ 11 | var TD = {}; 12 | (function() { 13 | var LIGHTCOL = "#09F"; 14 | function toElement(html) { 15 | var div = document.createElement('div'); 16 | div.innerHTML = html; 17 | return div.childNodes[0]; 18 | } 19 | function sendChanges(el, value) { 20 | if (el.opts.name) { 21 | var o = {}; 22 | o[el.opts.name] = value; 23 | handleChange(o); 24 | } 25 | if (el.opts.onchange) { 26 | el.opts.onchange(el, value); 27 | } 28 | } 29 | function togglePressed(el) { 30 | el.pressed = 0|!+el.getAttribute("pressed"); 31 | el.setAttribute("pressed", el.pressed); 32 | sendChanges(el, el.pressed); 33 | if (!el.toggle) { 34 | // non-toggleable elements always go back to not pressed 35 | el.pressed = 0; 36 | setTimeout(function() { 37 | el.setAttribute("pressed", 0); 38 | }, 200); 39 | } 40 | } 41 | function formatText(txt) { 42 | if ("number"!=typeof txt) 43 | return txt; 44 | if (Math.floor(txt)==txt) return txt; // ints 45 | if (Math.abs(txt)>1000) return txt.toFixed(0); 46 | if (Math.abs(txt)>100) return txt.toFixed(1); 47 | return txt.toFixed(2); 48 | } 49 | /// set up position/etc on the html element 50 | function setup(type, opts, el) { 51 | el.type = type; 52 | el.style="width:"+opts.width+"px;height:"+opts.height+"px;left:"+opts.x+"px;top:"+opts.y+"px;"; 53 | el.opts = opts; 54 | return el; 55 | } 56 | function handleChange(data) { 57 | console.log("Change", data); 58 | } 59 | 60 | // -------------------------------------------------------------------------- 61 | /* Update any named elements with the new data */ 62 | TD.update = function(data) { 63 | var els = document.getElementsByClassName("td"); 64 | for (var i=0;i'+opts.label+'')); 74 | }; 75 | /* {label, glyph, value, toggle}*/ 76 | TD.button = function(opts) { 77 | var pressed = opts.value?1:0; 78 | opts.glyph = opts.glyph || "💡"; 79 | var el = setup("button",opts,toElement('
'+opts.label+'
'+opts.glyph+'
')); 80 | el.getElementsByClassName("td_btn_a")[0].onclick = function() { 81 | togglePressed(el); 82 | }; 83 | el.setValue = function(v) { 84 | el.pressed = v?1:0; 85 | el.setAttribute("pressed", el.pressed); 86 | }; 87 | return el; 88 | }; 89 | /* {label,value}*/ 90 | TD.toggle = function(opts) { 91 | var pressed = opts.value?1:0; 92 | var el = setup("toggle",opts,toElement('
'+opts.label+'
')); 93 | el.toggle = true; 94 | el.getElementsByClassName("td_toggle_a")[0].onclick = function() { 95 | togglePressed(el); 96 | }; 97 | el.setValue = function(v) { 98 | el.pressed = v?1:0; 99 | el.setAttribute("pressed", el.pressed); 100 | }; 101 | return el; 102 | }; 103 | /* {label,value,step,min,max} 104 | if step is specified, clickable up/down arrows are added */ 105 | TD.value = function(opts) { 106 | var html; 107 | opts.value = parseFloat(opts.value); 108 | if (opts.step) 109 | html = '
'; 110 | else html = '
'; 111 | var el = setup("value",opts,toElement('
'+opts.label+''+html+'
')); 112 | el.setValue = function(v) { 113 | if (opts.min && vopts.max) v=opts.max; 115 | if (opts.value != v) { 116 | sendChanges(el, el.pressed); 117 | opts.value = v; 118 | } 119 | el.getElementsByClassName("td_val_a")[0].innerHTML = formatText(v); 120 | }; 121 | if (opts.step) { 122 | var b = el.getElementsByClassName("td_val_b"); 123 | b[0].onclick = function(e) { 124 | el.setValue(opts.value-opts.step); 125 | }; 126 | b[1].onclick = function(e) { 127 | el.setValue(opts.value+opts.step); 128 | }; 129 | } 130 | el.setValue(opts.value); 131 | return el; 132 | }; 133 | /* {label,value,min,max}*/ 134 | TD.gauge = function(opts) { 135 | var v = (opts.value===undefined)?0:opts.value; 136 | var min = (opts.min===undefined)?0:opts.min; 137 | var max = (opts.max===undefined)?1:opts.max; 138 | var el = setup("gauge",opts,toElement('
'+opts.label+'
'+v+'
')); 139 | el.value = v; 140 | var c = el.getElementsByTagName("canvas")[0]; 141 | var ctx = c.getContext("2d"); 142 | function draw() { 143 | c.width = c.clientWidth; 144 | c.height = c.clientHeight; 145 | var s = Math.min(c.width,c.height); 146 | ctx.lineCap="round"; 147 | ctx.clearRect(0,0,c.width,c.height); 148 | ctx.beginPath(); 149 | ctx.lineWidth=20; 150 | ctx.strokeStyle = "#000"; 151 | ctx.arc(c.width/2, c.height/2+20, (s/2)-24, Math.PI*0.75, 2.25 * Math.PI); 152 | ctx.stroke(); 153 | ctx.beginPath(); 154 | ctx.lineWidth=16; 155 | ctx.strokeStyle = LIGHTCOL; 156 | var v = (el.value-min) / (max-min); 157 | if (v<0) v=0; 158 | if (v>1) v=1; 159 | ctx.arc(c.width/2, c.height/2+20, (s/2)-24, Math.PI*0.75, (0.75+(1.5*v))*Math.PI); 160 | ctx.stroke(); 161 | } 162 | setTimeout(draw,100); 163 | el.onresize = draw; 164 | el.setValue = function(v) { 165 | el.value = v; 166 | el.getElementsByClassName("td_gauge_a")[0].innerHTML = formatText(v); 167 | draw(); 168 | }; 169 | return el; 170 | }; 171 | /* {label 172 | gridy - optional - grid value for y. Also enables labels on axis 173 | ylabel - optional - function(y_value) to format y axis labels, eg: function(y) { return y.toFixed(1); } 174 | gridx - optional - grid value for x. Also enables labels on axis 175 | xlabel - optional - function(x_value) to format x axis labels, eg: function(x) { return x.toFixed(1); } 176 | } 177 | */ 178 | TD.graph = function(opts) { 179 | var el = setup("graph",opts,toElement('
'+opts.label+'
')); 180 | var c = el.getElementsByTagName("canvas")[0]; 181 | var ctx = c.getContext("2d"); 182 | el.setData = function(d) { 183 | el.opts.data = d; 184 | el.draw(); 185 | }; 186 | el.draw = function() { 187 | c.width = c.clientWidth; 188 | c.height = c.clientHeight; 189 | var s = Math.min(c.width,c.height); 190 | var xbase = 18; 191 | var ybase = c.height-18; 192 | var xs = (c.width-8-xbase); 193 | var ys = (ybase-28); 194 | ctx.font = "8px Sans"; 195 | ctx.fillStyle = "#000"; 196 | ctx.fillRect(4,24,c.width-8,c.height-28); 197 | var dxmin,dxmax,dymin,dymax; 198 | if (el.opts.data !== undefined) { 199 | var traces = ("object"==typeof el.opts.data[0]) ? 200 | el.opts.data : [el.opts.data]; 201 | traces.forEach(function(trace) { 202 | for (var i in trace) { 203 | i = parseFloat(i); 204 | var v = parseFloat(trace[i]); 205 | if (dxmin===undefined || idxmax) dxmax=i; 207 | if (dymin===undefined || vdymax) dymax=v; 209 | } 210 | }); 211 | if (el.opts.gridy) { 212 | var gy = el.opts.gridy; 213 | dymin = gy*Math.floor(dymin/gy); 214 | dymax = gy*Math.ceil(dymax/gy); 215 | } 216 | var dxs = dxmax+1-dxmin; 217 | var dys = dymax-dymin; 218 | if (dxs==0) dxs=1; 219 | if (dys==0) dys=1; 220 | function getx(x) { return xbase+(xs*(x-dxmin)/dxs); } 221 | function gety(y) { return ybase-(ys*(y-dymin)/dys); } 222 | traces.forEach(function(trace, idx) { 223 | ctx.beginPath(); 224 | ctx.strokeStyle = (traces.length>1) ? "hsl("+(idx*360/traces.length)+", 100%, 50%)" : LIGHTCOL; 225 | for (var i in trace) { 226 | ctx.lineTo(getx(parseFloat(i)), 227 | gety(parseFloat(trace[i]))); 228 | } 229 | ctx.stroke(); 230 | }); 231 | ctx.fillStyle = "#fff"; 232 | if (el.opts.gridy) { 233 | ctx.textAlign="right"; 234 | for (var i=dymin;i<=dymax;i+=el.opts.gridy) { 235 | var y = gety(i); 236 | var t = el.opts.ylabel?el.opts.ylabel(i):i; 237 | ctx.fillRect(xbase-1, y, 3, 1); 238 | if (y>ctx.measureText(t).width/2) // does it fit? 239 | ctx.fillText(t, xbase-5, y+2); 240 | } 241 | } 242 | if (el.opts.gridx) { 243 | var gx = el.opts.gridx; 244 | ctx.textAlign="center"; 245 | for (var i=gx*Math.ceil(dxmin/gx);i<=dxmax;i+=gx) { 246 | var x = getx(i); 247 | var t = el.opts.xlabel?el.opts.xlabel(i):i; 248 | ctx.fillRect(x,ybase-1, 1, 3); 249 | ctx.fillText(t, x, ybase+10); 250 | } 251 | } 252 | } else { 253 | ctx.fillStyle = "#888"; 254 | ctx.textAlign = "center"; 255 | ctx.fillText("[No Data]", xbase+(xs/2), ybase-(ys/2)); 256 | } 257 | // axes 258 | ctx.beginPath(); 259 | ctx.strokeStyle = "#fff"; 260 | ctx.moveTo(xbase,ybase-ys); 261 | ctx.lineTo(xbase,ybase+10); 262 | ctx.moveTo(xbase-10,ybase); 263 | ctx.lineTo(xbase+xs,ybase); 264 | ctx.stroke(); 265 | } 266 | setTimeout(el.draw,100); 267 | el.onresize = el.draw; 268 | return el; 269 | }; 270 | /* {label,text} 271 | text = newline separated linex 272 | */ 273 | TD.log = function(opts) { 274 | if (!opts.text) opts.text=""; 275 | var el = setup("log",opts,toElement('
'+opts.label+'
')); 276 | el.update = function() { 277 | el.getElementsByClassName("td_log_a")[0].innerHTML = opts.text.replace(/\n/g,"
\n"); 278 | }; 279 | el.log = function(txt) { 280 | opts.text += "\n"+txt; 281 | el.update(); 282 | var e = el.getElementsByClassName("td_log_a")[0]; 283 | e.scrollTop = e.scrollHeight; 284 | }; 285 | el.clear = function() { 286 | opts.text = ""; 287 | el.update(); 288 | }; 289 | return el; 290 | }; 291 | /* {label}*/ 292 | TD.modal = function(opts) { 293 | var el = setup("modal",opts,toElement('
'+opts.label+'
')); 294 | el.onclick = function() { 295 | togglePressed(el); 296 | if (!el.opts.onchange) 297 | el.remove(); 298 | }; 299 | el.remove = function() { 300 | if (el.parentNode) 301 | el.parentNode.removeChild(el); 302 | }; 303 | return el; 304 | }; 305 | 306 | })(); 307 | -------------------------------------------------------------------------------- /www/tinydash_mqtt.js: -------------------------------------------------------------------------------- 1 | /* 2 | Automatically connects to EspruinoHub via MQTT over WebSockets and updates 3 | graphs and guages using the EspruinoHub history. From then on, everything 4 | updates in real-time. 5 | 6 | Use as follows: 7 | 8 | 9 | 10 | 11 | 12 |