UNPKG

@mkerix/noble

Version:

A Node.js BLE (Bluetooth Low Energy) central library.

267 lines (215 loc) 9.2 kB
const debug = require('debug')('gap'); const events = require('events'); const os = require('os'); const util = require('util'); const isChip = (os.platform() === 'linux') && (os.release().indexOf('-ntc') !== -1); const Gap = function (hci) { this._hci = hci; this._scanState = null; this._scanFilterDuplicates = null; this._discoveries = {}; this._hci.on('error', this.onHciError.bind(this)); this._hci.on('leScanParametersSet', this.onHciLeScanParametersSet.bind(this)); this._hci.on('leScanEnableSet', this.onHciLeScanEnableSet.bind(this)); this._hci.on('leAdvertisingReport', this.onHciLeAdvertisingReport.bind(this)); this._hci.on('leScanEnableSetCmd', this.onLeScanEnableSetCmd.bind(this)); }; util.inherits(Gap, events.EventEmitter); Gap.prototype.startScanning = function (allowDuplicates) { if (['starting', 'started'].includes(this._scanState)) { return; } this._scanState = 'starting'; this._scanFilterDuplicates = !allowDuplicates; // Always set scan parameters before scanning // https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=229737 // p106 - p107 this._hci.setScanEnabled(false, true); this._hci.setScanParameters(); if (isChip) { // work around for Next Thing Co. C.H.I.P, always allow duplicates, to get scan response this._scanFilterDuplicates = false; } this._hci.setScanEnabled(true, this._scanFilterDuplicates); }; Gap.prototype.stopScanning = function () { this._scanState = 'stopping'; this._hci.setScanEnabled(false, true); }; Gap.prototype.resetBindings = function () { this._scanState = null; this._scanFilterDuplicates = null; this._discoveries = {}; } Gap.prototype.onHciError = function (error) { console.warn(error); // TODO: Better error handling }; Gap.prototype.onHciLeScanParametersSet = function () { }; // Called when receive an event "Command Complete" for "LE Set Scan Enable" Gap.prototype.onHciLeScanEnableSet = function (status) { // Check the status we got from the command complete function. if (status !== 0) { // If it is non-zero there was an error, and we should not change // our status as a result. return; } if (this._scanState === 'starting') { this._scanState = 'started'; this.emit('scanStart', this._scanFilterDuplicates); } else if (this._scanState === 'stopping') { this._scanState = 'stopped'; this.emit('scanStop'); } }; // Called when we see the actual command "LE Set Scan Enable" Gap.prototype.onLeScanEnableSetCmd = function (enable, filterDuplicates) { // Check to see if the new settings differ from what we expect. // If we are scanning, then a change happens if the new command stops // scanning or if duplicate filtering changes. // If we are not scanning, then a change happens if scanning was enabled. if ((this._scanState === 'starting' || this._scanState === 'started')) { if (!enable) { this.emit('scanStop'); } else if (this._scanFilterDuplicates !== filterDuplicates) { this._scanFilterDuplicates = filterDuplicates; this.emit('scanStart', this._scanFilterDuplicates); } } else if ((this._scanState === 'stopping' || this._scanState === 'stopped') && enable) { // Someone started scanning on us. this.emit('scanStart', this._scanFilterDuplicates); } }; Gap.prototype.onHciLeAdvertisingReport = function (status, type, address, addressType, eir, rssi) { const previouslyDiscovered = !!this._discoveries[address]; const advertisement = previouslyDiscovered ? this._discoveries[address].advertisement : { localName: undefined, txPowerLevel: undefined, manufacturerData: undefined, serviceData: [], serviceUuids: [], solicitationServiceUuids: [] }; let discoveryCount = previouslyDiscovered ? this._discoveries[address].count : 0; let hasScanResponse = previouslyDiscovered ? this._discoveries[address].hasScanResponse : false; if (type === 0x04) { hasScanResponse = true; } else { // reset service and manufacturer data every non-scan response event advertisement.serviceData = []; advertisement.serviceUuids = []; advertisement.serviceSolicitationUuids = []; advertisement.manufacturerData = undefined; } discoveryCount++; let i = 0; while ((i + 1) < eir.length) { var length = eir.readUInt8(i); if (length < 1) { debug(`invalid EIR data, length = ${length}`); break; } const eirType = eir.readUInt8(i + 1); // https://www.bluetooth.org/en-us/specification/assigned-numbers/generic-access-profile if ((i + length + 1) > eir.length) { debug('invalid EIR data, out of range of buffer length'); break; } const bytes = eir.slice(i + 2).slice(0, length - 1); switch (eirType) { case 0x02: // Incomplete List of 16-bit Service Class UUID case 0x03: // Complete List of 16-bit Service Class UUIDs for (let j = 0; j < bytes.length - 1; j += 2) { const serviceUuid = bytes.readUInt16LE(j).toString(16); if (advertisement.serviceUuids.indexOf(serviceUuid) === -1) { advertisement.serviceUuids.push(serviceUuid); } } break; case 0x06: // Incomplete List of 128-bit Service Class UUIDs case 0x07: // Complete List of 128-bit Service Class UUIDs for (let j = 0; j < bytes.length - 15; j += 16) { const serviceUuid = bytes.slice(j, j + 16).toString('hex').match(/.{1,2}/g).reverse().join(''); if (advertisement.serviceUuids.indexOf(serviceUuid) === -1) { advertisement.serviceUuids.push(serviceUuid); } } break; case 0x08: // Shortened Local Name case 0x09: // Complete Local Name» advertisement.localName = bytes.toString('utf8'); break; case 0x0a: // Tx Power Level advertisement.txPowerLevel = bytes.readInt8(0); break; case 0x14: // List of 16 bit solicitation UUIDs for (let j = 0; j < bytes.length - 1; j += 2) { const serviceSolicitationUuid = bytes.readUInt16LE(j).toString(16); if (advertisement.serviceSolicitationUuids.indexOf(serviceSolicitationUuid) === -1) { advertisement.serviceSolicitationUuids.push(serviceSolicitationUuid); } } break; case 0x15: // List of 128 bit solicitation UUIDs for (let j = 0; j < bytes.length - 15; j += 16) { const serviceSolicitationUuid = bytes.slice(j, j + 16).toString('hex').match(/.{1,2}/g).reverse().join(''); if (advertisement.serviceSolicitationUuids.indexOf(serviceSolicitationUuid) === -1) { advertisement.serviceSolicitationUuids.push(serviceSolicitationUuid); } } break; case 0x16: // 16-bit Service Data, there can be multiple occurences advertisement.serviceData.push({ uuid: bytes.slice(0, 2).toString('hex').match(/.{1,2}/g).reverse().join(''), data: bytes.slice(2, bytes.length) }); break; case 0x20: // 32-bit Service Data, there can be multiple occurences advertisement.serviceData.push({ uuid: bytes.slice(0, 4).toString('hex').match(/.{1,2}/g).reverse().join(''), data: bytes.slice(4, bytes.length) }); break; case 0x21: // 128-bit Service Data, there can be multiple occurences advertisement.serviceData.push({ uuid: bytes.slice(0, 16).toString('hex').match(/.{1,2}/g).reverse().join(''), data: bytes.slice(16, bytes.length) }); break; case 0x1f: // List of 32 bit solicitation UUIDs for (let j = 0; j < bytes.length - 3; j += 4) { const serviceSolicitationUuid = bytes.readUInt32LE(j).toString(16); if (advertisement.serviceSolicitationUuids.indexOf(serviceSolicitationUuid) === -1) { advertisement.serviceSolicitationUuids.push(serviceSolicitationUuid); } } break; case 0xff: // Manufacturer Specific Data if (advertisement.manufacturerData) { advertisement.manufacturerData = Buffer.concat([ advertisement.manufacturerData, bytes.slice(2) ]); } else { advertisement.manufacturerData = bytes; } break; } i += (length + 1); } debug(`advertisement = ${JSON.stringify(advertisement, null, 0)}`); const connectable = (type === 0x04 && previouslyDiscovered) ? this._discoveries[address].connectable : (type !== 0x03); this._discoveries[address] = { address: address, addressType: addressType, connectable: connectable, advertisement: advertisement, rssi: rssi, count: discoveryCount, hasScanResponse: hasScanResponse }; // only report after a scan response event or if non-connectable or more than one discovery without a scan response, so more data can be collected if (type === 0x04 || !connectable || (discoveryCount > 1 && !hasScanResponse) || process.env.NOBLE_REPORT_ALL_HCI_EVENTS) { this.emit('discover', status, address, addressType, connectable, advertisement, rssi); } }; module.exports = Gap;