UNPKG

ble-glucose

Version:

Reads blood glucose values from Bluetooth LE enabled meters

441 lines (380 loc) 13.7 kB
/* * == BSD2 LICENSE == * Copyright (c) 2019, Tidepool Project * * This program is free software; you can redistribute it and/or modify it under * the terms of the associated License, which is identical to the BSD 2-Clause * License as published by the Open Source Initiative at opensource.org. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the License for more details. * * You should have received a copy of the License along with this program; if * not, you can obtain one from Tidepool Project at tidepool.org. * == BSD2 LICENSE == */ /* global navigator, BluetoothUUID */ /* eslint-disable global-require, no-global-assign */ import sundial from 'sundial'; import bows from 'bows'; const isBrowser = typeof window !== 'undefined'; const debug = isBrowser ? bows('ble-glucose') : console.log; const options = { filters: [{ services: ['glucose'], }], optionalServices: [ 'device_information', 0xFFF0, // i-SENS v1.4 custom service 'c4dea010-5a9d-11e9-8647-d663bd873d93', // i-SENS v1.5 custom service '00001523-1212-efde-1523-785feabcd123', // Nordic LED button service ], }; const FLAGS = { TIME_OFFSET_PRESENT: { value: 0x01, name: 'Time offset present' }, GLUCOSE_PRESENT: { value: 0x02, name: 'Glucose concentration, type and sample location present' }, IS_MMOL: { value: 0x04, name: 'Glucose concentration units' }, STATUS_PRESENT: { value: 0x08, name: 'Sensor status annunciation present' }, CONTEXT_INFO: { value: 0x10, name: 'Context information follows' }, }; const CONTEXT_FLAGS = { CARBS: { value: 0x01, name: 'Carbohydrate ID and Carbohydrate present' }, MEAL: { value: 0x02, name: 'Meal present' }, TESTER_HEALTH: { value: 0x04, name: 'Tester-Health present' }, EXERCISE: { value: 0x08, name: 'Exercise Duration and Exercise Intensity present' }, MEDICATION: { value: 0x10, name: 'Medication ID and Medication present' }, UNITS: { value: 0x20, name: 'Medication value units' }, HBA1C: { value: 0x40, name: 'HbA1c present' }, EXTENDED: { value: 0x80, name: 'Extended flags present' }, }; let self = null; export default class bluetoothLE extends EventTarget { constructor() { super(); this.records = []; this.contextRecords = []; self = this; // so that we can access it from event handler } static timeout(delay) { return new Promise((resolve, reject) => setTimeout(reject, delay, new Error('Timeout error'))); } async scan() { debug('Requesting Bluetooth Device...'); debug(`with ${JSON.stringify(options)}`); if (typeof navigator !== 'undefined') { this.device = await Promise.race([ bluetoothLE.timeout(15000), navigator.bluetooth.requestDevice(options), ]); debug(`Name: ${this.device.name}`); debug(`Id: ${this.device.id}`); debug(`Connected: ${this.device.gatt.connected}`); } else { throw new Error('navigator not available.'); } } async connectTimeout(timeout = 40000) { await Promise.race([ this.connect(), bluetoothLE.timeout(timeout), ]).catch((err) => { debug('Error:', err); throw err; }); } async connect() { try { this.server = await this.device.gatt.connect(); debug('Connected.'); this.deviceInfoService = await this.server.getPrimaryService('device_information'); this.glucoseService = await this.server.getPrimaryService('glucose'); debug('Retrieved services.'); const glucoseFeature = await this.glucoseService.getCharacteristic('glucose_feature'); const features = await glucoseFeature.readValue(); debug('Glucose features:', features.getUint16().toString(2).padStart(16, '0')); this.glucoseMeasurement = await this.glucoseService.getCharacteristic('glucose_measurement'); await this.glucoseMeasurement.startNotifications(); try { this.glucoseMeasurementContext = await this.glucoseService.getCharacteristic('glucose_measurement_context'); await this.glucoseMeasurementContext.startNotifications(); this.glucoseMeasurementContext.addEventListener('characteristicvaluechanged', bluetoothLE.handleContextNotifications); } catch (err) { debug(err); } this.racp = await this.glucoseService.getCharacteristic('record_access_control_point'); await this.racp.startNotifications(); debug('Notifications started.'); this.glucoseMeasurement.addEventListener('characteristicvaluechanged', this.handleNotifications); this.racp.addEventListener('characteristicvaluechanged', this.handleRACP); debug('Event listeners added.'); } catch (error) { debug(`Argh! ${error}`); throw error; } } async disconnect() { if (!this.device) { return; } debug('Stopping notifications and removing event listeners...'); try { this.glucoseMeasurement.removeEventListener( 'characteristicvaluechanged', this.handleNotifications, ); await this.glucoseMeasurement.stopNotifications(); this.glucoseMeasurement = null; } catch (err) { debug('Could not stop glucose measurement'); } try { this.glucoseMeasurementContext.removeEventListener( 'characteristicvaluechanged', this.handleContextNotifications, ); await this.glucoseMeasurementContext.stopNotifications(); this.glucoseMeasurementContext = null; } catch (err) { debug('Could not stop glucose measurement context'); } try { this.racp.removeEventListener( 'characteristicvaluechanged', this.handleRACP, ); debug('Removed RACP listener'); await this.racp.stopNotifications(); this.racp = null; } catch (err) { debug('Could not stop RACP'); } debug('Disconnecting from Bluetooth Device...'); if (this.device && this.device.gatt && this.device.gatt.connected) { this.device.gatt.disconnect(); } else { debug('Bluetooth Device is already disconnected'); } } async getDeviceInfo() { debug('Getting Device Information Characteristics...'); const characteristics = await this.deviceInfoService.getCharacteristics(); self.deviceInfo = {}; const decoder = new TextDecoder('utf-8'); /* eslint-disable no-await-in-loop */ for (let i = 0; i < characteristics.length; i += 1) { switch (characteristics[i].uuid) { case BluetoothUUID.getCharacteristic('manufacturer_name_string'): self.deviceInfo.manufacturers = [decoder.decode(await characteristics[i].readValue())]; break; case BluetoothUUID.getCharacteristic('model_number_string'): self.deviceInfo.model = decoder.decode(await characteristics[i].readValue()); break; default: break; } } /* eslint-enable no-await-in-loop */ return self.deviceInfo; } async sendCommand(cmd) { await this.racp.writeValueWithResponse(new Uint8Array(cmd)); debug('Sent command.'); } async getNumberOfRecords() { await this.sendCommand([0x04, 0x01]); } async getDeltaNumberOfRecords(seqNum) { const buffer = new ArrayBuffer(5); const view = new DataView(buffer); view.setUint8(0, 0x04); // op code: report number of stored records view.setUint8(1, 0x03); // operator: greater than or equal to view.setUint8(2, 0x01); // operand: filter type - sequence number view.setUint16(3, seqNum, true); // operand: sequence number await this.sendCommand(buffer); } async getAllRecords() { self.records = []; self.contextRecords = []; await this.sendCommand([0x01, 0x01]); } async getDeltaRecords(seqNum) { const buffer = new ArrayBuffer(5); const view = new DataView(buffer); view.setUint8(0, 0x01); // op code: report stored records view.setUint8(1, 0x03); // operator: greater than or equal to view.setUint8(2, 0x01); // operand: filter type - sequence number view.setUint16(3, seqNum, true); // operand: sequence number self.records = []; self.contextRecords = []; await this.sendCommand(buffer); } static handleContextNotifications(event) { const { value } = event.target; debug('Received context:', bluetoothLE.buf2hex(value.buffer)); this.parsed = bluetoothLE.parseMeasurementContext(value); self.contextRecords.push(this.parsed); } handleNotifications(event) { const { value } = event.target; debug('Received:', bluetoothLE.buf2hex(value.buffer)); this.parsed = bluetoothLE.parseGlucoseMeasurement(value); if (this.parsed.seqNum !== self.records[self.records.length-1]?.seqNum) { self.dispatchEvent(new CustomEvent('sequenceNumber', { detail: this.parsed.seqNum, })); self.records.push(this.parsed); } else { debug('Skipping double entry..'); } } handleRACP(event) { const { value } = event.target; this.racpObject = { opCode: value.getUint8(0), operator: value.getUint8(1), operand: value.getUint16(2, true), }; debug('RACP Event:', this.racpObject); switch (this.racpObject.opCode) { case 0x05: self.dispatchEvent(new CustomEvent('numberOfRecords', { detail: this.racpObject.operand, })); break; case 0x06: if (this.racpObject.operand === 0x0101) { debug('Success.'); self.dispatchEvent(new CustomEvent('data', { detail: { records: self.records, contextRecords: self.contextRecords, }, })); } else if (this.racpObject.operand === 0x0601) { // no records found self.dispatchEvent(new CustomEvent('data', { detail: [], })); } break; default: throw Error('Unrecognized op code'); } } static parseMeasurementContext(result) { const record = { flags: result.getUint8(0), seqNum: result.getUint16(1, true), }; let offset = 3; if (this.hasFlag(CONTEXT_FLAGS.EXTENDED, record.flags)) { record.extended = result.getUint8(offset); offset += 1; } if (this.hasFlag(CONTEXT_FLAGS.CARBS, record.flags)) { record.carbID = result.getUint8(offset); record.carbUnits = result.getUint16(offset + 1, true); offset += 2; } if (this.hasFlag(CONTEXT_FLAGS.MEAL, record.flags)) { record.meal = result.getUint8(offset); offset += 1; } return record; } static parseGlucoseMeasurement(result) { const record = { flags: result.getUint8(0), seqNum: result.getUint16(1, true), }; let offset = 0; const dateTime = { year: result.getUint16(3, true), month: result.getUint8(5), day: result.getUint8(6), hours: result.getUint8(7), minutes: result.getUint8(8), seconds: result.getUint8(9), }; if (dateTime.month === 13) { // handle i-SENS firmware bug, where the month for base time // is calculated incorrectly when they subtract time offset dateTime.month = 1; } if (this.hasFlag(FLAGS.TIME_OFFSET_PRESENT, record.flags)) { record.payload = { internalTime: sundial.buildTimestamp(dateTime), timeOffset: result.getInt16(10, true), }; offset += 2; } if ((self.device.name != 'TNG VOICE') && record.payload?.timeOffset) { // TNG VOICE doesn't use time offsets correctly, so we only apply them for other meters record.timestamp = sundial.applyOffset( record.payload.internalTime, record.payload.timeOffset, ); } else { record.timestamp = sundial.buildTimestamp(dateTime); } if (this.hasFlag(FLAGS.GLUCOSE_PRESENT, record.flags)) { if (this.hasFlag(FLAGS.IS_MMOL, record.flags)) { record.units = 'mmol/L'; } else { record.units = 'mg/dL'; } record.value = this.getSFLOAT(result.getUint16(offset + 10, true), record.units); record.type = result.getUint8(offset + 12) >> 4; record.location = result.getUint8(offset + 12) && 0x0F; if (this.hasFlag(FLAGS.STATUS_PRESENT, record.flags)) { record.status = result.getUint16(offset + 13, true); } } else { debug('No glucose value present for ', sundial.formatDeviceTime(record.timestamp)); } record.hasContext = this.hasFlag(FLAGS.CONTEXT_INFO, record.flags); return record; } static getSFLOAT(value, units) { switch (value) { case 0x07FF: return NaN; case 0x0800: return NaN; case 0x07FE: return Number.POSITIVE_INFINITY; case 0x0802: return Number.NEGATIVE_INFINITY; case 0x0801: return NaN; default: break; } let exponent = value >> 12; let mantissa = value & 0x0FFF; if (exponent >= 0x0008) { exponent = -((0x000F + 1) - exponent); } if (units === 'mg/dL') { exponent += 5; // convert kg/L to mg/dL } else if (units === 'mmol/L') { exponent += 3; // convert mol/L to mmol/L } else { throw Error('Illegal units for glucose value'); } if (mantissa >= 0x0800) { mantissa = -((0x0FFF + 1) - mantissa); } return mantissa * (10 ** exponent); } static hasFlag(flag, v) { if (flag.value & v) { return true; } return false; } static buf2hex(buffer) { return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(' '); } }