Merge branch 'master' of github.com:espruino/BangleApps

master
Gordon Williams 2022-02-04 08:05:57 +00:00
commit c972abe75c
17 changed files with 1193 additions and 264 deletions

View File

@ -0,0 +1,60 @@
name: Bangle bug report
description: "Bangle: Create a issue to help us improve!"
title: "Short description and provide the affected app/widget"
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
# Attention: If you have a question then ask it at the [bangle forum](http://forum.espruino.com/microcosms/1424/), please!
- type: dropdown
id: hwversion
attributes:
label: Affected hardware version
description: |
Which Bangle hardware version(s) is/are affected?
_Hint: You can select multiple entries._
options:
- Bangle 1
- Bangle 2
multiple: true
validations:
required: true
- type: input
id: fwversion
attributes:
label: Your firmware version
description: |
## Please make sure you installed the latest (released) firmware!
How to see FW version? Within the AppLoader at "More..."-page: Device info; via the "about"-app or the "firmware"-widget.
If the issue will occur in "Cutting Edge build" only, please mention this.
FW Update instructions:
**Bangle 2: [firmware update instructions](https://www.espruino.com/Bangle.js2#firmware-updates)**
**Bangle 1: [firmware update instructions](https://www.espruino.com/Bangle.js#firmware-updates)**
_Hint: The links will open inplace hold e.g. ctrl/cmd-key and click to open in a new tab instead._
placeholder: e.g. 2v12
validations:
required: true
- type: textarea
id: report
attributes:
label: The bug
description: |
## Please also mention the expected behaviour and steps to reproduce
placeholder: |
###Describe the bug
A clear and concise description of what the bug is.
###Expected behavior
A clear and concise description of what you expected to happen.
###Steps to reproduce
1. Start app xy
2. choose abc
3. bug occurs
It could be helpfull for us to provide the devopment folder in 'bangle apps' folder
validations:
required: true

View File

@ -7,3 +7,10 @@
Show actual source of event in app Show actual source of event in app
0.04: Automatically reconnect BT sensor 0.04: Automatically reconnect BT sensor
App buzzes if no BTHRM events for more than 3 seconds App buzzes if no BTHRM events for more than 3 seconds
0.05: Allow reading additional data if available: HRM battery, position and RR
Better caching of scanned BT device properties
New setting for not starting the BTHRM together with HRM
Save some RAM by not defining functions if disabled in settings
Always emit BTHRM event
Cleanup promises code and allow to configure custom additional waiting times to work around bugs
Disconnect cleanly on exit

View File

@ -1,32 +1,222 @@
(function() { (function() {
//var sf = require("Storage").open("bthrm.log","a"); var settings = Object.assign(
require('Storage').readJSON("bthrm.default.json", true) || {},
require('Storage').readJSON("bthrm.json", true) || {}
);
var log = function(text, param){ var log = function(text, param){
/*var logline = Date.now().toFixed(3) + " - " + text; if (settings.debuglog){
var logline = new Date().toISOString() + " - " + text;
if (param){ if (param){
logline += " " + JSON.stringify(param); logline += " " + JSON.stringify(param);
} }
sf.write(logline + "\n"); print(logline);
print(logline);*/ }
};
log("Settings: ", settings);
if (settings.enabled){
function clearCache(){
return require('Storage').erase("bthrm.cache.json");
}
function getCache(){
return require('Storage').readJSON("bthrm.cache.json", true) || {};
}
function addNotificationHandler(characteristic){
log("Setting notification handler: " + supportedCharacteristics[characteristic.uuid].handler);
characteristic.on('characteristicvaluechanged', supportedCharacteristics[characteristic.uuid].handler);
}
function writeCache(cache){
var oldCache = getCache();
if (oldCache != cache) {
log("Writing cache");
require('Storage').writeJSON("bthrm.cache.json", cache)
} else {
log("No changes, don't write cache");
}
}
function characteristicsToCache(characteristics){
log("Cache characteristics");
var cache = getCache();
if (!cache.characteristics) cache.characteristics = {};
for (var c of characteristics){
//"handle_value":16,"handle_decl":15
log("Saving handle " + c.handle_value + " for characteristic: ", c);
cache.characteristics[c.uuid] = {
"handle": c.handle_value,
"uuid": c.uuid,
"notify": c.properties.notify,
"read": c.properties.read
};
}
writeCache(cache);
}
function characteristicsFromCache(){
log("Read cached characteristics");
var cache = getCache();
if (!cache.characteristics) return [];
var restored = [];
for (var c in cache.characteristics){
var cached = cache.characteristics[c];
var r = new BluetoothRemoteGATTCharacteristic();
log("Restoring characteristic ", cached);
r.handle_value = cached.handle;
r.uuid = cached.uuid;
r.properties = {};
r.properties.notify = cached.notify;
r.properties.read = cached.read;
addNotificationHandler(r);
log("Restored characteristic: ", r);
restored.push(r);
}
return restored;
} }
log("Start"); log("Start");
var blockInit = false; var lastReceivedData={
};
var serviceFilters = [{
services: [ "180d" ]
}];
supportedServices = [
"0x180d", "0x180f"
];
var supportedCharacteristics = {
"0x2a37": {
//Heart rate measurement
handler: function (event){
var dv = event.target.value;
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;
}
var idx = 2 + (flags&1);
var energyExpended;
if (flags & 8){
energyExpended = dv.getUint16(idx,1);
idx += 2;
}
var interval;
if (flags & 16) {
interval = [];
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
}
}
var location;
if (lastReceivedData && lastReceivedData["0x180d"] && lastReceivedData["0x180d"]["0x2a38"]){
location = lastReceivedData["0x180d"]["0x2a38"];
}
var battery;
if (lastReceivedData && lastReceivedData["0x180f"] && lastReceivedData["0x180f"]["0x2a19"]){
battery = lastReceivedData["0x180f"]["0x2a19"];
}
if (settings.replace){
var newEvent = {
bpm: bpm,
confidence: (sensorContact || sensorContact === undefined)? 100 : 0,
src: "bthrm"
};
log("Emitting HRM: ", newEvent);
Bangle.emit("HRM", newEvent);
}
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){
if (!lastReceivedData["0x180d"]) lastReceivedData["0x180d"] = {};
if (!lastReceivedData["0x180d"]["0x2a38"]) lastReceivedData["0x180d"]["0x2a38"] = data.target.value;
}
},
"0x2a19": {
//Battery
handler: function (event){
if (!lastReceivedData["0x180f"]) lastReceivedData["0x180f"] = {};
if (!lastReceivedData["0x180f"]["0x2a19"]) lastReceivedData["0x180f"]["0x2a19"] = event.target.value.getUint8(0);
}
}
};
var device;
var gatt; var gatt;
var characteristics = [];
var blockInit = false;
var currentRetryTimeout; var currentRetryTimeout;
var initialRetryTime = 40; var initialRetryTime = 40;
var maxRetryTime = 60000; var maxRetryTime = 60000;
var retryTime = initialRetryTime; var retryTime = initialRetryTime;
var origIsHRMOn = Bangle.isHRMOn; var connectSettings = {
minInterval: 7.5,
Bangle.isBTHRMOn = function(){ maxInterval: 1500
return (gatt!==undefined && gatt.connected);
}; };
Bangle.isHRMOn = function() { function waitingPromise(timeout) {
var settings = require('Storage').readJSON("bthrm.json", true) || {}; return new Promise(function(resolve){
log("Start waiting for " + timeout);
setTimeout(()=>{
log("Done waiting for " + timeout);
resolve();
}, timeout);
});
}
if (settings.enabled){
Bangle.isBTHRMOn = function(){
return (Bangle._PWR && Bangle._PWR.BTHRM && Bangle._PWR.BTHRM.length > 0);
};
Bangle.isBTHRMConnected = function(){
return gatt && gatt.connected;
};
}
if (settings.replace){
var origIsHRMOn = Bangle.isHRMOn;
Bangle.isHRMOn = function() {
if (settings.enabled && !settings.replace){ if (settings.enabled && !settings.replace){
return origIsHRMOn(); return origIsHRMOn();
} else if (settings.enabled && settings.replace){ } else if (settings.enabled && settings.replace){
@ -34,24 +224,28 @@
} }
return origIsHRMOn() || Bangle.isBTHRMOn(); return origIsHRMOn() || Bangle.isBTHRMOn();
}; };
}
var serviceFilters = [{ function clearRetryTimeout(){
services: [
"180d"
]
}];
function retry(){
log("Retry with time " + retryTime);
if (currentRetryTimeout){ if (currentRetryTimeout){
log("Clearing timeout " + currentRetryTimeout); log("Clearing timeout " + currentRetryTimeout);
clearTimeout(currentRetryTimeout); clearTimeout(currentRetryTimeout);
currentRetryTimeout = undefined; currentRetryTimeout = undefined;
} }
}
function retry(){
log("Retry");
if (!currentRetryTimeout){
var clampedTime = retryTime < 100 ? 100 : retryTime;
var clampedTime = retryTime < 200 ? 200 : initialRetryTime;
currentRetryTimeout = setTimeout(() => {
log("Set timeout for retry as " + clampedTime); log("Set timeout for retry as " + clampedTime);
clearRetryTimeout();
currentRetryTimeout = setTimeout(() => {
log("Retrying");
currentRetryTimeout = undefined;
initBt(); initBt();
}, clampedTime); }, clampedTime);
@ -59,37 +253,95 @@
if (retryTime > maxRetryTime){ if (retryTime > maxRetryTime){
retryTime = maxRetryTime; retryTime = maxRetryTime;
} }
} else {
log("Already in retry...");
}
} }
var buzzing = false;
function onDisconnect(reason) { function onDisconnect(reason) {
log("Disconnect: " + reason); log("Disconnect: " + reason);
log("Gatt: ", gatt); log("GATT: ", gatt);
log("Characteristics: ", characteristics);
retryTime = initialRetryTime;
clearRetryTimeout();
switchInternalHrm();
blockInit = false;
if (settings.warnDisconnect && !buzzing){
buzzing = true;
Bangle.buzz(500,0.3).then(()=>waitingPromise(4500)).then(()=>{buzzing = false;});
}
if (Bangle.isBTHRMOn()){
retry(); retry();
} }
}
function onCharacteristic(event) { function createCharacteristicPromise(newCharacteristic){
var settings = require('Storage').readJSON("bthrm.json", true) || {}; log("Create characteristic promise: ", newCharacteristic);
var dv = event.target.value; var result = Promise.resolve();
var flags = dv.getUint8(0); if (newCharacteristic.properties.notify){
// 0 = 8 or 16 bit result = result.then(()=>{
// 1,2 = sensor contact log("Starting notifications for: ", newCharacteristic);
// 3 = energy expended shown var startPromise = newCharacteristic.startNotifications().then(()=>log("Notifications started for ", newCharacteristic));
// 4 = RR interval if (settings.gracePeriodNotification > 0){
var bpm = (flags & 1) ? (dv.getUint16(1) / 100 /* ? */ ) : dv.getUint8(1); // 8 or 16 bit log("Add " + settings.gracePeriodNotification + "ms grace period after starting notifications");
/* var idx = 2 + (flags&1); // index of next field startPromise = startPromise.then(()=>{
if (flags&8) idx += 2; // energy expended log("Wait after connect");
if (flags&16) { waitingPromise(settings.gracePeriodNotification)
var interval = dv.getUint16(idx,1); // in milliseconds });
}*/ }
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));
}
Bangle.emit(settings.replace ? "HRM" : "BTHRM", { function attachCharacteristicPromise(promise, characteristic){
bpm: bpm, return promise.then(()=>{
confidence: bpm == 0 ? 0 : 100, log("Handling characteristic:", characteristic);
src: settings.replace ? "bthrm" : undefined return createCharacteristicPromise(characteristic);
}); });
} }
var reUseCounter=0; function createCharacteristicsPromise(newCharacteristics){
log("Create characteristics promise: ", newCharacteristics);
var result = Promise.resolve();
for (var c of newCharacteristics){
if (!supportedCharacteristics[c.uuid]) continue;
log("Supporting characteristic: ", c);
characteristics.push(c);
if (c.properties.notify){
addNotificationHandler(c);
}
result = attachCharacteristicPromise(result, c);
}
return result.then(()=>log("Handled characteristics"));
}
function createServicePromise(service){
log("Create service promise: ", service);
var result = Promise.resolve();
result = result.then(()=>{
log("Handling service: " + service.uuid);
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() { function initBt() {
log("initBt with blockInit: " + blockInit); log("initBt with blockInit: " + blockInit);
@ -100,66 +352,127 @@
blockInit = true; blockInit = true;
var connectionPromise; if (reUseCounter > 10){
log("Reuse counter to high");
if (reUseCounter > 3){
log("Reuse counter to high")
if (gatt.connected == true){
try {
log("Force disconnect with gatt: ", gatt);
gatt.disconnect();
} catch(e) {
log("Error during force disconnect", e);
}
}
gatt=undefined; gatt=undefined;
reUseCounter = 0; reUseCounter = 0;
} }
if (!gatt){ var promise;
var requestPromise = NRF.requestDevice({ filters: serviceFilters });
connectionPromise = requestPromise.then(function(device) { if (!device){
gatt = device.gatt; promise = NRF.requestDevice({ filters: serviceFilters });
log("Gatt after request:", gatt);
gatt.device.on('gattserverdisconnected', onDisconnect); 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 { } else {
reUseCounter++; promise = Promise.resolve();
log("Reusing gatt:", gatt); log("Reuse device: ", device);
connectionPromise = gatt.connect();
} }
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");
clearCache();
}
var newCache = getCache();
newCache.name = device.name;
writeCache(newCache);
gatt = device.gatt;
}
var servicePromise = connectionPromise.then(function() { return Promise.resolve(gatt);
return gatt.getPrimaryService(0x180d);
}); });
var characteristicPromise = servicePromise.then(function(service) { promise = promise.then((gatt)=>{
log("Got service:", service); if (!gatt.connected){
return service.getCharacteristic(0x2A37); var connectPromise = gatt.connect(connectSettings);
if (settings.gracePeriodConnect > 0){
log("Add " + settings.gracePeriodConnect + "ms grace period after connecting");
connectPromise = connectPromise.then(()=>{
log("Wait after connect");
return waitingPromise(settings.gracePeriodConnect);
});
}
return connectPromise;
} else {
return Promise.resolve();
}
}); });
var notificationPromise = characteristicPromise.then(function(c) { promise = promise.then(()=>{
log("Got characteristic:", c); if (!characteristics || characteristics.length == 0){
c.on('characteristicvaluechanged', onCharacteristic); characteristics = characteristicsFromCache();
return c.startNotifications(); }
}); });
notificationPromise.then(()=>{
log("Wait for notifications"); promise = promise.then(()=>{
retryTime = initialRetryTime; var getCharacteristicsPromise = Promise.resolve();
blockInit=false; if (characteristics.length == 0){
getCharacteristicsPromise = getCharacteristicsPromise.then(()=>{
log("Getting services");
return gatt.getPrimaryServices();
}); });
notificationPromise.catch((e) => {
getCharacteristicsPromise = getCharacteristicsPromise().then((services)=>{
log("Got services:", services);
var result = Promise.resolve();
for (var service of services){
if (!(supportedServices.includes(service.uuid))) continue;
log("Supporting service: ", service.uuid);
result = attachServicePromise(result, service);
}
if (settings.gracePeriodService > 0) {
log("Add " + settings.gracePeriodService + "ms grace period after services");
result = result.then(()=>{
log("Wait after services");
return waitingPromise(settings.gracePeriodService)
});
}
return result;
});
} else {
for (var characteristic of characteristics){
getCharacteristicsPromise = attachCharacteristicPromise(getCharacteristicsPromise, characteristic, true);
}
}
return getCharacteristicsPromise;
});
promise = promise.then(()=>{
log("Connection established, waiting for notifications");
reUseCounter = 0;
characteristicsToCache(characteristics);
clearRetryTimeout();
}).catch((e) => {
characteristics = [];
log("Error:", e); log("Error:", e);
blockInit = false; onDisconnect(e);
retry();
}); });
} }
Bangle.setBTHRMPower = function(isOn, app) { Bangle.setBTHRMPower = function(isOn, app) {
var settings = require('Storage').readJSON("bthrm.json", true) || {};
// Do app power handling // Do app power handling
if (!app) app="?"; if (!app) app="?";
if (Bangle._PWR===undefined) Bangle._PWR={}; if (Bangle._PWR===undefined) Bangle._PWR={};
@ -169,43 +482,58 @@
isOn = Bangle._PWR.BTHRM.length; isOn = Bangle._PWR.BTHRM.length;
// so now we know if we're really on // so now we know if we're really on
if (isOn) { if (isOn) {
if (!Bangle.isBTHRMOn()) { if (!Bangle.isBTHRMConnected()) initBt();
initBt();
}
} else { // not on } else { // not on
log("Power off for " + app); log("Power off for " + app);
if (gatt) { if (gatt) {
try { if (gatt.connected){
log("Disconnect with gatt: ", gatt); log("Disconnect with gatt: ", gatt);
gatt.disconnect(); gatt.disconnect().then(()=>{
} catch(e) { log("Successful disconnect", e);
}).catch(()=>{
log("Error during disconnect", e); log("Error during disconnect", e);
});
} }
blockInit = false;
gatt = undefined;
} }
} }
}; };
var origSetHRMPower = Bangle.setHRMPower; var origSetHRMPower = Bangle.setHRMPower;
if (settings.startWithHrm){
Bangle.setHRMPower = function(isOn, app) { Bangle.setHRMPower = function(isOn, app) {
log("setHRMPower for " + app + ":" + (isOn?"on":"off")); log("setHRMPower for " + app + ": " + (isOn?"on":"off"));
var settings = require('Storage').readJSON("bthrm.json", true) || {}; if (settings.enabled){
if (settings.enabled || !isOn){
log("Enable BTHRM power");
Bangle.setBTHRMPower(isOn, app); Bangle.setBTHRMPower(isOn, app);
} }
if ((settings.enabled && !settings.replace) || !settings.enabled || !isOn){ if ((settings.enabled && !settings.replace) || !settings.enabled){
log("Enable HRM power");
origSetHRMPower(isOn, app); origSetHRMPower(isOn, app);
} }
};
}
var fallbackInterval;
function switchInternalHrm(){
if (settings.allowFallback && !fallbackInterval){
log("Fallback to HRM enabled");
origSetHRMPower(1, "bthrm_fallback");
fallbackInterval = setInterval(()=>{
if (Bangle.isBTHRMConnected()){
origSetHRMPower(0, "bthrm_fallback");
clearInterval(fallbackInterval);
fallbackInterval = undefined;
log("Fallback to HRM disabled");
}
}, settings.fallbackTimeout);
}
} }
var settings = require('Storage').readJSON("bthrm.json", true) || {}; if (settings.replace){
if (settings.enabled && settings.replace){
log("Replace HRM event"); log("Replace HRM event");
if (!(Bangle._PWR===undefined) && !(Bangle._PWR.HRM===undefined)){ if (Bangle._PWR && Bangle._PWR.HRM){
for (var i = 0; i < Bangle._PWR.HRM.length; i++){ for (var i = 0; i < Bangle._PWR.HRM.length; i++){
var app = Bangle._PWR.HRM[i]; var app = Bangle._PWR.HRM[i];
log("Moving app " + app); log("Moving app " + app);
@ -214,5 +542,16 @@
if (Bangle._PWR.HRM===undefined) break; if (Bangle._PWR.HRM===undefined) break;
} }
} }
switchInternalHrm();
}
E.on("kill", ()=>{
if (gatt && gatt.connected){
log("Got killed, trying to disconnect");
var promise = gatt.disconnect();
promise.then(()=>log("Disconnected on kill"));
promise.catch((e)=>log("Error during disconnnect on kill", e));
}
});
} }
})(); })();

View File

@ -1,69 +1,95 @@
var btm = g.getHeight()-1; var btm = g.getHeight()-1;
var eventInt = null; var intervalInt;
var eventBt = null; var intervalBt;
var counterInt = 0;
var counterBt = 0;
function clear(y){
function draw(y, event, type, counter) {
var px = g.getWidth()/2;
g.reset(); g.reset();
g.setFontAlign(0,0);
g.clearRect(0,y,g.getWidth(),y+75); g.clearRect(0,y,g.getWidth(),y+75);
if (type == null || event == null || counter == 0){
return;
}
var str = event.bpm + "";
g.setFontVector(40).drawString(str,px,y+20);
str = "Confidence: " + event.confidence;
g.setFontVector(12).drawString(str,px,y+50);
str = "Event: " + type;
if (type == "HRM") str += " Source: " + (event.src ? event.src : "internal");
g.setFontVector(12).drawString(str,px,y+60);
} }
function draw(y, type, event) {
clear(y);
var px = g.getWidth()/2;
var str = event.bpm + "";
g.reset();
g.setFontAlign(0,0);
g.setFontVector(40).drawString(str,px,y+20);
str = "Event: " + type;
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 (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.rr && event.rr.length > 0) str += " RR: " + event.rr.join(",");
g.setFontVector(12).drawString(str,px,y+50);
str= "";
if (event.contact) str += " Contact: " + event.contact;
if (event.energy) str += " kJoule: " + event.energy.toFixed(0);
g.setFontVector(12).drawString(str,px,y+60);
}
}
var firstEventBt = true;
var firstEventInt = true;
function onBtHrm(e) { function onBtHrm(e) {
//print("Event for BT " + JSON.stringify(e)); if (firstEventBt){
clear(24);
firstEventBt = false;
}
draw(100, "BTHRM", e);
if (e.bpm == 0){ if (e.bpm == 0){
Bangle.buzz(100,0.2); Bangle.buzz(100,0.2);
} }
if (counterBt == 0){ if (intervalBt){
Bangle.buzz(200,0.5); clearInterval(intervalBt);
} }
counterBt += 3; intervalBt = setInterval(()=>{
eventBt = e; clear(100);
}, 2000);
} }
function onHrm(e) { function onHrm(e) {
//print("Event for Int " + JSON.stringify(e)); if (firstEventInt){
counterInt += 3; clear(24);
eventInt = e; firstEventInt = false;
}
draw(24, "HRM", e);
if (intervalInt){
clearInterval(intervalInt);
}
intervalInt = setInterval(()=>{
clear(24);
}, 2000);
} }
var settings = require('Storage').readJSON("bthrm.json", true) || {};
Bangle.on('BTHRM', onBtHrm); Bangle.on('BTHRM', onBtHrm);
Bangle.on('HRM', onHrm); Bangle.on('HRM', onHrm);
Bangle.setHRMPower(1,'bthrm'); Bangle.setHRMPower(1,'bthrm');
if (!(settings.startWithHrm)){
Bangle.setBTHRMPower(1,'bthrm');
}
g.clear(); g.clear();
Bangle.loadWidgets(); Bangle.loadWidgets();
Bangle.drawWidgets(); Bangle.drawWidgets();
if (Bangle.setBTHRMPower){
g.reset().setFont("6x8",2).setFontAlign(0,0); g.reset().setFont("6x8",2).setFontAlign(0,0);
g.drawString("Please wait...",g.getWidth()/2,g.getHeight()/2 - 16); g.drawString("Please wait...",g.getWidth()/2,g.getHeight()/2 - 24);
} else {
function drawInt(){ g.reset().setFont("6x8",2).setFontAlign(0,0);
counterInt--; g.drawString("BTHRM disabled",g.getWidth()/2,g.getHeight()/2 + 32);
if (counterInt < 0) counterInt = 0;
if (counterInt > 3) counterInt = 3;
draw(24, eventInt, "HRM", counterInt);
}
function drawBt(){
counterBt--;
if (counterBt < 0) counterBt = 0;
if (counterBt > 3) counterBt = 3;
draw(100, eventBt, "BTHRM", counterBt);
} }
var interval = setInterval(drawInt, 1000); E.on('kill', ()=>Bangle.setBTHRMPower(0,'bthrm'));
var interval = setInterval(drawBt, 1000);

13
apps/bthrm/default.json Normal file
View File

@ -0,0 +1,13 @@
{
"enabled": true,
"replace": true,
"debuglog": false,
"startWithHrm": true,
"allowFallback": true,
"warnDisconnect": false,
"fallbackTimeout": 10,
"gracePeriodNotification": 0,
"gracePeriodConnect": 0,
"gracePeriodService": 0,
"gracePeriodRequest": 0
}

View File

@ -2,7 +2,7 @@
"id": "bthrm", "id": "bthrm",
"name": "Bluetooth Heart Rate Monitor", "name": "Bluetooth Heart Rate Monitor",
"shortName": "BT HRM", "shortName": "BT HRM",
"version": "0.04", "version": "0.05",
"description": "Overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.", "description": "Overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.",
"icon": "app.png", "icon": "app.png",
"type": "app", "type": "app",
@ -14,6 +14,7 @@
{"name":"bthrm.recorder.js","url":"recorder.js"}, {"name":"bthrm.recorder.js","url":"recorder.js"},
{"name":"bthrm.boot.js","url":"boot.js"}, {"name":"bthrm.boot.js","url":"boot.js"},
{"name":"bthrm.img","url":"app-icon.js","evaluate":true}, {"name":"bthrm.img","url":"app-icon.js","evaluate":true},
{"name":"bthrm.settings.js","url":"settings.js"} {"name":"bthrm.settings.js","url":"settings.js"},
{"name":"bthrm.default.json","url":"default.json"}
] ]
} }

View File

@ -1,26 +1,38 @@
(function(recorders) { (function(recorders) {
recorders.bthrm = function() { recorders.bthrm = function() {
var bpm = ""; var bpm = "";
var bat = "";
var energy = "";
var contact = "";
var rr= "";
function onHRM(h) { function onHRM(h) {
bpm = h.bpm; bpm = h.bpm;
bat = h.bat;
energy = h.energy;
contact = h.contact;
if (h.rr) rr = h.rr.join(";");
} }
return { return {
name : "BTHR", name : "BT HR",
fields : ["BT Heartrate"], fields : ["BT Heartrate", "BT Battery", "Energy expended", "Contact", "RR"],
getValues : () => { getValues : () => {
result = [bpm]; result = [bpm,bat,energy,contact,rr];
bpm = ""; bpm = "";
rr = "";
bat = "";
energy = "";
contact = "";
return result; return result;
}, },
start : () => { start : () => {
Bangle.on('BTHRM', onHRM); Bangle.on('BTHRM', onHRM);
Bangle.setBTHRMPower(1,"recorder"); if (Bangle.setBTRHMPower) Bangle.setBTHRMPower(1,"recorder");
}, },
stop : () => { stop : () => {
Bangle.removeListener('BTHRM', onHRM); Bangle.removeListener('BTHRM', onHRM);
Bangle.setBTHRMPower(0,"recorder"); if (Bangle.setBTRHMPower) Bangle.setBTHRMPower(0,"recorder");
}, },
draw : (x,y) => g.setColor(Bangle.isBTHRMOn()?"#00f":"#88f").drawImage(atob("DAwBAAAAMMeef+f+f+P8H4DwBgAA"),x,y) draw : (x,y) => g.setColor((Bangle.isBTHRMConnected && Bangle.isBTHRMConnected())?"#00f":"#88f").drawImage(atob("DAwBAAAAMMeef+f+f+P8H4DwBgAA"),x,y)
}; };
} }
}) })

View File

@ -1,33 +1,247 @@
(function(back) { (function(back) {
var FILE = "bthrm.json"; function writeSettings(key, value) {
var s = require('Storage').readJSON(FILE, true) || {};
var settings = Object.assign({ s[key] = value;
enabled: true, require('Storage').writeJSON(FILE, s);
replace: true, readSettings();
}, require('Storage').readJSON(FILE, true) || {});
function writeSettings() {
require('Storage').writeJSON(FILE, settings);
} }
E.showMenu({ function readSettings(){
settings = Object.assign(
require('Storage').readJSON("bthrm.default.json", true) || {},
require('Storage').readJSON(FILE, true) || {}
);
}
var FILE="bthrm.json";
var settings;
readSettings();
var mainmenu = {
'': { 'title': 'Bluetooth HRM' }, '': { 'title': 'Bluetooth HRM' },
'< Back': back, '< Back': back,
'Use BT HRM': { 'Use BT HRM': {
value: !!settings.enabled, value: !!settings.enabled,
format: v => settings.enabled ? "On" : "Off", format: v => settings.enabled ? "On" : "Off",
onchange: v => { onchange: v => {
settings.enabled = v; writeSettings("enabled",v);
writeSettings();
} }
}, },
'Use HRM event': { 'Replace HRM': {
value: !!settings.replace, value: !!settings.replace,
format: v => settings.replace ? "On" : "Off", format: v => settings.replace ? "On" : "Off",
onchange: v => { onchange: v => {
settings.replace = v; writeSettings("replace",v);
writeSettings(); }
},
'Start with HRM': {
value: !!settings.startWithHrm,
format: v => settings.startWithHrm ? "On" : "Off",
onchange: v => {(function(back) {
function writeSettings(key, value) {
var s = require('Storage').readJSON(FILE, true) || {};
s[key] = value;
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();
var mainmenu = {
'': { 'title': 'Bluetooth HRM' },
'< Back': back,
'Use BT HRM': {
value: !!settings.enabled,
format: v => settings.enabled ? "On" : "Off",
onchange: v => {
writeSettings("enabled",v);
}
},
'Replace HRM': {
value: !!settings.replace,
format: v => settings.replace ? "On" : "Off",
onchange: v => {
writeSettings("replace",v);
}
},
'Start w. HRM': {
value: !!settings.startWithHrm,
format: v => settings.startWithHrm ? "On" : "Off",
onchange: v => {
writeSettings("startWithHrm",v);
}
},
'HRM Fallback': {
value: !!settings.allowFallback,
format: v => settings.allowFallback ? "On" : "Off",
onchange: v => {
writeSettings("allowFallback",v);
}
},
'Fallback Timeout': {
value: settings.fallbackTimeout,
min: 5,
max: 60,
step: 5,
format: v=>v+"s",
onchange: v => {
writeSettings("fallbackTimout",v*1000);
}
},
'Conn. Alert': {
value: !!settings.warnDisconnect,
format: v => settings.warnDisconnect ? "On" : "Off",
onchange: v => {
writeSettings("warnDisconnect",v);
}
},
'Debug log': {
value: !!settings.debuglog,
format: v => settings.debuglog ? "On" : "Off",
onchange: v => {
writeSettings("debuglog",v);
}
},
'Grace periods >': function() { E.showMenu(submenu); }
};
var submenu = {
'' : { title: "Grace periods"},
'< Back': function() { E.showMenu(mainmenu); },
'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(mainmenu);
})
writeSettings("startWithHrm",v);
}
},
'Fallback to HRM': {
value: !!settings.allowFallback,
format: v => settings.allowFallback ? "On" : "Off",
onchange: v => {
writeSettings("allowFallback",v);
}
},
'Fallback Timeout': {
value: settings.fallbackTimeout,
min: 5,
max: 60,
step: 5,
format: v=>v+"s",
onchange: v => {
writeSettings("fallbackTimout",v*1000);
}
},
'Conn. Alert': {
value: !!settings.warnDisconnect,
format: v => settings.warnDisconnect ? "On" : "Off",
onchange: v => {
writeSettings("warnDisconnect",v);
}
},
'Debug log': {
value: !!settings.debuglog,
format: v => settings.debuglog ? "On" : "Off",
onchange: v => {
writeSettings("debuglog",v);
}
},
'Grace periods': function() { E.showMenu(submenu); }
};
var submenu = {
'' : { title: "Grace periods"},
'< Back': function() { E.showMenu(mainmenu); },
'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(mainmenu);
}) })

11
apps/bthrv/ChangeLog Normal file
View File

@ -0,0 +1,11 @@
0.01: New App!
0.02: Make overriding the HRM event optional
Emit BTHRM event for external sensor
Add recorder app plugin
0.03: Prevent readings from internal sensor mixing into BT values
Mark events with src property
Show actual source of event in app
0.04: Allow reading additional data if available: HRM battery and position
Better caching of scanned BT device properties
New setting for not starting the BTHRM together with HRM
Save some RAM by not definining functions if disabled in settings

11
apps/bthrv/README.md Normal file
View File

@ -0,0 +1,11 @@
# Bluetooth Heart Rate Variance
This app uses [BTHRM](https://banglejs.com/apps/#bthrm) and can calculate the HRV if the used bluetooth heart rate monitor delivers interval data.
## Usage
Just install and start the app. Select button resets the already measured values.
## Creator
[halemmerich](https://github.com/halemmerich)

1
apps/bthrv/app-icon.js Normal file
View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwJC/ABUMAokcAq0eAok+Aok2AgcCm0EAoUHmw2DAoMOAgMDh9jEgPAg/98cfn/gg/58cbv/ggcB8cz8HADIPjmIECgHB8OAAoVB8AFDgPgIQcBCwYFMAH4ARA"))

143
apps/bthrv/app.js Normal file
View File

@ -0,0 +1,143 @@
var btm = g.getHeight()-1;
var ui = false;
function clear(y){
g.reset();
g.clearRect(0,y,g.getWidth(),g.getHeight());
}
var startingTime;
var currentSlot = 0;
var hrvSlots = [10,20,30,60,120,300];
var hrvValues = {};
var rrRmsProgress;
var saved = false;
var rrNumberOfValues = 0;
var rrSquared = 0;
var rrLastValue
var rrMax;
var rrMin;
function calcHrv(rr){
//Calculate HRV with RMSSD method: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5624990/
for (currentRr of rr){
if (!rrMax) rrMax = currentRr;
if (!rrMin) rrMin = currentRr;
rrMax = Math.max(rrMax, currentRr);
rrMin = Math.min(rrMin, currentRr);
//print("Calc for: " + currentRr);
rrNumberOfValues++;
if (!rrLastValue){
rrLastValue = currentRr;
continue;
}
rrSquared += (rrLastValue - currentRr)*(rrLastValue - currentRr);
//print("rr²: " + rrSquared);
rrLastValue = currentRr;
}
var rms = Math.sqrt(rrSquared / rrNumberOfValues);
//print("rms: " + rms);
return rms;
}
function draw(y, hrv) {
clear(y);
var px = g.getWidth()/2;
var str = hrv.toFixed(1) + "ms";
g.reset();
g.setFontAlign(0,0);
g.setFontVector(40).drawString(str,px,y+20);
for (var i = 0; i < hrvSlots.length; i++){
str = hrvSlots[i] + "s: ";
if (hrvValues[hrvSlots[i]]) str += hrvValues[hrvSlots[i]].toFixed(1) + "ms";
g.setFontVector(16).drawString(str,px,y+44+(i*17));
}
g.setRotation(3);
g.setFontVector(12).drawString("Reset",g.getHeight()/2, g.getWidth()-10);
g.setRotation(0);
}
function onBtHrm(e) {
if (e.rr && !startingTime) Bangle.buzz(500);
if (e.rr && !startingTime) startingTime=Date.now();
//print("Event:" + e.rr);
var hrv = calcHrv(e.rr);
if (hrv){
if (currentSlot <= hrvSlots.length && (Date.now() - startingTime) > (hrvSlots[currentSlot] * 1000) && !hrvValues[hrvSlots[currentSlot]]){
hrvValues[hrvSlots[currentSlot]] = hrv;
currentSlot++;
}
}
if (!saved && currentSlot == hrvSlots.length){
var file = require('Storage').open("bthrv.csv", "a");
var data = new Date(startingTime).toISOString();
for (var c of hrvSlots){
data+=","+hrvValues[c];
}
data+="," + rrMax + "," + rrMin + ","+rrNumberOfValues;
data+="\n";
file.write(data);
saved = true;
Bangle.buzz(500);
}
if (hrv){
if (!ui){
Bangle.setUI("leftright", ()=>{
resetHrv();
clear(30);
});
ui = true;
}
draw(30, hrv);
}
}
function resetHrv(){
hrvValues={};
startingTime=undefined;
currentSlot=0;
saved=false;
rrNumberOfValues = 0;
rrSquared = 0;
rrLastValue = undefined;
rrMax = undefined;
rrMin = undefined;
}
var settings = require('Storage').readJSON("bthrm.json", true) || {};
g.clear();
Bangle.loadWidgets();
Bangle.drawWidgets();
if (Bangle.setBTHRMPower){
Bangle.on('BTHRM', onBtHrm);
Bangle.setBTHRMPower(1,'bthrv');
if (require('Storage').list(/bthrv.csv/).length == 0){
var file = require('Storage').open("bthrv.csv", "a");
var data = "Time";
for (var c of hrvSlots){
data+="," + c + "s";
}
data+=",RR_max,RR_min,Measurements";
data+="\n";
file.write(data);
}
g.reset().setFont("6x8",2).setFontAlign(0,0);
g.drawString("Please wait...",g.getWidth()/2,g.getHeight()/2 - 16);
} else {
g.reset().setFont("6x8",2).setFontAlign(0,0);
g.drawString("Missing BT HRM",g.getWidth()/2,g.getHeight()/2 - 16);
}
E.on('kill', ()=>Bangle.setBTHRMPower(0,'bthrv'));

BIN
apps/bthrv/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

17
apps/bthrv/metadata.json Normal file
View File

@ -0,0 +1,17 @@
{
"id": "bthrv",
"name": "Bluetooth Heart Rate variance calculator",
"shortName": "BT HRV",
"version": "0.01",
"description": "Calculates HRV from a a BT HRM with interval data",
"icon": "app.png",
"type": "app",
"tags": "health,bluetooth",
"supports": ["BANGLEJS","BANGLEJS2"],
"readme": "README.md",
"storage": [
{"name":"bthrv.app.js","url":"app.js"},
{"name":"bthrv.recorder.js","url":"recorder.js"},
{"name":"bthrv.img","url":"app-icon.js","evaluate":true}
]
}

51
apps/bthrv/recorder.js Normal file
View File

@ -0,0 +1,51 @@
(function(recorders) {
recorders.bthrv = function() {
var lastGetValue = 0;
var lastUpdate = 0;
var rrHistory = [];
var hrv = "";
function onHRM(h) {
if(!h.rr) return;
if (lastUpdate + 3000 < Date.now()){
rrHistory = [];
}
rrHistory = rrHistory.concat(h.rr);
lastUpdate=Date.now();
}
return {
name : "BT HRV",
fields : ["BT HRV"],
getValues : () => {
if (lastGetValue + 10000 < Date.now()){
lastGetValue = Date.now();
if (rrHistory.length > 0){
if (rrHistory.length > 1){
var squaredSum = 0;
var last = rrHistory[0]
for (var i = 1; i < rrHistory.length; i++){
squaredSum += (last - rrHistory[i])*(last - rrHistory[i]);
last = rrHistory[i];
}
hrv = Math.sqrt(squaredSum/rrHistory.length);
}
}
}
result = [hrv];
hrv = "";
rrHistory = [];
return result;
},
start : () => {
Bangle.on('BTHRM', onHRM);
if (Bangle.setBTRHMPower) Bangle.setBTHRMPower(1,"recorder");
},
stop : () => {
Bangle.removeListener('BTHRM', onHRM);
if (Bangle.setBTRHMPower) Bangle.setBTHRMPower(0,"recorder");
},
draw : (x,y) => g.setColor((rrHistory.length > 0)?"#00f":"#008").drawImage(atob("DAwBAAAACECECECEDGClacEEAAAA"),x,y)
};
}
})

BIN
apps/bthrv/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -12,6 +12,11 @@
return settings; return settings;
} }
function updateSettings(settings) {
require("Storage").writeJSON("recorder.json", settings);
if (WIDGETS["recorder"]) WIDGETS["recorder"].reload();
}
function getRecorders() { function getRecorders() {
var recorders = { var recorders = {
gps:function() { gps:function() {
@ -52,17 +57,18 @@
}; };
}, },
hrm:function() { hrm:function() {
var bpm = "", bpmConfidence = ""; var bpm = "", bpmConfidence = "", src="";
function onHRM(h) { function onHRM(h) {
bpmConfidence = h.confidence; bpmConfidence = h.confidence;
bpm = h.bpm; bpm = h.bpm;
srv = h.src;
} }
return { return {
name : "HR", name : "HR",
fields : ["Heartrate", "Confidence"], fields : ["Heartrate", "Confidence", "Source"],
getValues : () => { getValues : () => {
var r = [bpm,bpmConfidence]; var r = [bpm,bpmConfidence,src];
bpm = ""; bpmConfidence = ""; bpm = ""; bpmConfidence = ""; src="";
return r; return r;
}, },
start : () => { start : () => {
@ -227,15 +233,32 @@
Bangle.drawWidgets(); // relayout all widgets Bangle.drawWidgets(); // relayout all widgets
},setRecording:function(isOn) { },setRecording:function(isOn) {
var settings = loadSettings(); var settings = loadSettings();
if (isOn && !settings.recording && require("Storage").list(settings.file).length) if (isOn && !settings.recording && require("Storage").list(settings.file).length){
return E.showPrompt("Overwrite\nLog " + settings.file.match(/\d+/)[0] + "?",{title:"Recorder",buttons:{Yes:"yes",No:"no"}}).then(selection=>{ var logfiles=require("Storage").list(/recorder.log.*/);
var maxNumber=0;
for (var c of logfiles){
maxNumber = Math.max(maxNumber, c.match(/\d+/)[0]);
}
var newFileName;
if (maxNumber < 99){
newFileName="recorder.log" + (maxNumber + 1) + ".csv";
updateSettings(settings);
}
var buttons={Yes:"yes",No:"no"};
if (newFileName) buttons["New"] = "new";
var prompt = E.showPrompt("Overwrite\nLog " + settings.file.match(/\d+/)[0] + "?",{title:"Recorder",buttons:buttons}).then(selection=>{
if (selection=="no") return false; // just cancel if (selection=="no") return false; // just cancel
if (selection=="yes") require("Storage").open(settings.file,"r").erase(); if (selection=="yes") require("Storage").open(settings.file,"r").erase();
// TODO: Add 'new file' option if (selection=="new"){
settings.file = newFileName;
updateSettings(settings);
}
return WIDGETS["recorder"].setRecording(1); return WIDGETS["recorder"].setRecording(1);
}); });
return prompt;
}
settings.recording = isOn; settings.recording = isOn;
require("Storage").write("recorder.json", settings); updateSettings(settings);
WIDGETS["recorder"].reload(); WIDGETS["recorder"].reload();
return Promise.resolve(settings.recording); return Promise.resolve(settings.recording);
}/*,plotTrack:function(m) { // m=instance of openstmap module }/*,plotTrack:function(m) { // m=instance of openstmap module