├── .gitignore ├── test ├── .eslintrc.js ├── eddystoneSpec.js ├── mocks.js ├── ruuviSpec.js └── parseSpec.js ├── index.js ├── dataformats ├── index.js ├── 2and4.js ├── 3.js └── 5.js ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .eslintrc.js ├── package.json ├── adapter.js ├── lib ├── eddystone.js └── parse.js ├── LICENSE ├── README.md └── ruuvi.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jasmine: true, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const adapter = require('./adapter.js'); 2 | const Ruuvi = require('./ruuvi.js'); 3 | 4 | module.exports = new Ruuvi(adapter); -------------------------------------------------------------------------------- /dataformats/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | formats_2_and_4: require("./2and4"), 3 | format_3: require("./3"), 4 | format_5: require("./5"), 5 | }; 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "03:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: sinon 11 | versions: 12 | - 10.0.0 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["eslint:recommended"], 4 | parserOptions: { 5 | ecmaVersion: 2017, 6 | }, 7 | env: { 8 | es6: true, 9 | node: true, 10 | }, 11 | globals: {}, 12 | rules: { 13 | "comma-style": [2, "last"], 14 | "no-unused-vars": "warn", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /dataformats/2and4.js: -------------------------------------------------------------------------------- 1 | // parser for data formats 2 and 4 2 | 3 | function unSign(signed) { 4 | // takes signed byte value, returns integer 5 | // see: https://github.com/ruuvi/ruuvi-sensor-protocols#protocol-specification-data-format-2-and-4 6 | return signed & 0x80 ? -1 * (signed & 0x7f) : signed; 7 | } 8 | 9 | module.exports = { 10 | parse: buffer => { 11 | return { 12 | humidity: buffer[1] / 2, 13 | temperature: unSign(buffer[2]), 14 | pressure: (buffer[4] * 256 + buffer[5] + 50000) / 100, 15 | eddystoneId: buffer.length === 7 ? buffer[6] : undefined, 16 | }; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: 11 | #- 8.x 12 | #- 10.x 13 | - 22.x 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm i 23 | npm run build --if-present 24 | npm test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-ruuvitag", 3 | "version": "5.2.0", 4 | "engines": { 5 | "node": ">6.0.0" 6 | }, 7 | "repository": "https://github.com/pakastin/node-ruuvitag", 8 | "description": "Read data from RuuviTag weather station", 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "jasmine test/*Spec.js", 12 | "lint": "eslint '**/*.js'", 13 | "prettify": "prettier --write '**/*.js'" 14 | }, 15 | "author": "", 16 | "license": "BSD-3", 17 | "devDependencies": { 18 | "eslint": "^9.33.0", 19 | "jasmine": "^5.9.0", 20 | "mockery": "^2.1.0", 21 | "prettier": "^3.6.2", 22 | "sinon": "^21.0.0" 23 | }, 24 | "dependencies": { 25 | "@abandonware/noble": "^1.9.2-26" 26 | }, 27 | "prettier": { 28 | "printWidth": 120, 29 | "trailingComma": "all" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /adapter.js: -------------------------------------------------------------------------------- 1 | const noble = require('@abandonware/noble'); 2 | const EventEmitter = require('events').EventEmitter; 3 | 4 | class Adapter extends EventEmitter { 5 | constructor () { 6 | super(); 7 | 8 | noble.on('discover', (peripheral) => { 9 | this.emit('discover', peripheral); 10 | }); 11 | 12 | noble.on('warning', (warning) => { 13 | this.emit('warning', warning); 14 | }); 15 | 16 | // start scanning 17 | if (noble.state === 'poweredOn') { 18 | this.start(); 19 | } else { 20 | noble.once('stateChange', (state) => { 21 | if (state === 'poweredOn') { 22 | this.start(); 23 | } else { 24 | this.stop(); 25 | } 26 | this.emit('stateChange', state); 27 | }); 28 | } 29 | } 30 | 31 | start () { 32 | if (this._scanning) { 33 | return; 34 | } 35 | this._scanning = true; 36 | noble.startScanning([], true); 37 | } 38 | 39 | stop () { 40 | if (!this._scanning) { 41 | return; 42 | } 43 | this._scanning = false; 44 | noble.stopScanning(); 45 | } 46 | } 47 | 48 | module.exports = new Adapter(); 49 | -------------------------------------------------------------------------------- /lib/eddystone.js: -------------------------------------------------------------------------------- 1 | const prefixes = ["http://www.", "https://www.", "http://", "https://"]; 2 | 3 | const suffixes = [ 4 | ".com/", 5 | ".org/", 6 | ".edu/", 7 | ".net/", 8 | ".info/", 9 | ".biz/", 10 | ".gov/", 11 | ".com", 12 | ".org", 13 | ".edu", 14 | ".net", 15 | ".info", 16 | ".biz", 17 | ".gov", 18 | ]; 19 | 20 | module.exports = function parseEddystoneBeacon(serviceDataBuffer) { 21 | // Parse url from an Eddystone beacon 22 | // 23 | // Returns undefined if it's not an Eddystone URL packet 24 | // Otherwise returns url as a string 25 | 26 | const frameType = serviceDataBuffer.readUInt8(0); 27 | 28 | // Check that this is a URL frame type 29 | if (frameType !== 0x10) { 30 | return; 31 | } 32 | 33 | const prefix = serviceDataBuffer.readUInt8(2); 34 | if (prefix > prefixes.length) { 35 | return; 36 | } 37 | 38 | let url = prefixes[prefix]; 39 | 40 | for (let i = 3; i < serviceDataBuffer.length; i++) { 41 | if (serviceDataBuffer[i] < suffixes.length) { 42 | url += suffixes[serviceDataBuffer[i]]; 43 | } else { 44 | url += String.fromCharCode(serviceDataBuffer[i]); 45 | } 46 | } 47 | 48 | return url; 49 | }; 50 | -------------------------------------------------------------------------------- /test/eddystoneSpec.js: -------------------------------------------------------------------------------- 1 | const parseEddystoneBeacon = require("../lib/eddystone"); 2 | 3 | const dataBuffers = { 4 | ruuviTag: Buffer.from([ 5 | 0x10, 6 | 0xf9, 7 | 0x03, 8 | 0x72, 9 | 0x75, 10 | 0x75, 11 | 0x2e, 12 | 0x76, 13 | 0x69, 14 | 0x2f, 15 | 0x23, 16 | 0x42, 17 | 0x45, 18 | 0x51, 19 | 0x5a, 20 | 0x41, 21 | 0x4d, 22 | 0x4c, 23 | 0x73, 24 | 0x4f, 25 | ]), 26 | telemetryFrame: Buffer.from([ 27 | 0x20, 28 | 0xf9, 29 | 0x03, 30 | 0x73, 31 | 0x75, 32 | 0x75, 33 | 0x2e, 34 | 0x76, 35 | 0x69, 36 | 0x2f, 37 | 0x23, 38 | 0x42, 39 | 0x45, 40 | 0x51, 41 | 0x5a, 42 | 0x41, 43 | 0x4d, 44 | 0x4c, 45 | 0x73, 46 | 0x4f, 47 | ]), 48 | }; 49 | 50 | describe("Module eddystone", () => { 51 | it("should return undefined if it's not an Eddystone URL packet", () => { 52 | expect(parseEddystoneBeacon(dataBuffers.telemetryFrame)).toBe(undefined); 53 | }); 54 | 55 | it("should return url if it's an Eddystone URL packet", () => { 56 | const result = parseEddystoneBeacon(dataBuffers.ruuviTag); 57 | expect(result).toMatch(/^https:\/\/ruu\.vi\//); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | const dataFormats = require("../dataformats/index"); 2 | 3 | function stripUrl(url) { 4 | const match = url.match(/#(.+)$/); 5 | return match ? match[1] : new Error("Invalid url"); 6 | } 7 | 8 | function getReadings(encodedData) { 9 | function addPaddingIfNecessary(str) { 10 | // if encoded data is truncated (data format 4), add some random padding 11 | return str.length === 9 ? str + "a==" : str; 12 | } 13 | 14 | const buffer = Buffer.from(addPaddingIfNecessary(encodedData), "base64"); 15 | 16 | // validate 17 | if (buffer.length < 6 || buffer.length > 7) { 18 | return new Error("Invalid data"); 19 | } 20 | const dataFormat = buffer[0]; 21 | 22 | return dataFormat === 2 || dataFormat === 4 23 | ? Object.assign({ dataFormat: dataFormat }, dataFormats.formats_2_and_4.parse(buffer)) 24 | : new Error("Unsupported data format: " + dataFormat); 25 | } 26 | 27 | const that = (module.exports = {}); 28 | 29 | that.parseUrl = url => { 30 | if (!url.match(/ruu\.vi/)) { 31 | return new Error("Not a ruuviTag url"); 32 | } 33 | 34 | const encodedData = stripUrl(url); 35 | 36 | return encodedData instanceof Error ? encodedData : getReadings(encodedData); 37 | }; 38 | 39 | that.parseManufacturerData = dataBuffer => { 40 | let dataFormat = dataBuffer[2]; 41 | switch (dataFormat) { 42 | case 3: 43 | return dataFormats.format_3.parse(dataBuffer); 44 | case 5: 45 | return dataFormats.format_5.parse(dataBuffer); 46 | default: 47 | return new Error("Data format not supported"); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Esa Toivola 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-ruuvitag 2 | Node.js module for reading data from a [Ruuvitag](http://tag.ruuvi.com) 3 | weather station. 4 | 5 | Tested on Raspberry Pi 3. Depends on [noble](https://github.com/abandonware/noble). See [instructions](https://github.com/abandonware/noble) on 6 | how to enable BLE on RasPi and how to run without root. 7 | 8 | ### Installation 9 | 10 | ``` 11 | npm install node-ruuvitag 12 | ``` 13 | 14 | 15 | ### Usage example 16 | ```js 17 | const ruuvi = require('node-ruuvitag'); 18 | 19 | ruuvi.on('found', tag => { 20 | console.log('Found RuuviTag, id: ' + tag.id); 21 | tag.on('updated', data => { 22 | console.log('Got data from RuuviTag ' + tag.id + ':\n' + 23 | JSON.stringify(data, null, '\t')); 24 | }); 25 | }); 26 | 27 | ruuvi.on('warning', message => { 28 | console.error(new Error(message)); 29 | }); 30 | 31 | ``` 32 | 33 | ### Events 34 | #### found 35 | Module ```ruuvi``` emits a ```found``` event, when a new RuuviTag 36 | is discovered. Event's payload is a ```ruuviTag``` object (see below) 37 | ### warning 38 | Module relays noble's [```warning``` events](https://github.com/noble/noble#warnings) (see below) 39 | 40 | ### API 41 | 42 | ##### ruuvi.findTags() 43 | 44 | Finds available ruuvitags. Returns a promise which is resolved with an 45 | array of ```ruuviTag``` objects or rejected with an error if no tags were 46 | found. 47 | 48 | If you call ```findTags``` multiple times, it always returns **all** 49 | found RuuviTags this far. 50 | 51 | ### ```ruuviTag``` object 52 | 53 | Is an ```eventEmitter``` . 54 | 55 | **Properties:** 56 | 57 | * ```id```: id of beacon 58 | * ```address```: address of beacon 59 | * ```addressType```: addressType of address 60 | * ```connectable```: flag if beacon is connectable 61 | 62 | **Events:** 63 | 64 | ```updated```: emitted when weather station data is received. 65 | Object ```data``` has 66 | following properties (depending on data format): 67 | 68 | * ```url``` -- original broadcasted url if any 69 | * ```temperature``` 70 | * ```pressure``` 71 | * ```humidity``` 72 | * ```eddystoneId``` -- in data format 4 73 | * ```rssi``` 74 | * ```battery``` (battery voltage) 75 | * ```accelerationX``` 76 | * ```accelerationY``` 77 | * ```accelerationZ``` 78 | * ```txPower``` -- in data format 5 79 | * ```movementCounter``` -- in data format 5 80 | * ```measurementSequenceNumber``` -- in data format 5 81 | * ```mac``` -- in data format 5 82 | 83 | See [data formats](https://github.com/ruuvi/ruuvi-sensor-protocols) for 84 | info about RuuviTag sensor values. 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /dataformats/3.js: -------------------------------------------------------------------------------- 1 | // This function is borrowed from https://github.com/ojousima/node-red/blob/master/ruuvi-node/ruuvitag.js 2 | // which is licenced under BSD-3 3 | // Credits to GitHub user ojousima 4 | 5 | const parseRawRuuvi = function(manufacturerDataString) { 6 | let humidityStart = 6; 7 | let humidityEnd = 8; 8 | let temperatureStart = 8; 9 | let temperatureEnd = 12; 10 | let pressureStart = 12; 11 | let pressureEnd = 16; 12 | let accelerationXStart = 16; 13 | let accelerationXEnd = 20; 14 | let accelerationYStart = 20; 15 | let accelerationYEnd = 24; 16 | let accelerationZStart = 24; 17 | let accelerationZEnd = 28; 18 | let batteryStart = 28; 19 | let batteryEnd = 32; 20 | 21 | let robject = {}; 22 | 23 | let humidity = manufacturerDataString.substring(humidityStart, humidityEnd); 24 | humidity = parseInt(humidity, 16); 25 | humidity /= 2; //scale 26 | robject.humidity = humidity; 27 | 28 | let temperatureString = manufacturerDataString.substring(temperatureStart, temperatureEnd); 29 | let temperature = parseInt(temperatureString.substring(0, 2), 16); //Full degrees 30 | temperature += parseInt(temperatureString.substring(2, 4), 16) / 100; //Decimals 31 | if (temperature > 128) { 32 | // Ruuvi format, sign bit + value 33 | temperature = temperature - 128; 34 | temperature = 0 - temperature; 35 | } 36 | robject.temperature = +temperature.toFixed(2); // Round to 2 decimals, format as a number 37 | 38 | let pressure = parseInt(manufacturerDataString.substring(pressureStart, pressureEnd), 16); // uint16_t pascals 39 | pressure += 50000; //Ruuvi format 40 | robject.pressure = pressure; 41 | 42 | let accelerationX = parseInt(manufacturerDataString.substring(accelerationXStart, accelerationXEnd), 16); // milli-g 43 | if (accelerationX > 32767) { 44 | accelerationX -= 65536; 45 | } //two's complement 46 | 47 | let accelerationY = parseInt(manufacturerDataString.substring(accelerationYStart, accelerationYEnd), 16); // milli-g 48 | if (accelerationY > 32767) { 49 | accelerationY -= 65536; 50 | } //two's complement 51 | 52 | let accelerationZ = parseInt(manufacturerDataString.substring(accelerationZStart, accelerationZEnd), 16); // milli-g 53 | if (accelerationZ > 32767) { 54 | accelerationZ -= 65536; 55 | } //two's complement 56 | 57 | robject.accelerationX = accelerationX; 58 | robject.accelerationY = accelerationY; 59 | robject.accelerationZ = accelerationZ; 60 | 61 | let battery = parseInt(manufacturerDataString.substring(batteryStart, batteryEnd), 16); // milli-g 62 | robject.battery = battery; 63 | 64 | return robject; 65 | }; 66 | 67 | module.exports = { 68 | parse: buffer => parseRawRuuvi(buffer.toString("hex")), 69 | }; 70 | -------------------------------------------------------------------------------- /test/mocks.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require("events").EventEmitter; 2 | 3 | const generateRandomUrl = () => { 4 | const dataFormat = 4; 5 | const randomValues = [1, 2, 3, 4, 5].map(item => Math.floor(Math.random() * 256)); 6 | return "https://ruu.vi/#" + Buffer.from([dataFormat].concat(randomValues)).toString("base64"); 7 | }; 8 | 9 | const manufacturerData = Buffer.from("990403501854c2c60042ffe503ef0b8300000000", "hex"); 10 | 11 | const ruuviTags = [ 12 | { 13 | id: "c283c5a63ccb", 14 | address: "c2:83:c5:a6:3c:cb", 15 | addressType: "random", 16 | connectable: false, 17 | dataFormat: 3, 18 | manufacturerData: manufacturerData, 19 | }, 20 | { 21 | id: "fbf6df2d6abe", 22 | address: "fb:f6:df:2d:6a:be", 23 | addressType: "random", 24 | connectable: false, 25 | dataFormat: 4, 26 | }, 27 | { 28 | id: "fbf6df2d6abe", 29 | address: "fb:f6:df:2d:6a:be", 30 | addressType: "random", 31 | connectable: false, 32 | dataFormat: 4, 33 | }, 34 | ]; 35 | 36 | class NobleMock extends EventEmitter { 37 | constructor() { 38 | super(); 39 | this.state = "unknown"; 40 | this.tagsAvailable = false; 41 | this.advertiseInterval = 900; 42 | } 43 | 44 | startScanning() { 45 | setInterval(() => { 46 | if (!this.tagsAvailable) { 47 | return; 48 | } 49 | ruuviTags.forEach(tag => { 50 | if (tag.dataFormat === 3) { 51 | this.emit("discover", { 52 | id: tag.id, 53 | advertisement: { manufacturerData: tag.manufacturerData }, 54 | }); 55 | } else { 56 | this.emit("discover", { 57 | id: tag.id, 58 | advertisement: { 59 | serviceData: [ 60 | { 61 | uuid: "feaa", 62 | data: Buffer.from([ 63 | 0x10, 64 | 0xf9, 65 | 0x03, 66 | 0x72, 67 | 0x75, 68 | 0x75, 69 | 0x2e, 70 | 0x76, 71 | 0x69, 72 | 0x2f, 73 | 0x23, 74 | 0x42, 75 | 0x45, 76 | 0x51, 77 | 0x5a, 78 | 0x41, 79 | 0x4d, 80 | 0x4c, 81 | 0x73, 82 | 0x4f, 83 | ]), 84 | }, 85 | ], 86 | }, 87 | }); 88 | } 89 | }); 90 | }, this.advertiseInterval); 91 | } 92 | 93 | disableTagFinding() { 94 | this.tagsAvailable = false; 95 | } 96 | 97 | enableTagFinding() { 98 | this.tagsAvailable = true; 99 | } 100 | } 101 | 102 | const obj = (module.exports = { 103 | nobleMock: { 104 | mock: new NobleMock(), 105 | }, 106 | }); 107 | -------------------------------------------------------------------------------- /ruuvi.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const parser = require('./lib/parse'); 3 | const parseEddystoneBeacon = require('./lib/eddystone'); 4 | 5 | class RuuviTag extends EventEmitter { 6 | constructor (data) { 7 | super(); 8 | this.id = data.id; 9 | this.address = data.address; 10 | this.addressType = data.addressType; 11 | this.connectable = data.connectable; 12 | } 13 | } 14 | 15 | class Ruuvi extends EventEmitter { 16 | constructor (adapter) { 17 | super(); 18 | this._adapter = adapter; 19 | this._foundTags = []; // this array will contain registered RuuviTags 20 | this._tagLookup = {}; 21 | this.scanning = false; 22 | this.listenerAttached = false; 23 | 24 | const registerTag = tag => { 25 | this._foundTags.push(tag); 26 | this._tagLookup[tag.id] = tag; 27 | }; 28 | 29 | this._adapter.on('warning', warning => { 30 | console.error(new Error(warning)); 31 | }); 32 | 33 | this._adapter.on('discover', peripheral => { 34 | let newRuuviTag; 35 | 36 | // Scan for new RuuviTags, add them to the array of found tags 37 | // is it a RuuviTag in RAW mode? 38 | const manufacturerData = peripheral.advertisement ? peripheral.advertisement.manufacturerData : undefined; 39 | if (manufacturerData && manufacturerData[0] === 0x99 && manufacturerData[1] === 0x04) { 40 | if (!this._tagLookup[peripheral.id]) { 41 | newRuuviTag = new RuuviTag({ 42 | id: peripheral.id, 43 | address: peripheral.address, 44 | addressType: peripheral.addressType, 45 | connectable: peripheral.connectable 46 | }); 47 | registerTag(newRuuviTag); 48 | this.emit('found', newRuuviTag); 49 | } 50 | } else { 51 | // is it a RuuviTag in Eddystone mode? 52 | 53 | const serviceDataArray = peripheral.advertisement ? peripheral.advertisement.serviceData : undefined; 54 | const serviceData = serviceDataArray && serviceDataArray.length ? serviceDataArray[0] : undefined; 55 | if (serviceData && serviceData.uuid === 'feaa') { 56 | const url = parseEddystoneBeacon(serviceData.data); 57 | if (url && url.match(/ruu\.vi/)) { 58 | if (!this._tagLookup[peripheral.id]) { 59 | newRuuviTag = new RuuviTag({ 60 | id: peripheral.id, 61 | address: peripheral.address, 62 | addressType: peripheral.addressType, 63 | connectable: peripheral.connectable 64 | }); 65 | registerTag(newRuuviTag); 66 | this.emit('found', newRuuviTag); 67 | } 68 | } 69 | } 70 | } 71 | 72 | // Check if it is an advertisement by an already found RuuviTag, emit "updated" event 73 | const ruuviTag = this._tagLookup[peripheral.id]; 74 | 75 | if (ruuviTag) { 76 | if (peripheral.advertisement && peripheral.advertisement.manufacturerData) { 77 | const dataFormat = peripheral.advertisement.manufacturerData[2]; 78 | return ruuviTag.emit( 79 | 'updated', 80 | Object.assign( 81 | { dataFormat: dataFormat, rssi: peripheral.rssi }, 82 | parser.parseManufacturerData(peripheral.advertisement.manufacturerData) 83 | ) 84 | ); 85 | } 86 | 87 | // is data format 2 or 4 88 | 89 | const serviceDataArray = peripheral.advertisement.serviceData; 90 | const serviceData = serviceDataArray && serviceDataArray.length ? serviceDataArray[0] : undefined; 91 | const url = serviceData ? parseEddystoneBeacon(serviceData.data) : undefined; 92 | const parsed = url ? parser.parseUrl(url) : undefined; 93 | if (parsed && !(parsed instanceof Error)) { 94 | ruuviTag.emit('updated', { 95 | url: url, 96 | dataFormat: parsed.dataFormat, 97 | rssi: peripheral.rssi, 98 | humidity: parsed.humidity, 99 | temperature: parsed.temperature, 100 | pressure: parsed.pressure, 101 | eddystoneId: parsed.eddystoneId 102 | }); 103 | } 104 | } 105 | }); 106 | } 107 | 108 | findTags () { 109 | return new Promise((resolve, reject) => { 110 | setTimeout(() => { 111 | if (this._foundTags.length) { 112 | return resolve(this._foundTags); 113 | } 114 | reject(new Error('No beacons found')); 115 | }, 5000); 116 | }); 117 | } 118 | 119 | start () { 120 | if (!this.scanning) { 121 | this.scanning = true; 122 | } 123 | } 124 | 125 | stop () { 126 | if (this.scanning) { 127 | this.scanning = false; 128 | } 129 | } 130 | } 131 | 132 | module.exports = Ruuvi; 133 | -------------------------------------------------------------------------------- /test/ruuviSpec.js: -------------------------------------------------------------------------------- 1 | const mockery = require('mockery'); 2 | const sinon = require('sinon'); 3 | const mocks = require('./mocks'); 4 | const nobleMock = mocks.nobleMock; 5 | const EventEmitter = require('events').EventEmitter; 6 | 7 | const catchFail = done => { 8 | return err => done.fail(err); 9 | }; 10 | 11 | describe('module ruuvi', () => { 12 | const findTagsScanTime = 5000; 13 | const numberOfRuuviTags = 2; 14 | 15 | let ruuvi; 16 | 17 | beforeEach(() => { 18 | mockery.enable(); 19 | mockery.registerMock('@abandonware/noble', nobleMock.mock); 20 | mockery.registerMock('noble', nobleMock.mock); 21 | mockery.registerAllowable('../adapter'); 22 | mockery.registerAllowable('../ruuvi'); 23 | mockery.registerAllowable('./lib/parse'); 24 | mockery.registerAllowable('./lib/eddystone'); 25 | mockery.registerAllowable('events'); 26 | nobleMock.mock.enableTagFinding(); 27 | adapter = require('../adapter'); 28 | Ruuvi = require('../ruuvi'); 29 | ruuvi = new Ruuvi(adapter); 30 | jasmine.clock().install(); 31 | nobleMock.mock.startScanning(); 32 | }); 33 | 34 | it('should be eventEmitter', () => { 35 | const EventEmitter = require('events').EventEmitter; 36 | expect(ruuvi instanceof EventEmitter).toBeTruthy(); 37 | }); 38 | 39 | describe('method findTags', () => { 40 | beforeEach(() => { 41 | ruuvi._foundTags = []; 42 | ruuvi._tagLookup = {}; 43 | }); 44 | 45 | it('should return a promise which is resolved with an array of ruuviTag objects', done => { 46 | ruuvi 47 | .findTags() 48 | .then(tags => { 49 | expect(tags).toEqual(jasmine.any(Array)); 50 | expect(tags.length).toBe(numberOfRuuviTags); 51 | // We'll test if objects are instances of EventEmitter, perhaps a better test will be written later 52 | tags.forEach(tag => { 53 | expect(tag).toEqual(jasmine.any(EventEmitter)); 54 | }); 55 | done(); 56 | }) 57 | .catch(catchFail(done)); 58 | jasmine.clock().tick(findTagsScanTime); 59 | }); 60 | 61 | it('should return a promise which is rejected if no tags were found', done => { 62 | nobleMock.mock.disableTagFinding(); 63 | ruuvi 64 | .findTags() 65 | .then(data => done.fail('Should have returned an error')) 66 | .catch(err => { 67 | expect(err.message).toBe('No beacons found'); 68 | done(); 69 | }); 70 | jasmine.clock().tick(findTagsScanTime); 71 | }); 72 | }); 73 | 74 | describe('events: ', () => { 75 | it('should emit "found" when a new RuuviTag is found', done => { 76 | ruuvi._foundTags = []; 77 | ruuvi._tagLookup = {}; 78 | let count = 0; 79 | 80 | ruuvi.on('found', data => { 81 | count++; 82 | expect('id' in data).toBeTruthy(); 83 | expect('address' in data).toBeTruthy(); 84 | expect('addressType' in data).toBeTruthy(); 85 | expect('connectable' in data).toBeTruthy(); 86 | expect(data instanceof EventEmitter).toBeTruthy(); 87 | }); 88 | 89 | setTimeout(function () { 90 | expect(count).toBe(numberOfRuuviTags); 91 | done(); 92 | }, 5000); 93 | 94 | jasmine.clock().tick(5000); 95 | }); 96 | }); 97 | 98 | describe('class RuuviTag', () => { 99 | let tags; 100 | 101 | beforeEach(done => { 102 | ruuvi 103 | .findTags() 104 | .then(result => { 105 | tags = result; 106 | done(); 107 | }) 108 | .catch(err => done.fail(err)); 109 | jasmine.clock().tick(findTagsScanTime); 110 | }); 111 | 112 | describe('instantiated object', () => { 113 | it('should have properties "id", "address", "addressType", "connectable"', () => { 114 | expect('id' in tags[0]).toBeTruthy(); 115 | expect('address' in tags[0]).toBeTruthy(); 116 | expect('addressType' in tags[0]).toBeTruthy(); 117 | expect('connectable' in tags[0]).toBeTruthy(); 118 | }); 119 | 120 | it('should emit "updated" when ruuvitag signal is received', done => { 121 | tags.forEach(tag => tag.on('updated', data => (tag.hasEmitted = true))); 122 | setTimeout(() => { 123 | expect(tags.filter(tag => tag.hasEmitted).length).toBe(2); 124 | done(); 125 | }, nobleMock.mock.advertiseInterval); 126 | jasmine.clock().tick(nobleMock.mock.advertiseInterval); 127 | }); 128 | 129 | describe('emitted data', () => { 130 | beforeEach(done => { 131 | const waitTime = nobleMock.mock.advertiseInterval; 132 | tags.forEach(tag => tag.on('updated', data => (tag.receivedData = data))); 133 | setTimeout(() => { 134 | done(); 135 | }, waitTime); 136 | jasmine.clock().tick(waitTime + 1); 137 | }); 138 | 139 | it('should have sensor data', () => { 140 | const expectedDataKeys = (function () { 141 | const tag_1_keys = ['humidity', 'temperature', 'pressure', 'rssi']; 142 | return { 143 | tag_1: tag_1_keys, 144 | tag_0: tag_1_keys.concat(['accelerationX', 'accelerationY', 'accelerationZ', 'battery']) 145 | }; 146 | })(); 147 | 148 | expectedDataKeys.tag_0.forEach(key => expect(key in tags[0].receivedData).toBeTruthy()); 149 | expectedDataKeys.tag_1.forEach(key => expect(key in tags[1].receivedData).toBeTruthy()); 150 | }); 151 | }); 152 | }); 153 | }); 154 | 155 | afterEach(function () { 156 | jasmine.clock().uninstall(); 157 | mockery.deregisterAll(); 158 | mockery.disable(); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /test/parseSpec.js: -------------------------------------------------------------------------------- 1 | const parser = require('../lib/parse'); 2 | 3 | const createManufacturerData = function () { 4 | const values = { 5 | humidity: 58.5, 6 | temperature: 21.58, 7 | pressure: 101300, 8 | accelerationX: 14850, 9 | accelerationY: -9235, 10 | accelerationZ: 580, 11 | battery: 2958 12 | }; 13 | const manufacturerId = [0x99, 0x04]; 14 | const dataFormat = [0x03]; 15 | const valuesArray = [0x75, 21, 58, 0xc8, 0x64, 0x3a, 0x02, 0xdb, 0xed, 0x02, 0x44, 0x0b, 0x8e]; 16 | return { 17 | values: values, 18 | buffer: Buffer.from(manufacturerId.concat(dataFormat).concat(valuesArray)) 19 | }; 20 | }; 21 | 22 | describe('parse.js', () => { 23 | const data = [0x98, 0x15, 0x00, 0xc0, 0x30]; 24 | const dataBufferFormat2 = Buffer.from([0x02].concat(data)); 25 | const dataBufferFormat4 = Buffer.from([0x04].concat(data).concat([0x3e])); 26 | const testUrlDataFormat2 = 'ruu.vi/#' + dataBufferFormat2.toString('base64'); 27 | const testUrlDataFormat4 = ('ruu.vi/#' + dataBufferFormat4.toString('base64')).slice(0, 17); 28 | const dataFormat5 = [0x05, 0x12, 0xFC, 0x53, 0x94, 0xC3, 0x7C, 0x00, 0x04, 0xFF, 0xFC, 0x04, 0x0C, 0xAC, 0x36, 0x42, 0x00, 0xCD, 0xCB, 0xB8, 0x33, 0x4C, 0x88, 0x01]; 29 | 30 | it('should return error if not a ruuviTag url', done => { 31 | const result = parser.parseUrl('https://bad.url.com/#foo'); 32 | if (!(result instanceof Error)) { 33 | return done.fail('Should have got an error'); 34 | } 35 | expect(result.message).toMatch(/not a ruuvitag url/i); 36 | done(); 37 | }); 38 | 39 | it("should return error if url doesn't contain data", done => { 40 | const result = parser.parseUrl('https://ruu.vi/foo'); 41 | if (!(result instanceof Error)) { 42 | return done.fail('Should have got an error'); 43 | } 44 | expect(result.message).toMatch(/invalid url/i); 45 | done(); 46 | }); 47 | 48 | it('should return error if url contains invalid data', done => { 49 | const result = parser.parseUrl('https://ruu.vi/#foo'); 50 | if (!(result instanceof Error)) { 51 | return done.fail('Should have got an error'); 52 | } 53 | expect(result.message).toMatch(/invalid data/i); 54 | done(); 55 | }); 56 | 57 | it('should return error if data format is unsupported', done => { 58 | const result = parser.parseUrl('https://ruu.vi/#' + Buffer.from([5, 6, 7, 8, 9, 10]).toString('base64')); 59 | if (!(result instanceof Error)) { 60 | return done.fail('Should have got an error'); 61 | } 62 | expect(result.message).toMatch(/unsupported data format: 5/i); 63 | done(); 64 | }); 65 | 66 | describe('parsing data format 2', () => { 67 | const result = parser.parseUrl(testUrlDataFormat2); 68 | it('should parse humidity value', () => { 69 | expect(result.humidity).toBe(76); 70 | }); 71 | it('should parse temperature value', () => { 72 | expect(result.temperature).toBe(21); 73 | }); 74 | it('should parse pressure value', () => { 75 | expect(result.pressure).toBe(992); 76 | }); 77 | }); 78 | 79 | describe('parsing data format 3', () => { 80 | const data = createManufacturerData(); 81 | const testValues = Object.keys(data.values).map(key => key); 82 | 83 | it('should parse all values correctly', () => { 84 | const result = parser.parseManufacturerData(data.buffer); 85 | testValues.forEach(key => { 86 | expect(result[key]).toBe(data.values[key]); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('parsing data format 4', () => { 92 | const result = parser.parseUrl(testUrlDataFormat4); 93 | 94 | it("shouldn't return error", () => { 95 | expect(result instanceof Error).toBeFalsy(); 96 | }); 97 | 98 | it('should parse humidity value', () => { 99 | expect(result.humidity).toBe(76); 100 | }); 101 | it('should parse temperature value', () => { 102 | expect(result.temperature).toBe(21); 103 | }); 104 | it('should parse pressure value', () => { 105 | expect(result.pressure).toBe(992); 106 | }); 107 | it('should parse eddystoneId', () => { 108 | expect(result.eddystoneId).toBeTruthy(); 109 | }); 110 | }); 111 | 112 | describe('parsing data format 5', () => { 113 | const result = parser.parseManufacturerData(Buffer.from([0x99, 0x04].concat(dataFormat5))); 114 | 115 | it("shouldn't return error", () => { 116 | expect(result instanceof Error).toBeFalsy(); 117 | }); 118 | 119 | it('should parse temperature value', () => { 120 | expect(result.temperature).toBe(24.3); 121 | }); 122 | 123 | it('should parse pressure value', () => { 124 | expect(result.pressure).toBe(100044); 125 | }); 126 | 127 | it('should parse humidity value', () => { 128 | expect(result.humidity).toBe(53.49); 129 | }); 130 | 131 | it('should parse accelerationX', () => { 132 | expect(result.accelerationX).toBe(4); 133 | }); 134 | 135 | it('should parse accelerationY', () => { 136 | expect(result.accelerationY).toBe(-4); 137 | }); 138 | 139 | it('should parse accelerationZ', () => { 140 | expect(result.accelerationZ).toBe(1036); 141 | }); 142 | 143 | it('should parse txPower', () => { 144 | expect(result.txPower).toBe(4); 145 | }); 146 | 147 | it('should parse battery', () => { 148 | expect(result.battery).toBe(2977); 149 | }); 150 | 151 | it('should parse movementCounter', () => { 152 | expect(result.movementCounter).toBe(66) 153 | }); 154 | 155 | it('should parse measurementSequenceNumber', () => { 156 | expect(result.measurementSequenceNumber).toBe(205); 157 | }); 158 | 159 | it('should parse MAC address', () => { 160 | expect(result.mac).toBe('CB:B8:33:4C:88:01'); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /dataformats/5.js: -------------------------------------------------------------------------------- 1 | const parseRawRuuvi = function (data) { 2 | const robject = {}; 3 | 4 | /*************************************************************************** 5 | * Modifications by pbfulmar: 6 | * - added handling of invalid data received from ruuvitags to fully implement 7 | * the Ruuvi Dataformat v5 specification 8 | * - parseRawRuuvi returns null in case of invalid data received 9 | * - fixed small calculation inaccuracy (~ 0.02 degree Celsius) in the temperature conversion 10 | * - added code for manual testing (now commented out) 11 | * 12 | * Testing: 13 | * Unfortunately I've got no idea how to automate this, so I fell back 14 | * to modifying the code. 15 | * If you uncomment the code below, no matter what data was originally received, 16 | * parseRawRuuvi() returns constantly one of the test vectors. 17 | * 18 | * Instructions: 19 | * - uncomment one of the test vectors 20 | * - uncomment the code to replace the received data with the test data 21 | * 22 | * Detailed spec for RUUVI DATAFORMAT V5: 23 | * see github ruuvi/ruuvi-sensor-protocols 24 | * * Test vectors for format v5: 25 | */ 26 | 27 | //const hex = '0512FC5394C37C0004FFFC040CAC364200CDCBB8334C884F'; // valid data 28 | //const hex = '057FFFFFFEFFFE7FFF7FFF7FFFFFDEFEFFFECBB8334C884F'; // maximum values 29 | //const hex = '058001000000008001800180010000000000CBB8334C884F'; // minimum values 30 | //const hex = '058000FFFFFFFF800080008000FFFFFFFFFFFFFFFFFFFFFF'; // invalid data 31 | 32 | /* Uncomment the following two lines to replace the received data with the test data. 33 | * The hex string is converted to bytes and loaded into data[], starting at data[2]. 34 | */ 35 | //data = []; data.push(null); data.push(null); 36 | //for (c = 0; c < hex.length; c += 2) data.push(parseInt('0x' + hex.substr(c, 2), 16)); 37 | 38 | /* End Testing 39 | ************************************************************************* 40 | */ 41 | 42 | 43 | /* my binary arithmetic got a bit rusty ... so: 44 | 0x8000 = 32768 := invalid by Ruuvi 45 | 0x7fff = 32767 46 | 0xffff = 65535 resp. -1 in 2's complement 47 | 0x8001 = 32769 resp. -32767 in 2s complement 48 | old temperature converversion: 49 | 32768 - 65534 = -32766, but should be invalid, 50 | 32769 - 65534 = -32765, is not correct, should be -32769 51 | */ 52 | 53 | let temperature = (data[3] << 8) | (data[4] & 0xff); 54 | if (temperature == 32768) { // ruuvi spec := 'invalid/not available' 55 | robject.temperature = null; 56 | } 57 | else if (temperature > 32768) { // two's complement 58 | robject.temperature = (temperature - 65536) * 0.005; 59 | } 60 | else { 61 | robject.temperature = temperature * 0.005; 62 | } 63 | 64 | let humidity = ((data[5] & 0xff) << 8) | (data[6] & 0xff); 65 | if (humidity == 65535) { // ruuvi spec := 'invalid/not available' 66 | robject.humidity = null; 67 | } 68 | else { 69 | robject.humidity = humidity * 0.0025; // 0% .. 100%; >100% := faulty/miscalibrated sensor 70 | } 71 | 72 | let pressure = ((data[7] & 0xff) << 8) | (data[8] & 0xff); 73 | if (pressure == 65535) { // ruuvi spec := 'invalid/not available' 74 | robject.pressure = null; 75 | } 76 | else { 77 | robject.pressure = pressure + 50000; 78 | } 79 | 80 | let accelerationX = (data[9] << 8) | (data[10] & 0xff); 81 | if (accelerationX == 32768) { // ruuvi spec := 'invalid/not available' 82 | robject.accelerationX = null; 83 | } 84 | else if (accelerationX > 32768) { // two's complement 85 | robject.accelerationX = (accelerationX - 65536); 86 | } 87 | else { 88 | robject.accelerationX = accelerationX; 89 | } 90 | 91 | let accelerationY = (data[11] << 8) | (data[12] & 0xff); 92 | if (accelerationY == 32768) { // ruuvi spec := 'invalid/not available' 93 | robject.accelerationY = null; 94 | } 95 | else if (accelerationY > 32768) { // two's complement 96 | robject.accelerationY = (accelerationY - 65536); 97 | } 98 | else { 99 | robject.accelerationY = accelerationY; 100 | } 101 | 102 | let accelerationZ = (data[13] << 8) | (data[14] & 0xff); 103 | if (accelerationZ == 32768) { // ruuvi spec := 'invalid/not available' 104 | robject.accelerationZ = null; 105 | } 106 | else if (accelerationZ > 32768) { // two's complement 107 | robject.accelerationZ = (accelerationZ - 65536); 108 | } 109 | else { 110 | robject.accelerationZ = accelerationZ; 111 | } 112 | 113 | let powerInfo = ((data[15] & 0xff) << 8) | (data[16] & 0xff); 114 | let battery = powerInfo >>> 5; 115 | if (battery == 2047) { // ruuvi spec := 'invalid/not available' 116 | robject.battery = null; 117 | } 118 | else { 119 | robject.battery = battery + 1600; 120 | } 121 | let txPower = powerInfo & 0b11111; 122 | if (txPower == 31) { // ruuvi spec := 'invalid/not available' 123 | robject.txPower = null; 124 | } 125 | else { 126 | robject.txPower = txPower * 2 - 40; 127 | } 128 | 129 | let movementCounter = data[17] & 0xff; 130 | if (movementCounter == 255) { // ruuvi spec := 'invalid/not available' 131 | robject.movementCounter = null; 132 | } 133 | else { 134 | robject.movementCounter = movementCounter; 135 | } 136 | 137 | let measurementSequenceNumber = ((data[18] & 0xff) << 8) | (data[19] & 0xff); 138 | if (measurementSequenceNumber == 65535) { // ruuvi spec := 'invalid/not available' 139 | robject.measurementSequenceNumber = null; 140 | } 141 | else { 142 | robject.measurementSequenceNumber = measurementSequenceNumber; 143 | } 144 | 145 | robject.mac = [ 146 | int2Hex(data[20]), 147 | int2Hex(data[21]), 148 | int2Hex(data[22]), 149 | int2Hex(data[23]), 150 | int2Hex(data[24]), 151 | int2Hex(data[25]) 152 | ].join(':'); 153 | 154 | return robject; 155 | }; 156 | 157 | module.exports = { 158 | parse: buffer => parseRawRuuvi(buffer) 159 | }; 160 | 161 | function int2Hex (str) { 162 | return ('0' + str.toString(16).toUpperCase()).slice(-2); 163 | } 164 | --------------------------------------------------------------------------------