1
0
Fork 0

Merge pull request #1655 from GrandVizierOlaf/bt-hrm-active

[bthrm] Various fixes
master
Gordon Williams 2022-05-24 16:27:12 +01:00 committed by GitHub
commit e517262237
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 193 additions and 224 deletions

View File

@ -2,7 +2,7 @@
When this app is installed it overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.
HRM is requested it searches on Bluetooth for a heart rate monitor, connects, and sends data back using the `Bangle.on('HRM'` event as if it came from the on board monitor.
HRM is requested it searches on Bluetooth for a heart rate monitor, connects, and sends data back using the `Bangle.on('HRM')` event as if it came from the on board monitor.
This means it's compatible with many Bangle.js apps including:
@ -16,19 +16,23 @@ as that requires live sensor data (rather than just BPM readings).
Just install the app, then install an app that uses the heart rate monitor.
Once installed it'll automatically try and connect to the first bluetooth
heart rate monitor it finds.
Once installed you will have to go into this app's settings while your heart rate monitor
is available for bluetooth pairing and scan for devices.
**To disable this and return to normal HRM, uninstall the app**
## Compatible Heart Rate Monitors
This works with any heart rate monitor providing the standard Bluetooth
Heart Rate Service (`180D`) and characteristic (`2A37`).
Heart Rate Service (`180D`) and characteristic (`2A37`). It additionally supports
the location (`2A38`) characteristic and the Battery Service (`180F`), reporting
that information in the `BTHRM` event when they are available.
So far it has been tested on:
* CooSpo Bluetooth Heart Rate Monitor
* Polar H10
* Polar OH1
* Wahoo TICKR X 2
## Internals
@ -38,7 +42,6 @@ This replaces `Bangle.setHRMPower` with its own implementation.
## TODO
* A widget to show connection state?
* Specify a specific device by address?
## Creator

View File

@ -3,7 +3,7 @@
require('Storage').readJSON("bthrm.default.json", true) || {},
require('Storage').readJSON("bthrm.json", true) || {}
);
var log = function(text, param){
if (settings.debuglog){
var logline = new Date().toISOString() + " - " + text;
@ -13,39 +13,38 @@
print(logline);
}
};
log("Settings: ", settings);
if (settings.enabled){
function clearCache(){
var clearCache = function() {
return require('Storage').erase("bthrm.cache.json");
}
};
function getCache(){
var getCache = function() {
var cache = require('Storage').readJSON("bthrm.cache.json", true) || {};
if (settings.btname && settings.btname == cache.name) return cache;
if (settings.btid && settings.btid === cache.id) return cache;
clearCache();
return {};
}
function addNotificationHandler(characteristic){
};
var addNotificationHandler = function(characteristic) {
log("Setting notification handler: " + supportedCharacteristics[characteristic.uuid].handler);
characteristic.on('characteristicvaluechanged', supportedCharacteristics[characteristic.uuid].handler);
}
function writeCache(cache){
characteristic.on('characteristicvaluechanged', (ev) => supportedCharacteristics[characteristic.uuid].handler(ev.target.value));
};
var writeCache = function(cache) {
var oldCache = getCache();
if (oldCache != cache) {
if (oldCache !== cache) {
log("Writing cache");
require('Storage').writeJSON("bthrm.cache.json", cache)
require('Storage').writeJSON("bthrm.cache.json", cache);
} else {
log("No changes, don't write cache");
}
}
};
function characteristicsToCache(characteristics){
var characteristicsToCache = function(characteristics) {
log("Cache characteristics");
var cache = getCache();
if (!cache.characteristics) cache.characteristics = {};
@ -60,9 +59,9 @@
};
}
writeCache(cache);
}
};
function characteristicsFromCache(){
var characteristicsFromCache = function() {
log("Read cached characteristics");
var cache = getCache();
if (!cache.characteristics) return [];
@ -81,38 +80,34 @@
restored.push(r);
}
return restored;
}
};
log("Start");
var lastReceivedData={
};
var serviceFilters = [{
services: [ "180d" ]
}];
supportedServices = [
"0x180d", "0x180f"
var supportedServices = [
"0x180d", // Heart Rate
"0x180f", // Battery
];
var supportedCharacteristics = {
"0x2a37": {
//Heart rate measurement
handler: function (event){
var dv = event.target.value;
handler: function (dv){
var flags = dv.getUint8(0);
var bpm = (flags & 1) ? (dv.getUint16(1) / 100 /* ? */ ) : dv.getUint8(1); // 8 or 16 bit
var sensorContact;
if (flags & 2){
sensorContact = (flags & 4) ? true : false;
sensorContact = !!(flags & 4);
}
var idx = 2 + (flags&1);
var energyExpended;
if (flags & 8){
energyExpended = dv.getUint16(idx,1);
@ -121,11 +116,11 @@
var interval;
if (flags & 16) {
interval = [];
maxIntervalBytes = (dv.byteLength - idx);
var maxIntervalBytes = (dv.byteLength - idx);
log("Found " + (maxIntervalBytes / 2) + " rr data fields");
for(var i = 0 ; i < maxIntervalBytes / 2; i++){
interval[i] = dv.getUint16(idx,1); // in milliseconds
idx += 2
idx += 2;
}
}
@ -140,45 +135,44 @@
}
if (settings.replace){
var newEvent = {
var repEvent = {
bpm: bpm,
confidence: (sensorContact || sensorContact === undefined)? 100 : 0,
src: "bthrm"
};
log("Emitting HRM: ", newEvent);
Bangle.emit("HRM", newEvent);
log("Emitting HRM: ", repEvent);
Bangle.emit("HRM", repEvent);
}
var newEvent = {
bpm: bpm
};
if (location) newEvent.location = location;
if (interval) newEvent.rr = interval;
if (energyExpended) newEvent.energy = energyExpended;
if (battery) newEvent.battery = battery;
if (sensorContact) newEvent.contact = sensorContact;
log("Emitting BTHRM: ", newEvent);
Bangle.emit("BTHRM", newEvent);
}
},
"0x2a38": {
//Body sensor location
handler: function(data){
handler: function(dv){
if (!lastReceivedData["0x180d"]) lastReceivedData["0x180d"] = {};
if (!lastReceivedData["0x180d"]["0x2a38"]) lastReceivedData["0x180d"]["0x2a38"] = data.target.value;
lastReceivedData["0x180d"]["0x2a38"] = parseInt(dv.buffer, 10);
}
},
"0x2a19": {
//Battery
handler: function (event){
handler: function (dv){
if (!lastReceivedData["0x180f"]) lastReceivedData["0x180f"] = {};
if (!lastReceivedData["0x180f"]["0x2a19"]) lastReceivedData["0x180f"]["0x2a19"] = event.target.value.getUint8(0);
lastReceivedData["0x180f"]["0x2a19"] = dv.getUint8(0);
}
}
};
var device;
@ -195,7 +189,7 @@
maxInterval: 1500
};
function waitingPromise(timeout) {
var waitingPromise = function(timeout) {
return new Promise(function(resolve){
log("Start waiting for " + timeout);
setTimeout(()=>{
@ -203,7 +197,7 @@
resolve();
}, timeout);
});
}
};
if (settings.enabled){
Bangle.isBTHRMOn = function(){
@ -215,7 +209,6 @@
};
}
if (settings.replace){
var origIsHRMOn = Bangle.isHRMOn;
@ -229,15 +222,15 @@
};
}
function clearRetryTimeout(){
var clearRetryTimeout = function() {
if (currentRetryTimeout){
log("Clearing timeout " + currentRetryTimeout);
clearTimeout(currentRetryTimeout);
currentRetryTimeout = undefined;
}
}
};
function retry(){
var retry = function() {
log("Retry");
if (!currentRetryTimeout){
@ -252,17 +245,17 @@
initBt();
}, clampedTime);
retryTime = Math.pow(retryTime, 1.1);
retryTime = Math.pow(clampedTime, 1.1);
if (retryTime > maxRetryTime){
retryTime = maxRetryTime;
}
} else {
log("Already in retry...");
}
}
};
var buzzing = false;
function onDisconnect(reason) {
var onDisconnect = function(reason) {
log("Disconnect: " + reason);
log("GATT: ", gatt);
log("Characteristics: ", characteristics);
@ -277,11 +270,23 @@
if (Bangle.isBTHRMOn()){
retry();
}
}
};
function createCharacteristicPromise(newCharacteristic){
var createCharacteristicPromise = function(newCharacteristic) {
log("Create characteristic promise: ", newCharacteristic);
var result = Promise.resolve();
// For values that can be read, go ahead and read them, even if we might be notified in the future
// Allows for getting initial state of infrequently updating characteristics, like battery
if (newCharacteristic.readValue){
result = result.then(()=>{
log("Reading data for " + JSON.stringify(newCharacteristic));
return newCharacteristic.readValue().then((data)=>{
if (supportedCharacteristics[newCharacteristic.uuid] && supportedCharacteristics[newCharacteristic.uuid].handler) {
supportedCharacteristics[newCharacteristic.uuid].handler(data);
}
});
});
}
if (newCharacteristic.properties.notify){
result = result.then(()=>{
log("Starting notifications for: ", newCharacteristic);
@ -290,31 +295,23 @@
log("Add " + settings.gracePeriodNotification + "ms grace period after starting notifications");
startPromise = startPromise.then(()=>{
log("Wait after connect");
waitingPromise(settings.gracePeriodNotification)
return waitingPromise(settings.gracePeriodNotification);
});
}
return startPromise;
});
} else if (newCharacteristic.read){
result = result.then(()=>{
readData(newCharacteristic);
log("Reading data for " + newCharacteristic);
return newCharacteristic.read().then((data)=>{
supportedCharacteristics[newCharacteristic.uuid].handler(data);
});
});
}
return result.then(()=>log("Handled characteristic: ", newCharacteristic));
}
function attachCharacteristicPromise(promise, characteristic){
};
var attachCharacteristicPromise = function(promise, characteristic) {
return promise.then(()=>{
log("Handling characteristic:", characteristic);
return createCharacteristicPromise(characteristic);
});
}
function createCharacteristicsPromise(newCharacteristics){
};
var createCharacteristicsPromise = function(newCharacteristics) {
log("Create characteristics promise: ", newCharacteristics);
var result = Promise.resolve();
for (var c of newCharacteristics){
@ -324,13 +321,13 @@
if (c.properties.notify){
addNotificationHandler(c);
}
result = attachCharacteristicPromise(result, c);
}
return result.then(()=>log("Handled characteristics"));
}
function createServicePromise(service){
};
var createServicePromise = function(service) {
log("Create service promise: ", service);
var result = Promise.resolve();
result = result.then(()=>{
@ -338,15 +335,13 @@
return service.getCharacteristics().then((c)=>createCharacteristicsPromise(c));
});
return result.then(()=>log("Handled service" + service.uuid));
}
function attachServicePromise(promise, service){
return promise.then(()=>createServicePromise(service));
}
var reUseCounter = 0;
};
function initBt() {
var attachServicePromise = function(promise, service) {
return promise.then(()=>createServicePromise(service));
};
var initBt = function () {
log("initBt with blockInit: " + blockInit);
if (blockInit){
retry();
@ -355,63 +350,58 @@
blockInit = true;
if (reUseCounter > 10){
log("Reuse counter to high");
gatt=undefined;
reUseCounter = 0;
}
var promise;
var filters;
if (!device){
var filters = serviceFilters;
if (settings.btname){
log("Configured device name", settings.btname);
filters = [{name: settings.btname}];
if (settings.btid){
log("Configured device id", settings.btid);
filters = [{ id: settings.btid }];
} else {
return;
}
log("Requesting device with filters", filters);
promise = NRF.requestDevice({ filters: filters });
promise = NRF.requestDevice({ filters: filters, active: true });
if (settings.gracePeriodRequest){
log("Add " + settings.gracePeriodRequest + "ms grace period after request");
}
promise = promise.then((d)=>{
log("Got device: ", d);
d.on('gattserverdisconnected', onDisconnect);
device = d;
});
promise = promise.then(()=>{
log("Wait after request");
return waitingPromise(settings.gracePeriodRequest);
});
} else {
promise = Promise.resolve();
log("Reuse device: ", device);
}
promise = promise.then(()=>{
if (gatt){
log("Reuse GATT: ", gatt);
} else {
log("GATT is new: ", gatt);
characteristics = [];
var cachedName = getCache().name;
if (device.name != cachedName){
log("Device name changed from " + cachedName + " to " + device.name + ", clearing cache");
var cachedId = getCache().id;
if (device.id !== cachedId){
log("Device ID changed from " + cachedId + " to " + device.id + ", clearing cache");
clearCache();
}
var newCache = getCache();
newCache.name = device.name;
newCache.id = device.id;
writeCache(newCache);
gatt = device.gatt;
}
return Promise.resolve(gatt);
});
promise = promise.then((gatt)=>{
if (!gatt.connected){
var connectPromise = gatt.connect(connectSettings);
@ -427,16 +417,28 @@
return Promise.resolve();
}
});
/* promise = promise.then(() => {
log(JSON.stringify(gatt.getSecurityStatus()));
if (gatt.getSecurityStatus()['bonded']) {
log("Already bonded");
return Promise.resolve();
} else {
log("Start bonding");
return gatt.startBonding()
.then(() => console.log(gatt.getSecurityStatus()));
}
});*/
promise = promise.then(()=>{
if (!characteristics || characteristics.length == 0){
if (!characteristics || characteristics.length === 0){
characteristics = characteristicsFromCache();
}
});
promise = promise.then(()=>{
var characteristicsPromise = Promise.resolve();
if (characteristics.length == 0){
if (characteristics.length === 0){
characteristicsPromise = characteristicsPromise.then(()=>{
log("Getting services");
return gatt.getPrimaryServices();
@ -454,24 +456,22 @@
log("Add " + settings.gracePeriodService + "ms grace period after services");
result = result.then(()=>{
log("Wait after services");
return waitingPromise(settings.gracePeriodService)
return waitingPromise(settings.gracePeriodService);
});
}
return result;
});
} else {
for (var characteristic of characteristics){
characteristicsPromise = attachCharacteristicPromise(characteristicsPromise, characteristic, true);
}
}
return characteristicsPromise;
});
promise = promise.then(()=>{
return promise.then(()=>{
log("Connection established, waiting for notifications");
reUseCounter = 0;
characteristicsToCache(characteristics);
clearRetryTimeout();
}).catch((e) => {
@ -479,7 +479,7 @@
log("Error:", e);
onDisconnect(e);
});
}
};
Bangle.setBTHRMPower = function(isOn, app) {
// Do app power handling
@ -487,7 +487,7 @@
if (Bangle._PWR===undefined) Bangle._PWR={};
if (Bangle._PWR.BTHRM===undefined) Bangle._PWR.BTHRM=[];
if (isOn && !Bangle._PWR.BTHRM.includes(app)) Bangle._PWR.BTHRM.push(app);
if (!isOn && Bangle._PWR.BTHRM.includes(app)) Bangle._PWR.BTHRM = Bangle._PWR.BTHRM.filter(a=>a!=app);
if (!isOn && Bangle._PWR.BTHRM.includes(app)) Bangle._PWR.BTHRM = Bangle._PWR.BTHRM.filter(a=>a!==app);
isOn = Bangle._PWR.BTHRM.length;
// so now we know if we're really on
if (isOn) {
@ -510,7 +510,7 @@
}
}
};
var origSetHRMPower = Bangle.setHRMPower;
if (settings.startWithHrm){
@ -525,11 +525,10 @@
}
};
}
var fallbackInterval;
function switchInternalHrm(){
var switchInternalHrm = function() {
if (settings.allowFallback && !fallbackInterval){
log("Fallback to HRM enabled");
origSetHRMPower(1, "bthrm_fallback");
@ -542,7 +541,7 @@
}
}, settings.fallbackTimeout);
}
}
};
if (settings.replace){
log("Replace HRM event");
@ -557,11 +556,11 @@
}
switchInternalHrm();
}
E.on("kill", ()=>{
if (gatt && gatt.connected){
log("Got killed, trying to disconnect");
var promise = gatt.disconnect().then(()=>log("Disconnected on kill")).catch((e)=>log("Error during disconnnect on kill", e));
gatt.disconnect().then(()=>log("Disconnected on kill")).catch((e)=>log("Error during disconnnect on kill", e));
}
});
}

View File

@ -1,7 +1,16 @@
var btm = g.getHeight()-1;
var intervalInt;
var intervalBt;
var BODY_LOCS = {
0: 'Other',
1: 'Chest',
2: 'Wrist',
3: 'Finger',
4: 'Hand',
5: 'Ear Lobe',
6: 'Foot',
}
function clear(y){
g.reset();
g.clearRect(0,y,g.getWidth(),y+75);
@ -15,17 +24,17 @@ function draw(y, type, event) {
g.setFontAlign(0,0);
g.setFontVector(40).drawString(str,px,y+20);
str = "Event: " + type;
if (type == "HRM") {
if (type === "HRM") {
str += " Confidence: " + event.confidence;
g.setFontVector(12).drawString(str,px,y+40);
str = " Source: " + (event.src ? event.src : "internal");
g.setFontVector(12).drawString(str,px,y+50);
}
if (type == "BTHRM"){
if (type === "BTHRM"){
if (event.battery) str += " Bat: " + (event.battery ? event.battery : "");
g.setFontVector(12).drawString(str,px,y+40);
str= "";
if (event.location) str += "Loc: " + event.location.toFixed(0) + "ms";
if (event.location) str += "Loc: " + BODY_LOCS[event.location];
if (event.rr && event.rr.length > 0) str += " RR: " + event.rr.join(",");
g.setFontVector(12).drawString(str,px,y+50);
str= "";
@ -45,7 +54,7 @@ function onBtHrm(e) {
firstEventBt = false;
}
draw(100, "BTHRM", e);
if (e.bpm == 0){
if (e.bpm === 0){
Bangle.buzz(100,0.2);
}
if (intervalBt){

View File

@ -7,10 +7,10 @@
"allowFallback": true,
"warnDisconnect": false,
"fallbackTimeout": 10,
"custom_replace": false,
"custom_replace": true,
"custom_debuglog": false,
"custom_startWithHrm": false,
"custom_allowFallback": false,
"custom_startWithHrm": true,
"custom_allowFallback": true,
"custom_warnDisconnect": false,
"custom_fallbackTimeout": 10,
"gracePeriodNotification": 0,

View File

@ -6,7 +6,7 @@
"description": "Overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.",
"icon": "app.png",
"type": "app",
"tags": "health,bluetooth",
"tags": "health,bluetooth,hrm,bthrm",
"supports": ["BANGLEJS","BANGLEJS2"],
"readme": "README.md",
"storage": [

View File

@ -5,14 +5,14 @@
require('Storage').writeJSON(FILE, s);
readSettings();
}
function readSettings(){
settings = Object.assign(
require('Storage').readJSON("bthrm.default.json", true) || {},
require('Storage').readJSON(FILE, true) || {}
);
}
var FILE="bthrm.json";
var settings;
readSettings();
@ -61,12 +61,13 @@
}
};
if (settings.btname){
var name = "Clear " + settings.btname;
if (settings.btname || settings.btid){
var name = "Clear " + (settings.btname || settings.btid);
mainmenu[name] = function() {
E.showPrompt("Clear current device name?").then((r)=>{
E.showPrompt("Clear current device?").then((r)=>{
if (r) {
writeSettings("btname",undefined);
writeSettings("btid",undefined);
}
E.showMenu(buildMainMenu());
});
@ -78,9 +79,7 @@
mainmenu.Debug = function() { E.showMenu(submenu_debug); };
return mainmenu;
}
var submenu_debug = {
'' : { title: "Debug"},
'< Back': function() { E.showMenu(buildMainMenu()); },
@ -103,35 +102,39 @@
function createMenuFromScan(){
E.showMenu();
E.showMessage("Scanning");
E.showMessage("Scanning for 4 seconds");
var submenu_scan = {
'' : { title: "Scan"},
'< Back': function() { E.showMenu(buildMainMenu()); }
};
var packets=10;
var scanStart=Date.now();
NRF.setScan(function(d) {
packets--;
if (packets<=0 || Date.now() - scanStart > 5000){
NRF.setScan();
E.showMenu(submenu_scan);
} else if (d.name){
print("Found device", d);
submenu_scan[d.name] = function(){
E.showPrompt("Set "+d.name+"?").then((r)=>{
if (r) {
writeSettings("btname",d.name);
}
E.showMenu(buildMainMenu());
NRF.findDevices(function(devices) {
submenu_scan[''] = { title: `Scan (${devices.length} found)`};
if (devices.length === 0) {
E.showAlert("No devices found")
.then(() => E.showMenu(buildMainMenu()));
return;
} else {
devices.forEach((d) => {
print("Found device", d);
var shown = (d.name || d.id.substr(0, 17));
submenu_scan[shown] = function () {
E.showPrompt("Set " + shown + "?").then((r) => {
if (r) {
writeSettings("btid", d.id);
// Store the name for displaying later. Will connect by ID
if (d.name) {
writeSettings("btname", d.name);
}
}
E.showMenu(buildMainMenu());
});
};
});
};
}
}, { filters: [{services: [ "180d" ]}]});
E.showMenu(submenu_scan);
}, { timeout: 4000, active: true, filters: [{services: [ "180d" ]}]});
}
var submenu_custom = {
'' : { title: "Custom mode"},
'< Back': function() { E.showMenu(buildMainMenu()); },
@ -167,7 +170,7 @@
}
},
};
var submenu_grace = {
'' : { title: "Grace periods"},
'< Back': function() { E.showMenu(submenu_debug); },
@ -212,51 +215,6 @@
}
}
};
var submenu = {
'' : { title: "Grace periods"},
'< Back': function() { E.showMenu(buildMainMenu()); },
'Request': {
value: settings.gracePeriodRequest,
min: 0,
max: 3000,
step: 100,
format: v=>v+"ms",
onchange: v => {
writeSettings("gracePeriodRequest",v);
}
},
'Connect': {
value: settings.gracePeriodConnect,
min: 0,
max: 3000,
step: 100,
format: v=>v+"ms",
onchange: v => {
writeSettings("gracePeriodConnect",v);
}
},
'Notification': {
value: settings.gracePeriodNotification,
min: 0,
max: 3000,
step: 100,
format: v=>v+"ms",
onchange: v => {
writeSettings("gracePeriodNotification",v);
}
},
'Service': {
value: settings.gracePeriodService,
min: 0,
max: 3000,
step: 100,
format: v=>v+"ms",
onchange: v => {
writeSettings("gracePeriodService",v);
}
}
};
E.showMenu(buildMainMenu());
})
});

View File

@ -2,7 +2,7 @@
Logs health data to a file every 10 minutes, and provides an app to view it
**BETA - requires firmware 2v11**
**BETA - requires firmware 2v11 or later**
## Usage

View File

@ -140,7 +140,7 @@ declare const require: ((module: 'heatshrink') => {
declare const Bangle: {
// functions
buzz: () => void;
buzz: (duration?: number, intensity?: number) => Promise<void>;
drawWidgets: () => void;
isCharging: () => boolean;
// events
@ -158,9 +158,9 @@ declare type Image = {
};
declare type GraphicsApi = {
reset: () => void;
reset: () => GraphicsApi;
flip: () => void;
setColor: (color: string) => void; // TODO we can most likely type color more usefully than this
setColor: (color: string) => GraphicsApi; // TODO we can most likely type color more usefully than this
drawImage: (
image: string | Image | ArrayBuffer,
xOffset: number,
@ -169,7 +169,7 @@ declare type GraphicsApi = {
rotate?: number;
scale?: number;
}
) => void;
) => GraphicsApi;
// TODO add more
};