├── .gitignore ├── src ├── __tests__ │ └── hello.test.ts ├── index.ts ├── beacon │ ├── index.ts │ ├── enums.ts │ ├── __tests__ │ │ └── url.test.ts │ ├── Beacon.ts │ ├── url.ts │ └── BeaconService.ts ├── Eddystone.ts └── constants.ts ├── .codeclimate.yml ├── assets └── demo.gif ├── demo ├── src │ ├── index.css │ ├── App.css │ ├── index.js │ ├── App.js │ ├── logic │ │ └── try-it-out.js │ └── registerServiceWorker.js ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── README.md └── package.json ├── tslint.json ├── jest.config.js ├── scripts └── demo-deploy.sh ├── tsconfig.json ├── LICENSE ├── .travis.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | coverage/ 4 | node_modules/ -------------------------------------------------------------------------------- /src/__tests__/hello.test.ts: -------------------------------------------------------------------------------- 1 | it('works', () => {}); 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './beacon'; 2 | export * from './Eddystone'; 3 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | tslint: 3 | enabled: true 4 | channel: beta -------------------------------------------------------------------------------- /src/beacon/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Beacon'; 2 | export * from './BeaconService'; 3 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zurfyx/eddystone-web-bluetooth/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | button.big { 6 | padding: 20px 40px; 7 | } -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zurfyx/eddystone-web-bluetooth/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb", 3 | "rules": { 4 | "no-inferrable-types": true, 5 | "strict-boolean-expressions": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | This folder contains working examples of `eddystone-web-bluetooth`, running on a very simple React setup. 4 | 5 | The relevant generic bits of code are located into `src/logic`. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '.(ts|tsx)': '/node_modules/ts-jest/preprocessor.js' 4 | }, 5 | testMatch: [ 6 | '**/__tests__/**/*.test.{t,j}s?(x)' 7 | ], 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 9 | }; 10 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /src/beacon/enums.ts: -------------------------------------------------------------------------------- 1 | enum LOCK_VALUES { 2 | LOCKED = 0x00, 3 | UNLOCKED = 0x01, 4 | UNLOCKED_AND_AUTOMATIC_RELOCK_DISABLED = 0x02, 5 | } 6 | 7 | enum DATA_VALUES { 8 | UID = 0x00, 9 | URL = 0x10, 10 | TLM = 0x20, 11 | EID = 0x40, 12 | } 13 | 14 | export { 15 | LOCK_VALUES, 16 | DATA_VALUES, 17 | }; 18 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import tryItOut from './logic/try-it-out'; 4 | import './App.css'; 5 | 6 | class App extends Component { 7 | render() { 8 | return ( 9 |
10 |

Eddystone Web Bluetooth

11 | 12 |
13 | ); 14 | } 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Eddystone Web Bluetooth 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Eddystone.ts: -------------------------------------------------------------------------------- 1 | import constants from './constants'; 2 | import { Beacon } from './beacon'; 3 | 4 | export class Eddystone { 5 | 6 | async request(): Promise { 7 | const bluetooth: Bluetooth = navigator.bluetooth; 8 | if (!bluetooth) { 9 | return Promise.reject('Your browser does not support Web Bluetooth.'); 10 | } 11 | const requestOptions = { filters: [{ services: [constants.EDDYSTONE_CONFIG_SERVICE_UUID] }] }; 12 | const device = await bluetooth.requestDevice(requestOptions); 13 | return new Beacon(device); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "homepage": "https://zurfyx.github.io/eddystone-web-bluetooth", 5 | "private": true, 6 | "dependencies": { 7 | "eddystone-web-bluetooth": "^1.0.1", 8 | "gh-pages": "^1.0.0", 9 | "react": "^15.6.1", 10 | "react-dom": "^15.6.1" 11 | }, 12 | "devDependencies": { 13 | "react-scripts": "1.0.11" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test --env=jsdom", 19 | "eject": "react-scripts eject", 20 | "predeploy": "npm run build", 21 | "deploy": "gh-pages -d build" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/beacon/__tests__/url.test.ts: -------------------------------------------------------------------------------- 1 | import { decodeUrl, encodeUrl } from '../url'; 2 | 3 | const URL1 = 'https://example.com'; 4 | const URL2 = 'https://www.npmjs.com/features'; 5 | 6 | it('should decode the same value it has encoded', () => { 7 | const result1 = decodeUrl(encodeUrl(URL1)); 8 | expect(result1).toBe(URL1); 9 | 10 | const result2 = decodeUrl(encodeUrl(URL2)); 11 | expect(result2).toBe(URL2); 12 | }); 13 | 14 | it('the encoded value should be as short as possible (by using prefixed codes)', () => { 15 | const result1 = encodeUrl(URL1); 16 | expect(result1.byteLength).toBe(9); 17 | 18 | const result2 = encodeUrl(URL2); 19 | expect(result2.byteLength).toBe(15); 20 | }); 21 | -------------------------------------------------------------------------------- /scripts/demo-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # http://www.steveklabnik.com/automatically_update_github_pages_with_travis_example/ 3 | 4 | set -o errexit -o nounset 5 | 6 | if [ "$TRAVIS_BRANCH" != "master" ] 7 | then 8 | echo "This commit was made against the $TRAVIS_BRANCH and not the master! No deploy!" 9 | exit 0 10 | fi 11 | 12 | rev=$(git rev-parse --short HEAD) 13 | 14 | cd demo/build 15 | 16 | git init 17 | git config user.name "Travis CI" 18 | git config user.email "tra@vi.s" 19 | 20 | git remote add upstream "https://$GH_TOKEN@github.com/zurfyx/eddystone-web-bluetooth.git" 21 | git fetch upstream 22 | git reset upstream/gh-pages 23 | 24 | touch . 25 | 26 | git add -A . 27 | git commit -m "rebuild pages at ${rev}" --allow-empty 28 | git push -q upstream HEAD:gh-pages -------------------------------------------------------------------------------- /demo/src/logic/try-it-out.js: -------------------------------------------------------------------------------- 1 | import { Eddystone } from 'eddystone-web-bluetooth'; 2 | 3 | export default function tryItOut() { 4 | var eddystone = new Eddystone(); 5 | var beacon, service; 6 | eddystone.request() // Scan for Eddystone beacons. 7 | .then((newBeacon) => { 8 | beacon = newBeacon; 9 | return beacon.connect(); // Connect to the Beacon's GATT service. 10 | }) 11 | .then((newService) => { 12 | service = newService; 13 | return service.isLocked(); // Check if the beacon is locked. 14 | }) 15 | .then((isLocked) => { 16 | if (isLocked) { 17 | return Promise.reject('The beacon is locked. Can\'t write new URL'); 18 | } 19 | // Beacon's not locked. We can proceed with the recording of the new URL. 20 | // Keep in mind that the encoded URL must NOT be longer than 18 characters. 21 | return service.writeUrl('https://goo.gl/XXw2hi'); 22 | }) 23 | .then(() => { 24 | beacon.disconnect(); 25 | alert('Beacon has been written!'); 26 | }); 27 | } -------------------------------------------------------------------------------- /src/beacon/Beacon.ts: -------------------------------------------------------------------------------- 1 | import constants from '../constants'; 2 | import { BeaconService } from './BeaconService'; 3 | 4 | export class Beacon { 5 | 6 | constructor(public device: BluetoothDevice) {} 7 | 8 | onDisconnect(listener: (this: this, ev: Event) => any) { 9 | this.device.addEventListener('gattserverdisconnected', listener); 10 | } 11 | 12 | async connect(): Promise { 13 | if (!this.device.gatt) { 14 | return Promise.reject('Bluetooth device is probably not a beacon - it does not support GATT'); 15 | } 16 | const bluetoothGattServer = await this.device.gatt.connect(); 17 | const service = await bluetoothGattServer 18 | .getPrimaryService(constants.EDDYSTONE_CONFIG_SERVICE_UUID); 19 | return new BeaconService(service); 20 | } 21 | 22 | disconnect() : void { 23 | const gatt: BluetoothRemoteGATTServer | undefined = this.device.gatt; 24 | if (!(gatt && gatt.connected)) { 25 | console.warn('Ignored disconnection request. You are not connected!'); 26 | return; 27 | } 28 | gatt.disconnect(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "declaration": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "noEmitHelpers": true, 12 | "importHelpers": true, 13 | 14 | /* Strict Type-Checking Options */ 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "noImplicitThis": true, 19 | 20 | /* Additional Checks */ 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | /* Module Resolution Options */ 27 | "moduleResolution": "node", 28 | "baseUrl": "./src", 29 | 30 | /* Experimental Options */ 31 | "experimentalDecorators": true 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gerard Rovira Sánchez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 8 6 | 7 | install: 8 | - npm install 9 | - npm install --global coveralls@^2.0.0 10 | 11 | script: 12 | - npm run lint 13 | - npm run coverage 14 | 15 | - cd demo 16 | - npm install 17 | - npm test 18 | - npm run build 19 | - cd .. 20 | 21 | after_success: 22 | - cat ./coverage/lcov.info | coveralls 23 | 24 | deploy: 25 | provider: script 26 | script: scripts/demo-deploy.sh 27 | skip_cleanup: true 28 | 29 | env: 30 | global: 31 | - secure: "AJQepBd/HbTe3amOZH8nbONfDXThxOHwj+4/RXyt+cIrTAMEqropCNBZfFm9wm7lUowyaqplh0zI7lJs6nMElGRJ/p9AiwRDc3FhRGj53jqRlKerJO8uuuKOL2SJLgcmW2kb69xLFDSVhER8N1/QlRjGSYPbwxJr0KxCtu6u10Mv9nWAB1+hBgnpA+VQbZtDi3JN06/UHbLg0ZuQX5kP/eQgK6AC5UhMFE/MELHzsw3HbnGwcD8H9+PrNhhR94xipAkuWQ10YzFKX3s65dwidXhmdmpY1ZaM4pderiPEBjxUeKYMITX/p18FI71XFgKLcwTRUdYlRq+wSZINhPEjUaVr1UB0ik4zKMyfTOssfRfQvrYjvcXv80LJQ7DtcXKXMGwN+wBTomOpSECLVEQjbXvgv8pSeryRxG/IRfuiOQh/fdcNLpFy1Yyms68pkqeGGm4e+yBKkuOQ1qJYf0RxK1UjXalXnauquJUMYEntGQf9ulHViWcu2KNslgU0h7jqPZRd5XQ6plRPQeHmOJquzDV94MJTz4MWDFTX03cNBR7Myr9FtzM71MpEiSXc0nYDYzyaryCJoWB43g0YbNSeYkeY8tXromWyJqpDKHt5ysgqTiTwwEOz/W+J4QPXriXeYzDAKOXrmviLivwcDh0nmKomLPnvWzbZHT4oRbCsiBk=" -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | EDDYSTONE_CONFIG_SERVICE_UUID: 'a3c87500-8ed3-4bdf-8a39-a01bebede295', 3 | CAPABILITIES_CHARACTERISTIC_UUID: 'a3c87501-8ed3-4bdf-8a39-a01bebede295', 4 | ACTIVE_SLOT_CHARACTERISTIC_UUID: 'a3c87502-8ed3-4bdf-8a39-a01bebede295', 5 | ADVERTISING_INTERVAL_CHARACTERISTIC_UUID: 'a3c87503-8ed3-4bdf-8a39-a01bebede295', 6 | RADIO_TX_POWER_CHARACTERISTIC_UUID: 'a3c87504-8ed3-4bdf-8a39-a01bebede295', 7 | ADVANCED_ADVERTISED_TX_POWER_CHARACTERISTIC_UUID: 'a3c87505-8ed3-4bdf-8a39-a01bebede295', 8 | EDDYSTONE_LOCK_STATE_CHARACTERISTIC_UUID: 'a3c87506-8ed3-4bdf-8a39-a01bebede295', 9 | EDDYSTONE_UNLOCK_CHARACTERISTIC_UUID: 'a3c87507-8ed3-4bdf-8a39-a01bebede295', 10 | PUBLIC_ECDH_KEY_CHARACTERISTIC_UUID: 'a3c87508-8ed3-4bdf-8a39-a01bebede295', 11 | EID_IDENTITY_KEY_CHARACTERISTIC_UUID: 'a3c87509-8ed3-4bdf-8a39-a01bebede295', 12 | ADV_SLOT_DATA_CHARACTERISTIC_UUID: 'a3c8750a-8ed3-4bdf-8a39-a01bebede295', 13 | ADVANCED_FACTORY_RESET_CHARACTERISTIC_UUID: 'a3c8750b-8ed3-4bdf-8a39-a01bebede295', 14 | ADVANCED_REMAIN_CONNECTABLE_CHARACTERISTIC_UUID: 'a3c8750c-8ed3-4bdf-8a39-a01bebede295', 15 | }); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eddystone-web-bluetooth", 3 | "description": "Eddystone Web Bluetooth client (works with Physical Web).", 4 | "author": { 5 | "name": "Gerard Rovira Sánchez", 6 | "email": "zurfyx@gmail.com", 7 | "url": "zurfyx.com" 8 | }, 9 | "version": "1.0.1", 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "scripts": { 13 | "prebuild": "npm run build:clean", 14 | "build": "tsc", 15 | "build:clean": "rimraf dist", 16 | "build:watch": "tsc --watch", 17 | "prepublishOnly": "npm run build", 18 | "postpublish": "npm run build:clean", 19 | "lint": "tslint './src/**/*.{ts,tsx}' --project ./tsconfig.json --type-check", 20 | "pretest": "npm run build", 21 | "test": "jest", 22 | "coverage": "jest --coverage", 23 | "check": "npm-run-all test lint build:clean" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/zurfyx/eddystone-web-bluetooth.git" 28 | }, 29 | "keywords": [ 30 | "eddystone", 31 | "web", 32 | "bluetooth", 33 | "client", 34 | "config", 35 | "url", 36 | "typescript" 37 | ], 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/zurfyx/eddystone-web-bluetooth/issues" 41 | }, 42 | "homepage": "https://github.com/zurfyx/eddystone-web-bluetooth#readme", 43 | "dependencies": { 44 | "@types/node": "^8.0.0", 45 | "@types/text-encoding": "0.0.30", 46 | "@types/web-bluetooth": "0.0.2", 47 | "text-encoding": "^0.6.4", 48 | "tslib": "^1.7.1" 49 | }, 50 | "devDependencies": { 51 | "@types/jest": "^20.0.0", 52 | "jest": "^20.0.4", 53 | "npm-run-all": "^4.0.2", 54 | "rimraf": "^2.6.1", 55 | "ts-jest": "^20.0.6", 56 | "tslint": "^5.4.3", 57 | "tslint-config-airbnb": "^5.3.0", 58 | "typescript": "^2.3.4" 59 | }, 60 | "files": [ 61 | "dist", 62 | "src" 63 | ], 64 | "publishConfig": { 65 | "registry": "https://registry.npmjs.org/" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/beacon/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/google/eddystone/tree/master/eddystone-url 3 | */ 4 | 5 | import { TextEncoder } from 'text-encoding'; 6 | 7 | type HexTypes = { [code: number]: string }; 8 | 9 | const URL_SCHEMES: HexTypes = { 10 | 0x00: 'http://www.', 11 | 0x01: 'https://www.', 12 | 0x02: 'http://', 13 | 0x03: 'https://', 14 | }; 15 | 16 | const URL_CODES: HexTypes = { 17 | 0x00: '.com/', 18 | 0x01: '.org/', 19 | 0x02: '.edu/', 20 | 0x03: '.net/', 21 | 0x04: '.info/', 22 | 0x05: '.biz/', 23 | 0x06: '.gov/', 24 | 0x07: '.com', 25 | 0x08: '.org', 26 | 0x09: '.edu', 27 | 0x0a: '.net', 28 | 0x0b: '.info', 29 | 0x0c: '.biz', 30 | 0x0d: '.gov', 31 | }; 32 | 33 | function decodeUrl(raw: DataView): string { 34 | const scheme: string = URL_SCHEMES[raw.getUint8(0)]; 35 | const url = Array.from(Array(raw.byteLength).keys()) 36 | .slice(1) 37 | .map((bytePos) => { 38 | const byteVal: number = raw.getUint8(bytePos); 39 | return URL_CODES[byteVal] || String.fromCharCode(byteVal); 40 | }) 41 | .join(''); 42 | return `${scheme}${url}`; 43 | } 44 | 45 | function encodeUrl(val: string): DataView { 46 | const encoder = new TextEncoder('utf-8'); 47 | const encoded: number[] = []; 48 | 49 | for (let i = 0; i < val.length; i += 1) { 50 | // Try shorten the result as much as possible by using the above references. 51 | const shortEncoded = shortEncode(val.slice(i)); 52 | if (shortEncoded) { 53 | encoded.push(shortEncoded.code); 54 | i += shortEncoded.jump - 1; 55 | continue; 56 | } 57 | // If it can't be shortened, simply encode the character. 58 | encoded.push(encoder.encode(val[i])[0]); 59 | } 60 | 61 | const buffer = new ArrayBuffer(encoded.length); 62 | const raw = new DataView(buffer); 63 | encoded.forEach((character, i) => raw.setUint8(i, character)); 64 | return raw; 65 | } 66 | 67 | function shortEncode(val: string): { code: number, jump: number } | undefined { 68 | return shortEncodeWithDict(val, URL_SCHEMES) 69 | || shortEncodeWithDict(val, URL_CODES); 70 | } 71 | 72 | function shortEncodeWithDict(val: string, hexTypes: HexTypes) 73 | : { code: number, jump: number } | undefined { 74 | const matching: string[] = Object.keys(hexTypes).filter((codeIndex: string) => { 75 | const code = Number(codeIndex); 76 | return val.startsWith(hexTypes[code]); 77 | }); 78 | if (matching.length === 0) { 79 | return undefined; 80 | } 81 | 82 | matching.sort(); 83 | const bestMatch = Number(matching[0]); 84 | return { 85 | code: bestMatch, 86 | jump: hexTypes[bestMatch].length, 87 | }; 88 | } 89 | 90 | export { 91 | encodeUrl, 92 | decodeUrl, 93 | }; 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eddystone Web Bluetooth 2 | 3 | > Web Bluetooth Eddystone made easier 4 | 5 | [![Build Status](https://travis-ci.org/zurfyx/eddystone-web-bluetooth.svg?branch=master)](https://travis-ci.org/zurfyx/eddystone-web-bluetooth) 6 | [![David](https://david-dm.org/zurfyx/eddystone-web-bluetooth.svg)](https://david-dm.org/zurfyx/eddystone-web-bluetooth) 7 | [![David](https://david-dm.org/zurfyx/eddystone-web-bluetooth/dev-status.svg)](https://david-dm.org/zurfyx/eddystone-web-bluetooth#info=devDependencies) 8 | [![Code Climate](https://codeclimate.com/github/zurfyx/eddystone-web-bluetooth/badges/gpa.svg)](https://codeclimate.com/github/zurfyx/eddystone-web-bluetooth) 9 | 10 |

11 |
12 | Getting started source-code using Physical Web beacons 13 |

14 | 15 | ## Features 16 | 17 | - [x] Scan Eddystone beacons 18 | - [x] Connect / Disconnect 19 | - [x] Monitor connection status 20 | - [ ] Read Capabilities 21 | - [ ] Read / Write Active Slot 22 | - [x] Read / Write Advertising Interval 23 | - [x] Read / Write Radio Tx Power 24 | - [x] Read / Write Advertised Tx Power 25 | - [x] Read Lock State 26 | - [ ] Write Lock State 27 | - [ ] Read / Write Unlock 28 | - [ ] Read Public ECDH Key 29 | - [ ] Read EID Identity Key 30 | - [x] Read / Write ADV Slot Data 31 | - [x] Write Factory reset 32 | - [ ] Read / Write Remain Connectable 33 | 34 | ## Getting started 35 | 36 | ``` 37 | npm install --save eddystone-web-bluetooth 38 | ``` 39 | 40 | ```javascript 41 | var eddystone = new Eddystone(); 42 | var beacon, service; 43 | eddystone.request() // Scan for Eddystone beacons. 44 | .then((newBeacon) => { 45 | beacon = newBeacon; 46 | return beacon.connect(); // Connect to the Beacon's GATT service. 47 | }) 48 | .then((newService) => { 49 | service = newService; 50 | return service.isLocked(); // Check if the beacon is locked. 51 | }) 52 | .then((isLocked) => { 53 | if (isLocked) { 54 | return Promise.reject('The beacon is locked. Can\'t write new URL'); 55 | } 56 | // Beacon's not locked. We can proceed with the recording of the new URL. 57 | // Keep in mind that the encoded URL must NOT be longer than 18 characters. 58 | return service.writeUrl('https://www.google.com'); 59 | }) 60 | .then(() => { 61 | beacon.disconnect(); 62 | alert('OK!'); 63 | }); 64 | ``` 65 | 66 | See the rest of the services [here](https://github.com/zurfyx/eddystone-web-bluetooth/blob/master/src/beacon/BeaconService.ts). 67 | 68 | ## Development 69 | 70 | Eddystone Web Bluetooth implementation is based on the official specifications: 71 | 72 | [https://github.com/google/eddystone/tree/master/configuration-service](https://github.com/google/eddystone/tree/master/configuration-service) 73 | 74 | ## Contributions 75 | 76 | Contributions are very welcome. 77 | 78 | ## License 79 | 80 | MIT © [Gerard Rovira Sánchez](//zurfyx.com) 81 | 82 | ---- 83 | 84 | Special thanks to @beaufortfrancois for providing https://github.com/beaufortfrancois/sandbox/blob/gh-pages/web-bluetooth/eddystone-url-config/app.js magnificent example source code. 85 | -------------------------------------------------------------------------------- /demo/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (!isLocalhost) { 36 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl); 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/beacon/BeaconService.ts: -------------------------------------------------------------------------------- 1 | import constants from '../constants'; 2 | import { LOCK_VALUES, DATA_VALUES } from './enums'; 3 | import { decodeUrl, encodeUrl } from './url'; 4 | 5 | export class BeaconService { 6 | 7 | constructor(public service: BluetoothRemoteGATTService) {} 8 | 9 | private async readCharacteristic(uuid: string): Promise { 10 | const characteristic = await this.service.getCharacteristic(uuid); 11 | return characteristic.readValue(); 12 | } 13 | 14 | private async writeCharacteristic(uuid: string, value: BufferSource): Promise { 15 | const characteristic = await this.service.getCharacteristic(uuid); 16 | return characteristic.writeValue(value); 17 | } 18 | 19 | /** 20 | * Interval. 21 | */ 22 | async readInterval(): Promise { 23 | const uuid = constants.ADVERTISING_INTERVAL_CHARACTERISTIC_UUID; 24 | const rawVal = await this.readCharacteristic(uuid); 25 | const val = rawVal.getUint16(0, false); // Big-Endian. 26 | return val; 27 | } 28 | 29 | async writeInterval(ms: number): Promise { 30 | const uuid = constants.ADVERTISING_INTERVAL_CHARACTERISTIC_UUID; 31 | const rawMs = new DataView(new ArrayBuffer(2)); // 2 * 8bit 32 | rawMs.setUint16(0, ms, false); 33 | return this.writeCharacteristic(uuid, rawMs); 34 | } 35 | 36 | /** 37 | * LOCK 38 | */ 39 | 40 | async isLocked(): Promise { 41 | const uuid = constants.EDDYSTONE_LOCK_STATE_CHARACTERISTIC_UUID; 42 | const rawVal = await this.readCharacteristic(uuid); 43 | const val = rawVal.getUint8(0); 44 | return val === LOCK_VALUES.LOCKED; 45 | } 46 | 47 | /** 48 | * RADIO 49 | */ 50 | 51 | async readRadioTxPower(): Promise { 52 | const uuid = constants.RADIO_TX_POWER_CHARACTERISTIC_UUID; 53 | const rawVal = await this.readCharacteristic(uuid); 54 | const val = rawVal.getInt8(0); 55 | return val; 56 | } 57 | 58 | /** 59 | * Writes Radio Tx Power. 60 | * @param dbm Tx power. Values should range between -100 and +20 dBm. 61 | * If a power is selected that is not supported by the radio, the beacon should select 62 | * the next highest power supported, or else the maximum power. 63 | * @see https://github.com/google/eddystone/blob/master/eddystone-url/README.md#tx-power-level 64 | */ 65 | async writeRadioTxPower(dbm: number): Promise { 66 | const uuid = constants.RADIO_TX_POWER_CHARACTERISTIC_UUID; 67 | const dbmByte = new Int8Array([dbm]); 68 | return this.writeCharacteristic(uuid, dbmByte); 69 | } 70 | 71 | async readAdvertisedTxPower(): Promise { 72 | const uuid = constants.ADVANCED_ADVERTISED_TX_POWER_CHARACTERISTIC_UUID; 73 | const rawVal = await this.readCharacteristic(uuid); 74 | const val = rawVal.getInt8(0); 75 | return val; 76 | } 77 | 78 | async writeAdvertisedTxPower(dbm: number): Promise { 79 | const uuid = constants.ADVANCED_ADVERTISED_TX_POWER_CHARACTERISTIC_UUID; 80 | const dbmByte = new Int8Array([dbm]); 81 | return this.writeCharacteristic(uuid, dbmByte); 82 | } 83 | 84 | /** 85 | * URL 86 | */ 87 | async readUrl(): Promise { 88 | const uuid = constants.ADV_SLOT_DATA_CHARACTERISTIC_UUID; 89 | const rawVal = await this.readCharacteristic(uuid); 90 | const type = rawVal.getUint8(0); 91 | if (type !== DATA_VALUES.URL) { 92 | return Promise.reject('Advertised data is not a URL'); 93 | } 94 | const rawUrl = new DataView(rawVal.buffer, 2); // w/o type. 95 | return decodeUrl(rawUrl); 96 | } 97 | 98 | async writeUrl(url: string): Promise { 99 | const uuid = constants.ADV_SLOT_DATA_CHARACTERISTIC_UUID; 100 | const raw = encodeUrl(url); 101 | if (raw.byteLength > 18) { 102 | return Promise.reject('Encoded URL is longer than 18 bytes'); 103 | } 104 | const urlBytes = Array.from(Array(raw.byteLength).keys()).map((bytePos) => { 105 | return raw.getUint8(bytePos); 106 | }); 107 | const fullBytes = new Uint8Array([DATA_VALUES.URL, ...urlBytes]); // With URL type preceding. 108 | return this.writeCharacteristic(uuid, fullBytes); 109 | } 110 | 111 | async clearUrl(): Promise { 112 | const uuid = constants.ADV_SLOT_DATA_CHARACTERISTIC_UUID; 113 | const clearByte = new Uint8Array([0x00]); 114 | return this.writeCharacteristic(uuid, clearByte); 115 | } 116 | 117 | /** 118 | * MISC 119 | */ 120 | 121 | async factoryReset(): Promise { 122 | const uuid = constants.ADVANCED_FACTORY_RESET_CHARACTERISTIC_UUID; 123 | const factoryResetByte = new Uint8Array([0x0B]); 124 | return this.writeCharacteristic(uuid, factoryResetByte); 125 | } 126 | } 127 | --------------------------------------------------------------------------------