├── .gitignore ├── Bluez.js ├── BluezAdapter.js ├── BluezAgent.js ├── BluezCharacteristic.js ├── BluezDBus.js ├── BluezDevice.js ├── BluezService.js ├── LICENSE ├── README.md ├── app.js ├── config.json ├── package.json ├── utils.js └── utils └── getGattAssignedNumbers.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | tags 4 | 5 | node_modules 6 | 7 | resources/characteristics.json 8 | resources/services.json 9 | -------------------------------------------------------------------------------- /Bluez.js: -------------------------------------------------------------------------------- 1 | const events = require('events'); 2 | const debug = require('debug')('Bluez'); 3 | const bluezDBus = require('./BluezDBus'); 4 | const BluezAdapter = require('./BluezAdapter'); 5 | 6 | var bluez = new events.EventEmitter(); 7 | 8 | function interfaceAdded(path, objects) { 9 | /* We're only interested in Adapters */ 10 | if (objects['org.bluez.Adapter1'] === undefined) 11 | return; 12 | 13 | debug('An adapter was added: ' + path); 14 | var adapter = new BluezAdapter(path); 15 | adapter.init((err) => { 16 | if (err) { 17 | debug('Failed initializing new adapter ' + path + ': ' + err); 18 | return; 19 | } 20 | bluez.emit('adapter', adapter); 21 | }); 22 | } 23 | 24 | bluezDBus.getAllObjects(function(err, objects) { 25 | if (err) 26 | throw 'Failed getting all objects'; 27 | 28 | Object.keys(objects).forEach((key) => interfaceAdded(key, objects[key])); 29 | }); 30 | 31 | bluezDBus.onInterfaces(interfaceAdded); 32 | 33 | module.exports = bluez; 34 | -------------------------------------------------------------------------------- /BluezAdapter.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const util = require('util'); 3 | const _ = require('underscore'); 4 | const bluezDBus = require('./BluezDBus'); 5 | const BluezDevice = require('./BluezDevice'); 6 | 7 | module.exports = BluezAdapter; 8 | 9 | function BluezAdapter(path){ 10 | this.debug = require('debug')('Bluez:Adapter:' + path); 11 | this.path = path; 12 | this.debug('New adapter'); 13 | } 14 | 15 | util.inherits(BluezAdapter, EventEmitter); 16 | 17 | BluezAdapter.prototype.init = function(cb) { 18 | /* Get Adapter1 interface */ 19 | bluezDBus.getInterface(this.path, 'org.bluez.Adapter1', (err, iface) => { 20 | if (err) { 21 | this.debug('Failed getting Adapter1 interface:' + err); 22 | if (cb) cb(err); 23 | return; 24 | } 25 | 26 | /* Save this interface */ 27 | this.iface = iface; 28 | 29 | /* Get Protperties interface */ 30 | this.props = bluezDBus.getProperties(this.path, 'org.bluez.Adapter1', 31 | this._devicePropertiesUpdate.bind(this), /* Property changed */ 32 | (err) => { /* All propertires were resolved */ 33 | if (err) { 34 | this.debug('Failed getting all properties:' + err); 35 | if (cb) cb(err); 36 | return; 37 | } 38 | 39 | /* The new adapter is ready, wait 2 seconds until it settles down */ 40 | if (cb) setTimeout(cb, 2000); 41 | }); 42 | }); 43 | 44 | /* Save the event handler so we can remove it later */ 45 | this.ifaceEvents = bluezDBus.onInterfaces(this._interfaceAdded.bind(this), 46 | this._interfaceRemoved.bind(this)); 47 | } 48 | 49 | BluezAdapter.prototype.toString = function() { 50 | return '[Object BluezAdapter (' + this.path + ')]'; 51 | } 52 | 53 | BluezAdapter.prototype._isOwnDevice = function(path, objects) { 54 | /* If we're not powered, we don't care about cached devices yet */ 55 | if (!this.Powered) 56 | return false; 57 | 58 | /* Make sure this device was discovered using this adapter */ 59 | if (!path.startsWith(this.path)) 60 | return false; 61 | 62 | /* We're only interested in devices */ 63 | if (objects['org.bluez.Device1'] === undefined) 64 | return false; 65 | 66 | return true; 67 | } 68 | 69 | BluezAdapter.prototype._onPowerChanged = function(powered) { 70 | if (!powered) 71 | return; 72 | 73 | /* The adapter was powered on, now let's check if there are any relevant 74 | * objects (devices) */ 75 | bluezDBus.getAllObjects((err, objects) => { 76 | if (err) 77 | throw 'Failed getting all objects'; 78 | 79 | Object.keys(objects).forEach((key) => { 80 | this._interfaceAdded(key, objects[key]); 81 | }); 82 | }); 83 | } 84 | 85 | BluezAdapter.prototype._devicePropertiesUpdate = function(key, value) { 86 | if (_.isEqual(this[key], value)) 87 | return; 88 | 89 | this.debug(key + ' changed from ' + this[key] + ' to ' + value); 90 | this[key] = value; 91 | 92 | if (key == 'Powered') 93 | this._onPowerChanged(value); 94 | 95 | this.emit('propertyChanged', key, value); 96 | } 97 | 98 | BluezAdapter.prototype._interfaceAdded = function(path, objects) { 99 | /* We're only interested in devices under this adapter */ 100 | if (!this._isOwnDevice(path, objects)) 101 | return; 102 | 103 | this.debug('A device was added: ' + path); 104 | var device = new BluezDevice(path); 105 | device.init((err) => { 106 | if (err) { 107 | this.debug('Failed initializing new device ' + path + ': ' + err); 108 | return; 109 | } 110 | this.emit('device', device); 111 | }); 112 | } 113 | 114 | BluezAdapter.prototype._interfaceRemoved = function(path, objects) { 115 | /* We're only interested in ourselves */ 116 | if (this.path !== path) 117 | return; 118 | 119 | this.emit('removed'); 120 | this.removeAllListeners(); 121 | this.props.close(); 122 | this.ifaceEvents.close(); 123 | } 124 | 125 | BluezAdapter.prototype.powerOn = function(cb) { 126 | this.iface.setProperty('Powered', true, (err) => { 127 | if (err) 128 | this.debug('Failed powering on:' + err); 129 | else 130 | this.debug('Powered on'); 131 | 132 | if (cb) cb(err); 133 | }); 134 | } 135 | 136 | BluezAdapter.prototype.powerOff = function(cb) { 137 | this.iface.setProperty('Powered', false, (err) => { 138 | if (err) 139 | this.debug('Failed powering off:' + err); 140 | else 141 | this.debug('Powered off'); 142 | 143 | if (cb) cb(err); 144 | }); 145 | } 146 | 147 | BluezAdapter.prototype.discoveryStart = function(cb) { 148 | this.iface.StartDiscovery['finish'] = () => { 149 | this.debug('Started discovering'); 150 | if (cb) cb(); 151 | }; 152 | this.iface.StartDiscovery['error'] = (err) => { 153 | this.debug('Failed starting discovery: ' + err); 154 | if (cb) cb(err); 155 | }; 156 | this.iface.StartDiscovery(); 157 | } 158 | 159 | BluezAdapter.prototype.discoveryStop = function(cb) { 160 | this.iface.StopDiscovery['finish'] = () => { 161 | this.debug('Stopped discovering'); 162 | if (cb) cb(); 163 | }; 164 | this.iface.StopDiscovery['error'] = (err) => { 165 | this.debug('Failed stopping discovery: ' + err); 166 | if (cb) cb(err); 167 | }; 168 | this.iface.StopDiscovery(); 169 | } 170 | 171 | BluezAdapter.prototype.discoveryFilterSet = function(filter, cb) { 172 | this.iface.SetDiscoveryFilter['finish'] = () => { 173 | this.debug('Set discovery filter:', filter); 174 | if (cb) cb(); 175 | }; 176 | this.iface.SetDiscoveryFilter['error'] = (err) => { 177 | this.debug('Failed setting discovery filter: ' + err); 178 | if (cb) cb(err); 179 | }; 180 | this.iface.SetDiscoveryFilter(filter); 181 | } 182 | 183 | BluezAdapter.prototype.removeDevice = function(device, cb) { 184 | this.iface.RemoveDevice['finish'] = () => { 185 | this.debug('Removed device'); 186 | if (cb) cb(); 187 | }; 188 | this.iface.RemoveDevice['error'] = (err) => { 189 | this.debug('Failed removing device: ' + err); 190 | if (cb) cb(err); 191 | }; 192 | this.iface.RemoveDevice(device.path); 193 | } 194 | -------------------------------------------------------------------------------- /BluezAgent.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const util = require('util'); 3 | const DBus = require('dbus'); 4 | const dbus = new DBus(); 5 | const bluezDBus = require('./BluezDBus'); 6 | 7 | module.exports = BluezAgent; 8 | 9 | function BluezAgent(serviceName, path, onRegistered) { 10 | this.debug = require('debug')('Bluez:Agent:' + path); 11 | this.path = path; 12 | this.debug('New agent'); 13 | 14 | /* Create service based on org.bluez.Agent1 definition. We only support BLE 15 | * devices at the moment and can only provide a passkey */ 16 | var service = dbus.registerService('system', serviceName); 17 | var obj = service.createObject(path); 18 | var iface = obj.createInterface('org.bluez.Agent1'); 19 | 20 | iface.addMethod('Release', {}, function(callback) { 21 | this.debug('Release()'); 22 | callback(); 23 | }); 24 | 25 | iface.addMethod('RequestPasskey', { in: [{ type: 'o' }], out: { type: 'u' } }, 26 | (device, callback) => { 27 | this.debug('RequestPasskey(' + device + ')'); 28 | var passkey = null; 29 | if (this.passkeyHandler !== undefined) 30 | passkey = this.passkeyHandler(device); 31 | 32 | this.debug('Providing passkey ' + passkey + ' for ' + device); 33 | callback(passkey ? passkey : new Error('org.bluez.Error.Canceled')); 34 | } 35 | ); 36 | 37 | iface.addMethod('Cancel', {}, function(callback) { 38 | debug('Cancel()'); 39 | callback(); 40 | }); 41 | 42 | iface.update(); 43 | } 44 | 45 | util.inherits(BluezAgent, EventEmitter); 46 | 47 | BluezAgent.prototype.register = function(cb) { 48 | /* Register agent with Bluez */ 49 | bluezDBus.getInterface('/org/bluez', 'org.bluez.AgentManager1', (err, iface) => 50 | { 51 | if (err) { 52 | this.debug('Failed getting AgentManager1 interface:' + err); 53 | return; 54 | } 55 | 56 | /* Save this interface */ 57 | this.iface = iface; 58 | 59 | iface.RegisterAgent['finish'] = () => { 60 | this.debug('Registered agent'); 61 | if (cb) cb(); 62 | }; 63 | iface.RegisterAgent['error'] = (err) => { 64 | this.debug('Failed registered agent: ' + err); 65 | if (cb) cb(err); 66 | }; 67 | iface.RegisterAgent(this.path, 'KeyboardOnly'); 68 | }); 69 | } 70 | 71 | BluezAgent.prototype.setDefault = function(cb) { 72 | if (!this.iface) { 73 | if (cb) cb('Agent was not registered yet'); 74 | return; 75 | } 76 | 77 | this.iface.RequestDefaultAgent['finish'] = () => { 78 | this.debug('Set agent as default'); 79 | if (cb) cb(); 80 | }; 81 | this.iface.RequestDefaultAgent['error'] = (err) => { 82 | this.debug('Failed settings as default agent: ' + err); 83 | if (cb) cb(err); 84 | }; 85 | this.iface.RequestDefaultAgent(this.path); 86 | } 87 | 88 | BluezAgent.prototype.setPasskeyHandler = function(func) { 89 | this.passkeyHandler = func; 90 | } 91 | -------------------------------------------------------------------------------- /BluezCharacteristic.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const util = require('util'); 3 | const _ = require('underscore'); 4 | const bluezDBus = require('./BluezDBus'); 5 | 6 | module.exports = BluezCharacteristic; 7 | 8 | function BluezCharacteristic(path) { 9 | this.debug = require('debug')('Bluez:Characteristic:' + path); 10 | this.path = path; 11 | this.debug('New characteristic'); 12 | } 13 | 14 | util.inherits(BluezCharacteristic, EventEmitter); 15 | 16 | BluezCharacteristic.prototype.init = function(cb) { 17 | /* Get GattCharacteristic1 interface */ 18 | bluezDBus.getInterface(this.path, 'org.bluez.GattCharacteristic1', 19 | (err, iface) => { 20 | if (err) { 21 | this.debug('Failed getting GattCharacteristic1 interface:' + err); 22 | if (cb) cb(err); 23 | return; 24 | } 25 | 26 | /* Save this interface */ 27 | this.iface = iface; 28 | 29 | /* Get Protperties interface */ 30 | this.props = bluezDBus.getProperties(this.path, 31 | 'org.bluez.GattCharacteristic1', 32 | this._characteristicPropertiesUpdate.bind(this), /* Property changed */ 33 | (err) => { /* All propertires were resolved */ 34 | if (err) { 35 | this.debug('Failed getting all properties:' + err); 36 | if (cb) cb(err); 37 | return; 38 | } 39 | 40 | /* The new characteristic is ready */ 41 | if (cb) cb(); 42 | }); 43 | } 44 | ); 45 | 46 | /* Save the event handler so we can remove it later */ 47 | this.ifaceEvents = bluezDBus.onInterfaces(null, 48 | this._interfaceRemoved.bind(this)); 49 | } 50 | 51 | BluezCharacteristic.prototype.toString = function() { 52 | return '[Object BluezCharacteristic (' + this.path + ')]'; 53 | } 54 | 55 | BluezCharacteristic.prototype._characteristicPropertiesUpdate = function(key, value) { 56 | if (_.isEqual(this[key], value)) 57 | return; 58 | 59 | this.debug(key + ' changed from ' + this[key] + ' to ' + value); 60 | this[key] = value; 61 | this.emit('propertyChanged', key, value); 62 | } 63 | 64 | BluezCharacteristic.prototype._interfaceRemoved = function(path, objects) { 65 | /* We're only interested in ourselves */ 66 | if (this.path !== path) 67 | return; 68 | 69 | this.debug('Removed characteristic'); 70 | this.emit('removed'); 71 | this.removeAllListeners(); 72 | this.props.close(); 73 | this.ifaceEvents.close(); 74 | } 75 | 76 | BluezCharacteristic.prototype.Read = function(cb) { 77 | this.iface.ReadValue['finish'] = (value) => { 78 | this.debug('Read: ' + value); 79 | if (cb) cb(null, value); 80 | }; 81 | this.iface.ReadValue['error'] = (err) => { 82 | this.debug('Failed reading: ' + err); 83 | if (cb) cb(err); 84 | }; 85 | this.iface.ReadValue({}); 86 | } 87 | 88 | BluezCharacteristic.prototype.Write = function(value, cb) { 89 | this.iface.WriteValue['finish'] = () => { 90 | this.debug('Wrote value: ' + value); 91 | if (cb) cb(); 92 | }; 93 | this.iface.WriteValue['error'] = (err) => { 94 | this.debug('Failed writing: ' + err); 95 | if (cb) cb(err); 96 | }; 97 | this.iface.WriteValue(value, {}); 98 | } 99 | 100 | BluezCharacteristic.prototype.NotifyStart = function(cb) { 101 | this.iface.StartNotify['finish'] = () => { 102 | this.debug('Listening on notifications'); 103 | if (cb) cb(); 104 | }; 105 | this.iface.StartNotify['error'] = (err) => { 106 | this.debug('Failed listening on notifications: ' + err); 107 | if (cb) cb(err); 108 | }; 109 | this.iface.StartNotify(); 110 | } 111 | 112 | BluezCharacteristic.prototype.NotifyStop = function(cb) { 113 | this.iface.StopNotify['finish'] = () => { 114 | this.debug('Stopped listening on notifications'); 115 | if (cb) cb(); 116 | }; 117 | this.iface.StopNotify['error'] = (err) => { 118 | this.debug('Failed stopping listening on notifications: ' + err); 119 | if (cb) cb(err); 120 | }; 121 | this.iface.StopNotify(); 122 | } 123 | -------------------------------------------------------------------------------- /BluezDBus.js: -------------------------------------------------------------------------------- 1 | const events = require('events') 2 | const debug = require('debug')('BluezDBus'); 3 | const DBus = require('dbus'); 4 | const dbus = new DBus(); 5 | const bus = dbus.getBus('system'); 6 | 7 | var bluezDBus = new events.EventEmitter(); 8 | 9 | var objectManagerInterface = null; 10 | 11 | bluezDBus.getInterface = function(path, iface, cb) { 12 | bus.getInterface('org.bluez', path, iface, cb); 13 | } 14 | 15 | function _notifyPropertyChange(func, properties) { 16 | if (!func) 17 | return; 18 | 19 | Object.keys(properties).forEach((key) => func(key, properties[key])); 20 | } 21 | 22 | bluezDBus.setMaxListeners(Infinity); 23 | bluezDBus.getProperties = function(path, interfaceName, propertyChangedCb, resolvedCb) { 24 | var ctx = { 25 | close: function() { 26 | this.iface.removeListener('PropertiesChanged', this.cb); 27 | } 28 | } 29 | bluezDBus.getInterface(path, 'org.freedesktop.DBus.Properties', 30 | (err, iface) => { 31 | if (err) { 32 | debug('Failed getting properties for ' + path + ': ' + err); 33 | return; 34 | } 35 | 36 | /* Save interface an callback so we can unregister later */ 37 | ctx.iface = iface; 38 | ctx.cb = (_interfaceName, properties) => { 39 | if (_interfaceName !== interfaceName) 40 | return; 41 | 42 | _notifyPropertyChange(propertyChangedCb, properties); 43 | }; 44 | /* Listen on changes */ 45 | iface.on('PropertiesChanged', ctx.cb); 46 | 47 | /* Get all properties */ 48 | iface.GetAll['finish'] = (properties) => { 49 | _notifyPropertyChange(propertyChangedCb, properties); 50 | 51 | if (resolvedCb) 52 | resolvedCb(); 53 | }; 54 | iface.GetAll['error'] = resolvedCb; 55 | iface.GetAll(interfaceName); 56 | } 57 | ); 58 | 59 | return ctx; 60 | } 61 | 62 | bluezDBus.getAllObjects = function(cb) { 63 | if (objectManagerInterface === null) { 64 | /* ObjectManager isn't available yet, try again later */ 65 | setTimeout(() => bluezDBus.getAllObjects(cb), 100); 66 | return; 67 | } 68 | 69 | /* Set callback functions */ 70 | objectManagerInterface.GetManagedObjects['finish'] = (objects) => { 71 | cb(undefined, objects); 72 | }; 73 | objectManagerInterface.GetManagedObjects['error'] = cb; 74 | 75 | /* Initiate call */ 76 | objectManagerInterface.GetManagedObjects(); 77 | } 78 | 79 | bluezDBus.onInterfaces = function(interfaceAddedCb, interfaceRemovedCb) { 80 | if (interfaceAddedCb) this.on('interfaceAdded', interfaceAddedCb); 81 | if (interfaceRemovedCb) this.on('interfaceRemoved', interfaceRemovedCb); 82 | 83 | return { 84 | _addedCb: interfaceAddedCb, 85 | _removedCb: interfaceRemovedCb, 86 | _bluezDBus: this, 87 | close: function() { 88 | if (this._addedCb) 89 | this._bluezDBus.removeListener('interfaceAdded', this._addedCb); 90 | if (this._removedCb) 91 | this._bluezDBus.removeListener('interfaceRemoved', this._removedCb); 92 | } 93 | }; 94 | } 95 | 96 | /* Get ObjectManager and register events */ 97 | bluezDBus.getInterface('/', 'org.freedesktop.DBus.ObjectManager', 98 | (err, iface) => { 99 | if (err) 100 | throw 'Failed getting the ObjectManager interface: ' + err; 101 | 102 | /* Save interface */ 103 | objectManagerInterface = iface; 104 | 105 | /* Listen on object changes and notify */ 106 | objectManagerInterface.on('InterfacesAdded', (path, objects) => { 107 | bluezDBus.emit('interfaceAdded', path, objects); 108 | }); 109 | objectManagerInterface.on('InterfacesRemoved', (path, objects) => { 110 | bluezDBus.emit('interfaceRemoved', path, objects); 111 | }); 112 | } 113 | ); 114 | 115 | module.exports = bluezDBus; 116 | -------------------------------------------------------------------------------- /BluezDevice.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const util = require('util'); 3 | const _ = require('underscore'); 4 | const bluezDBus = require('./BluezDBus'); 5 | const BluezService = require('./BluezService'); 6 | 7 | module.exports = BluezDevice; 8 | 9 | function BluezDevice(path) { 10 | this.debug = require('debug')('Bluez:Device:' + path); 11 | this.path = path; 12 | this.debug('New device'); 13 | } 14 | 15 | util.inherits(BluezDevice, EventEmitter); 16 | 17 | BluezDevice.prototype.init = function(cb) { 18 | /* Get Device1 interface */ 19 | bluezDBus.getInterface(this.path, 'org.bluez.Device1', (err, iface) => { 20 | if (err) { 21 | this.debug('Failed getting Device1 interface:' + err); 22 | if (cb) cb(err); 23 | return; 24 | } 25 | 26 | /* Save this interface */ 27 | this.iface = iface; 28 | 29 | /* Get Protperties interface */ 30 | this.props = bluezDBus.getProperties(this.path, 'org.bluez.Device1', 31 | this._devicePropertiesUpdate.bind(this), /* Property changed */ 32 | (err) => { /* All propertires were resolved */ 33 | if (err) { 34 | this.debug('Failed getting all properties:' + err); 35 | if (cb) cb(err); 36 | return; 37 | } 38 | 39 | /* The new device is ready */ 40 | if (cb) cb(); 41 | }); 42 | }); 43 | 44 | /* Save the event handler so we can remove it later */ 45 | this.ifaceEvents = bluezDBus.onInterfaces(this._interfaceAdded.bind(this), 46 | this._interfaceRemoved.bind(this)); 47 | } 48 | 49 | BluezDevice.prototype.toString = function() { 50 | return '[Object BluezDevice (' + this.path + ')]'; 51 | } 52 | 53 | BluezDevice.prototype._isOwnService = function(path, objects) { 54 | /* If the services were not resolved yet, we don't care about cached ones */ 55 | if (!this.ServicesResolved) 56 | return false; 57 | 58 | /* Make sure this services was discovered on this device */ 59 | if (!path.startsWith(this.path)) 60 | return false; 61 | 62 | /* We're only interested in services */ 63 | if (objects['org.bluez.GattService1'] === undefined) 64 | return false; 65 | 66 | return true; 67 | } 68 | 69 | BluezDevice.prototype._onServicesResolvedChanged = function(servicesResolved) { 70 | if (!servicesResolved) 71 | return; 72 | 73 | /* The device is resolved, now let's check if there are any relevant objects 74 | * (services) */ 75 | bluezDBus.getAllObjects((err, objects) => { 76 | if (err) 77 | throw 'Failed getting all objects'; 78 | 79 | Object.keys(objects).forEach((key) => { 80 | this._interfaceAdded(key, objects[key]); 81 | }); 82 | }); 83 | } 84 | 85 | BluezDevice.prototype._devicePropertiesUpdate = function(key, value) { 86 | if (_.isEqual(this[key], value)) 87 | return; 88 | 89 | this.debug(key + ' changed from ' + this[key] + ' to ' + value); 90 | this[key] = value; 91 | 92 | if (key == 'ServicesResolved') 93 | this._onServicesResolvedChanged(value); 94 | 95 | this.emit('propertyChanged', key, value); 96 | } 97 | 98 | BluezDevice.prototype._interfaceAdded = function(path, objects) { 99 | /* We're only interested in services under this device */ 100 | if (!this._isOwnService(path, objects)) 101 | return; 102 | 103 | this.debug('A service was added: ' + path); 104 | var service = new BluezService(path); 105 | service.init((err) => { 106 | if (err) { 107 | this.debug('Failed initializing new service ' + path + ': ' + err); 108 | return; 109 | } 110 | this.emit('service', service); 111 | }); 112 | } 113 | 114 | BluezDevice.prototype._interfaceRemoved = function(path, objects) { 115 | /* We're only interested in ourselves */ 116 | if (this.path !== path) 117 | return; 118 | 119 | this.debug('Removed device'); 120 | this.emit('removed'); 121 | this.removeAllListeners(); 122 | this.props.close(); 123 | this.ifaceEvents.close(); 124 | } 125 | 126 | BluezDevice.prototype.Connect = function(cb) { 127 | this.iface.Connect['finish'] = () => { 128 | this.debug('Connected'); 129 | if (cb) cb(); 130 | }; 131 | this.iface.Connect['error'] = (err) => { 132 | this.debug('Failed connecting: ' + err); 133 | if (cb) cb(err); 134 | }; 135 | this.iface.Connect(); 136 | } 137 | 138 | BluezDevice.prototype.Disconnect = function(cb) { 139 | this.iface.Disconnect['finish'] = () => { 140 | this.debug('Disconnected'); 141 | if (cb) cb(); 142 | }; 143 | this.iface.Disconnect['error'] = (err) => { 144 | this.debug('Failed disconnecting: ' + err); 145 | if (cb) cb(err); 146 | }; 147 | this.iface.Disconnect(); 148 | } 149 | -------------------------------------------------------------------------------- /BluezService.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const util = require('util'); 3 | const _ = require('underscore'); 4 | const bluezDBus = require('./BluezDBus'); 5 | const BluezCharacteristic = require('./BluezCharacteristic'); 6 | 7 | module.exports = BluezService; 8 | 9 | function BluezService(path) { 10 | this.debug = require('debug')('Bluez:Service:' + path); 11 | this.path = path; 12 | this.debug('New service'); 13 | } 14 | 15 | util.inherits(BluezService, EventEmitter); 16 | 17 | BluezService.prototype.init = function(cb) { 18 | /* Get GattService1 interface */ 19 | bluezDBus.getInterface(this.path, 'org.bluez.GattService1', (err, iface) => { 20 | if (err) { 21 | this.debug('Failed getting GattService1 interface:' + err); 22 | if (cb) cb(err); 23 | return; 24 | } 25 | 26 | /* Save this interface */ 27 | this.iface = iface; 28 | 29 | /* Get Protperties interface */ 30 | this.props = bluezDBus.getProperties(this.path, 'org.bluez.GattService1', 31 | this._servicePropertiesUpdate.bind(this), /* Property changed */ 32 | (err) => { /* All propertires were resolved */ 33 | if (err) { 34 | this.debug('Failed getting all properties:' + err); 35 | if (cb) cb(err); 36 | return; 37 | } 38 | 39 | /* At this point, the device is connected and the services are already 40 | * resolved, just look for the relevant characteristics */ 41 | bluezDBus.getAllObjects((err, objects) => { 42 | if (err) 43 | throw 'Failed getting all objects'; 44 | 45 | Object.keys(objects).forEach((key) => { 46 | this._interfaceAdded(key, objects[key]); 47 | }); 48 | 49 | /* The new service is ready */ 50 | if (cb) cb(); 51 | }); 52 | }); 53 | }); 54 | 55 | /* Save the event handler so we can remove it later */ 56 | this.ifaceEvents = bluezDBus.onInterfaces(this._interfaceAdded.bind(this), 57 | this._interfaceRemoved.bind(this)); 58 | } 59 | 60 | BluezService.prototype.toString = function() { 61 | return '[Object BluezService (' + this.path + ')]'; 62 | } 63 | 64 | BluezService.prototype._isOwnCharacteristic = function(path, objects) { 65 | /* Make sure this characteristic was discovered on this service */ 66 | if (!path.startsWith(this.path)) 67 | return false; 68 | 69 | /* We're only interested in services */ 70 | if (objects['org.bluez.GattCharacteristic1'] === undefined) 71 | return false; 72 | 73 | return true; 74 | } 75 | 76 | BluezService.prototype._servicePropertiesUpdate = function(key, value) { 77 | if (_.isEqual(this[key], value)) 78 | return; 79 | 80 | this.debug(key + ' changed from ' + this[key] + ' to ' + value); 81 | this[key] = value; 82 | this.emit('propertyChanged', key, value); 83 | } 84 | 85 | BluezService.prototype._interfaceAdded = function(path, objects) { 86 | /* We're only interested in characteristics under this service */ 87 | if (!this._isOwnCharacteristic(path, objects)) 88 | return; 89 | 90 | this.debug('A characteristic was added: ' + path); 91 | var characteristic = new BluezCharacteristic(path); 92 | characteristic.init((err) => { 93 | if (err) { 94 | this.debug('Failed initializing new characteristic ' + path + ': ' + err); 95 | return; 96 | } 97 | this.emit('characteristic', characteristic); 98 | }); 99 | } 100 | 101 | BluezService.prototype._interfaceRemoved = function(path, objects) { 102 | /* We're only interested in ourselves */ 103 | if (this.path !== path) 104 | return; 105 | 106 | this.debug('Removed service'); 107 | this.emit('removed'); 108 | this.removeAllListeners(); 109 | this.props.close(); 110 | this.ifaceEvents.close(); 111 | } 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Assaf Inbal 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BLE2MQTT 2 | 3 | **Depreciation Notice** - This project is no longer maintained in favor of the 4 | [ESP32](https://github.com/shmuelzon/esp32-ble2mqtt) variant. Pull requests and 5 | questions are still welcome. 6 | 7 | This project aims to be a BLE to MQTT bridge, i.e. expose BLE GATT 8 | characteristics as MQTT topics for bidirectional communication. It relies on the 9 | BlueZ DBus API and as such is supported on Linux only. 10 | 11 | For example, if a device with a MAC address of `A0:E6:F8:50:72:53` exposes the 12 | [0000180f-0000-1000-8000-00805f9b34fb service](https://developer.bluetooth.org/gatt/services/Pages/ServiceViewer.aspx?u=org.bluetooth.service.battery_service.xml) 13 | (Battery Service) which includes the 14 | [00002a19-0000-1000-8000-00805f9b34fb characteristic](https://developer.bluetooth.org/gatt/characteristics/Pages/CharacteristicViewer.aspx?u=org.bluetooth.characteristic.battery_level.xml) 15 | (Battery Level), the `A0:E6:F8:50:72:53/BatteryService/BatteryLevel` 16 | MQTT topic is published with a value representing the battery level. 17 | 18 | In order to set a GATT value, publish a message to a writable characteristic 19 | using the above format suffixed with `/Set`. Note that values are byte arrays so 20 | writing a 64-bit value would look like `10,231,32,24`. 21 | 22 | ## Configuration 23 | 24 | While the basic configuration file provided in the repository 25 | ([config.json](https://github.com/shmuelzon/ble2mqtt/blob/master/config.json)) 26 | should be enough for most users, it is possible to tweak it a bit to fit one's 27 | specific needs. 28 | 29 | The `mqtt` section below includes the following entries: 30 | ```json 31 | { 32 | "mqtt": { 33 | "server": { 34 | "host": "127.0.0.1", 35 | "port": 1883 36 | }, 37 | "publish": { 38 | "retain": true 39 | }, 40 | "topics" :{ 41 | "device_name": "Address", 42 | "set_suffix": "/Set" 43 | } 44 | } 45 | } 46 | ``` 47 | * `server` - define which MQTT broker the application should connect to 48 | * `publish` - configuration for publishing topics. This object is passed as-is 49 | to the [mqtt.publish()](https://github.com/mqttjs/MQTT.js#publish) method 50 | * `topics` 51 | * `device name` - define which attribute of a device (as exposed by Bluez) 52 | should be used to identify it in the MQTT topic 53 | * `set_suffix` - Which suffix should be added to the MQTT value topic in order 54 | to write a new value to the characteristic. Set this to an empty string if 55 | you wish to use the same topic for reading and writing 56 | 57 | The `ble` section of the configuration file includes the following default 58 | configuration: 59 | ```json 60 | { 61 | "ble": { 62 | "services": { }, 63 | "characteristics": { } 64 | } 65 | } 66 | ``` 67 | * `services` - add an additional service or override an existing definition to 68 | the ones grabbed automatically on first run from http://www.bluetooth.org. 69 | Each service can include a `name` field which will be used in the MQTT topic 70 | instead of its UUID. For example: 71 | 72 | ```json 73 | "services": { 74 | "00002f00-0000-1000-8000-00805f9b34fb": { 75 | "name": "Relay Service" 76 | }, 77 | } 78 | ``` 79 | * `characteristics` - add an additional characteristic or override an existing 80 | definition to the ones grabbed automatically on first run from 81 | http://www.bluetooth.org. Each characteristic can include a `name` field which 82 | will be used in the MQTT topic instead of its UUID, a `types` array defining 83 | how to parse the byte array reflecting the characteristic's value and a `poll` 84 | value (in seconds) for the application to poll the BLE device for a new value. 85 | For example: 86 | 87 | ```json 88 | "characteristics": { 89 | "00002a19-0000-1000-8000-00805f9b34fb": { 90 | "//": "Poll the battery level characteristic every day", 91 | "poll": 86400 92 | }, 93 | "00002f01-0000-1000-8000-00805f9b34fb": { 94 | "name": "Relay State", 95 | "types": [ 96 | "boolean" 97 | ] 98 | } 99 | } 100 | ``` 101 | * `whitelist`/`blacklist` - An array of strings or regular expressions used to 102 | match MAC addresses of devices. If `whitelist` is used, only devices with a 103 | MAC address matching one of the entries will be connected while if `blacklist` 104 | is used, only devices that do not match any entry will be connected to. 105 | 106 | ```json 107 | "whitelist": [ 108 | "A0:E6:F8:.*" 109 | ] 110 | ``` 111 | 112 | * `passkeys` - An object containing the passkey (number 000000~999999) used for 113 | out-of-band authorization. Each entry is the MAC address of the BLE device and 114 | the value is the passkey to use. 115 | 116 | ```json 117 | "passkeys": { 118 | "B0:B4:48:D3:63:98": 123456 119 | } 120 | ``` 121 | 122 | ## Installation 123 | 124 | This app requires node version >= 4.3.2 (need support for arrow functions) as 125 | well as a fairly recent version of Bluez (>= 5.40). 126 | 127 | > Note that you can probably point your apt sources to stretch/testing to get 128 | > newer versions of these packages. I, personally, haven't tried that yet 129 | 130 | ### Bluez 131 | 132 | My personal setup is a Raspberry Pi 3 utilizing its built-in Bluetooth radio. I 133 | needed to build a newer version of Bluez and needed it to be a Debian package 134 | since a different package (pi-bluetooth, which creates the HCI device) depends 135 | on it. To overcome this, I ran the following: 136 | 137 | ```bash 138 | # Get dependencies 139 | sudo apt-get update 140 | sudo apt-get install -y libusb-dev libdbus-1-dev libglib2.0-dev libudev-dev \ 141 | libical-dev libreadline-dev checkinstall 142 | 143 | # Compile + Install Bluez 5.45 144 | mkdir -p ~/Downloads 145 | wget -O ~/Downloads/bluez-5.45.tar.xz http://www.kernel.org/pub/linux/bluetooth/bluez-5.45.tar.xz 146 | mkdir -p ~/code 147 | cd ~/code 148 | tar -xvf ~/Downloads/bluez-5.45.tar.xz 149 | cd bluez-5.45 150 | 151 | # Allow tabs to be tabs (for patches) 152 | bind '\C-i:self-insert' 153 | 154 | patch -p1 << EOF 155 | --- a/tools/hciattach.c 156 | +++ b/tools/hciattach.c 157 | @@ -1236,7 +1236,7 @@ 158 | { 159 | struct uart_t *u = NULL; 160 | int detach, printpid, raw, opt, i, n, ld, err; 161 | - int to = 10; 162 | + int to = 30; 163 | int init_speed = 0; 164 | int send_break = 0; 165 | pid_t pid; 166 | --- a/tools/hciattach_bcm43xx.c 167 | +++ b/tools/hciattach_bcm43xx.c 168 | @@ -43,7 +43,7 @@ 169 | #include "hciattach.h" 170 | 171 | #ifndef FIRMWARE_DIR 172 | -#define FIRMWARE_DIR "/etc/firmware" 173 | +#define FIRMWARE_DIR "/lib/firmware" 174 | #endif 175 | 176 | #define FW_EXT ".hcd" 177 | @@ -366,11 +366,8 @@ 178 | return -1; 179 | 180 | if (bcm43xx_locate_patch(FIRMWARE_DIR, chip_name, fw_path)) { 181 | - fprintf(stderr, "Patch not found, continue anyway\n"); 182 | + fprintf(stderr, "Patch not found for %s, continue anyway\n", chip_name); 183 | } else { 184 | - if (bcm43xx_set_speed(fd, ti, speed)) 185 | - return -1; 186 | - 187 | if (bcm43xx_load_firmware(fd, fw_path)) 188 | return -1; 189 | 190 | @@ -380,6 +377,7 @@ 191 | return -1; 192 | } 193 | 194 | + sleep(1); 195 | if (bcm43xx_reset(fd)) 196 | return -1; 197 | } 198 | --- a/src/bluetooth.conf 199 | +++ b/src/bluetooth.conf 200 | @@ -34,6 +34,10 @@ 201 | 202 | 203 | 204 | + 205 | + 206 | + 207 | + 208 | 209 | 210 | 211 | --- a/src/bluetooth.service.in 212 | +++ b/src/bluetooth.service.in 213 | @@ -6,7 +6,7 @@ 214 | [Service] 215 | Type=dbus 216 | BusName=org.bluez 217 | -ExecStart=@libexecdir@/bluetoothd 218 | +ExecStart=@libexecdir@/bluetoothd -E 219 | NotifyAccess=main 220 | #WatchdogSec=10 221 | #Restart=on-failure 222 | EOF 223 | 224 | # Re-enable tabs 225 | bind '\C-i:complete' 226 | 227 | ./configure --disable-cups --disable-obex --enable-deprecated --prefix=/usr --libexecdir=/usr/lib --localstatedir=/var/lib/bluetooth/ 228 | make -j 4 229 | sudo checkinstall -y --nodoc --maintainer=shmuelzon@gmail.com 230 | ``` 231 | 232 | ### Node 233 | To install Node, I used the following: 234 | ```bash 235 | # Install Node 236 | curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash - 237 | sudo apt-get install -y nodejs 238 | ``` 239 | 240 | ### Startup on boot 241 | 242 | Raspbian Jesse uses systemd as its init process, so I created a service file for 243 | it. Make sure to add your user to the `bluetooth` group so you can run this 244 | application without running as root. 245 | 246 | ```bash 247 | cat << EOF > ble2mqtt@$USER.service 248 | [Unit] 249 | Description=BLE2MQTT Bridge for %i 250 | After=network.target bluetooth.service 251 | 252 | [Service] 253 | Type=simple 254 | WorkingDirectory=/home/%i/code/ble2mqtt 255 | ExecStart=/usr/bin/npm start 256 | User=%i 257 | SendSIGKILL=no 258 | Restart=on-failure 259 | 260 | [Install] 261 | WantedBy=multi-user.target 262 | EOF 263 | 264 | sudo mv ble2mqtt@$USER.service /lib/systemd/system/ 265 | sudo systemctl daemon-reload 266 | sudo systemctl enable ble2mqtt@$USER.service 267 | sudo systemctl start ble2mqtt@$USER.service 268 | ``` 269 | 270 | ## To Do 271 | 272 | * Add configuration file: 273 | * ~~MQTT settings~~ 274 | * ~~Single/Split topic for get/set~~ 275 | * MQTT topic prefix (to distinguish between different instances of the app) 276 | * ~~Error handling:~~ 277 | * ~~What happens when an adapter/device is disconnected? Do we need to cleanup 278 | anything? What happens to events on removed devices?~~ 279 | * Pretty names (should be configurable): 280 | * ~~Allow using different properties as device name~~ 281 | * Listen on changes in the property used for the device name as if it 282 | changes, topic names (both published and subscribed) need to be updated 283 | * ~~Use service/characteristic name instead of UUID~~ 284 | * ~~Extendable via configuration file~~ 285 | * ~~Pretty values (convert byte array to Boolean, String, etc.):~~ 286 | * ~~Configuration file can define custom characteristics~~ 287 | * Refactoring 288 | * Create a separate NPM module out of the BlueZ code 289 | * Lots of similar code copy-pasted, we can do better 290 | * ~~Security~~ 291 | * ~~Support pairing via AgentManager1 API~~ 292 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('ble2mqtt'); 2 | const _ = require('underscore'); 3 | const underscoreDeepExtend = require('underscore-deep-extend'); 4 | const _mqtt = require('mqtt'); 5 | const bluez = require('./Bluez'); 6 | const BluezAgent = require('./BluezAgent'); 7 | const config = require('./config'); 8 | const servicesList = require('./resources/services'); 9 | const characteristicsList = require('./resources/characteristics'); 10 | const utils = require('./utils'); 11 | 12 | var characteristics = {}; 13 | 14 | var mqtt = _mqtt.connect(config.mqtt.server); 15 | 16 | _.mixin({deepExtend: underscoreDeepExtend(_)}); 17 | /* Add user-defined names from the configuration file */ 18 | _.deepExtend(servicesList, config.ble.services); 19 | _.deepExtend(characteristicsList, config.ble.characteristics); 20 | 21 | function getDeviceName(device) { 22 | return device[config.mqtt.topics.device_name]; 23 | } 24 | 25 | function getServiceName(service) { 26 | var s = servicesList[service.UUID]; 27 | return s ? s.name.replace(/\s/g, '') : service.UUID; 28 | } 29 | 30 | function getCharacteristicName(characteristic) { 31 | var c = characteristicsList[characteristic.UUID] 32 | return c ? c.name.replace(/\s/g, '') : characteristic.UUID; 33 | } 34 | 35 | function getCharacteristicPoll(characteristic) { 36 | var c = characteristicsList[characteristic.UUID] 37 | return c ? c.poll : 0; 38 | } 39 | 40 | function getCharacteristicValue(characteristic) { 41 | var c = characteristicsList[characteristic.UUID] 42 | var res; 43 | 44 | try { 45 | res = utils.bufferToGattTypes(Buffer.from(characteristic.Value), 46 | c && c.types ? c.types : []); 47 | } catch (e) { 48 | debug('Failed parsing ' + characteristic.UUID); 49 | res = characteristic.Value; 50 | } 51 | 52 | return (res.length == 1 ? res[0] : res).toString(); 53 | } 54 | 55 | function shouldConnect(device) { 56 | /* Action taken if device is in the list */ 57 | var action = config.ble.whitelist ? true : false; 58 | var list = config.ble.whitelist ? config.ble.whitelist : config.ble.blacklist; 59 | var str = device.Address; 60 | 61 | /* No list was defined, accept all */ 62 | if (!list) 63 | return true; 64 | 65 | return _(list).find(item => str.search(item) !== -1) ? action : !action; 66 | } 67 | 68 | mqtt.on('connect', (connack) => debug('Connected to MQTT server')); 69 | 70 | mqtt.on('message', (topic, message) => { 71 | var c = characteristics[topic]; 72 | if (!c) 73 | return; 74 | 75 | /* Convert value back to byte array */ 76 | var info = characteristicsList[c.UUID]; 77 | var newBuf = utils.gattTypesToBuffer(message.toString().split(','), 78 | c.Value.length, info && info.types ? info.types : []); 79 | 80 | /* Is this a different value? */ 81 | if (newBuf.compare(Buffer.from(c.Value)) == 0) 82 | return; 83 | 84 | /* Write the new value and read it back */ 85 | debug('Writing "' + message + '" to ' + getCharacteristicName(c)); 86 | c.Write(Array.prototype.slice.call(newBuf.slice(), 0), () => c.Read()); 87 | }); 88 | 89 | process.on('exit', () => mqtt.end(true)); 90 | 91 | /* Create agent for pairing devices */ 92 | if (!_.isEmpty(config.ble.passkeys)) { 93 | var agent = new BluezAgent('com.shmulzon.ble2mqtt.agent', 94 | '/com/shmuelzon/ble2mqtt/agent'); 95 | agent.setPasskeyHandler((device) => { 96 | var mac = device.match(/([0-9A-F]{2}_?){6}/)[0].replace(/_/g, ':'); 97 | var list = config.ble.passkeys; 98 | var passkey = list ? list[mac] : null 99 | 100 | debug('Passkey for ' + mac + ': ' + passkey); 101 | return passkey; 102 | }); 103 | agent.register((err) => { 104 | if (err) { 105 | debug('Failed registering agent'); 106 | return; 107 | } 108 | 109 | debug('Registered agent'); 110 | agent.setDefault() 111 | }); 112 | } 113 | 114 | bluez.on('adapter', (adapter) => { 115 | debug('Found new adapter: ' + adapter); 116 | 117 | adapter.on('device', (device) => { 118 | if (!shouldConnect(device)) return; 119 | debug('Found new device: ' + device.Address + ' (' + device.Alias +')'); 120 | 121 | device.on('service', (service) => { 122 | debug('Found new service: ' + [getDeviceName(device), 123 | getServiceName(service)].join('/')); 124 | 125 | service.on('characteristic', (characteristic) => { 126 | var get_topic = [getDeviceName(device), getServiceName(service), 127 | getCharacteristicName(characteristic)].join('/'); 128 | var set_topic = get_topic + config.mqtt.topics.set_suffix; 129 | debug('Found new characteristic: ' + get_topic + ' (' + 130 | characteristic.Flags + ')'); 131 | 132 | var read = function() { 133 | characteristic.Read((err) => { 134 | if (!err) return; 135 | /* If the characteristic was removed while reading, ignore it */ 136 | if (err.message == 'org.freedesktop.DBus.Error.UnknownObject') 137 | return; 138 | 139 | debug('Failed reading ' + get_topic + ' ('+ err + '), retrying.'); 140 | setImmediate(() => read()); 141 | }); 142 | } 143 | var publish = function() { 144 | var val = getCharacteristicValue(characteristic); 145 | 146 | debug('Publishing ' + get_topic + ': ' + val); 147 | mqtt.publish(get_topic, val, config.mqtt.publish); 148 | } 149 | 150 | /* Listen on notifications */ 151 | if (characteristic.Flags.indexOf('notify') !== -1) 152 | characteristic.NotifyStart(); 153 | 154 | /* Publish cached value, if exists */ 155 | if (characteristic.Value && characteristic.Value.length != 0) 156 | publish(); 157 | 158 | /* Read current value */ 159 | if (characteristic.Flags.indexOf('read') !== -1) 160 | read(); /* We'll get the new value in the propertyChanged event */ 161 | 162 | /* Poll characteristic if configured */ 163 | var poll_period = getCharacteristicPoll(characteristic); 164 | if (poll_period > 0) 165 | setInterval(() => { characteristic.Read(); }, poll_period * 1000); 166 | 167 | characteristic.on('propertyChanged', (key, value) => { 168 | if (key === 'Value') 169 | publish(); 170 | }); 171 | 172 | characteristic.on('removed', () => { 173 | /* The characteristic was removed, unsubscribe from the MQTT topic */ 174 | debug('Removed characteristic: ' + characteristic.UUID); 175 | mqtt.unsubscribe(set_topic); 176 | delete characteristics[set_topic]; 177 | }); 178 | 179 | /* If characteristic is writable, allow setting it via MQTT */ 180 | if (characteristic.Flags.indexOf('write') !== -1) { 181 | characteristics[set_topic] = characteristic; 182 | mqtt.subscribe(set_topic); 183 | } 184 | }); 185 | }); 186 | 187 | device.on('propertyChanged', (key, value) => { 188 | if (key === 'Connected' && value === false) { 189 | debug('Disconnected from ' + device); 190 | /* We'll now remove the device. This will also remove all of the 191 | * services and characteristics of this device which will remove the 192 | * event listeners and allow cleaning up the MQTT related subscriptions. 193 | * If the device is still around, we'll discover it again, reconnect and 194 | * resubscribe to the MQTT topics */ 195 | setImmediate(() => adapter.removeDevice(device)); 196 | } 197 | }); 198 | 199 | device.Connect((err) => { 200 | if (err) { 201 | debug('Failed connecting to ' + device + ': ' + err); 202 | /* Remove the device so it will be rediscovered when it's available */ 203 | setImmediate(() => adapter.removeDevice(device)); 204 | return; 205 | } 206 | 207 | debug('Connected to ' + device); 208 | }); 209 | }); 210 | 211 | adapter.on('removed', () => debug(adapter + ' was removed')); 212 | 213 | adapter.powerOn((err) => { 214 | if (err) return; 215 | 216 | debug('Powered on ' + adapter); 217 | adapter.discoveryFilterSet({ 'Transport': 'le' }, (err) => { 218 | if (err) return; 219 | 220 | debug('Filtered only LE devices'); 221 | adapter.discoveryStart((err) => { 222 | if (err) return; 223 | 224 | debug('Started discovery on ' + adapter); 225 | }); 226 | }) 227 | }); 228 | 229 | process.on('exit', () => { 230 | if (adapter.Powered === true) 231 | adapter.powerOff(); 232 | }); 233 | }); 234 | 235 | process.on('SIGINT', () => process.exit(0)); 236 | process.on('SIGTERM', () => process.exit(0)); 237 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt": { 3 | "server": { 4 | "host": "127.0.0.1", 5 | "port": 1883 6 | }, 7 | "publish": { 8 | "retain": true 9 | }, 10 | "topics" :{ 11 | "device_name": "Address", 12 | "set_suffix": "/Set" 13 | } 14 | }, 15 | "ble": { 16 | "services": { }, 17 | "characteristics": { } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ble2mqtt", 3 | "version": "1.0.0", 4 | "description": "A BLE to MQTT bridge", 5 | "main": "app.js", 6 | "scripts": { 7 | "prestart": "node utils/getGattAssignedNumbers.js", 8 | "start": "DEBUG=ble2mqtt node app.js", 9 | "dev": "DEBUG=* node app.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "shmuelzon", 13 | "license": "MIT", 14 | "private": true, 15 | "dependencies": { 16 | "request": "2.88.0", 17 | "dbus": "0.2.19", 18 | "debug": "2.6.9", 19 | "mqtt": "1.10.0", 20 | "underscore": "1.8.3", 21 | "underscore-deep-extend": "1.1.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('ble2mqtt:utils') 2 | const _ = require('underscore') 3 | 4 | module.exports = {} 5 | 6 | /* IEEE-11073 conversion algorithm shamelessly ripped off from Antidote: 7 | * http://oss.signove.com/index.php/Antidote:_IEEE_11073-20601_stack */ 8 | function numberToIeee11073(number, FLOAT_NAN, FLOAT_MAX, FLOAT_MIN, 9 | FLOAT_POSITIVE_INFINITY, FLOAT_NEGATIVE_INFINITY, FLOAT_EPSILON, 10 | FLOAT_MANTISSA_MAX, FLOAT_EXPONENT_MAX, FLOAT_EXPONENT_MIN, 11 | FLOAT_EXPONENT_MASK, FLOAT_EXPONENT_SHIFT, FLOAT_MANTISSA_MASK, 12 | FLOAT_PERCISION) 13 | { 14 | var result; 15 | 16 | if (isNaN(number)) 17 | return FLOAT_NAN; 18 | if (number > FLOAT_MAX) 19 | return FLOAT_POSITIVE_INFINITY; 20 | if (number < FLOAT_MIN) 21 | return FLOAT_NEGATIVE_INFINITY; 22 | if (number >= -FLOAT_EPSILON && number <= FLOAT_EPSILON) 23 | return 0; 24 | 25 | var sgn = number > 0 ? +1 : -1; 26 | var mantissa = Math.abs(number); 27 | var exponent = 0; /* Note: 10**x exponent, not 2**x */ 28 | 29 | /* Scale up if number is too big */ 30 | while (mantissa > FLOAT_MANTISSA_MAX) { 31 | mantissa /= 10.0; 32 | exponent++; 33 | if (exponent > FLOAT_EXPONENT_MAX) { 34 | /* Argh, should not happen */ 35 | if (sgn > 0) 36 | return FLOAT_POSITIVE_INFINITY; 37 | else 38 | return FLOAT_NEGATIVE_INFINITY; 39 | } 40 | } 41 | 42 | /* Scale down if number is too small */ 43 | while (mantissa < 1) { 44 | mantissa *= 10; 45 | exponent--; 46 | if (exponent < FLOAT_EXPONENT_MIN) { 47 | /* Argh, should not happen */ 48 | return 0; 49 | } 50 | } 51 | 52 | /* Scale down if number needs more precision */ 53 | var smantissa = Math.round(mantissa * FLOAT_PERCISION); 54 | var rmantissa = Math.round(mantissa) * FLOAT_PERCISION; 55 | var mdiff = Math.abs(smantissa - rmantissa); 56 | while (mdiff > 0.5 && exponent > FLOAT_EXPONENT_MIN && 57 | (mantissa * 100) <= FLOAT_MANTISSA_MAX) 58 | { 59 | mantissa *= 10; 60 | exponent--; 61 | smantissa = Math.round(mantissa * FLOAT_PERCISION); 62 | rmantissa = Math.round(mantissa) * FLOAT_PERCISION; 63 | mdiff = Math.abs(smantissa - rmantissa); 64 | } 65 | 66 | var int_mantissa = Math.round(sgn * mantissa); 67 | return ((exponent & FLOAT_EXPONENT_MASK) << FLOAT_EXPONENT_SHIFT) | 68 | (int_mantissa & FLOAT_MANTISSA_MASK); 69 | } 70 | 71 | function numberToSFloat(number) { 72 | return numberToIeee11073(number, 0x07FF, 20450000000.0, -20450000000.0, 73 | 0x07FE, 0x0802, 1e-8, 0x07FD, 7, -8, 0xF, 12, 0xFFF, 10000); 74 | } 75 | 76 | function numberToFloat(number) { 77 | return numberToIeee11073(number, 0x007FFFFF, 8.388604999999999e+133, 78 | -8.388604999999999e+133, 0x007FFFFE, 0x00800002, 1e-128, 0x007FFFFD, 127, 79 | -128, 0xFF, 24, 0xFFFFFF, 10000000); 80 | } 81 | 82 | module.exports.bufferToGattTypes = function(buf, types) { 83 | var res = []; 84 | var offset = 0; 85 | 86 | _(types).each((type) => { 87 | var val; 88 | 89 | switch (type) { 90 | case 'boolean': 91 | val = buf.readUIntLE(offset, 1); 92 | val &= 0x01; 93 | val = val == 0 ? false : true; 94 | offset += 1; 95 | break; 96 | case '2bit': 97 | val = buf.readUIntLE(offset, 1); 98 | val &= 0x03; 99 | offset += 1; 100 | break; 101 | case '4bit': 102 | case 'nibble': 103 | val = buf.readUIntLE(offset, 1); 104 | val &= 0x0F; 105 | offset += 1; 106 | break; 107 | case '8bit': 108 | case 'uint8': 109 | val = buf.readUIntLE(offset, 1); 110 | offset += 1; 111 | break; 112 | case 'uint12': 113 | val = buf.readUIntLE(offset, 2); 114 | val &= 0x0FFF; 115 | offset += 2; 116 | break; 117 | case '16bit': 118 | case 'uint16': 119 | val = buf.readUIntLE(offset, 2); 120 | offset += 2; 121 | break; 122 | case '24bit': 123 | case 'uint24': 124 | val = buf.readUIntLE(offset, 3); 125 | offset += 3; 126 | break; 127 | case '32bit': 128 | case 'uint32': 129 | val = buf.readUIntLE(offset, 4); 130 | offset += 4; 131 | break; 132 | case 'uint40': 133 | val = buf.readUIntLE(offset, 5); 134 | offset += 5; 135 | break; 136 | case 'uint48': 137 | val = buf.readUIntLE(offset, 6); 138 | offset += 6; 139 | break; 140 | case 'sint8': 141 | val = buf.readIntLE(offset, 1); 142 | offset += 1; 143 | break; 144 | case 'sint16': 145 | val = buf.readIntLE(offset, 2); 146 | offset += 2; 147 | break; 148 | case 'sint24': 149 | val = buf.readIntLE(offset, 3); 150 | offset += 3; 151 | break; 152 | case 'sint32': 153 | val = buf.readIntLE(offset, 4); 154 | offset += 4; 155 | break; 156 | case 'sint48': 157 | val = buf.readIntLE(offset, 6); 158 | offset += 6; 159 | break; 160 | /* String values consume the rest of the buffer */ 161 | case 'utf8s': 162 | val = buf.toString('utf8', offset); 163 | offset = buf.length; 164 | break; 165 | case 'utf16s': 166 | val = buf.toString('utf16', offset); 167 | offset = buf.length; 168 | break; 169 | /* IEEE-754 floating point format */ 170 | case 'float32': 171 | val = buf.readFloatLE(offset); 172 | offset += 4; 173 | break; 174 | case 'float64': 175 | val = buf.readDoubleLE(offset); 176 | offset += 8; 177 | break; 178 | /* IEEE-11073 floating point format */ 179 | case 'SFLOAT': 180 | val = buf.readUIntLE(offset, 2); 181 | var mantissa = val & 0x0FFF; 182 | var exponent = val >> 12; 183 | 184 | /* Fix sign */ 185 | if (exponent >= 0x0008) 186 | exponent = -((0x000F + 1) - exponent); 187 | if (mantissa >= 0x0800) 188 | mantissa = -((0x0FFF + 1) - mantissa); 189 | 190 | val = mantissa * Math.pow(10, exponent); 191 | offset += 2; 192 | case 'FLOAT': 193 | var exponent = buf.readIntLE(offset, 1); 194 | var mantissa = buf.readIntLE(offset + 1, 3); 195 | val = mantissa * Math.pow(10, exponent); 196 | offset += 4; 197 | /* Unhandled types */ 198 | case 'uint64': 199 | case 'uint128': 200 | case 'sint12': 201 | case 'sint64': 202 | case 'sint128': 203 | case 'duint16': 204 | case 'struct': 205 | case 'gatt_uuid': 206 | case 'reg-cert-data-list': 207 | case 'variable': 208 | default: 209 | debug('Unhandled characteristic format type: ' + type); 210 | return; 211 | } 212 | 213 | res.push(val); 214 | }); 215 | 216 | /* Save remaining buffer to byte array */ 217 | if (offset < buf.length) 218 | res.push(Array.prototype.slice.call(buf.slice(offset), 0)); 219 | 220 | return res; 221 | } 222 | 223 | module.exports.gattTypesToBuffer = function(arr, length, types) { 224 | var buf = Buffer.allocUnsafe(1024); 225 | var offset = 0; 226 | 227 | _(types).each((type, i) => { 228 | var val = Number(arr[i]); 229 | 230 | switch (type) { 231 | case 'boolean': 232 | buf.writeUIntLE(arr[i] === 'true' ? 1 : 0, offset, 1); 233 | offset += 1; 234 | break; 235 | case '2bit': 236 | buf.writeUIntLE(val & 0x03, offset, 1); 237 | offset += 1; 238 | break; 239 | case '4bit': 240 | case 'nibble': 241 | buf.writeUIntLE(val & 0x0F, offset, 1); 242 | offset += 1; 243 | break; 244 | case '8bit': 245 | case 'uint8': 246 | buf.writeUIntLE(val, offset, 1); 247 | offset += 1; 248 | break; 249 | case 'uint12': 250 | buf.writeUIntLE(val & 0x0FFF, offset, 2); 251 | offset += 2; 252 | break; 253 | case '16bit': 254 | case 'uint16': 255 | buf.writeUIntLE(val, offset, 2); 256 | offset += 2; 257 | break; 258 | case '24bit': 259 | case 'uint24': 260 | buf.writeUIntLE(val, offset, 3); 261 | offset += 3; 262 | break; 263 | case '32bit': 264 | case 'uint32': 265 | buf.writeUIntLE(val, offset, 4); 266 | offset += 4; 267 | break; 268 | case 'uint40': 269 | buf.writeUIntLE(val, offset, 5); 270 | offset += 5; 271 | break; 272 | case 'uint48': 273 | buf.writeUIntLE(offset, 6); 274 | offset += 6; 275 | break; 276 | case 'sint8': 277 | buf.writeIntLE(offset, 1); 278 | offset += 1; 279 | break; 280 | case 'sint16': 281 | buf.writeIntLE(offset, 2); 282 | offset += 2; 283 | break; 284 | case 'sint24': 285 | buf.writeIntLE(offset, 3); 286 | offset += 3; 287 | break; 288 | case 'sint32': 289 | buf.writeIntLE(offset, 4); 290 | offset += 4; 291 | break; 292 | case 'sint48': 293 | buf.writeIntLE(offset, 6); 294 | offset += 6; 295 | break; 296 | /* String values consume the rest of the buffer */ 297 | case 'utf8s': 298 | offset += buf.write(arr[i], offset, 'utf8'); 299 | break; 300 | case 'utf16s': 301 | offset += buf.write(arr[i], offset, 'utf16'); 302 | break; 303 | /* IEEE-754 floating point format */ 304 | case 'float32': 305 | buf.writeFloatLE(val, offset); 306 | offset += 4; 307 | break; 308 | case 'float64': 309 | buf.writeDoubleLE(val, offset); 310 | offset += 8; 311 | break; 312 | /* IEEE-11073 floating point format */ 313 | case 'SFLOAT': 314 | buf.writeUIntLE(numberToSFloat(val), offset, 2); 315 | offset += 2; 316 | case 'FLOAT': 317 | buf.writeUIntLE(numberToFloat(val), offset, 4); 318 | offset += 4; 319 | /* Unhandled types */ 320 | case 'uint64': 321 | case 'uint128': 322 | case 'sint12': 323 | case 'sint64': 324 | case 'sint128': 325 | case 'duint16': 326 | case 'struct': 327 | case 'gatt_uuid': 328 | case 'reg-cert-data-list': 329 | case 'variable': 330 | default: 331 | debug('Unhandled characteristic format type: ' + type); 332 | return; 333 | } 334 | }); 335 | 336 | /* Assume the rest is a byte array */ 337 | for (i = types.length; i < arr.length; i++) 338 | buf.writeUIntLE(arr[i], offset++, 1); 339 | 340 | return buf.slice(0, offset); 341 | } 342 | -------------------------------------------------------------------------------- /utils/getGattAssignedNumbers.js: -------------------------------------------------------------------------------- 1 | /* This script is used to get the list of approved GATT services and 2 | * characteristics and convert it to JSON format. It's simplistic by design to 3 | * keep the implementation simple and so it won't require any additional 4 | * packages (e.g. XML parser) */ 5 | 6 | const request = require('request') 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | const url = require('url'); 10 | const util = require('util'); 11 | const debug = require('debug')('GattNumbersToJson') 12 | 13 | const GATT_URL = 'https://www.bluetooth.com/specifications/gatt' 14 | const SERVICES_URL = GATT_URL + '/services'; 15 | const CHARACTERISTICS_URL = GATT_URL + '/characteristics' 16 | const CHARACTERISTIC_URL = 17 | 'https://www.bluetooth.com/api/gatt/XmlFile?xmlFileName=' 18 | 19 | const SERVICES_FILE = 'resources/services.json'; 20 | const CHARACTERISTICS_FILE = 'resources/characteristics.json'; 21 | 22 | var services = { 23 | '//': 'This file was automatically generated by ' + __filename 24 | } 25 | var characteristics = { 26 | '//': 'This file was automatically generated by ' + __filename 27 | } 28 | 29 | function buildBluetoothUuid(number) { 30 | return util.format('0000%s-0000-1000-8000-00805f9b34fb', 31 | parseInt(number, 16).toString(16)); 32 | } 33 | 34 | function buildGattRegex(type) { 35 | /* Nasty, nasty regex to parse a service/characterisitc row in the HTML. 36 | /* Note that the Sensor Location characteristic has a typo and is presented as 37 | * org.blueooth... so we need our Regex to catch that as well */ 38 | return new RegExp(']*>[^<]*]*>([^<]*)[^<]*[^<]*]*>(org.bluet?ooth.' + type + '.[^<]*)[^<]*' + 40 | ']*>([^<]*)<', 'gi'); 41 | } 42 | 43 | function download(uri, cb) { 44 | var options = { 45 | uri: uri, 46 | jar: true, 47 | /* Not the best solution, but the simplest and does not require any 48 | * additionall moduled (e.g. ssl-root-cas) */ 49 | strictSSL: false, 50 | }; 51 | 52 | request(options, function(err, res, body) { 53 | if (err) { 54 | console.error(err); 55 | process.exit(-1); 56 | } 57 | 58 | cb(body); 59 | }); 60 | } 61 | 62 | function saveObjectToFile(object, file) { 63 | var dir = path.dirname(file); 64 | var stat; 65 | 66 | /* Create directory, if needed */ 67 | try { stat = fs.statSync(dir); } catch(err) { } 68 | if (!stat) 69 | fs.mkdirSync(dir); 70 | 71 | debug('Writing out ' + file); 72 | fs.writeFileSync(file, JSON.stringify( 73 | Object.keys(object).sort().reduce((r, k) => (r[k] = object[k], r), {}), 74 | null, 2)); 75 | } 76 | 77 | function parseServices(str, file) { 78 | var regex = buildGattRegex('service'); 79 | var res; 80 | 81 | while ((res = regex.exec(str))) { 82 | /* res[1] = Name, res[2] = Type, res[3] = Assigned Number */ 83 | services[buildBluetoothUuid(res[3])] = { name: res[1] }; 84 | } 85 | 86 | /* When done, write results to disk */ 87 | process.on('exit', () => saveObjectToFile(services, file)); 88 | } 89 | 90 | function parseCharacteristics(str, file) { 91 | var regex = buildGattRegex('characteristic'); 92 | var res; 93 | 94 | while ((res = regex.exec(str))) (() => { 95 | /* res[1] = Name, res[2] = Type, res[3] = Assigned Number */ 96 | var name = res[1]; 97 | var type = res[2]; 98 | var uuid = buildBluetoothUuid(res[3]); 99 | characteristics[uuid] = { name: name, types: [] }; 100 | 101 | /* Get characteristic definition to get its type */ 102 | debug('Getting definition for ' + type); 103 | download( 104 | CHARACTERISTIC_URL + type.replace('blueooth', 'bluetooth') + '.xml', 105 | function (body) { 106 | var format = /([^<]*)<\/format>/gi 107 | var res; 108 | 109 | while ((res = format.exec(body))) 110 | characteristics[uuid].types.push(res[1]); 111 | } 112 | ); 113 | })(); 114 | 115 | /* When done, write results to disk */ 116 | process.on('exit', () => saveObjectToFile(characteristics, file)); 117 | } 118 | 119 | function getList(fileName, url, generator) { 120 | var stat; 121 | 122 | /* Check if the output file already exists */ 123 | try { stat = fs.statSync(fileName); } catch(err) { } 124 | 125 | /* The file already exists, nothing to do */ 126 | if (stat) { 127 | debug(fileName + ' already exists, nothing to do.'); 128 | return; 129 | } 130 | 131 | /* Create the file */ 132 | debug('Generating ' + fileName + ' from ' + url); 133 | download(url, (body) => generator(body, fileName)); 134 | } 135 | 136 | getList(SERVICES_FILE, SERVICES_URL, parseServices); 137 | getList(CHARACTERISTICS_FILE, CHARACTERISTICS_URL, parseCharacteristics); 138 | --------------------------------------------------------------------------------