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,218 +1,557 @@
(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){
if (param){ var logline = new Date().toISOString() + " - " + text;
logline += " " + JSON.stringify(param); if (param){
logline += " " + JSON.stringify(param);
}
print(logline);
} }
sf.write(logline + "\n");
print(logline);*/
}
log("Start");
var blockInit = false;
var gatt;
var currentRetryTimeout;
var initialRetryTime = 40;
var maxRetryTime = 60000;
var retryTime = initialRetryTime;
var origIsHRMOn = Bangle.isHRMOn;
Bangle.isBTHRMOn = function(){
return (gatt!==undefined && gatt.connected);
}; };
Bangle.isHRMOn = function() { log("Settings: ", settings);
var settings = require('Storage').readJSON("bthrm.json", true) || {};
if (settings.enabled && !settings.replace){ if (settings.enabled){
return origIsHRMOn();
} else if (settings.enabled && settings.replace){
return Bangle.isBTHRMOn();
}
return origIsHRMOn() || Bangle.isBTHRMOn();
};
var serviceFilters = [{ function clearCache(){
services: [ return require('Storage').erase("bthrm.cache.json");
"180d"
]
}];
function retry(){
log("Retry with time " + retryTime);
if (currentRetryTimeout){
log("Clearing timeout " + currentRetryTimeout);
clearTimeout(currentRetryTimeout);
currentRetryTimeout = undefined;
} }
var clampedTime = retryTime < 200 ? 200 : initialRetryTime; function getCache(){
currentRetryTimeout = setTimeout(() => { return require('Storage').readJSON("bthrm.cache.json", true) || {};
log("Set timeout for retry as " + clampedTime);
initBt();
}, clampedTime);
retryTime = Math.pow(retryTime, 1.1);
if (retryTime > maxRetryTime){
retryTime = maxRetryTime;
}
}
function onDisconnect(reason) {
log("Disconnect: " + reason);
log("Gatt: ", gatt);
retry();
}
function onCharacteristic(event) {
var settings = require('Storage').readJSON("bthrm.json", true) || {};
var dv = event.target.value;
var flags = dv.getUint8(0);
// 0 = 8 or 16 bit
// 1,2 = sensor contact
// 3 = energy expended shown
// 4 = RR interval
var bpm = (flags & 1) ? (dv.getUint16(1) / 100 /* ? */ ) : dv.getUint8(1); // 8 or 16 bit
/* var idx = 2 + (flags&1); // index of next field
if (flags&8) idx += 2; // energy expended
if (flags&16) {
var interval = dv.getUint16(idx,1); // in milliseconds
}*/
Bangle.emit(settings.replace ? "HRM" : "BTHRM", {
bpm: bpm,
confidence: bpm == 0 ? 0 : 100,
src: settings.replace ? "bthrm" : undefined
});
}
var reUseCounter=0;
function initBt() {
log("initBt with blockInit: " + blockInit);
if (blockInit){
retry();
return;
} }
blockInit = true; function addNotificationHandler(characteristic){
log("Setting notification handler: " + supportedCharacteristics[characteristic.uuid].handler);
characteristic.on('characteristicvaluechanged', supportedCharacteristics[characteristic.uuid].handler);
}
var connectionPromise; 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");
}
if (reUseCounter > 3){ }
log("Reuse counter to high")
if (gatt.connected == true){ function characteristicsToCache(characteristics){
try { log("Cache characteristics");
log("Force disconnect with gatt: ", gatt); var cache = getCache();
gatt.disconnect(); if (!cache.characteristics) cache.characteristics = {};
} catch(e) { for (var c of characteristics){
log("Error during force disconnect", e); //"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");
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);
} }
} }
gatt=undefined;
reUseCounter = 0;
}
if (!gatt){ };
var requestPromise = NRF.requestDevice({ filters: serviceFilters });
connectionPromise = requestPromise.then(function(device) { var device;
gatt = device.gatt; var gatt;
log("Gatt after request:", gatt); var characteristics = [];
gatt.device.on('gattserverdisconnected', onDisconnect); var blockInit = false;
var currentRetryTimeout;
var initialRetryTime = 40;
var maxRetryTime = 60000;
var retryTime = initialRetryTime;
var connectSettings = {
minInterval: 7.5,
maxInterval: 1500
};
function waitingPromise(timeout) {
return new Promise(function(resolve){
log("Start waiting for " + timeout);
setTimeout(()=>{
log("Done waiting for " + timeout);
resolve();
}, timeout);
}); });
} else { }
reUseCounter++;
log("Reusing gatt:", gatt); if (settings.enabled){
connectionPromise = gatt.connect(); Bangle.isBTHRMOn = function(){
return (Bangle._PWR && Bangle._PWR.BTHRM && Bangle._PWR.BTHRM.length > 0);
};
Bangle.isBTHRMConnected = function(){
return gatt && gatt.connected;
};
} }
var servicePromise = connectionPromise.then(function() { if (settings.replace){
return gatt.getPrimaryService(0x180d); var origIsHRMOn = Bangle.isHRMOn;
});
var characteristicPromise = servicePromise.then(function(service) { Bangle.isHRMOn = function() {
log("Got service:", service); if (settings.enabled && !settings.replace){
return service.getCharacteristic(0x2A37); return origIsHRMOn();
}); } else if (settings.enabled && settings.replace){
return Bangle.isBTHRMOn();
var notificationPromise = characteristicPromise.then(function(c) {
log("Got characteristic:", c);
c.on('characteristicvaluechanged', onCharacteristic);
return c.startNotifications();
});
notificationPromise.then(()=>{
log("Wait for notifications");
retryTime = initialRetryTime;
blockInit=false;
});
notificationPromise.catch((e) => {
log("Error:", e);
blockInit = false;
retry();
});
}
Bangle.setBTHRMPower = function(isOn, app) {
var settings = require('Storage').readJSON("bthrm.json", true) || {};
// Do app power handling
if (!app) app="?";
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);
isOn = Bangle._PWR.BTHRM.length;
// so now we know if we're really on
if (isOn) {
if (!Bangle.isBTHRMOn()) {
initBt();
}
} else { // not on
log("Power off for " + app);
if (gatt) {
try {
log("Disconnect with gatt: ", gatt);
gatt.disconnect();
} catch(e) {
log("Error during disconnect", e);
} }
blockInit = false; return origIsHRMOn() || Bangle.isBTHRMOn();
gatt = undefined; };
}
function clearRetryTimeout(){
if (currentRetryTimeout){
log("Clearing timeout " + currentRetryTimeout);
clearTimeout(currentRetryTimeout);
currentRetryTimeout = undefined;
} }
} }
};
var origSetHRMPower = Bangle.setHRMPower; function retry(){
log("Retry");
Bangle.setHRMPower = function(isOn, app) { if (!currentRetryTimeout){
log("setHRMPower for " + app + ":" + (isOn?"on":"off"));
var settings = require('Storage').readJSON("bthrm.json", true) || {};
if (settings.enabled || !isOn){
log("Enable BTHRM power");
Bangle.setBTHRMPower(isOn, app);
}
if ((settings.enabled && !settings.replace) || !settings.enabled || !isOn){
log("Enable HRM power");
origSetHRMPower(isOn, app);
}
}
var settings = require('Storage').readJSON("bthrm.json", true) || {}; var clampedTime = retryTime < 100 ? 100 : retryTime;
if (settings.enabled && settings.replace){
log("Replace HRM event"); log("Set timeout for retry as " + clampedTime);
if (!(Bangle._PWR===undefined) && !(Bangle._PWR.HRM===undefined)){ clearRetryTimeout();
for (var i = 0; i < Bangle._PWR.HRM.length; i++){ currentRetryTimeout = setTimeout(() => {
var app = Bangle._PWR.HRM[i]; log("Retrying");
log("Moving app " + app); currentRetryTimeout = undefined;
origSetHRMPower(0, app); initBt();
Bangle.setBTHRMPower(1, app); }, clampedTime);
if (Bangle._PWR.HRM===undefined) break;
retryTime = Math.pow(retryTime, 1.1);
if (retryTime > maxRetryTime){
retryTime = maxRetryTime;
}
} else {
log("Already in retry...");
} }
} }
var buzzing = false;
function onDisconnect(reason) {
log("Disconnect: " + reason);
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();
}
}
function createCharacteristicPromise(newCharacteristic){
log("Create characteristic promise: ", newCharacteristic);
var result = Promise.resolve();
if (newCharacteristic.properties.notify){
result = result.then(()=>{
log("Starting notifications for: ", newCharacteristic);
var startPromise = newCharacteristic.startNotifications().then(()=>log("Notifications started for ", newCharacteristic));
if (settings.gracePeriodNotification > 0){
log("Add " + settings.gracePeriodNotification + "ms grace period after starting notifications");
startPromise = startPromise.then(()=>{
log("Wait after connect");
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){
return promise.then(()=>{
log("Handling characteristic:", characteristic);
return createCharacteristicPromise(characteristic);
});
}
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() {
log("initBt with blockInit: " + blockInit);
if (blockInit){
retry();
return;
}
blockInit = true;
if (reUseCounter > 10){
log("Reuse counter to high");
gatt=undefined;
reUseCounter = 0;
}
var promise;
if (!device){
promise = NRF.requestDevice({ filters: serviceFilters });
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");
clearCache();
}
var newCache = getCache();
newCache.name = device.name;
writeCache(newCache);
gatt = device.gatt;
}
return Promise.resolve(gatt);
});
promise = promise.then((gatt)=>{
if (!gatt.connected){
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();
}
});
promise = promise.then(()=>{
if (!characteristics || characteristics.length == 0){
characteristics = characteristicsFromCache();
}
});
promise = promise.then(()=>{
var getCharacteristicsPromise = Promise.resolve();
if (characteristics.length == 0){
getCharacteristicsPromise = getCharacteristicsPromise.then(()=>{
log("Getting services");
return gatt.getPrimaryServices();
});
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);
onDisconnect(e);
});
}
Bangle.setBTHRMPower = function(isOn, app) {
// Do app power handling
if (!app) app="?";
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);
isOn = Bangle._PWR.BTHRM.length;
// so now we know if we're really on
if (isOn) {
if (!Bangle.isBTHRMConnected()) initBt();
} else { // not on
log("Power off for " + app);
if (gatt) {
if (gatt.connected){
log("Disconnect with gatt: ", gatt);
gatt.disconnect().then(()=>{
log("Successful disconnect", e);
}).catch(()=>{
log("Error during disconnect", e);
});
}
}
}
};
var origSetHRMPower = Bangle.setHRMPower;
if (settings.startWithHrm){
Bangle.setHRMPower = function(isOn, app) {
log("setHRMPower for " + app + ": " + (isOn?"on":"off"));
if (settings.enabled){
Bangle.setBTHRMPower(isOn, app);
}
if ((settings.enabled && !settings.replace) || !settings.enabled){
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);
}
}
if (settings.replace){
log("Replace HRM event");
if (Bangle._PWR && Bangle._PWR.HRM){
for (var i = 0; i < Bangle._PWR.HRM.length; i++){
var app = Bangle._PWR.HRM[i];
log("Moving app " + app);
origSetHRMPower(0, app);
Bangle.setBTHRMPower(1, app);
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