From d9441b3a40e11f3a57d13fc40dd805d951424c43 Mon Sep 17 00:00:00 2001 From: Gordon Williams Date: Thu, 21 Oct 2021 14:49:51 +0100 Subject: [PATCH] Added vernier respiration app --- apps.json | 15 ++ apps/vernierrespirate/ChangeLog | 1 + apps/vernierrespirate/README.md | 26 +++ apps/vernierrespirate/app-icon.js | 1 + apps/vernierrespirate/app.js | 256 ++++++++++++++++++++++++++++++ apps/vernierrespirate/app.png | Bin 0 -> 1986 bytes 6 files changed, 299 insertions(+) create mode 100644 apps/vernierrespirate/ChangeLog create mode 100644 apps/vernierrespirate/README.md create mode 100644 apps/vernierrespirate/app-icon.js create mode 100644 apps/vernierrespirate/app.js create mode 100644 apps/vernierrespirate/app.png diff --git a/apps.json b/apps.json index 50e53f4f4..8715ede73 100644 --- a/apps.json +++ b/apps.json @@ -3977,5 +3977,20 @@ {"name":"ffcniftya.app.js","url":"app.js"}, {"name":"ffcniftya.img","url":"app-icon.js","evaluate":true} ] + }, + { "id": "vernierrespirate", + "name": "Vernier Go Direct Respiration Belt", + "shortName":"Respiration Belt", + "version":"0.01", + "description": "Connects to a Go Direct Respiration Belt and shows respiration rate", + "icon": "app.png", + "tags": "health,bluetooth", + "supports" : ["BANGLEJS","BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"vernierrespirate.app.js","url":"app.js"}, + {"name":"vernierrespirate.img","url":"app-icon.js","evaluate":true} + ], + "data": [{"name":"vernierrespirate.json"}] } ] diff --git a/apps/vernierrespirate/ChangeLog b/apps/vernierrespirate/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/vernierrespirate/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/vernierrespirate/README.md b/apps/vernierrespirate/README.md new file mode 100644 index 000000000..54dfc274b --- /dev/null +++ b/apps/vernierrespirate/README.md @@ -0,0 +1,26 @@ +# Vernier Go Direct Respiration Belt + +Connects to a [Go Direct Respiration Belt](https://www.vernier.com/product/go-direct-respiration-belt/) via Bluetooth and shows respiration rate + +![](data:image/png;base64,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) + +## Usage + +In the main menu: + +* `Connect` - connect and start displaying respiration +* `Vib` - Should we vibrate if the breaths per minute (BPM) is above a certain value? + * `No` - don't vibrate + * `Calculated` - vibrate if the app's reading is high. This is based on raw + sensor data and it responds quickly but may not be accurate. + * `Vernier` - vibrate if the Vernier sensor's own reading is high. This is + more accurate but responds very slowly. +* `Connect` - connect and start displaying respiration + +## TODO + +* Logging to a file? + +## Creator + +Gordon Williams diff --git a/apps/vernierrespirate/app-icon.js b/apps/vernierrespirate/app-icon.js new file mode 100644 index 000000000..f687d2a9d --- /dev/null +++ b/apps/vernierrespirate/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///9nou30h/qJf8Ah/wBasK0ALhHcMBqALBgtABYsVqgLBAYILFqtUF4MVqoKEgoLFqALGEYQLFAwILEGAlV6tUlWoitXGAgLC1WqBYsBq+VqwLBAYPVMIUFBYN61Uq1oLBHgQLC1WohWqrwLDiteEIOggQDB2pICivqA4IFBlWq1YLCq/V9WkAoMa1YHBKQVf9XUAoMX1f1KgVVEYIpDEYILBLwIuBC4YFBMAMBLAILFLQILBrdV12UBYMW3VV8tAgt9q+2BYee6t9qEFuoLHroLBqte6wvDy+1ZoILBvdWSoeV9oLD+tfBYYFBBYTEBrq5CgN1aQNQgNVAALdEAAISBAYL1EfgISCAgIKDDAQSEAH4AQ")) diff --git a/apps/vernierrespirate/app.js b/apps/vernierrespirate/app.js new file mode 100644 index 000000000..945b72b77 --- /dev/null +++ b/apps/vernierrespirate/app.js @@ -0,0 +1,256 @@ + +// get settings +var settings = require("Storage").readJSON("vernierrespirate.json",1)||{}; +settings.vibrateBPM = settings.vibrateBPM||27; +// settings.vibrate; // undefined / "calculated" / "vernier" + +function saveSettings() { + require("Storage").writeJSON("vernierrespirate.json", settings); +} + + +g.clear(); +var graphHeight = g.getHeight()-100; +var last = { + time : Date.now(), + x : 0, + y : 24, +}; +var avrValue; +var aboveAvr = false; +var lastBreath; +var lastBreaths = []; +var vibrateInterval; + +function onMsg(txt) { + print(txt); + E.showMessage(txt); +} + +function setVibrate(isOn) { + var wasOn = vibrateInterval!==undefined; + if (isOn == wasOn) return; + + if (isOn) { + vibrateInterval = setInterval(function() { + Bangle.buzz(); + }, 1000); + } else { + clearInterval(vibrateInterval); + vibrateInterval = undefined; + } +} + +function onBreath() { + var t = Date.now(); + if (lastBreath!==undefined) { + // time between breaths + var value = 60000 / (t-lastBreath); + // average of last 3 + while (lastBreaths.length>=3) lastBreaths.shift(); // keep length small + lastBreaths.push(value); + value = E.sum(lastBreaths) / lastBreaths.length; + // draw value + g.reset(); + g.clearRect(0,g.getHeight()-100,g.getWidth(),g.getHeight()-50); + g.setFont("6x8").setFontAlign(0,0); + g.drawString("Calculated measurement", g.getWidth()/2, g.getHeight()-95); + g.setFont("Vector",40).setFontAlign(0,0); + g.drawString(value.toFixed(2), g.getWidth()/2, g.getHeight()-70); + // set vibration IF we're doing it from our calculations + if (settings.vibrate == "calculated") + setVibrate(value > settings.vibrateBPM); + } + lastBreath = t; +} + +function onData(n, value) { + g.reset(); + if (n==2) { + function scale(v) { + return Math.max(graphHeight - (1+v*4),24); + } + if (avrValue==undefined) avrValue=value; + avrValue = avrValue*0.95 + value*0.05; + if (avrValue < 1) avrValue = 1; + if (value > avrValue) { + if (!aboveAvr) onBreath(); + aboveAvr = true; + } else aboveAvr = false; + + var t = Date.now(); + var x = Math.round((t - last.time) / 100) // 10 per second + if (last.x>=g.getWidth()) { + x = 0; + last.x = 0; + last.time = t; + g.clearRect(0,24,g.getWidth(),graphHeight); + } + var y = scale(value); + g.setPixel(x, scale(avrValue), "#f00"); + g.drawLine(last.x, last.y, x, y); + last.x = x; + last.y = y; + } + if (n==4) { + g.clearRect(0,g.getHeight()-50,g.getWidth(),g.getHeight()); + g.setFont("6x8").setFontAlign(0,0); + g.drawString("GoDirect measurement", g.getWidth()/2, g.getHeight()-45); + g.setFont("Vector",40).setFontAlign(0,0); + g.drawString(value.toFixed(2), g.getWidth()/2, g.getHeight()-20); + // set vibration IF we're doing it from our calculations + if (settings.vibrate == "vernier") + setVibrate(value > settings.vibrateBPM); + } + Bangle.setLCDPower(1); // ensure LCD is on +} + +function connect() { + var gatt, service, rx, tx; + var rollingCounter = 0xFF; + + // any button to exit + Bangle.setUI("updown", function() { + setVibrate(false); + Bangle.buzz(); + try { + if (gatt) gatt.disconnect(); + } catch (e) { + } + setTimeout(mainMenu, 1000); + }); + + function sendCommand(subCommand) { + const command = new Uint8Array(4 + subCommand.length); + command.set(new Uint8Array(subCommand), 4); + // Populate the packet header bytes + command[0] = 0x58; // header + command[1] = command.length; + command[2] = --rollingCounter; + command[3] = E.sum(command) & 0xFF; // checksum + return tx.writeValue(command); + } + function firstSetBit(v) { + return v & -v; + } + function handleResponse(dv) { + //print(dv.buffer); + var resType = dv.getUint8(0); + if (resType==0x20) { + // [32, 25, 207, 216, 6, 6, 0, 2, 252, 128, 138, 7, 191, 0, 0, 192, 127, 128, 49, 8, 191, 0, 0, 192, 127]) + // 6 = data type = real + // 6,0 = bit mask for sensors + // 2 = value count + if (dv.getUint8(4)!=6) return; //throw "Not float32 data"; + var sensorIds = dv.getUint16(5, true); + // var count = dv.getUint8(7); doesn't seem right + var offs = 9; + while (sensorIds) { + var value = dv.getFloat32(offs, true); + var s = firstSetBit(sensorIds); + if (isFinite(value)) onData(s,value); + //else print(s,value); + sensorIds &= ~s; + offs += 4; + } + } else { + var cmd = dv.getUint8(4); // cmd + //print("CMD",dv.buffer); + } + } + + onMsg("Searching..."); + NRF.requestDevice({ filters: [{ namePrefix: 'GDX-RB' }] }).then(function(device) { + device.on("gattserverdisconnected", function() { + onMsg("Device disconnected"); + }); + onMsg("Found. Connecting..."); + return device.gatt.connect({minInterval:20, maxInterval:20}); + }).then(function(g) { + gatt = g; + return gatt.getPrimaryService("d91714ef-28b9-4f91-ba16-f0d9a604f112"); + }).then(function(s) { + service = s; + return service.getCharacteristic("f4bf14a6-c7d5-4b6d-8aa8-df1a7c83adcb"); + }).then(function(c) { + tx = c; + return service.getCharacteristic("b41e6675-a329-40e0-aa01-44d2f444babe"); + }).then(function(c) { + rx = c; + rx.on('characteristicvaluechanged', function(event) { + //print("EVT",event.target.value.buffer); + handleResponse(event.target.value); + }); + return rx.startNotifications(); + }).then(function() { + onMsg("Init"); + sendCommand([ // init + 0x1a, 0xa5, 0x4a, 0x06, + 0x49, 0x07, 0x48, 0x08, + 0x47, 0x09, 0x46, 0x0a, + 0x45, 0x0b, 0x44, 0x0c, + 0x43, 0x0d, 0x42, 0x0e, + 0x41, + ]); + /*setTimeout(function() { + print("Set measurement period"); + var us = 100000; // period in us + sendCommand([0x1b, 0xff, 0x00, + us & 255, + (us >> 8) & 255, + (us >> 16) & 255, + (us >> 24) & 255, + 0x00, + 0x00, + 0x00, + 0x00]); + }, 100);*/ + + /* setTimeout(function() { + print("Get sensor info"); + sendCommand([0x51, 0]); // get sensor IDs + // returns [152, 10, 1, 39, 81, 253, 54, 0, 0, 0] + // 54 is the bit mask of available channels + //sendCommand([106, 16]); // get sensor info + }, 2000);*/ + + setTimeout(function() { + onMsg("Start measurements"); + //https://github.com/VernierST/godirect-js/blob/main/src/Device.js#L588 + var channels = 6; // data channels 4 and 2 + sendCommand([ // start measurements + 0x18, 0xff, 0x01, channels, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00 + ]); + }, 500); + }).catch(function() { + onMsg("Connect Fail"); + }); +} + +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +function mainMenu() { + var vibText = ["No","Calculated","Vernier"]; + var vibValue = ["","calculated","vernier"]; + E.showMenu({"":{title:"Respiration Belt"}, + "< Back" : () => { saveSettings(); load(); }, + "Connect" : () => { saveSettings(); E.showMenu(); connect(); }, + "Vib" : { + value : Math.max(vibValue.indexOf(settings.vibrate),0), + format : v => vibText[v], + min:0,max:2, + onchange : v => { settings.vibrate=vibValue[v]; } + }, + "BPM" : { + value : settings.vibrateBPM, + min:10,max:50, + onchange : v => { settings.vibrateBPM=v; } + } + }); +} + +mainMenu(); diff --git a/apps/vernierrespirate/app.png b/apps/vernierrespirate/app.png new file mode 100644 index 0000000000000000000000000000000000000000..0f6b22af17e76005f58c052a2c72f7e43f90959f GIT binary patch literal 1986 zcmV;z2R-R7Gvns%k)0zo3?cw!x?&4iT7|;ukj9 zYnwP=hZ+o zbVaO?x(w1%1t$kWyY`h{Z#rv&4mJEtDRq#kpC3ip`T`24kSjr63x{^SnysU( z-Y4Ojo!>>&+cW8vT|XZ4H~Py{E+VnD>u4qUKiN725Dqm+Ci^5}qIQ<{G&aqoH)96+ ztLkeJb!;ZRM2sGJw>FVHfTp{fXX8VZ{Cc3KVeL$M(+1E}_3fb2^gjbRF@ws5pv{mt zr5(&5eMR1U)0moxLMu(WSpk7}I+xT3rrT=T01Q2UFR&tU6Jt#cxKrPN(W561fn@3? zwRgwMUz^g<6?|W=Nj@B5Wzg~MosQ#_0Sr~WP^pAh5@mX2F_@Vfm(q1cj-L80@Y5tY z+)W^~cl5}~*Qd&$3YzE@#mO=y`))4V9h!9jj%FMN@{*LROVEP;G+jMZLU4N>3fYyi zdy^=>30Mgcy&Qb@YiMN!>12W?T4+(KI7hdg1G5TXXvyv`0^8D*^Zcmt0<;3JBvg){ zz?Me{Jo6~3C@)!A)ZcQ0eJ|#{^weoVwgKZYKb6G{Tn&Z~ab2_pK~Nduj#%I~4{MtBM$QkO>CJR7^6DSWy9NH2h*&?z%+QcT za7V^bng*6l;GsQYetiwT<%<&zz-H|CXOZDiZ*BO{D;FZ8lej~-qim0s zbszunlmT=usSo71`VC-Vl9HjLFDpY8=cDBX@vW}*KDa{UUstd$TmzCP@pOnzV&kh{ zz2>!b5kvm53Ue7eGwU( z^fu5mR9*nFY^>(10O;w1Cu``{B>-5>R}srbxneC5joo$ z_miK4{Yf-aaw>cN?z;f?CtYayxiN9%Y%7uGHvCVo_qtD}iJ!tXp^Zvk+CGL4bHhr+ zahYg3+oVYl`mqZ0t_r6s;UWXpeO!N@y6yNr%wU+F~^-3vkltEGd9rZb4+ zVs2Q8GBQ2iPEPgAr1TGVyo zv->5U37;<8T@bO{CzX;qg{%`)72f}v0zf~a&WX