diff --git a/.github/ISSUE_TEMPLATE/bangle-bug-report-custom-form.yaml b/.github/ISSUE_TEMPLATE/bangle-bug-report-custom-form.yaml new file mode 100644 index 000000000..484b3ba85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bangle-bug-report-custom-form.yaml @@ -0,0 +1,60 @@ +name: Bangle.js bug report +description: "Create a issue to help us improve!" +title: "[app/widget name] Short description of bug" +labels: ["bug"] +assignees: [] +body: + - type: markdown + attributes: + value: | + **:fire: Attention: If you have a question then please ask on the [Bangle.js forum](http://forum.espruino.com/microcosms/1424/) :fire:** + ----------------------------------------------------- + - type: dropdown + id: hwversion + attributes: + label: Affected hardware version + description: | + Which Bangle hardware version(s) is/are affected? _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 have installed the latest (released) firmware!** + To find your firmware version, check the `About` Bangle.js app or connect with [the App Loader](https://banglejs.com/apps/), click `More...` and look for a `Device Info` heading. + If the issue occurs only in "Cutting Edge" builds, 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 in-place (hold 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. Do you have other apps/widgets installed that are relevant? + 2. Start app xy + 3. Perform some action + 4. bug occurs + + validations: + required: true diff --git a/apps/bthrm/ChangeLog b/apps/bthrm/ChangeLog index c035dde79..0f776586c 100644 --- a/apps/bthrm/ChangeLog +++ b/apps/bthrm/ChangeLog @@ -7,3 +7,10 @@ Show actual source of event in app 0.04: Automatically reconnect BT sensor 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 diff --git a/apps/bthrm/boot.js b/apps/bthrm/boot.js index e651515d5..c7f9cca53 100644 --- a/apps/bthrm/boot.js +++ b/apps/bthrm/boot.js @@ -1,218 +1,557 @@ (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 logline = Date.now().toFixed(3) + " - " + text; - if (param){ - logline += " " + JSON.stringify(param); + if (settings.debuglog){ + var logline = new Date().toISOString() + " - " + text; + 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() { - var settings = require('Storage').readJSON("bthrm.json", true) || {}; - - if (settings.enabled && !settings.replace){ - return origIsHRMOn(); - } else if (settings.enabled && settings.replace){ - return Bangle.isBTHRMOn(); - } - return origIsHRMOn() || Bangle.isBTHRMOn(); - }; + log("Settings: ", settings); - var serviceFilters = [{ - services: [ - "180d" - ] - }]; + if (settings.enabled){ - function retry(){ - log("Retry with time " + retryTime); - if (currentRetryTimeout){ - log("Clearing timeout " + currentRetryTimeout); - clearTimeout(currentRetryTimeout); - currentRetryTimeout = undefined; + function clearCache(){ + return require('Storage').erase("bthrm.cache.json"); + } + + function getCache(){ + return require('Storage').readJSON("bthrm.cache.json", true) || {}; } - var clampedTime = retryTime < 200 ? 200 : initialRetryTime; - currentRetryTimeout = setTimeout(() => { - 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; + function addNotificationHandler(characteristic){ + log("Setting notification handler: " + supportedCharacteristics[characteristic.uuid].handler); + characteristic.on('characteristicvaluechanged', supportedCharacteristics[characteristic.uuid].handler); } - blockInit = true; - - 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){ - try { - log("Force disconnect with gatt: ", gatt); - gatt.disconnect(); - } catch(e) { - log("Error during force disconnect", e); + 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"); + + 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) { - gatt = device.gatt; - log("Gatt after request:", gatt); - gatt.device.on('gattserverdisconnected', onDisconnect); + + }; + + var device; + var gatt; + var characteristics = []; + 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); - connectionPromise = gatt.connect(); + } + + if (settings.enabled){ + 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() { - return gatt.getPrimaryService(0x180d); - }); + if (settings.replace){ + var origIsHRMOn = Bangle.isHRMOn; - var characteristicPromise = servicePromise.then(function(service) { - log("Got service:", service); - return service.getCharacteristic(0x2A37); - }); - - 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); + Bangle.isHRMOn = function() { + if (settings.enabled && !settings.replace){ + return origIsHRMOn(); + } else if (settings.enabled && settings.replace){ + return Bangle.isBTHRMOn(); } - blockInit = false; - gatt = undefined; + return origIsHRMOn() || Bangle.isBTHRMOn(); + }; + } + + function clearRetryTimeout(){ + if (currentRetryTimeout){ + log("Clearing timeout " + currentRetryTimeout); + clearTimeout(currentRetryTimeout); + currentRetryTimeout = undefined; } } - }; - - var origSetHRMPower = Bangle.setHRMPower; + + function retry(){ + log("Retry"); + + if (!currentRetryTimeout){ + + var clampedTime = retryTime < 100 ? 100 : retryTime; + + log("Set timeout for retry as " + clampedTime); + clearRetryTimeout(); + currentRetryTimeout = setTimeout(() => { + log("Retrying"); + currentRetryTimeout = undefined; + initBt(); + }, clampedTime); + + 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)); + } - Bangle.setHRMPower = function(isOn, app) { - 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); + function attachCharacteristicPromise(promise, characteristic){ + return promise.then(()=>{ + log("Handling characteristic:", characteristic); + return createCharacteristicPromise(characteristic); + }); } - if ((settings.enabled && !settings.replace) || !settings.enabled || !isOn){ - log("Enable HRM power"); - origSetHRMPower(isOn, app); + + 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")); } - } - - var settings = require('Storage').readJSON("bthrm.json", true) || {}; - if (settings.enabled && settings.replace){ - log("Replace HRM event"); - if (!(Bangle._PWR===undefined) && !(Bangle._PWR.HRM===undefined)){ - 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; + + 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)); + } + }); } })(); diff --git a/apps/bthrm/bthrm.js b/apps/bthrm/bthrm.js index de769d085..cc533eedd 100644 --- a/apps/bthrm/bthrm.js +++ b/apps/bthrm/bthrm.js @@ -1,69 +1,95 @@ var btm = g.getHeight()-1; -var eventInt = null; -var eventBt = null; -var counterInt = 0; -var counterBt = 0; +var intervalInt; +var intervalBt; - -function draw(y, event, type, counter) { - var px = g.getWidth()/2; +function clear(y){ g.reset(); - g.setFontAlign(0,0); 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) { - //print("Event for BT " + JSON.stringify(e)); + if (firstEventBt){ + clear(24); + firstEventBt = false; + } + draw(100, "BTHRM", e); if (e.bpm == 0){ Bangle.buzz(100,0.2); } - if (counterBt == 0){ - Bangle.buzz(200,0.5); + if (intervalBt){ + clearInterval(intervalBt); } - counterBt += 3; - eventBt = e; + intervalBt = setInterval(()=>{ + clear(100); + }, 2000); } function onHrm(e) { - //print("Event for Int " + JSON.stringify(e)); - counterInt += 3; - eventInt = e; + if (firstEventInt){ + clear(24); + 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('HRM', onHrm); Bangle.setHRMPower(1,'bthrm'); +if (!(settings.startWithHrm)){ + Bangle.setBTHRMPower(1,'bthrm'); +} g.clear(); Bangle.loadWidgets(); Bangle.drawWidgets(); - -g.reset().setFont("6x8",2).setFontAlign(0,0); -g.drawString("Please wait...",g.getWidth()/2,g.getHeight()/2 - 16); - -function drawInt(){ - counterInt--; - 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); +if (Bangle.setBTHRMPower){ + g.reset().setFont("6x8",2).setFontAlign(0,0); + g.drawString("Please wait...",g.getWidth()/2,g.getHeight()/2 - 24); +} else { + g.reset().setFont("6x8",2).setFontAlign(0,0); + g.drawString("BTHRM disabled",g.getWidth()/2,g.getHeight()/2 + 32); } -var interval = setInterval(drawInt, 1000); -var interval = setInterval(drawBt, 1000); +E.on('kill', ()=>Bangle.setBTHRMPower(0,'bthrm')); diff --git a/apps/bthrm/default.json b/apps/bthrm/default.json new file mode 100644 index 000000000..c973eef25 --- /dev/null +++ b/apps/bthrm/default.json @@ -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 +} diff --git a/apps/bthrm/metadata.json b/apps/bthrm/metadata.json index 68734aafe..dd5db7e1d 100644 --- a/apps/bthrm/metadata.json +++ b/apps/bthrm/metadata.json @@ -2,7 +2,7 @@ "id": "bthrm", "name": "Bluetooth Heart Rate Monitor", "shortName": "BT HRM", - "version": "0.04", + "version": "0.05", "description": "Overrides Bangle.js's build in heart rate monitor with an external Bluetooth one.", "icon": "app.png", "type": "app", @@ -14,6 +14,7 @@ {"name":"bthrm.recorder.js","url":"recorder.js"}, {"name":"bthrm.boot.js","url":"boot.js"}, {"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"} ] } diff --git a/apps/bthrm/recorder.js b/apps/bthrm/recorder.js index b1c27660d..938990362 100644 --- a/apps/bthrm/recorder.js +++ b/apps/bthrm/recorder.js @@ -1,26 +1,38 @@ (function(recorders) { recorders.bthrm = function() { var bpm = ""; + var bat = ""; + var energy = ""; + var contact = ""; + var rr= ""; function onHRM(h) { bpm = h.bpm; + bat = h.bat; + energy = h.energy; + contact = h.contact; + if (h.rr) rr = h.rr.join(";"); } return { - name : "BTHR", - fields : ["BT Heartrate"], + name : "BT HR", + fields : ["BT Heartrate", "BT Battery", "Energy expended", "Contact", "RR"], getValues : () => { - result = [bpm]; + result = [bpm,bat,energy,contact,rr]; bpm = ""; + rr = ""; + bat = ""; + energy = ""; + contact = ""; return result; }, start : () => { Bangle.on('BTHRM', onHRM); - Bangle.setBTHRMPower(1,"recorder"); + if (Bangle.setBTRHMPower) Bangle.setBTHRMPower(1,"recorder"); }, stop : () => { 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) }; } }) diff --git a/apps/bthrm/settings.js b/apps/bthrm/settings.js index 8cb00614e..62d2e7ea3 100644 --- a/apps/bthrm/settings.js +++ b/apps/bthrm/settings.js @@ -1,33 +1,247 @@ (function(back) { - var FILE = "bthrm.json"; - - var settings = Object.assign({ - enabled: true, - replace: true, - }, require('Storage').readJSON(FILE, true) || {}); - - function writeSettings() { - require('Storage').writeJSON(FILE, settings); + 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(); - E.showMenu({ + var mainmenu = { '': { 'title': 'Bluetooth HRM' }, '< Back': back, 'Use BT HRM': { value: !!settings.enabled, format: v => settings.enabled ? "On" : "Off", onchange: v => { - settings.enabled = v; - writeSettings(); + writeSettings("enabled",v); } }, - 'Use HRM event': { + 'Replace HRM': { value: !!settings.replace, format: v => settings.replace ? "On" : "Off", onchange: v => { - settings.replace = v; - writeSettings(); + writeSettings("replace",v); + } + }, + '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); }) diff --git a/apps/bthrv/ChangeLog b/apps/bthrv/ChangeLog new file mode 100644 index 000000000..0e51186a4 --- /dev/null +++ b/apps/bthrv/ChangeLog @@ -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 diff --git a/apps/bthrv/README.md b/apps/bthrv/README.md new file mode 100644 index 000000000..8a80b0fd4 --- /dev/null +++ b/apps/bthrv/README.md @@ -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) diff --git a/apps/bthrv/app-icon.js b/apps/bthrv/app-icon.js new file mode 100644 index 000000000..4d4cf6354 --- /dev/null +++ b/apps/bthrv/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwJC/ABUMAokcAq0eAok+Aok2AgcCm0EAoUHmw2DAoMOAgMDh9jEgPAg/98cfn/gg/58cbv/ggcB8cz8HADIPjmIECgHB8OAAoVB8AFDgPgIQcBCwYFMAH4ARA")) diff --git a/apps/bthrv/app.js b/apps/bthrv/app.js new file mode 100644 index 000000000..7f6ec2d35 --- /dev/null +++ b/apps/bthrv/app.js @@ -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')); diff --git a/apps/bthrv/app.png b/apps/bthrv/app.png new file mode 100644 index 000000000..7a45b9c42 Binary files /dev/null and b/apps/bthrv/app.png differ diff --git a/apps/bthrv/metadata.json b/apps/bthrv/metadata.json new file mode 100644 index 000000000..6a8e7e940 --- /dev/null +++ b/apps/bthrv/metadata.json @@ -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} + ] +} diff --git a/apps/bthrv/recorder.js b/apps/bthrv/recorder.js new file mode 100644 index 000000000..0fce6971e --- /dev/null +++ b/apps/bthrv/recorder.js @@ -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) + }; + } +}) + diff --git a/apps/bthrv/screenshot.png b/apps/bthrv/screenshot.png new file mode 100644 index 000000000..1cd153160 Binary files /dev/null and b/apps/bthrv/screenshot.png differ diff --git a/apps/hrm/ChangeLog b/apps/hrm/ChangeLog index 9b390b63e..4a01008ac 100644 --- a/apps/hrm/ChangeLog +++ b/apps/hrm/ChangeLog @@ -4,3 +4,4 @@ 0.04: Update for new firmwares that have a 'HRM-raw' event 0.05: Tweaks for 'HRM-raw' handling 0.06: Add widgets +0.07: Update scaling for new firmware diff --git a/apps/hrm/heartrate.js b/apps/hrm/heartrate.js index a47251010..2e6a34985 100644 --- a/apps/hrm/heartrate.js +++ b/apps/hrm/heartrate.js @@ -38,9 +38,12 @@ function onHRM(h) { g.drawString("BPM",px+15,45); } Bangle.on('HRM', onHRM); + +var MID = (g.getHeight()+80)/2; /* On newer (2v10) firmwares we can subscribe to get HRM events as they happen */ Bangle.on('HRM-raw', function(v) { + h=v; hrmOffset++; if (hrmOffset>g.getWidth()) { hrmOffset=0; @@ -48,9 +51,9 @@ Bangle.on('HRM-raw', function(v) { lastHrmPt = [-100,0]; } - y = E.clip(btm-v.filt/4,btm-10,btm); + y = E.clip(btm-(8+v.filt/2000),btm-16,btm); g.setColor(1,0,0).fillRect(hrmOffset,btm, hrmOffset, y); - y = E.clip(170 - (v.raw/2),80,btm); + y = E.clip(btm - (v.raw/45),84,btm); g.setColor(g.theme.fg).drawLine(lastHrmPt[0],lastHrmPt[1],hrmOffset, y); lastHrmPt = [hrmOffset, y]; if (counter !==undefined) { @@ -95,3 +98,4 @@ function readHRM() { lastHrmPt = [hrmOffset, y]; } } + diff --git a/apps/hrm/metadata.json b/apps/hrm/metadata.json index 1504253bd..027e75d67 100644 --- a/apps/hrm/metadata.json +++ b/apps/hrm/metadata.json @@ -1,7 +1,7 @@ { "id": "hrm", "name": "Heart Rate Monitor", - "version": "0.06", + "version": "0.07", "description": "Measure your heart rate and see live sensor data", "icon": "heartrate.png", "tags": "health", diff --git a/apps/launch/ChangeLog b/apps/launch/ChangeLog index ceb0177da..b8c198d50 100644 --- a/apps/launch/ChangeLog +++ b/apps/launch/ChangeLog @@ -10,3 +10,5 @@ After 10s of being locked, the launcher goes back to the clock screen 0.10: added in selectable font in settings including scalable vector font 0.11: Merge Bangle.js 1 and 2 launchers, again +0.12: Add an option to hide clocks from the app list (fix #1015) + Add /*LANG*/ tags for internationalisation diff --git a/apps/launch/app.js b/apps/launch/app.js index 42aba1bb9..4ceabe751 100644 --- a/apps/launch/app.js +++ b/apps/launch/app.js @@ -1,9 +1,9 @@ var s = require("Storage"); -let fonts = g.getFonts(); var scaleval = 1; var vectorval = 20; var font = g.getFonts().includes("12x20") ? "12x20" : "6x8:2"; -let settings = require('Storage').readJSON("launch.json", true) || {}; +let settings = Object.assign({ showClocks: true }, s.readJSON("launch.json", true) || {}); + if ("vectorsize" in settings) { vectorval = parseInt(settings.vectorsize); } @@ -14,10 +14,10 @@ if ("font" in settings){ } else{ font = settings.font; - scaleval = (font.split('x')[1])/20; + scaleval = (font.split("x")[1])/20; } } -var apps = s.list(/\.info$/).map(app=>{var a=s.readJSON(app,1);return a&&{name:a.name,type:a.type,icon:a.icon,sortorder:a.sortorder,src:a.src};}).filter(app=>app && (app.type=="app" || app.type=="clock" || !app.type)); +var apps = s.list(/\.info$/).map(app=>{var a=s.readJSON(app,1);return a&&{name:a.name,type:a.type,icon:a.icon,sortorder:a.sortorder,src:a.src};}).filter(app=>app && (app.type=="app" || (app.type=="clock" && settings.showClocks) || !app.type)); apps.sort((a,b)=>{ var n=(0|a.sortorder)-(0|b.sortorder); if (n) return n; // do sortorder first @@ -54,10 +54,10 @@ E.showScroller({ var app = apps[i]; if (!app) return; if (!app.src || require("Storage").read(app.src)===undefined) { - E.showMessage("App Source\nNot found"); + E.showMessage(/*LANG*/"App Source\nNot found"); setTimeout(drawMenu, 2000); } else { - E.showMessage("Loading..."); + E.showMessage(/*LANG*/"Loading..."); load(app.src); } } @@ -72,7 +72,7 @@ if (process.env.HWVERSION==2) { // 10s of inactivity goes back to clock Bangle.setLocked(false); // unlock initially var lockTimeout; -Bangle.on('lock', locked => { +Bangle.on("lock", locked => { if (lockTimeout) clearTimeout(lockTimeout); lockTimeout = undefined; if (locked) diff --git a/apps/launch/metadata.json b/apps/launch/metadata.json index 1701d1f87..96bbf104b 100644 --- a/apps/launch/metadata.json +++ b/apps/launch/metadata.json @@ -2,7 +2,7 @@ "id": "launch", "name": "Launcher", "shortName": "Launcher", - "version": "0.11", + "version": "0.12", "description": "This is needed to display a menu allowing you to choose your own applications. You can replace this with a customised launcher.", "icon": "app.png", "type": "launch", diff --git a/apps/launch/settings.js b/apps/launch/settings.js index 8be1adb36..1bb3d5c9a 100644 --- a/apps/launch/settings.js +++ b/apps/launch/settings.js @@ -1,24 +1,30 @@ // make sure to enclose the function in parentheses (function(back) { - let settings = require('Storage').readJSON('launch.json',1)||{}; + let settings = Object.assign({ showClocks: true }, require("Storage").readJSON("launch.json", true) || {}); + let fonts = g.getFonts(); function save(key, value) { settings[key] = value; - require('Storage').write('launch.json',settings); + require("Storage").write("launch.json",settings); } const appMenu = { - '': {'title': 'Launcher Settings'}, - '< Back': back, - 'Font': { + /*LANG*/"": {"title": /*LANG*/"Launcher Settings"}, + /*LANG*/"< Back": back, + /*LANG*/"Font": { value: fonts.includes(settings.font)? fonts.indexOf(settings.font) : fonts.indexOf("12x20"), min:0, max:fonts.length-1, step:1,wrap:true, - onchange: (m) => {save('font', fonts[m])}, + onchange: (m) => {save("font", fonts[m])}, format: v => fonts[v] }, - 'Vector font size': { + /*LANG*/"Vector font size": { value: settings.vectorsize || 10, min:10, max: 20,step:1,wrap:true, - onchange: (m) => {save('vectorsize', m)} + onchange: (m) => {save("vectorsize", m)} + }, + /*LANG*/"Show clocks": { + value: settings.showClocks == true, + format: v => v ? /*LANG*/"Yes" : /*LANG*/"No", + onchange: (m) => {save("showClocks", m)} } }; E.showMenu(appMenu); diff --git a/apps/messages/README.md b/apps/messages/README.md index 4952b1877..23f9ba5c1 100644 --- a/apps/messages/README.md +++ b/apps/messages/README.md @@ -17,6 +17,29 @@ and `Messages`: If there is no user input for this amount of time then the app will exit and return to the clock where a ringing bell will be shown in the Widget bar. +## New Messages + +When a new message is received: + +* If you're in an app, the Bangle will buzz and a 'new message' icon appears in the Widget bar. You can tap this bar to view the message. +* If you're in a clock, the Messages app will automatically start and show the message + +When a message is shown, you'll see a screen showing the message title and text. + +### Android + +* The 'back-arrow' button goes back to Messages, marking the current message as read. +* If shown, the 'tick' button opens the notification on the phone +* If shown, the 'cross' button dismisses the notification on the phone +* The top-left icon shows more options, for instance deleting the message of marking unread + +### iOS + +* The 'back-arrow' button goes back to Messages, marking the current message as read. +* If shown, the 'tick' button responds positively to the notification (accept call/etc) +* If shown, the 'cross' button responds negatively to the notification (dismiss call/etc) +* The top-left icon shows more options, for instance deleting the message of marking unread + ## Images _1. Screenshot of a notification_ diff --git a/apps/recorder/widget.js b/apps/recorder/widget.js index 742d373a4..e10c99c0c 100644 --- a/apps/recorder/widget.js +++ b/apps/recorder/widget.js @@ -11,6 +11,11 @@ settings.recording = false; return settings; } + + function updateSettings(settings) { + require("Storage").writeJSON("recorder.json", settings); + if (WIDGETS["recorder"]) WIDGETS["recorder"].reload(); + } function getRecorders() { var recorders = { @@ -52,17 +57,18 @@ }; }, hrm:function() { - var bpm = "", bpmConfidence = ""; + var bpm = "", bpmConfidence = "", src=""; function onHRM(h) { bpmConfidence = h.confidence; bpm = h.bpm; + srv = h.src; } return { name : "HR", - fields : ["Heartrate", "Confidence"], + fields : ["Heartrate", "Confidence", "Source"], getValues : () => { - var r = [bpm,bpmConfidence]; - bpm = ""; bpmConfidence = ""; + var r = [bpm,bpmConfidence,src]; + bpm = ""; bpmConfidence = ""; src=""; return r; }, start : () => { @@ -227,15 +233,32 @@ Bangle.drawWidgets(); // relayout all widgets },setRecording:function(isOn) { var settings = loadSettings(); - 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=>{ + if (isOn && !settings.recording && require("Storage").list(settings.file).length){ + 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=="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 prompt; + } settings.recording = isOn; - require("Storage").write("recorder.json", settings); + updateSettings(settings); WIDGETS["recorder"].reload(); return Promise.resolve(settings.recording); }/*,plotTrack:function(m) { // m=instance of openstmap module diff --git a/apps/run/ChangeLog b/apps/run/ChangeLog index 11d63b402..fd28c4414 100644 --- a/apps/run/ChangeLog +++ b/apps/run/ChangeLog @@ -4,3 +4,4 @@ 0.03: Fixed distance calculation, tested against Garmin Etrex, Amazfit GTS 2 0.04: Use the exstats module, and make what is displayed configurable 0.05: exstats updated so update 'distance' label is updated, option for 'speed' +0.06: Add option to record a run using the recorder app automatically diff --git a/apps/run/README.md b/apps/run/README.md index 10f6bafdb..17975f92c 100644 --- a/apps/run/README.md +++ b/apps/run/README.md @@ -32,6 +32,8 @@ that, and then start the `Run` app. Under `Settings` -> `App` -> `Run` you can change settings for this app. +* `Record Run` (only displayed if `Recorder` app installed) should the Run app automatically +record GPS/HRM/etc data every time you start a run? * `Pace` is the distance that pace should be shown over - 1km, 1 mile, 1/2 Marathon or 1 Marathon * `Box 1/2/3/4/5/6` are what should be shown in each of the 6 boxes on the display. From the top left, down. If you set it to `-` nothing will be displayed, so you can display only 4 boxes of information diff --git a/apps/run/app.js b/apps/run/app.js index 9e313ea2a..a8515a71a 100644 --- a/apps/run/app.js +++ b/apps/run/app.js @@ -6,6 +6,7 @@ var fontHeading = "6x8:2"; var fontValue = B2 ? "6x15:2" : "6x8:3"; var headingCol = "#888"; var fixCount = 0; +var isMenuDisplayed = false; g.clear(); Bangle.loadWidgets(); @@ -13,6 +14,7 @@ Bangle.drawWidgets(); // --------------------------- let settings = Object.assign({ + record : true, B1 : "dist", B2 : "time", B3 : "pacea", @@ -39,6 +41,19 @@ function onStartStop() { // if stopping running, don't clear state // so we can at least refer to what we've done layout.render(); + // start/stop recording + if (settings.record && WIDGETS["recorder"]) { + if (running) { + isMenuDisplayed = true; + WIDGETS["recorder"].setRecording(true).then(() => { + isMenuDisplayed = false; + layout.forgetLazyState(); + layout.render(); + }); + } else { + WIDGETS["recorder"].setRecording(false); + } + } } var lc = []; @@ -80,5 +95,5 @@ Bangle.on("GPS", function(fix) { // We always call ourselves once a second to update setInterval(function() { layout.clock.label = locale.time(new Date(),1); - layout.render(); + if (!isMenuDisplayed) layout.render(); }, 1000); diff --git a/apps/run/metadata.json b/apps/run/metadata.json index 0d718a1b3..ea68f4734 100644 --- a/apps/run/metadata.json +++ b/apps/run/metadata.json @@ -1,6 +1,6 @@ { "id": "run", "name": "Run", - "version":"0.05", + "version":"0.06", "description": "Displays distance, time, steps, cadence, pace and more for runners.", "icon": "app.png", "tags": "run,running,fitness,outdoors,gps", diff --git a/apps/run/settings.js b/apps/run/settings.js index 91a8b4333..7eb8a8611 100644 --- a/apps/run/settings.js +++ b/apps/run/settings.js @@ -9,6 +9,7 @@ // This way saved values are preserved if a new version adds more settings const storage = require('Storage') let settings = Object.assign({ + record : true, B1 : "dist", B2 : "time", B3 : "pacea", @@ -35,8 +36,17 @@ var menu = { '': { 'title': 'Run' }, - '< Back': back + '< Back': back, }; + if (WIDGETS["recorder"]) + menu[/*LANG*/"Record Run"] = { + value : !!settings.record, + format : v => v?/*LANG*/"Yes":/*LANG*/"No", + onchange : v => { + settings.record = v; + saveSettings(); + } + }; ExStats.appendMenuItems(menu, settings, saveSettings); Object.assign(menu,{ 'Box 1': getBoxChooser("B1"),