Merge pull request #3827 from HeatSuiteLabs/master

Updates to 2 apps, including 1 new one (HeatSuite) - merged commits
master
thyttan 2025-05-16 19:18:04 +02:00 committed by GitHub
commit 0e2c8d1814
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 3956 additions and 228 deletions

1
.gitignore vendored
View File

@ -15,3 +15,4 @@ _site
Desktop.ini
.sync_*.db*
*.swp
apps/heatsuite/heatsuite.5sts.js

View File

@ -3,3 +3,4 @@
0.03: Move code for recording to this app
0.04: Use default Bangle formatter for booleans
0.05: Minor code improvements
0.06: Added advanced settings, adapted code to mirror bthrm funcitonality, and will work with latest firmware CORE Sensor Firmware (V0.87)

View File

@ -1,19 +1,43 @@
# CoreTemp display
Basic example of connecting to a bluetooth [CoreTemp](https://corebodytemp.com/) device and displaying the current skin and body core temperature readings.
Application to connect to the [CORE](https://corebodytemp.com/) or [calera](https://info.greenteg.com/calera-research) devices from greenteg and display the current skin and body core temperature readings.
This also includes a module (heavily influenced by the BTHRM app) so you can integrate the core sensor into your own apps/widgets. You can also pair an ANT+ heart rate strap to the CORE/calera sensor as well in the App Settings so that you can leverage the exertional algorthim for estimating core temperature.
## Usage
Background task connects to any CoreTemp device (2100/2101) and emits a CoreTemp signal value for each reading.
Application contains three components, one is a background task that monitors the sensor and emits a 'CoreTemp' signal on activity if activated in settings.
The widget shows when the sensor is enabled with a mini value and blinks on use.
The app listens for 'CoreTemp' signals and shows the current skin and core temperatures in large numbers.
Background task connects to a paired and emits a CORESensor signal value for each reading.
Application contains three components, one is a background task that monitors the sensor and emits a 'CORESensor' signal on activity if activated in settings.
The widget shows when the sensor is enabled and connected (green) or disconnected (grey).
The app listens for 'CORESensor' signals and shows the current data.
## CORESensor Module
With the module, you can add the CORE Sensor to your own app. Simply power on the module and listen to CORESensor:
```
Bangle.setCORESensorPower(1,appName);
Bangle.on('CORESensor', (x) =>{ ... });
```
The CORESensor emits an object with following keys:
* **core**: Estimated/Predicted core temperature
* **skin**: Measured skin temperature
* **unit**: "F" or "C"
* **hr**: Heart Rate (only when ANT+ heart rate monitor is paired)
* **heatflux**: (calera device only - needs encryption level b released by greenteg)
* **hsi**: Heat Strain Index ([read more here](https://help.corebodytemp.com/en/articles/10447107-heat-strain-index), exertional algorithm only)
* **battery**: battery level
* **quality**: Used to indicate the quality or trust level of the current measurement values
## TODO
* Integrate with other tracking/sports apps to log data.
* Add specific device selection
* Emit Bangle.js heart rate to device as a heart rate for internal algorthim
## Creator
## Creators/Contributors
Ivor Hewitt
[Nicholas Ravanelli](https://github.com/nravanelli)

View File

@ -1,3 +1,4 @@
{
"enabled":false
"enabled":false,
"debuglog": false
}

View File

@ -1,114 +1 @@
//
// If enabled in settings run constantly in background
//
(function() {
var log = function() {};//print
var settings = {};
var device;
var gatt;
var service;
var characteristic;
class CoreSensor {
constructor() {
this.unit = "";
this.core = -1;
this.skin = -1;
this.battery = 0;
}
updateSensor(event) {
if (event.target.uuid == "00002101-5b1e-4347-b07c-97b514dae121") {
var dv = event.target.value;
var flags = dv.buffer[0];
if (flags & 8) {
this.unit = "F";
} else {
this.unit = "C";
}
if (flags & 1) {
this.skin = (dv.buffer[4] * 256 + dv.buffer[3]) / 100;
} else {
this.skin = 0;
}
if (flags & 2) {
this.core = (dv.buffer[2] * 256 + dv.buffer[1]) / 100;
} else {
this.core = 0;
}
Bangle.emit('CoreTemp',
{core : this.core, skin : this.skin, unit : this.unit});
}
}
updateBatteryLevel(event) {
if (event.target.uuid == "0x2a19")
this.battery = event.target.value.getUint8(0);
}
}
var mySensor = new CoreSensor();
function getSensorBatteryLevel(gatt) {
gatt.getPrimaryService("180f")
.then(function(s) { return s.getCharacteristic("2a19"); })
.then(function(c) {
c.on('characteristicvaluechanged',
(event) => mySensor.updateBatteryLevel(event));
return c.startNotifications();
});
}
function connection_setup() {
log("Scanning for CoreTemp sensor...");
NRF.requestDevice({active:true,timeout : 20000, filters : [ {namePrefix : 'CORE'} ]})
.then(function(d) {
device = d;
log("Found device");
return device.gatt.connect();
})
.then(function(g) {
gatt = g;
return gatt.getPrimaryService('00002100-5b1e-4347-b07c-97b514dae121');
})
.then(function(s) {
service = s;
return service.getCharacteristic(
'00002101-5b1e-4347-b07c-97b514dae121');
})
.then(function(c) {
characteristic = c;
characteristic.on('characteristicvaluechanged',
(event) => mySensor.updateSensor(event));
return characteristic.startNotifications();
})
.then(function() {
log("Done!");
// getSensorBatteryLevel(gatt);
})
.catch(function(e) {
log(e.toString(), "ERROR");
log(e);
});
}
function connection_end() {
if (gatt != undefined)
gatt.disconnect();
}
settings = require("Storage").readJSON("coretemp.json", 1) || {};
log("Settings:");
log(settings);
if (settings.enabled) {
connection_setup();
NRF.on('disconnect', connection_setup);
}
E.on('kill', () => { connection_end(); });
})();
if ((require('Storage').readJSON("coretemp.json", true) || {}).enabled != false) require("CORESensor").enable();

View File

@ -1,5 +1,5 @@
var settings = require("Storage").readJSON("coretemp.json", 1) || {};
// Simply listen for core events and show data
//var btm = g.getHeight() - 1;
var px = g.getWidth() / 2;
@ -21,25 +21,14 @@ var corelogo = {
function onCore(c) {
// Large or small font
var sz = ((process.env.HWVERSION == 1) ? 3 : 2);
g.setFontAlign(0, 0);
g.clearRect(0, 32 + 48, g.getWidth(), 32 + 48 + 24 * 4);
g.setColor(g.theme.dark ? "#CCC" : "#333"); // gray
g.setFont("6x8", sz).drawString(
"Core: " + ((c.core < 327) ? (c.core + c.unit) : 'n/a'), px, 48 + 48);
g.setFont("6x8", sz).drawString("Skin: " + c.skin + c.unit, px, 48 + 48 + 24);
}
// Background task will activate once settings are enabled.
function enableSensor() {
settings = require("Storage").readJSON("coretemp.json", 1) || {};
if (!settings.enabled) {
settings.enabled = true;
require("Storage").write("coretemp.json", settings);
drawBackground("Waiting for\ndata...");
}
g.setFont("6x8", sz).drawString("Core: " + ((c.core < 327) ? (c.core + c.unit) : 'n/a'), px, 48 + 48);
g.setFont("6x8", sz).drawString("Skin: " + c.skin + c.unit, px, 48 + 48 + 14);
g.setFont("6x8", sz).drawString("HR: " + c.hr + " BPM", px, 48 + 48 + 28);
g.setFont("6x8", sz).drawString("HSI: " + c.hsi+ "/10", px, 48 + 48 + 42);
g.setFont("6x8", sz).drawString("BATT: " + c.battery+ "%", px, 48 + 48 + 56);
}
function drawBackground(message) {
@ -51,16 +40,11 @@ function drawBackground(message) {
g.drawString(message, g.getWidth() / 2, g.getHeight() / 2 + 16);
}
Bangle.on('CoreTemp', onCore);
settings = require("Storage").readJSON("coretemp.json", 1) || {};
if (!settings.enabled) {
drawBackground("Sensor off\nBTN" +
((process.env.HWVERSION == 1) ? '2' : '1') + " to enable");
drawBackground("Sensor off\nEnable in Settings");
} else {
Bangle.setCORESensorPower(1,"COREAPP");
Bangle.on('CORESensor', onCore);
drawBackground("Waiting for\ndata...");
}
setWatch(() => { enableSensor(); }, (process.env.HWVERSION == 1) ? BTN2 : BTN1,
{repeat : false});

279
apps/coretemp/lib.js Normal file
View File

@ -0,0 +1,279 @@
exports.enable = () => {
var settings = require("Storage").readJSON("coretemp.json", 1) || {};
let log = function () { };//print
Bangle.enableCORESensorLog = function () {
log = function (text, param) {
let logline = new Date().toISOString() + " - " + text;
if (param) logline += ": " + JSON.stringify(param);
print(logline);
};
};
let gatt;
let device;
let characteristics;
let blockInit = false;
let waitingPromise = function (timeout) {
return new Promise(function (resolve) {
log("Start waiting for " + timeout);
setTimeout(() => {
log("Done waiting for " + timeout);
resolve();
}, timeout);
});
};
if (settings.enabled && settings.cache) {
let addNotificationHandler = function (characteristic) {
log("Setting notification handler"/*supportedCharacteristics[characteristic.uuid].handler*/);
characteristic.on('characteristicvaluechanged', (ev) => supportedCharacteristics[characteristic.uuid].handler(ev.target.value));
};
let characteristicsFromCache = function (device) {
let service = { device: device }; // fake a BluetoothRemoteGATTService
log("Read cached characteristics");
let cache = settings.cache;
if (!cache.characteristics) return [];
let restored = [];
for (let c in cache.characteristics) {
let cached = cache.characteristics[c];
let 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;
r.service = service;
addNotificationHandler(r);
log("Restored characteristic: ", r);
restored.push(r);
}
return restored;
};
let supportedCharacteristics = {
"00002101-5b1e-4347-b07c-97b514dae121": {
handler: function (dv) {
log(dv);
let index = 0;
let flags = dv.getUint8(index++);
let coreTemp = dv.getInt16(index, true) / 100.0;
index += 2;
let skinTemp = dv.getInt16(index, true) / 100.0;
index += 2;
let coreReserved = dv.getInt16(index, true); //caleraGT only with firmware decryption provided by Greenteg
index += 2;
let qualityAndState = dv.getUint8(index++);
let heartRate = dv.getUint8(index++);
let heatStrainIndex = dv.getUint8(index) / 10.0;
let dataQuality = qualityAndState & 0x07;
let hrState = (qualityAndState >> 4) & 0x03;
let data = {
core: coreTemp,
skin: skinTemp,
unit: (flags & 0b00001000) ? "F" : "C",
hr: heartRate,
heatflux: coreReserved,
hsi: heatStrainIndex,
battery: 0,
dataQuality: dataQuality,
hrState: hrState
};
if (lastReceivedData.hasOwnProperty("0x180f")) {
data.battery = lastReceivedData["0x180f"]["0x2a19"];
}
log("data", data);
Bangle.emit("CORESensor", data);
}
},
"00002102-5b1e-4347-b07c-97b514dae121": {
handler: function (dv) {
log(dv);//just log the response, handle write and responses in another Promise Function (Bangle.CORESensorSendOpCode)
}
},
"0x2a19": {
//Battery
handler: function (dv) {
if (!lastReceivedData["0x180f"]) lastReceivedData["0x180f"] = {};
log("Got battery", dv);
lastReceivedData["0x180f"]["0x2a19"] = dv.getUint8(0);
}
}
};
let lastReceivedData = {
};
Bangle.isCORESensorOn = function () {
return (Bangle._PWR && Bangle._PWR.CORESensor && Bangle._PWR.CORESensor.length > 0);
};
Bangle.isCORESensorConnected = function () {
return gatt && gatt.connected;
};
let onDisconnect = function (reason) {
blockInit = false;
log("Disconnect: " + reason);
if (Bangle.isCORESensorOn()) {
setTimeout(initCORESensor, 5000);
}
};
let createCharacteristicPromise = function (newCharacteristic) {
log("Create characteristic promise", newCharacteristic);
let result = Promise.resolve();
if (newCharacteristic.readValue) {
result = result.then(() => {
log("Reading data", newCharacteristic);
return newCharacteristic.readValue().then((data) => {
if (supportedCharacteristics[newCharacteristic.uuid] && supportedCharacteristics[newCharacteristic.uuid].handler) {
supportedCharacteristics[newCharacteristic.uuid].handler(data);
}
});
});
}
if (newCharacteristic.properties.notify) {
result = result.then(() => {
log("Starting notifications", newCharacteristic);
let startPromise = newCharacteristic.startNotifications().then(() => log("Notifications started", newCharacteristic));
startPromise = startPromise.then(() => {
return waitingPromise(3000);
});
return startPromise;
});
}
return result.then(() => log("Handled characteristic", newCharacteristic));
};
let attachCharacteristicPromise = function (promise, characteristic) {
return promise.then(() => {
log("Handling characteristic:", characteristic);
return createCharacteristicPromise(characteristic);
});
};
let initCORESensor = function () {
if (!settings.btname) {
log("CORESensor not paired, quitting");
return;
}
if (blockInit) {
log("CORESensor already turned on by another app, quitting");
return;
}
blockInit = true;
NRF.setScan();
let promise;
let filters;
if (!device) {
if (settings.btname) {
log("Configured device name ", settings.btname);
filters = [{ name: settings.btname }];
} else {
return;
}
log("Requesting device with filters", filters);
try {
promise = NRF.requestDevice({ filters: filters, active: true });
} catch (e) {
log("Error during initial request:", e);
onDisconnect(e);
return;
}
promise = promise.then((d) => {
log("Wait after request");
return waitingPromise(2000).then(() => Promise.resolve(d));
});
promise = promise.then((d) => {
log("Got device", d);
d.on('gattserverdisconnected', onDisconnect);
device = d;
});
} else {
promise = Promise.resolve();
log("Reuse device", device);
}
promise = promise.then(() => {
gatt = device.gatt;
return Promise.resolve(gatt);
});
promise = promise.then((gatt) => {
if (!gatt.connected) {
log("Connecting...");
let connectPromise = gatt.connect().then(function () {
log("Connected.");
});
connectPromise = connectPromise.then(() => {
log("Wait after connect");
return waitingPromise(2000);
});
return connectPromise;
} else {
return Promise.resolve();
}
});
promise = promise.then(() => {
if (!characteristics || characteristics.length == 0) {
characteristics = characteristicsFromCache(device);
}
let characteristicsPromise = Promise.resolve();
for (let characteristic of characteristics) {
characteristicsPromise = attachCharacteristicPromise(characteristicsPromise, characteristic, true);
}
return characteristicsPromise;
});
return promise.then(() => {
log("Connection established, waiting for notifications");
}).catch((e) => {
characteristics = [];
log("Error:", e);
onDisconnect(e);
});
};
Bangle.setCORESensorPower = function (isOn, app) {
// Do app power handling
if (!app) app = "?";
log("setCORESensorPower ->", isOn, app);
if (Bangle._PWR === undefined) Bangle._PWR = {};
if (Bangle._PWR.CORESensor === undefined) Bangle._PWR.CORESensor = [];
if (isOn && !Bangle._PWR.CORESensor.includes(app)) Bangle._PWR.CORESensor.push(app);
if (!isOn && Bangle._PWR.CORESensor.includes(app)) Bangle._PWR.CORESensor = Bangle._PWR.CORESensor.filter(a => a != app);
isOn = Bangle._PWR.CORESensor.length;
// so now we know if we're really on
if (isOn) {
log("setCORESensorPower on" + app);
if (!Bangle.isCORESensorConnected()) initCORESensor();
} else { // being turned off!
log("setCORESensorPower turning off ", app);
if (gatt) {
if (gatt.connected) {
log("CORESensor: Disconnect with gatt", gatt);
try {
gatt.disconnect().then(() => {
log("CORESensor: Successful disconnect");
}).catch((e) => {
log("CORESensor: Error during disconnect promise", e);
});
} catch (e) {
log("CORESensor: Error during disconnect attempt", e);
}
}
}
}
};
// disconnect when swapping apps
E.on("kill", function () {
if (gatt) {
log("CORESensor connected - disconnecting");
try { gatt.disconnect(); } catch (e) {
log("CORESensor disconnect error", e);
}
gatt = undefined;
}
});
}
};

View File

@ -1,7 +1,7 @@
{
"id": "coretemp",
"name": "CoreTemp",
"version": "0.05",
"version": "0.06",
"description": "Display CoreTemp device sensor data",
"icon": "coretemp.png",
"type": "app",
@ -11,10 +11,11 @@
"storage": [
{"name":"coretemp.wid.js","url":"widget.js"},
{"name":"coretemp.app.js","url":"coretemp.js"},
{"name":"CORESensor","url":"lib.js"},
{"name":"coretemp.recorder.js","url":"recorder.js"},
{"name":"coretemp.settings.js","url":"settings.js"},
{"name":"coretemp.img","url":"coretemp-icon.js","evaluate":true},
{"name":"coretemp.boot.js","url":"boot.js"}
{"name":"coretemp.0.boot.js","url":"boot.js"}
],
"data": [{"name":"coretemp.json","url":"app-settings.json"}],
"screenshots": [{"url":"screenshot.png"}]

View File

@ -1,28 +1,40 @@
(function(recorders) {
recorders.coretemp = function() {
var core = "", skin = "";
var core = "", skin = "", unit="", hr="", heatflux="", hsi="", battery="", quality="";
var hasCore = false;
function onCore(c) {
core=c.core;
skin=c.skin;
hasCore = true;
unit = c.unit;
hr = c.hr;
heatflux = c.heatflux;
hsi = c.hsi;
battery= c.battery;
quality = c.dataQuality;
}
return {
name : "Core",
fields : ["Core","Skin"],
fields : ["Core","Skin","Unit","HeartRate","HeatFlux","HeatStrainIndex","Battery","Quality"],
getValues : () => {
var r = [core,skin];
var r = [core,skin,unit,hr,heatflux,hsi,battery,quality];
core = "";
skin = "";
unit="";
hr="";
heatflux="";
hsi="";
battery="";
quality="";
return r;
},
start : () => {
hasCore = false;
Bangle.on('CoreTemp', onCore);
Bangle.on('CORESensor', onCore);
},
stop : () => {
hasCore = false;
Bangle.removeListener('CoreTemp', onCore);
Bangle.removeListener('CORESensor', onCore);
},
draw : (x,y) => g.setColor(hasCore?"#0f0":"#8f8").drawImage(atob("DAyBAAHh0js3EuDMA8A8AWBnDj9A8A=="),x,y)
};

View File

@ -2,45 +2,532 @@
/**
* @param {function} back Use back() to return to settings menu
*/
(function(back) {
(function (back) {
var settings = {};
const SETTINGS_FILE = 'coretemp.json'
// initialize with default settings...
let s = {
'enabled': true,
}
// ...and overwrite them with any saved values
// This way saved values are preserved if a new version adds more settings
const storage = require('Storage')
const saved = storage.readJSON(SETTINGS_FILE, 1) || {}
for (const key in saved) {
s[key] = saved[key];
}
var CORECONNECTED = false;
// creates a function to safe a specific setting, e.g. save('color')(1)
function save(key) {
return function (value) {
function writeSettings(key, value) {
let s = require('Storage').readJSON(SETTINGS_FILE, true) || {};
s[key] = value;
storage.write(SETTINGS_FILE, s);
}
require('Storage').writeJSON(SETTINGS_FILE, s);
readSettings();
}
function updateSettings() {
require("Storage").write("coretemp.json", s);
if (WIDGETS["coretemp"])
WIDGETS["coretemp"].reload();
function readSettings() {
settings = Object.assign(
require('Storage').readJSON(SETTINGS_FILE, true) || {}
);
}
readSettings();
let log = () => { };
if (settings.debuglog)
log = print;
let supportedServices = [
"00002100-5b1e-4347-b07c-97b514dae121", // Core Body Temperature Service
"0x180f", // Battery
"0x1809", // Health Thermometer Service
];
let supportedCharacteristics = [
"00002101-5b1e-4347-b07c-97b514dae121", // Core Body Temperature Characteristic
"00002102-5b1e-4347-b07c-97b514dae121", //Core Temp Control Point (opCode for extra function)
//"0x2a1c", //Thermometer
//"0x2a1d", //Sensor Location (CORE)
"0x2a19", // Battery
];
var characteristicsToCache = function (characteristics) {
log("Cache characteristics");
let cache = {};
if (!cache.characteristics) cache.characteristics = {};
for (var c of characteristics) {
log("Saving handle " + c.handle_value + " for characteristic: ", c.uuid);
cache.characteristics[c.uuid] = {
"handle": c.handle_value,
"uuid": c.uuid,
"notify": c.properties.notify,
"read": c.properties.read,
"write": c.properties.write
};
}
writeSettings("cache", cache);
};
var controlPointChar;
let createCharacteristicPromise = function (newCharacteristic) {
log("Create characteristic promise", newCharacteristic.uuid);
if (newCharacteristic.uuid === "00002102-5b1e-4347-b07c-97b514dae121") {
log("Subscribing to CoreTemp Control Point Indications.");
controlPointChar = newCharacteristic;
return controlPointChar.writeValue(new Uint8Array([0x02]), {
type: "command",
handle: true
})
.then(() => {
log("Indications enabled! Listening for responses...");
return controlPointChar.startNotifications(); //now we can send opCodes
})
.then(() => log("Finished handling CoreTemp Control Point."))
.catch(error => {
log("Error enabling indications:", error);
});
}
return Promise.resolve().then(() => log("Handled characteristic", newCharacteristic.uuid));
};
let attachCharacteristicPromise = function (promise, characteristic) {
return promise.then(() => {
log("Handling characteristic:", characteristic.uuid);
return createCharacteristicPromise(characteristic);
});
};
let characteristics;
let createCharacteristicsPromise = function (newCharacteristics) {
log("Create characteristics promise ", newCharacteristics.length);
let result = Promise.resolve();
for (let c of newCharacteristics) {
if (!supportedCharacteristics.includes(c.uuid)) continue;
log("Supporting characteristic", c.uuid);
characteristics.push(c);
result = attachCharacteristicPromise(result, c);
}
return result.then(() => log("Handled characteristics"));
};
let createServicePromise = function (service) {
log("Create service promise", service.uuid);
let 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));
};
let attachServicePromise = function (promise, service) {
return promise.then(() => createServicePromise(service));
};
function writeToControlPoint(opCode, params) {
return new Promise((resolve, reject) => {
let data = new Uint8Array([opCode].concat(params));
if (!controlPointChar) {
log("Control Point characteristic not found! Reconnecting...");
return;
}
const menu = {
'' : {'title' : 'CoreTemp sensor'},
'< Back' : back,
'Enabled' : {
value : !!s.enabled,
onchange : v => {
s.enabled = v;
updateSettings();
}
// Temporary handler to capture the response
function handleResponse(event) {
let response = new Uint8Array(event.target.value.buffer);
//let responseOpCode = response[0];
let requestOpCode = response[1]; // Matches the sent OpCode
let resultCode = response[2]; // 0x01 = Success
controlPointChar.removeListener("characteristicvaluechanged", handleResponse);
if (requestOpCode === opCode) {
if (resultCode === 0x01) { //successful
resolve(response);
} else {
reject("Error Code: " + resultCode);
}
}
}
}
E.showMenu(menu);
controlPointChar.on("characteristicvaluechanged", handleResponse);
controlPointChar.writeValue(data)
.then(() => log("Sent OpCode:", opCode.toString(16), "Params:", data))
.catch(error => {
log("Write error:", error);
reject(error);
});
});
}
let gatt;
function cacheDevice(deviceName) {
let promise;
let filters;
characteristics = [];
filters = [{ name: deviceName }];
log("Requesting device with filters", filters);
promise = NRF.requestDevice({ filters: filters, active: settings.active });
promise = promise.then((d) => {
E.showMessage("Found!!\n" + deviceName + "\nConnecting...");
log("Got device", d);
gatt = d.gatt;
log("Connecting...");
d.on('gattserverdisconnected', function () {
CORECONNECTED = false;
log("Disconnected! ");
gatt = null;
//setTimeout(() => cacheDevice(deviceName), 5000); // Retry in 5 seconds
});
return gatt.connect().then(function () {
log("Connected.");
});
});
promise = promise.then(() => {
log(JSON.stringify(gatt.getSecurityStatus()));
if (gatt.getSecurityStatus().bonded) {
log("Already bonded");
return Promise.resolve();
} else {
log("Start bonding");
return gatt.startBonding()
.then(() => log("Security status after bonding" + gatt.getSecurityStatus()));
}
});
promise = promise.then(() => {
log("Getting services");
return gatt.getPrimaryServices();
});
promise = promise.then((services) => {
log("Got services", services.length);
let result = Promise.resolve();
for (let service of services) {
if (!(supportedServices.includes(service.uuid))) continue;
log("Supporting service", service.uuid);
result = attachServicePromise(result, service);
}
return result;
});
return promise.then(() => {
log("Connection established, saving cache");
E.showMessage("Found " + deviceName + "\nConnected!");
CORECONNECTED = true;
characteristicsToCache(characteristics);
});
}
function ConnectToDevice(d) {
E.showMessage("Connecting...");
let count = 0;
const successHandler = () => {
E.showMenu(buildMainMenu());
};
const errorHandler = (e) => {
count++;
log("ERROR", e);
if (count <= 10) {
E.showMessage("Error during caching\nRetry " + count + "/10", e);
return cacheDevice(d).then(successHandler).catch(errorHandler);
} else {
E.showAlert("Error during caching", e).then(() => {
E.showMenu(buildMainMenu());
});
}
};
return cacheDevice(d).then(successHandler).catch(errorHandler);
}
/*
function getPairedAntHRM() {
writeToControlPoint(0x04) // Get paired HRMs
.then(response => {
let totalHRMs = response[3]; // HRM count at index 3
log("📡 PAIRED ANT+:", totalHRMs);
let promises = [];
let hrmFound = [];
for (let i = 0; i < totalHRMs; i++) {
promises.push(
writeToControlPoint(0x05, [i]) // Get HRM ID from paired list
.then(hrmResponse => {
log("🔍 Response 0x05:", hrmResponse);
let byte1 = hrmResponse[3]; // LSB
let byte2 = hrmResponse[4]; // Middle Byte
let byte3 = hrmResponse[5]; // MSB
let txType = hrmResponse[5]; // Transmission Type
let hrmState = hrmResponse[6]; // Connection State
let pairedAntId = (byte1) | (byte2 << 8) | (byte3 << 16); // ✅ Corrected parsing
let stateText = ["Closed", "Searching", "Synchronized", "Reserved"][hrmState & 0x03];
log(`🔗 HRM ${i}: ANT ID = ${pairedAntId}, Tx-Type = ${txType}, State = ${stateText}`);
hrmFound.push({ index: i, antId: pairedAntId, txType: txType, stateText: stateText });
})
.catch(e => log(`❌ Error fetching HRM ${i} ID:`, e))
);
}
return Promise.all(promises).then(() => hrmFound);
})
.then(allHRMs => {
log("Retrieved all paired HRMs:", allHRMs);
return // Modified start scanning command
})
}
*/
function clearPairedHRM_ANT() {
return writeToControlPoint(0x01) // Send OpCode 0x01 to clear list
.then(response => {
let resultCode = response[2]; // Check the success flag
if (resultCode === 0x01) {
log("ANT+ HRM list cleared successfully.");
return Promise.resolve();
} else {
log("Failed to clear ANT+ HRM list. Error code:", resultCode);
return Promise.reject(new Error(`Error code: ${resultCode}`));
}
})
.catch(error => {
log("Error clearing ANT+ HRM list:", error);
return Promise.reject(error);
});
}
function scanUntilSynchronized(maxRetries, delay) {
let attempts = 0;
function checkHRMState() {
if (attempts >= maxRetries) {
log("Max scan attempts reached. HRM did not synchronize.");
E.showAlert("Max scan attempts reached. HRM did not synchronize.").then(() => E.showMenu(HRM_MENU()));
return;
}
log(`Attempt ${attempts + 1}/${maxRetries}: Checking HRM state...`);
writeToControlPoint(0x05, [0]) // Check paired HRM state
.then(hrmResponse => {
log("Sent OpCode: 0x05, response: ", hrmResponse);
let byte1 = hrmResponse[3]; // LSB of ANT ID
let byte2 = hrmResponse[4]; // MSB of ANT ID
let txType = hrmResponse[5]; // Transmission Type
let hrmState = hrmResponse[6]; // HRM State
let retrievedAntId = (byte1) | (byte2 << 8) | (txType << 16);
let stateText = ["Closed", "Searching", "Synchronized", "Reserved"][hrmState & 0x03];
log(`HRM Status: ANT ID = ${retrievedAntId}, Tx-Type = ${txType}, State = ${stateText}`);
E.showAlert(`HRM Status\nANT ID = ${retrievedAntId}\nState = ${stateText}`).then(() => E.showMenu(HRM_MENU()));
if (stateText === "Synchronized") {
return;
} else {
log(`HRM ${retrievedAntId} is not yet synchronized. Scanning again...`);
// Start scan again
writeToControlPoint(0x0D)
.then(() => writeToControlPoint(0x0A, [0xFF]))
.then(() => {
attempts++;
setTimeout(checkHRMState, delay); // Wait and retry
})
.catch(error => {
log("Error restarting scan:", error);
});
}
})
.catch(error => {
log("Error checking HRM state:", error);
});
}
log("Starting scan to synchronize HRM...");
writeToControlPoint(0x0A, [0xFF]) // Start initial scan
.then(() => {
setTimeout(checkHRMState, delay); // Wait and check state
})
.catch(error => {
log("Error starting initial scan:", error);
});
}
function scanHRM_ANT() {
E.showMenu();
E.showMessage("Scanning for 10 seconds"); // Increased scan time
writeToControlPoint(0x0A, [0xFF])
.then(response => {
log("Received Response for 0x0A:", response);
return new Promise(resolve => setTimeout(resolve, 10000)); // Extended scan time to 10 seconds
})
.then(() => {
return writeToControlPoint(0x0B); // Get HRM count
})
.then(response => {
let HRMCount = response[3];
log("HRM Count Response:", HRMCount);
let hrmFound = [];
let promises = [];
for (let i = 0; i < HRMCount; i++) {
promises.push(
writeToControlPoint(0x0C, [i]) // Get Scanned HRM IDs
.then(hrmResponse => {
log("Response 0x0C:", hrmResponse);
let byte1 = hrmResponse[3]; // LSB
let byte2 = hrmResponse[4]; // MSB
let txType = hrmResponse[5]; // Transmission Type
let scannedAntId = (byte1) | (byte2 << 8) | (txType << 16); //3 byte ANT+ ID
log(`HRM ${i} ID Response: ${scannedAntId}`);
hrmFound.push({ antId: scannedAntId });
})
.catch(e => log(`Error fetching HRM ${i} ID:`, e))
);
}
return Promise.all(promises).then(() => {
if (hrmFound > 0) {
let submenu_scan = {
'< Back': function () { E.showMenu(buildMainMenu()); }
};
hrmFound.forEach((hrm) => {
let id = hrm.antId;
submenu_scan[id] = function () {
E.showPrompt("Connect to\n" + id + "?", { title: "ANT+ Pairing" }).then((r) => {
if (r) {
E.showMessage("Connecting...");
let byte1 = id & 0xFF; // LSB
let byte2 = (id >> 8) & 0xFF; // Middle byte
let byte3 = (id >> 16) & 0xFF; // Transmission Type
return clearPairedHRM_ANT(). //FIRST CLEAR ALL ANT+ HRM
then(() => { writeToControlPoint(0x02, [byte1, byte2, byte3]) }) // Pair the HRM
.then(() => {
log(`HRM ${id} added to paired list.`);
writeSettings("ANT_HRM", hrm);
E.showMenu(HRM_MENU());
})
.catch(e => log(`Error adding HRM ${id} to paired list:`, e));
}
});
};
});
E.showMenu(submenu_scan);
} else {
E.showAlert("No ANT+ HRM found.").then(() => E.showMenu(HRM_MENU()));
}
});
})
.catch(e => log("ERROR:", e));
}
function buildMainMenu() {
let mainmenu = {
'': { 'title': 'CORE Sensor' },
'< Back': back,
'Enable': {
value: !!settings.enabled,
onchange: v => {
writeSettings("enabled", v);
},
},
'Widget': {
value: !!settings.widget,
onchange: v => {
writeSettings("widget", v);
},
}
};
if (settings.btname || settings.btid) {
let name = "Clear " + (settings.btname || settings.btid);
mainmenu[name] = function () {
E.showPrompt("Clear current device?").then((r) => {
if (r) {
writeSettings("btname", undefined);
writeSettings("btid", undefined);
writeSettings("cache", undefined);
if(gatt) gatt.disconnect();
}
E.showMenu(buildMainMenu());
});
};
if(!CORECONNECTED){
let connect = "Connect " + (settings.btname || settings.btid);
mainmenu[connect] = function () {ConnectToDevice(settings.btname)};
}else{
mainmenu['HRM Settings'] = function () { E.showMenu(HRM_MENU()); };
}
} else {
mainmenu['Scan for CORE'] = function () { ScanForCORESensor(); };
}
mainmenu['Debug'] = function () { E.showMenu(submenu_debug); };
return mainmenu;
}
let submenu_debug = {
'': { title: "Debug" },
'< Back': function () { E.showMenu(buildMainMenu()); },
'Alert on disconnect': {
value: !!settings.warnDisconnect,
onchange: v => {
writeSettings("warnDisconnect", v);
}
},
'Debug log': {
value: !!settings.debuglog,
onchange: v => {
writeSettings("debuglog", v);
}
}
};
function HRM_MENU() {
let menu = {
'': { 'title': 'CORE: HR' },
'< Back': function () { E.showMenu(buildMainMenu()); },
'Scan for ANT+': function () { scanHRM_ANT(); }
}
if (settings.btname) {
menu['ANT+ Status'] = function () { scanUntilSynchronized(10, 3000); },
menu['Clear ANT+'] = function () {
E.showPrompt("Clear ANT+ HRs?", { title: "CLear ANT+" }).then((r) => {
if (r) {
clearPairedHRM_ANT();
}
E.showMenu(HRM_MENU());
});
}
}
return menu;
}
function ScanForCORESensor() {
E.showMenu();
E.showMessage("Scanning for 5 seconds");
let submenu_scan = {
'< Back': function () { E.showMenu(buildMainMenu()); }
};
NRF.findDevices(function (devices) {
submenu_scan[''] = { title: `Scan (${devices.length} found)` };
if (devices.length === 0) {
E.showAlert("No devices found")
.then(() => E.showMenu(buildMainMenu()));
return;
} else {
devices.forEach((d) => {
log("Found device", d);
let shown = (d.name || d.id.substr(0, 17));
submenu_scan[shown] = function () {
E.showPrompt("Connect to\n" + shown + "?", { title: "Pairing" }).then((r) => {
if (r) {
E.showMessage("Connecting...");
let count = 0;
const successHandler = () => {
E.showPrompt("Success!", {
buttons: { "OK": true }
}).then(() => {
writeSettings("btid", d.id);
writeSettings("btname", d.name); //Seems to only like to connect by name
E.showMenu(HRM_MENU());
});
};
const errorHandler = (e) => {
count++;
log("ERROR", e);
if (count <= 10) {
E.showMessage("Error during caching\nRetry " + count + "/10", e);
return cacheDevice(d.name).then(successHandler).catch(errorHandler);
} else {
E.showAlert("Error during caching", e).then(() => {
E.showMenu(buildMainMenu());
});
}
};
return cacheDevice(d.name).then(successHandler).catch(errorHandler);
}
});
};
});
}
E.showMenu(submenu_scan);
}, { timeout: 5000, active: true, filters: [{ services: ["00002100-5b1e-4347-b07c-97b514dae121"] }] });
}
function init() {
E.showMenu();
E.showMenu(buildMainMenu());
}
init();
})

View File

@ -1,62 +1,54 @@
// TODO Change to a generic multiple sensor widget?
(() => {
var settings = {};
var count = 0;
var core = 0;
var CORESensorStatus = false;
// draw your widget
function draw() {
if (!settings.enabled)
if (!settings.widget)
return;
g.reset();
g.setFont("6x8", 1).setFontAlign(0, 0);
g.setFontAlign(0, 0);
g.clearRect(this.x, this.y, this.x + 23, this.y + 23);
if (count & 1) {
if (CORESensorStatus) {
g.setColor("#0f0"); // green
} else {
g.setColor(g.theme.dark ? "#333" : "#CCC"); // off = grey
}
g.drawImage(
atob("DAyBAAHh0js3EuDMA8A8AWBnDj9A8A=="),
this.x+(24-12)/2,this.y+1);
g.setColor(g.theme.fg);
g.drawString(parseInt(core)+"\n."+parseInt((core*100)%100), this.x + 24 / 2, this.y + 18);
atob("FBSCAAAAADwAAAPw/8AAP/PD8AP/wwDwD//PAPAP/APA8D/AA//wP8AA/8A/AAAAPP8AAAD8/wAAAPz/AAAA/D8AAAAAP8AAA/A/8AAP8A/8AD/wD///z8AD///PAAA///AAAAP/wAA="),
this.x + (24 - 12) / 2, this.y + 1);
g.setColor(-1);
}
// Set a listener to 'blink'
function onTemp(temp) {
count = count + 1;
core = temp.core;
WIDGETS["coretemp"].draw();
}
// Called by sensor app to update status
function reload() {
settings = require("Storage").readJSON("coretemp.json", 1) || {};
Bangle.removeListener('CoreTemp', onTemp);
if (!settings.widget) {
delete WIDGETS["coretemp"];
return;
}
if (settings.enabled) {
WIDGETS["coretemp"].width = 24;
Bangle.on('CoreTemp', onTemp);
} else {
WIDGETS["coretemp"].width = 0;
count = 0;
WIDGETS["CORESensor"].width = 0;
}
}
if (Bangle.hasOwnProperty("isCORESensorConnected")) {
setInterval(function () {
if (Bangle.isCORESensorConnected() != CORESensorStatus) {
CORESensorStatus = Bangle.isCORESensorConnected();
WIDGETS["coretemp"].draw();
}
}, 10000); //runs every 10 seconds
}
// add the widget
WIDGETS["coretemp"] = {
area : "tl",
width : 24,
draw : draw,
reload : function() {
area: "tl",
width: 24,
draw: draw,
reload: function () {
reload();
Bangle.drawWidgets(); // relayout all widgets
}

View File

@ -1,3 +1,4 @@
0.01: First version of GPS Setup app
0.02: Created gppsetup module
0.03: Added support for Bangle.js2
0.04: Added adaptive option for PSMOO with Bangle.js2

View File

@ -59,6 +59,7 @@ used. These settings will remain for all apps that use the GPS.
- fix_req (Bangle.js2 only) - the number of fixes required before the GPS turns off until next search for GPS signal. default is 1.
- adaptive (Bangle.js2 only) - When a GPS signal is acquired, this can reduce the time in seconds until next scan to generate higher temporal resolution of gps fixes. Off if set to 0.
## Module
A module is provided that'll allow you to set GPS configuration from your own
@ -69,11 +70,13 @@ app. To use it:
// needed unless the watch's battery has run down
require("gpssetup").setPowerMode();
// This sets up the PSMOO mode. update/search are optional in seconds
// This sets up the PSMOO mode. update/search/adaptive are optional in seconds
require("gpssetup").setPowerMode({
power_mode:"PSMOO",
update:optional (default 120),
search:optional (default 5)})
search:optional (default 5),
adaptive: optional (default 0)
});
// This sets up SuperE
require("gpssetup").setPowerMode({power_mode:"SuperE"})

View File

@ -165,9 +165,11 @@ function setupPSMOO(settings) {
var gpsTimeout = null;
var gpsActive = false;
var fix = 0;
var lastFix = false;
function cb(f){
if(parseInt(f.fix) === 1){
fix++;
lastFix = true;
if(fix >= settings.fix_req){
fix = 0;
turnOffGPS();
@ -180,9 +182,14 @@ function setupPSMOO(settings) {
clearTimeout(gpsTimeout);
Bangle.setGPSPower(0,settings.appName);
Bangle.removeListener('GPS', cb); // cleaning it up
var timeout = settings.update * 1000;
if(lastFix && settings.adaptive > 0){
timeout = settings.adaptive * 1000;
}
lastFix = false;
gpsTimeout = setTimeout(() => {
turnOnGPS();
}, settings.update * 1000);
}, timeout);
}
function turnOnGPS(){
if (gpsActive) return;
@ -213,15 +220,17 @@ exports.setPowerMode = function(options) {
if (options) {
if (options.update) settings.update = options.update;
if (options.search) settings.search = options.search;
if (options.adaptive) settings.adaptive = options.adaptive;
if (options.fix_req) settings.fix_req = options.fix_req;
if (options.power_mode) settings.power_mode = options.power_mode;
if (options.appName) settings.appName = options.appName;
}
settings.update = settings.update||120;
settings.search = settings.search||5;
settings.adaptive = settings.adaptive||0;
settings.fix_req = settings.fix_req||1; //default to just one fix and will turn off
settings.power_mode = settings.power_mode||"SuperE";
settings.appName = settings.appName || "gpssetup";
settings.appName = settings.appName||"gpssetup";
if (options) require("Storage").write(SETTINGS_FILE, settings);
if(!Bangle.isGPSOn()) Bangle.setGPSPower(1,settings.appName); //always know its on - no point calling this otherwise!!!
if (settings.power_mode === "PSMOO") {

View File

@ -2,7 +2,7 @@
"id": "gpssetup",
"name": "GPS Setup",
"shortName": "GPS Setup",
"version": "0.03",
"version": "0.04",
"description": "Configure the GPS power options and store them in the GPS nvram",
"icon": "gpssetup.png",
"tags": "gps,tools,outdoors",

7
apps/heatsuite/ChangeLog Normal file
View File

@ -0,0 +1,7 @@
0.01: New App!
0.05: Added functionality for incorporating BTHRM
0.06: Modified BLE Broadcasting; Includes Optical HR and Wrist Temperature
0.07: Fixing storage overloaded issue
0.08: Added scrolling to Surveys. CORESensor app added.
0.09: Added High Temporal Accelerometry logging (x,y,z per second)
0.10: Fixes to settings.js to account for optimizations in studyTasks.json

101
apps/heatsuite/README.md Normal file
View File

@ -0,0 +1,101 @@
# HeatSuite Watch Application
This is the HeatSuite Watch Application which allows for seemless integration into the HeatSuite platform ([read the docs](https://heatsuitelabs.github.io/HeatSuiteDocs/) and our [research](#research-using-heatsuite)). You may use this watch application independent of the full(er) HeatSuite platform.
## What is HeatSuite?
HeatSuite is a comprehensive all in one solution for researchers to monitor the physiological, behavioural, and perceptual responses of individuals and their personal environmental exposure. Learn more on the details of the HeatSuite platform by [reading the docs](https://heatsuitelabs.github.io/HeatSuiteDocs/).
## Why do we need this?
Consumer and research-based wearables have largely determined what researchers can measure in the field, and/or have proprietary postprocessing embedded into the hardware/software stack which limits transparency and transferrability of data collected. HeatSuite challenges this one-sided relationship, offering a solution for researchers who desire access and awareness of how and what data they are collecting from *their* participants.
## Watch Specific Features
This is a list of current features available when using the HeatSuite Watch Application:
+ Per minute averaging and/or sum of onboard watch sensor data (Heart rate, barometer temperature and pressure, accelerometer, battery)
+ High temporal resolution accelerometer logging (x,y,z per second)
+ Can connect external bluetooth devices for added physiological monitoring (e.g. Bluetooth Heart Rate, CORE Sensor) - more being added
+ Connect and store data from other devices including:
+ Blood Pressure Monitor (A&D Medical UA651-BLE)
+ Oral Temperature using custom dongle - Contact [Nicholas Ravanelli, PhD](emailto:nick.ravanelli@gmail.com)
+ Body Mass Scale (Xiaomi Composition Scale 2)
+ Collect perceptions and behaviour using ecological momentary assessments with onboard questionnaires
+ Mictruition frequency and color analysis for index of hydration status
+ Create study schedules for participants to receive programmatic nudges daily, specific to each task
+ Programmatic GPS monitoring, with adaptive power switching for battery optimization
+ Fall Detection and bluetooth broadcasting (beta)
## I just installed HeatSuite and I see a bunch of options. What do they mean?
HeatSuite uses a default studyTasks.json - visible and fully editable in the `Tasks` tab when installing HeatSuite on the watch. You can customize this to your liking by following the instructions [in the docs](https://heatsuitelabs.github.io/HeatSuiteDocs/watchapp/watchapp-tasks/). Some specific details are provided in this readme.
## What do the button colors mean?
![HeatSuite Main Application](heatsuite_layout.png)
The colors of each of the buttons provide the user some feedback:
+ <font color="green">Green</font> Means the task has been completed recently, or the user can press the button to navigate to or do a task.*
+ <font color="red">Red</font> means that the task can't be completed yet because another step needs to be done. This example shows that the external devices have yet to be paired with the watch.
+ <font color="yellow">Yellow</font> means you are scheduled to do this task.
_*Note: If the task requires a bluetooth device, the app will scan for the device and automatically handle the task once found._
## What does `Searching...` at the bottom of the HeatSuite app layout mean?
The `Searching...` text at the bottom of the screen shows that the watch is searching for devices that it may need to connect to via Bluetooth. This text will change when a device is found, and the appropriate handling of the task will ensue. As Bluetooth scanning drains the battery, the HeatSuite App will timeout after 3 minutes and revert back to the clock.
_Note: This will only show when a bluetooth device is associated with a task._
## Why does swiping right open the HeatSuite App?
The objective of HeatSuite was to make data collection in the field easier for participants. By default, swiping right when the HeatSuite widget is visible will open the app. This can be toggled off in the app settings.
## Can I request or add a feature?
Certainly!! To help ensure that the release on BangleApps is always functional and works with the other devices within the HeatSuite ecosystem, please make any feature requests or code updates to the forked version within the [HeatSuiteLabs repository](https://github.com/HeatSuiteLabs/BangleApps). That is our testing bed before we push to the official Bangle Apps git.
## Watch accelerometer data
The current interation of the HeatSuite application provides the option to average the accelerometer `x,y,z` every second or as needed (known as High Temporal Resolution Accelerometer Logging), and/or magnitude per minute. Magnitude is calculated in the Espruino firmware as:
```
sqrt(x^2 + y^2 + z^2)
```
To transform this to [Euclidean norm minus one (ENMO)](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0061691) format:
```
ENMO = acc_avg - 1
```
Where `acc_avg` is the average acceleration magnitude per minute, available in the CSV file. While this may be satisfactory for offline long term monitoring (+2 weeks), it is recommended to use per second `mag` data.
Previous research has demonstrated strong agreement between the onboard accelerometer of the Bangle.js2 and a research grade ActiGraph GT9X;
Van Laerhoven, K., Hoelzemann, A., Pahmeier, I. et al. Validation of an open-source ambulatory assessment system in support of replicable activity studies. *Ger J Exerc Sport Res* 52, 262272 (2022). https://doi.org/10.1007/s12662-022-00813-2
## Applications/modules that HeatSuite integrates:
* [BTHRM](https://banglejs.com/apps/#bthrm)
* [coretemp](https://banglejs.com/apps/#coretemp)
* [gpssetup](https://banglejs.com/apps/#gpssetup)
* [recorder](https://banglejs.com/apps/#recorder) (modified in HeatSuite code to incorporate per minute averaging)
## Icons
HeatSuite uses icons from [Flaticon.com](https://www.flaticon.com) & [Freepik.com](https://www.freepik.com)
## Research Using HeatSuite
A full list of peer-reviewed research and conference preceedings using HeatSuite can be found [here](https://heatsuitelabs.github.io/HeatSuiteDocs/research/)
## To Do
* Finish Download All and Delete All functions on custom.html
* Graphical User Interface for EMA and Task Development
## Creator
[Nicholas Ravanelli, PhD](https://github.com/nravanelli)

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEw4kC///A4IDBo37x1zhljkEAvvv+ecjnn8n6oMI7//mGEsMIwUQkQABCwIAOgQUCCyQYGBpEM4AKIFxmTnIwLBZED0c6mAwJFxMD1l6C5MCC5U84YXKA43Iw5HFvGMTBkJ+MbwEMnWj4EI2MfuAXLgkV6NnGAIuBuMdqNAC5cP28PjIXDyPwvdQCpLPBgsVxcVyAXBhNR3FRC4MMCw2F+uQL4MR+P9p/970fiMfBYNfrIWEg9h+P5gGUrHF2Me8MbrmFpMAz8fjZ7EhsdgsYA4eb3IBBA4eBqFB6CLEjEFjp7E897+AHDoNAwIXEhNhsMRrPIxJ7Bi0ROYOYxmViMWi2QMAmLt9PiIaBwEA+1lgEICgMR+n29CnI6Mf6MXT4IHBa4PxKYKnHLYe8a4QACyNQMYoAHa4OGiqcCg/xa4YAHyczZYPhiOx/tf+tLjbXDmc5CwkM1U6m6HBpF02MbAIPcwiXBuej1RoBAAUI1MD1LXE8+We4IACzUwxSnECoIXG+4BBC4uaC4kHI4d47PNI4Uexmdw5HDQQR3G+MR8O1O4Nbj53KU4tBCAIGBDwNkqNAX5bXXaoOBi4HDuMYqNoChGf3+PiIAB6EAre1gEEBAX4/auEKgOxBYP5vFJMYIGBqEAymHz4GBjaPEgkY4sUcAcP3ObLYcMoNc6I7BC4kFjAHDv23vbXEwNQC4sJs329MAvtJ4nxj4BB6GU68Az1vizvEgGP3+Aa4Xx2h3BpbXDhH7/CPFhhmBa4PWiuQgcwhNRtrXCg7XK27XCC4LXDSYIAKF4LXCC4TXCF4IAKL4MW6EMnWj4EEsJfBC5cA5tZFwOjnQwByuMCA0gDRED1nKC4IAHgQXXkQXJI4YAHkUiGBEMmcz4AuJAAIjIhgWIFwQwKABIuDDCQWDA"))

614
apps/heatsuite/custom.html Normal file
View File

@ -0,0 +1,614 @@
<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
<link rel="stylesheet" href="../../css/spectre-icons.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
.jsoneditor-container {
height: 500px;
margin-bottom: 30px;
border: 1px solid #ccc;
}
.editor-section {
margin-bottom: 40px;
}
</style>
</head>
<body>
<ul class="tab tab-block" id="tab-navigate">
<li class="tab-item active" id="tab-settingsContainer">
<a href="javascript:showTab('settingsContainer')">Settings</a>
</li>
<li class="tab-item" id="tab-tasksContainer">
<a href="javascript:showTab('tasksContainer')">Tasks</a>
</li>
<li class="tab-item" id="tab-surveyContainer">
<a href="javascript:showTab('surveyContainer')">EMA</a>
</li>
<li class="tab-item" id="tab-downloadData">
<a href="javascript:showTab('downloadData')">
<div id="downloadData-tab-label" class="" data-badge="0">Download
</div>
</a>
</li>
</ul>
<div class="container apploader-tab" id="settingsContainer">
<form id="heatsuiteSettings">
<div class="row pt-2"><strong>Recorder Options:</strong>
<br>Select what sensor/data you want to record and average each minute:
</div>
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="record" value="steps">
<i class="form-icon"></i> Steps
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="hrm">
<i class="form-icon"></i> Optical Heart Rate
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="acc">
<i class="form-icon"></i> Accelerometry (per minute magnitude)
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="bat">
<i class="form-icon"></i> Battery
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="movement">
<i class="form-icon"></i> Movement
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="baro">
<i class="form-icon"></i> Temperature/Pressure
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="bthrm">
<i class="form-icon"></i> Bluetooth HRM (Uses BTHRM app/module)
</label>
<label class="form-switch">
<input type="checkbox" name="record" value="CORESensor">
<i class="form-icon"></i> CORE Sensor (Uses coretemp app/module)
</label>
</div>
<div class="row pt-2"><strong>High Resolution Accelerometer Data:</strong>
<br>If you want to record high temporal resolution accelerometer data (magnitude of x,y,z; can store up to 4 days of data at 1 second, will rollover).
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="highAcc">
<i class="form-icon"></i> High Temporal Accelerometry &radic;(x&sup2; + y&sup2; + z&sup2;)
</label>
<label class="form-label" for="input-AccLogInt">Interval (seconds)</label>
<input class="form-input" type="number" name="AccLogInt" id="input-AccLogInt" value=1 min=1>
<p class="form-input-hint mb-0">Interval for logging averaged magnitude and sum from accelerometer - default is 1 second
</p>
</div>
</div>
<div class="row pt-2"><strong>GPS PSMOO Options:</strong>
<br>Option to have GPS work in PSMOO (uses GPSSetup app/module)
</div>
<div class="form-group">
<label class="form-switch">
<input type="checkbox" name="GPS">
<i class="form-icon"></i> Turn GPS On
</label>
<label class="form-label" for="input-GPSScanTime">Scan Time (mins)</label>
<input class="form-input" type="number" name="GPSScanTime" id="input-GPSScanTime" value=2 min=0>
<p class="form-input-hint mb-0">The time spent scanning for a GPS signal.</strong></p>
<label class="form-label" for="input-GPSInterval">Scan Interval (mins)</label>
<input class="form-input" type="number" name="GPSInterval" id="input-GPSInterval" value=10 min=0>
<p class="form-input-hint mb-0">The time between scans when a <strong>signal is not acquired.</strong></p>
<label class="form-label" for="input-GPSAdaptiveTime">Adaptive Time (mins)</label>
<input class="form-input" type="number" name="GPSAdaptiveTime" id="input-GPSAdaptiveTime" value=2 min=0>
<p class="form-input-hint mb-0">The time between scans when a <strong>signal is acquired.</strong></p>
</div>
<div class="row pt-2"><strong>Extras:</strong>
<label class="form-switch">
<input type="checkbox" name="fallDetect">
<i class="form-icon"></i> Fall Detection (beta)
</label>
<p class="form-input-hint mb-0">Will detect falls.</p>
<label class="form-switch">
<input type="checkbox" name="surveyRandomize">
<i class="form-icon"></i> Randomize EMA questions
</label>
<p class="form-input-hint mb-0">Enable this if you want your questions to be shuffled at random each time.</p>
<label class="form-switch">
<input type="checkbox" name="swipeOpen" checked>
<i class="form-icon"></i> Swipe to Launch HeatSuite App
</label>
<p class="form-input-hint mb-0">On by default. Will enable quick access to the HeatSuite app for participants by simply swiping right on the screen.</p>
<label class="form-label" for="input-studyID">Study ID:</label>
<input class="form-input" type="text" name="studyID" id="input-studyID" value="" minlength="1" maxlength="4">
<p class="form-input-hint mb-0">For communicating with HeatSuite Nodes. Maximum of 4 (no special) characters.
</p>
<label class="form-label" for="input-filePrefix">File Prefix</label>
<input class="form-input" type="text" name="filePrefix" id="input-filePrefix" value="htst" minlength="1"
maxlength="5">
<p class="form-input-hint mb-0">ONLY CHANGE IF YOU KNOW WHAT YOU ARE DOING.</p>
</div>
</form>
</div>
<div class="container apploader-tab pb-2" id="tasksContainer" style="display:none;">
<div class="row pt-2"><strong>Edit your Task JSON File Editor:</strong>
<br><a href="https://heatsuitelabs.github.io/HeatSuiteDocs/" target="_blank">Read the Docs</a> on how to properly format the JSON file. GUI coming soon to BangleApps.
</div>
<div id="heatsuite_taskFile_editor" class="jsoneditor-container"></div>
<button class="btn btn-primary" id="heatsuite_taskFile_resetBtn">Restore Default</button>
</div>
<div class="container apploader-tab pb-2" id="surveyContainer" style="display:none;">
<div class="row pt-2"><strong>Ecological Momentary Assessment (EMA) JSON File Editor:</strong>
<br><a href="https://heatsuitelabs.github.io/HeatSuiteDocs/" target="_blank">Read the Docs</a> on how to properly format the JSON file. GUI coming soon to BangleApps.
</div>
<div id="heatsuite_surveyFile_editor" class="jsoneditor-container"></div>
<button class="btn btn-primary" id="heatsuite_surveyFile_resetBtn">Restore Default</button>
</div>
<div class="container apploader-tab pb-2" id="downloadData" style="display:none;">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>File name</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="downloadData-tab-content">
</tbody>
<div id="progressElementHeatSuite" style="margin-top:10px; font-weight:bold;"></div>
</table>
</div>
<p class="p-2 m-2"><button id="upload" class="btn btn-success">Upload</button></p>
<script src="../../core/lib/customize.js"></script>
<link href="https://cdn.jsdelivr.net/npm/jsoneditor@latest/dist/jsoneditor.min.css" rel="stylesheet" type="text/css">
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@latest/dist/jsoneditor.min.js"></script>
<script>
let HeatSuiteFileList = [];
//default Schema
let heatsuite__taskFile_defaultSchema = [
{
"id": "survey",
"icon": 'require("heatshrink").decompress(atob("lEo4kA///6H7BIP2m9hjEpyQLBxeq0UAp3js1Z5F59GB9nChvL+83E4cCkQABkA6NgUq293rVCChsq6MRAAOcoQmM4ISCAAPqFBclCQkRjJpBExOxCYsR8goJkmRB4XuAYUVCZMsJYdEFgUZoATIpwTC1XnooFC2QTIoIhCC4OqCYVyMRGhBoXhjNLAoXSCZkR2mpAgXilGIwRnFCYlKPgd40czmeoCglJCYd5AgdYn////zCgknBwfZAgeqCQIUCwQTClQOD3oDCj1DCYf/nATHAAcawYTE/Q8CkoTHvE/+c6mY8FMYgACi9Kn84xAqDmSfC7euCYnE0f/1CKBPQRQBgWqkUk3zeCvWKBwJ4BnATFKQUipWq0koBoSyBxATDZYsCkUAOgk4AoYTGAAUjCYgmCUAgAFlQTDRQQTGgUowQuBJ4f/nQEDRYIiD81u0UAhAODmYECeAkCq1ms1nkECbIoACD4ImCCQIAB2QGBFAYADHYchs3nv1msguBY4IAD+bhBCYVGs261YTCMovz1GKnSfCCYIACtRXCxTHBeAJYBxGACYek8wnDSYWIxAjCdocls0eE4PiXhAAEhEWCQNhoATNgFOCYPkCRxABpdqIQQUP2gSQCYMiCaMAHRw="))',
"cbBtn": "Bangle.load('heatsuite.survey.js');",
"tod": [
900,
1200,
1500,
1800
],
"debounce": 300
},
{
"id": "bloodPressure",
"icon": 'require("heatshrink").decompress(atob("lEo4kA///zND//3BYOe98ggHGhEllM533ssW5odBhFOud642DhE10omCnczHSO3s3jmATP3MRjNrCh+2s0ZzNrCJkDn1mv1382X8YlMyMRi1xy1hzwSKhdxs12CYIWBu5RKm8WtMeCYfp8ATJ2MRiITDjPpsCUCu4ADv2w24TBjITCiOZE4MLCQgABve+MQMXCYeW2ATBv2ZAAeeve28w7Ey3mE4dCkQACx172INBtd+s1m81xJ4ITBpH/wn//oTB29pjIRBs1nAgITD7l3zGXv4nDsJRBiORz1hCYdCogBCCYWhiNusNutIWByA7EKIXXCYOq1QPBAAIZBiA7DvITCp4TDAAYTBHYp3CpgTNxvdAAX+CZl+9wAECZjDBbIQEBCZwlC9ITOy98zOcFAITOzl+vgTQvJSCJ6QWBCYMaCZED852CAAXj21pCgkRswTBgEzmc78/rAgMwm1usLvDz1m2ATBAAMD5+TAodpiIVBt3njN+CYvn8YGDnwnEu18uAMDgHpDQkAnfms1mtc+/4TFvngAwgxCAQV3EAt8AwoWFvIMF5gTKgF/HYv5mATKnPLAoc3+4TLh3J8ayCvieDKBN85LVBznJJxaSCvPM5nJuYSMCgW73ezBIoA="))',
"tod": [
830,
1330,
1930
],
"debounce": 3600,
"btPair": true,
"btInfo": {
"service": "1810",
"supported": [
"A&D_UA-651"
]
}
},
{
"id": "coreTemperature",
"icon": 'require("heatshrink").decompress(atob("lEo4kA///A4Pf99fx3YtnjjEkkN/7+X0ujoFK/VusdBhH0ykIoXY2ozPjnBIyEBr2piATPiuq1WREx9aCYIoPjmaCYIoOgNo9ITC1gTMi3yvwoC9gmNw8nFAVRExmL5dyFAOlMZcBt+2223FAImN+9Emlru/cExlo6kzme8x9hJptkmYnBwwmN21DmdG3Am/TZF28YmPgvyl9rEx0AjsikX28wmNgFnkVyFAImNgHnl7pBvAmNgOyk/3u/2ExsM6UiEx8Ajd3u9/6omNgO3u/b8wSNgEeu974IRNEwPvu+xCRzrB+/+HBwABrF/8ISPhnXYBwAC5t9qImQ2/cCR8Ajm32ASPgPr5YmQj3eMCEB9qHQEycA5wmROoImRgAA=="))',
"tod": [
830,
1330,
1930
],
"TaskDesc": "coreTemperature_TaskDesc",
"debounce": 3600,
"btPair": true,
"btInfo": {
"service": "1809",
"supported": [
"BLEThermistorPod"
]
}
},
{
"id": "bodyMass",
"icon": "require('heatshrink').decompress(atob('lEo4kB3//AAIJB7//wUQjnn485t97vvvoMIhlj9dasspptbz33kshiIApkUpylP/nGswAB5n/pOSkQSEl+3utVAAOI93uxAGCq96+QSCil1gczABdXyITBo4SDmt6ut61Wq09TBQU+ukRiVwDgcK5t1vvd7vc0ALD88hjgbDmcLvxOBAAOH2ALDgvBk5EEq1+wpfBrGHsoLDh1yyouEtN+q3M5llw+WvwMCgF5CYMFu9V3l2E4tn5dVu9Qh1ZCYN0/9E2s65phBMYWjrdE/9HCYOXmv2u9kqaLBRIKLDmtLu9vqF5y83o930lzWA83+4MCvMnmtKCZYgB2lQuQTBtmr5ijEAAc15mr+1euUqTYNESwgAFBgUO0Urgc+uwmIFAVn8fu2UW8czu2DCZM4s8z9FhCYVcE5fFmflsMcCAITP4MfCaPxigQB8yeIUAVumHloMZCYMLCZewCYORCYU32YTJndzCYUSEgM34p7BAA0+rgTBg8hCYM+ulE2oTHrdEo/g84TCmvLvdHgYSFmF009kuYTEu93tiNGDwN3vm+CYWjm9nCZvqkMR2E3pV6pQ7H1932lw9cRiNonGk+llMY9Wp9KwFWCYNFZgN3HQflrw8Du9emFUCYMqG41cdIIAEh2yCYMUbI1VqpSF89BCYMRo7FIAAc4ugSCiMZ2tY9wAGn3uwt7yITDiMp+1mAAtqrVmt+SCQgAl'))",
"tod": [
800
],
"debounce": 3600,
"btPair": false,
"btInfo": {
"service": "181b",
"supported": [
"MIBFS"
]
}
},
{
"id": "urine",
"icon": "require('heatshrink').decompress(atob('lEo4kA///7/3oMInGVtdC//3rvn5lS7/Gx1rksZDYIXB7edkAFB4wqGhcukXgHp0Lkkz6eaCheykWq9Ux5nM42elWilwjH0c97VG5lVqtcjOp7s3JwIAEgU4xF6tgSBAAPBzXf/FACYsK7GD1vFCYdcjuIx+bCY/azgSDAANRyePzwTHzQ6EAAWax87CY2Z1I6EHgVqvNyMY0q1ATGqsX1XrRg1JoITH4dJRY0Ao5iGAAPN1MkogVFogTI42p1uIUItEngTJ0lE0AnQle7J59d1JjHoemO4/JoYTHpGjWZGTCY9BzWMCYvBzVjCZFn1tc5gADtOtqewCY2py/qvOZAAeu1OUCY0LSYN3ogAEo90Yo0AgU4xAAJYooTBzvdABLFFAAMq1QAJMY+0ExMyCQ0AgkRjiKEAAQTIhVhirbGrmeCY8C6MRCY1VvYTHhwTI4x2HAANhCY/Ndw4AB0YTG5mERQ5QCods5nFrnM4Np1YSIAAOy1PWiMRtE31wSKgEL0mq0lEAQJNJHgc96c5zM9xFLCZcE7GPAAP4x7FIAAe5xGP/4AC7QTLlMzAAk0RRIAB8UiAAkuCYwA=='))",
"cbBtn": "Bangle.load('heatsuite.urine.js');",
"debounce": 0,
"tod": [],
"btPair": false,
"btInfo": null,
"notify": false
},
{
"id": "sleep",
"icon": "require('heatshrink').decompress(atob('lEo4kA///v3nBIPDjE968CqOzxlX/1m0VBhGBkHb61qpV4/0978Y2xZ1gQTmkQ9VkA9llJKMJZMjmRADlM85gAC4eSBYUCCIOW23Mmc281v30RAAXr+wLBmfMt9py0Rj27CAgAEBYIMCtMsB5AAE///33u4UD2ISLjfMnNu9cwhPxE5nr5m+9+QgVhBYhpBAAVrA4Mcn/ui0gCYsWnVVAANa0YLBsfxCZFjrGIAAWFm0fRQP+CY0fmoSDAANT/dmswTCMYkc1ATFxXBUIPuMYMDCYfsquqAAOlxGqqs7CYcwT4mzqfGGgNmu9onQTB93uT4ITFzHdAAd3xM/CYo7D2eWCYtpCYY7BMYgTNY4vzCY1jz4TCT4r5Bs92v996/3AoPM2J3BCYsfm/duvDtvWnk3u9z+ITIm3d63IE4Nss48BCYhjE5l9J4t35jHFT4fzr/dJoJRBu9lO4ITBT4MsNAPhHgPM7vfnnD/t3wf+8ITB93Cy0R9e//exspNBtGGHQQLC30RtOW+3Mmcz5/ztpOC692ngLC5lvtMjmUggAABhh5BWQciBYUCCIIQCAAcjs4SBuwMHAA+V42MrISOAAMikQSQAD4='))",
"cbBtn": 'modHS.saveDataToFile("sleep", "event", {"marker":"sleep"});Bangle.showClock();',
"debounce": 0,
"tod": [],
"btPair": false,
"btInfo": null,
"notify": false
}
];
let heatsuite__surveyFile_defaultSchema = {
"supported":{
"en_GB":"English (GB)",
"fr_CA":"Francais (CA)"
},
"questions":[{
"key":"comfort",
"text": {
"en_GB":"Thermal comfort?",
"fr_CA":"Confort thermique?"
},
"tod":[[0,2359]],
"oncePerDay": true,
"orderFix":false,
"options": [{
"text":{
"en_GB":"Comfortable",
"fr_CA":"Confortable"
},
"value":0,
"color":"#ffffff",
"btnColor":"#38ed35"
},{
"text":{
"en_GB":"Uncomfortable",
"fr_CA": "Inconfortable"
},
"value":1,
"color":"#ffffff",
"btnColor":"#ff0019"
}]
}]
};
//function from original index.js of BangleApps - required to bring here as the custom.html is loaded in an iframe
function showTab(tabname) {
document.querySelectorAll("#tab-navigate .tab-item").forEach(tab => {
tab.classList.remove("active");
});
document.querySelectorAll(".apploader-tab").forEach(tab => {
tab.style.display = "none";
});
document.getElementById("tab-" + tabname).classList.add("active");
document.getElementById(tabname).style.display = "inherit";
}
function formDataToJson(form) {
const formData = new FormData(form);
const data = {};
const forceArrayFields = ['record']; // force these to always be arrays
const checkboxHandled = new Set();
for (let [name, value] of formData.entries()) {
const field = form.querySelector(`[name="${name}"]`);
if (field && field.type === 'checkbox') {
if (checkboxHandled.has(name)) continue;
checkboxHandled.add(name);
const checkboxes = form.querySelectorAll(`input[type="checkbox"][name="${name}"]`);
const values = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.hasAttribute('value') ? cb.value : true);
data[name] = forceArrayFields.includes(name) ? values : (values.length ? values[0] : false);
} else {
if (data.hasOwnProperty(name)) {
if (!Array.isArray(data[name])) {
data[name] = [data[name]];
}
data[name].push(value);
} else {
if (name === "studyID" && !value.length) continue;
data[name] = value;
}
}
}
forceArrayFields.forEach(field => {
if (!Array.isArray(data[field])) {
data[field] = data[field] !== undefined ? [data[field]] : [];
}
});
return data;
}
function fillFormFromJson(form, data) {
for (let [name, value] of Object.entries(data)) {
const fields = form.querySelectorAll(`[name="${name}"]`);
if (!fields.length) continue;
fields.forEach(field => {
if (field.type === 'checkbox') {
if (Array.isArray(value)) {
field.checked = value.includes(field.value);
} else {
field.checked = value === true || value === field.value || value === "true";
}
} else if (field.type === 'radio') {
field.checked = (field.value === value);
} else {
field.value = value;
}
});
}
}
//autosave form data to localstorage
function autosaveSettings(){
const form = document.getElementById('heatsuiteSettings');
const formjson = formDataToJson(form);
localStorage.setItem("heatuite__settings", JSON.stringify(formjson));
}
function readStorageJSONAsync(filename) {
return new Promise((resolve, reject) => {
Util.readStorageJSON(filename, function (data) {
try {
resolve(data || {});
} catch (err) {
reject(err);
}
});
});
}
function readStorageAsync(filename) {
return new Promise((resolve, reject) => {
Util.readStorageFile(filename, function (data) {
try {
resolve(data);
} catch (err) {
reject(err);
}
});
});
}
function downloadSingleFile(filename, callback) {
Util.readStorageFile(filename, (c) => {
let url;
let blob;
const a = document.createElement('a');
let fnArr = filename.split('_');
if (fnArr[1] !== 'accel') {
blob = new Blob([c], { type: 'text/plain' });
url = URL.createObjectURL(blob);
if (callback) return callback(filename, blob); // Call with original blob
} else {
function secondsToClock(seconds) {
let h = Math.floor(seconds / 3600);
let m = Math.floor((seconds % 3600) / 60);
let s = seconds % 60;
return [h, m, s].map(v => v.toString().padStart(2, '0')).join(':');
}
let csv = "time,seconds,mag_avg,mag_sum\n";
let lines = c.trim().split("\n");
for (let line of lines) {
let parts = line.split(",").map(v => parseInt(v, 10));
if (parts.length !== 3) continue;
let seconds = parts[0];
let avg = parts[1] / 8192;
let sum = parts[2] / 1024;
let time = secondsToClock(seconds);
csv += `${time},${seconds},${avg},${sum}\n`;
}
blob = new Blob([csv], { type: 'text/csv' });
}
if (callback) return callback(filename, blob);
url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
});
}
function downloadAllFiles() {
console.log("Downloading all files", HeatSuiteFileList);
const zip = new JSZip();
let index = 0;
const progressEl = document.getElementById("progressElementHeatSuite");
if (progressEl) progressEl.textContent = "Preparing to download...";
function processNext() {
if (index >= HeatSuiteFileList.length) {
if (progressEl) progressEl.textContent = "Creating ZIP...";
zip.generateAsync({ type: "blob" }).then(content => {
const a = document.createElement('a');
const url = URL.createObjectURL(content);
a.href = url;
a.download = "all_files.zip";
a.click();
URL.revokeObjectURL(url);
if (progressEl) progressEl.textContent = "Download complete!";
});
return;
}
const filename = HeatSuiteFileList[index];
if (progressEl) progressEl.textContent = `Downloading file ${index + 1} of ${HeatSuiteFileList.length}: ${filename}`;
downloadSingleFile(filename, (name, blob) => {
zip.file(name, blob);
index++;
processNext();
});
}
processNext();
}
function deleteFile(filename){
if (confirm(`Are you sure you want to delete ${filename}?`)) {
Util.eraseStorageFile(filename,(c) =>{
var filePrefix = settings.filePrefix || 'htst';
return Puck.eval('require("Storage").list(/^'+filePrefix+'/)',renderDownloadTab);
});
}
}
function deleteAllFiles() {
if (!confirm("Are you sure you want to delete all files?")) return;
console.log("Deleting all files", HeatSuiteFileList);
let index = 0;
const progressEl = document.getElementById("progressElementHeatSuite");
if (progressEl) progressEl.textContent = "Preparing to delete files...";
function processNext() {
if (index >= HeatSuiteFileList.length) {
if (progressEl) progressEl.textContent = "All files deleted.";
// Refresh the list via Puck/E.show
const filePrefix = settings.filePrefix || 'htst';
Puck.eval('require("Storage").list(/^' + filePrefix + '/)', renderDownloadTab);
return;
}
const filename = HeatSuiteFileList[index];
if (progressEl) progressEl.textContent = `Deleting file ${index + 1} of ${HeatSuiteFileList.length}: ${filename}`;
Util.eraseStorageFile(filename, () => {
index++;
processNext();
});
}
processNext(); // Start the chain
}
function renderDownloadTabLogs(e){
var element = document.getElementById("downloadData-tab-content");
if(e.length > 0){
}else{
e.innerHTML = "No files to download.";
}
}
function renderDownloadTab(e){
HeatSuiteFileList = e.map(file => file.replace(/\x01$/, '')); //save it globally for later use...safer
var element = document.getElementById("downloadData-tab-content");
var tab_badge = document.getElementById("downloadData-tab-label");
if(HeatSuiteFileList.length > 0){
tab_badge.classList.add("badge");
tab_badge.dataset.badge = HeatSuiteFileList.length;
element.innerHTML = "";
HeatSuiteFileList.forEach((filename)=>{
element.insertAdjacentHTML('beforeend', `<tr id="file-${filename}"><td>${filename}</td><td><i class="icon icon-download" onclick="downloadSingleFile('${filename}');"></i>&nbsp; <i class="icon icon-delete" onclick="deleteFile('${filename}');"></i></td></tr>`);
});
element.insertAdjacentHTML('beforeend',`<tr id="end" class="text-bold"><td><button class="btn" onclick="downloadAllFiles();">Download All <i class="icon icon-download" ></i></button></td><td><button class="btn" onclick="">Delete All <i class="icon icon-delete" ></i></button> </td></tr>`);
}else{
tab_badge.classList.remove("badge");
tab_badge.dataset.badge = 0;
element.innerHTML = '<tr class="active"><td>No Files</td></tr>';
}
}
function onInit(device) {
let settings = {};
let promise = Promise.resolve();
promise = promise.then(() => {
return readStorageJSONAsync("heatsuite.default.json");
});
promise = promise.then(defaults => {
return readStorageJSONAsync("heatsuite.settings.json").then(user => {
settings = Object.assign({}, defaults, user);
});
});
promise = promise.then(() => {
const settingsForm = document.getElementById('heatsuiteSettings');
fillFormFromJson(settingsForm, settings);
});
promise = promise.then(() =>{
document.getElementById("downloadData-tab-content").innerHTML = '<div class="loading"></div>';;
var filePrefix = settings.filePrefix || 'htst';
return Puck.eval('require("Storage").list(/^'+filePrefix+'/)',renderDownloadTab);
});
//promise = promise.then(()=>{
// return Puck.eval('require("Storage").list(heatsuite.log)',renderDownloadTabLogs);
//});
promise.catch(error => {
console.error("Error loading settings:", error);
});
}
const editors = {};
function initJsonEditor({ elementId, storageKey, defaultSchema, resetBtnId }) {
const container = document.getElementById(elementId);
const savedContent = localStorage.getItem(storageKey);
const initialContent = savedContent ? JSON.parse(savedContent) : defaultSchema;
const options = {
modes: ['tree', 'form', 'code', 'text'],
mode: 'code',
onChange: function () {
try {
const currentContent = editor.get();
localStorage.setItem(storageKey, JSON.stringify(currentContent));
} catch (err) {
console.error(`[${storageKey}] Invalid JSON not saved.`);
}
}
};
const editor = new JSONEditor(container, options);
editor.set(initialContent);
editors[storageKey] = { editor, defaultSchema };
if (resetBtnId) {
const resetBtn = document.getElementById(resetBtnId);
resetBtn.addEventListener('click', () => {
editor.set(defaultSchema);
localStorage.setItem(storageKey, JSON.stringify(defaultSchema));
});
}
}
window.onload = function () {
const studyIDInput = document.getElementById('input-studyID');
studyIDInput.addEventListener("input", () => {
studyIDInput.value = studyIDInput.value.replace(/[^a-zA-Z0-9]/g, '');
});
const filePrefixInput = document.getElementById('input-filePrefix');
filePrefixInput.addEventListener("input", () => {
filePrefixInput.value = filePrefixInput.value.replace(/[^a-zA-Z0-9]/g, '');
});
const settingsForm = document.getElementById('heatsuiteSettings');
settingsForm.addEventListener('input', autosaveSettings);
settingsForm.addEventListener('change', autosaveSettings);
const storedSettings = localStorage.getItem("heatuite__settings");
if (storedSettings) { //lets fill form:
fillFormFromJson(settingsForm, JSON.parse(storedSettings));
}
//initialize both JsonEditors for tasks and surveys
initJsonEditor({
elementId: 'heatsuite_taskFile_editor',
storageKey: 'heatsuite__taskFile',
defaultSchema: heatsuite__taskFile_defaultSchema,
resetBtnId: 'heatsuite_taskFile_resetBtn'
});
initJsonEditor({
elementId: 'heatsuite_surveyFile_editor',
storageKey: 'heatsuite__surveyFile',
defaultSchema: heatsuite__surveyFile_defaultSchema,
resetBtnId: 'heatsuite_surveyFile_resetBtn'
});
};
// When the 'upload' button is clicked...
document.getElementById("upload").addEventListener("click", function () {
const form = document.getElementById('heatsuiteSettings');
//TO DO VALIDATE jsonEditors!
sendCustomizedApp({
storage: [
{ 'name': "heatsuite.settings.json", content: JSON.stringify(formDataToJson(form)) },
{ "name": "heatsuite.tasks.json", content: JSON.stringify(editors['heatsuite__taskFile'].editor.get()) },
{ "name": "heatsuite.survey.json", content: JSON.stringify(editors['heatsuite__surveyFile'].editor.get()) },
]
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,189 @@
{
let studyTasksJSON = "heatsuite.tasks.json";
let studyTasks = require('Storage').readJSON(studyTasksJSON, true) || {};
let Layout = require("Layout");
let modHS = require("HSModule");
let layout;
let NRFFindDeviceTimeout, TaskScreenTimeout;
let settings = modHS.getSettings();
let appCache = modHS.getCache();
function queueNRFFindDeviceTimeout() {
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
NRFFindDeviceTimeout = setTimeout(function () {
NRFFindDeviceTimeout = undefined;
findBtDevices();
}, 3000);
}
function findBtDevices() {
NRF.setScan(); //clear any scans running!
NRF.findDevices(function (devices) {
let found = false;
if (devices.length !== 0) {
devices.every((d) => {
modHS.log("Found device", d);
let services = d.services;
modHS.log("Services: ", services);
if (services !== undefined && services.includes('1810') && d.id === settings.bt_bloodPressure_id) {
//Blood Pressure
found = true;
layout.msg.label = "BP Found";
layout.render();
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
return Bangle.load('heatsuite.bp.js');
} else if (services !== undefined && services.includes('181b') && studyTasks.filter(task => task.id === "bodyMass")) {
let data = d.serviceData[services];
let ctlByte = data[1];
let weightRemoved = ctlByte & (1 << 7);
modHS.log(weightRemoved);
if (weightRemoved === 0) {
//Mass found
found = true;
layout.msg.label = "Scale Found";
layout.render();
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
return Bangle.load('heatsuite.mass.js');
}
modHS.log("No weight on scale");
} else if (services !== undefined && services.includes('1809') && d.id === settings.bt_coreTemperature_id) {
//Core Temperature
found = true;
layout.msg.label = "Temp Found";
layout.render();
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
return Bangle.load('heatsuite.bletemp.js');
}
});
}
if (!found) {
modHS.log("Search Complete, No Devices Found");
queueNRFFindDeviceTimeout();
} else {
if (TaskScreenTimeout) clearTimeout(TaskScreenTimeout);
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
}
}, { timeout: 3000, active: true});
}
function taskButtonInterpretter(string) {
//turn off FindDeviceHandler whenever we navigate off task screen
let command = 'if (NRFFindDeviceTimeout){clearTimeout(NRFFindDeviceTimeout);}' + string;
return eval(command);
}
function queueTaskScreenTimeout() {
if (TaskScreenTimeout) clearTimeout(TaskScreenTimeout);
if (TaskScreenTimeout === undefined) {
TaskScreenTimeout = setTimeout(function () {
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
Bangle.load();
}, 180000);
}
}
function draw() {
let btRequired = false;
g.clear();
g.reset();
if (studyTasks.length === 0) {
if(require("Storage").list().includes("heatsuite.survey.json")){ //likely just using for EMA survey
return Bangle.load('heatsuite.survey.js'); //go right to survey!
}
modHS.log('No Study Tasks loaded...');
layout = new Layout({
type: "v",
c: [
{
type: "txt",
font: "Vector:30",
label: "No Study Tasks Loaded.",
wrap: true,
fillx: 1,
filly: 1
}
]
});
layout.render();
return;
}
let taskArr = appCache.taskQueue;
let taskID = [];
if (taskArr !== undefined) {
taskID = taskArr.filter(function (taskArr) {
return taskArr.id;
}).map(function (taskArr) {
return taskArr.id;
});
}
let layoutOut = { type: "v", c: [] };
let row = { type: "h", c: [] };
let rowCount = 2;
if( studyTasks.length > 4){
rowCount = 3; //so we can include up to 9 tasks on the screen at once
}
studyTasks.forEach(task => {
let btn = { type: "btn", fillx: 1, filly: 1 };
btn.id = task.id;
btn.src = eval(task.icon);
//callback on button press
if (task.cbBtn) {
btn.cb = l => taskButtonInterpretter(task.cbBtn);
}
//back color determination
btn.btnFaceCol = "#90EE90";
//a to do!!
if (taskID.includes(task.id)) {
btn.btnFaceCol = "#FFFF00";
}
//no bt paired
if (task.btPair === true) {
if (settings["bt_" + task.id + "_id"] === undefined || !settings["bt_" + task.id + "_id"]) {
//make it clickable so we can go to settings and pair something
btn.btnFaceCol = "#FF0000";
btn.cb = l => eval(require("Storage").read("heatsuite.settings.js"))(()=>load("heatsuite.app.js"));
}
}
if(task.btInfo !== undefined){
btRequired = true;//we will be scanning for bluetooth devices
}
//builder for each icon in taskScreen
if (row.c.length >= rowCount) {
layoutOut.c.push(row);
row = { type: "h", c: [] };
}
row.c.push(btn);
});
//push that last row in if needed
if (row.c.length > 0) {
layoutOut.c.push(row);
}
//Final
if(btRequired) layoutOut.c.push({ type: "txt", font: "6x8:2", label: "Searching...", id: "msg", fillx: 1 });
let options = {
lazy: true,
btns:[{label:"Exit", cb: l=>Bangle.showClock() }],
remove: () => {
NRF.setScan(); //clear scan
if (TaskScreenTimeout) clearTimeout(TaskScreenTimeout);
if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout);
NRFFindDeviceTimeout = undefined;
TaskScreenTimeout = undefined;
require("widget_utils").show();
}
};
layout = new Layout(layoutOut, options);
layout.render();
if(btRequired) queueNRFFindDeviceTimeout();
queueTaskScreenTimeout();
}
Bangle.setLocked(false); //unlock screen!
Bangle.loadWidgets();
Bangle.drawWidgets();
require("widget_utils").hide();
draw();
}

View File

@ -0,0 +1,127 @@
var Layout = require("Layout");
const modHS = require('HSModule');
var layout;
var settings = modHS.getSettings();
//var appCache = modHS.getCache();
function log(msg) {
if (!settings.DEBUG) {
return;
} else {
console.log(msg);
}
}
//Schema for the message coming from the BLE ThermistorPod:
const Schema_ThermistorPodBLE = {
msgType: 'int32',
ta: 'float32',
rh: 'float32',
batP: 'int32',
temp: 'float32',
tempAvg: 'float32',
adc: 'int32',
resistance: 'float32',
ambLight: 'int32'
};
function getTcore(id) {
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Oral Temp", fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Waiting...", fillx: 1 },
]
}
]
});
g.clear();
layout.render();
var gatt;
var startTime;
var complete = false;
var TCoreData = {
"temp": null,
"ta": null,
"rh": null,
"measures": []
};
NRF.connect(id).then(function (g) {
gatt = g;
startTime = parseInt((getTime()).toFixed(0));
gatt.device.on('gattserverdisconnected', function (reason) {
gatt = null;
Bangle.load();
log("Disconnected ", reason);
});
return gatt.getPrimaryService("1809");
}).then(function (s) {
return s.getCharacteristic("00002A1F-0000-1000-8000-00805F9B34FB");
}).then(function (c) {
c.on('characteristicvaluechanged', function (event) {
const receivedData = modHS.parseBLEData(event.target.value, Schema_ThermistorPodBLE);
TCoreData.temp = receivedData.tempAvg;
TCoreData.ta = receivedData.ta;
TCoreData.rh = receivedData.rh;
TCoreData.measures.push(receivedData.adc);
var timeNow = parseInt((getTime()).toFixed(0));
var diff = timeNow - startTime;
var display;
if (diff > 90 && !complete) { // time to save the data and disconnect
complete = true;
if (modHS.saveDataToFile('coreTemp', 'coreTemperature', TCoreData)) {
display = {
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Saved!", fillx: 1 }
]
}
]
};
}
} else {
var remaining = 90 - diff;
display = {
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: remaining + " secs", fillx: 1 }
]
},
{
type: "h", c: [
{ type: "txt", font: "4x6:2", label: receivedData.adc + " " + receivedData.temp.toFixed(2) + "C", fillx: 1 }
]
}
]
};
}
layout = new Layout(display);
g.clear();
layout.render();
if (complete) {
if(gatt){
gatt.disconnect();
}
setTimeout(() => { Bangle.load() }, 2000);
}
});
return c.startNotifications();
}).then(function (d) {
}).catch(function (e) {
E.showAlert("error! " + e).then(function () { Bangle.load(); });
});
}
let macID = settings.bt_coreTemperature_id.split(" ");
//so you can see timeout
Bangle.setOptions({backlightTimeout: 0}) // turn off the timeout
Bangle.setBacklight(1); // keep screen on
getTcore(macID[0]);

View File

@ -0,0 +1,186 @@
var Layout = require("Layout");
const modHS = require('HSModule');
var layout;
var settings = modHS.getSettings();
//var appCache = modHS.getCache();
function log(msg) {
if (!settings.DEBUG) {
return;
} else {
console.log(msg);
}
}
//Schema for the message coming from the A&D Medical UA651BLE:
function analyzeBPData(data) {
const flags = data.getUint8(0, 1);
const buf = data.buffer;
let result = { //Schema for BP measures
"sbp" : null,
"dbp" : null,
"map" : null,
"hr" : null,
"moved" : null,
"cuffLoose" : null,
"irregularPulse" : null,
"improperMeasure" : null,
"year" : null,
"month" : null,
"day" : null,
"hour" : null,
"minute" : null,
"second" : null
};
let index = 1;
result.sbp = buf[index];
index += 2;
result.dbp = buf[index];
index += 2;
result.map = buf[index];
index += 2;
if (flags & 0x02) {
result.year = buf[index] + (buf[index + 1] << 8),
result.month = buf[index + 2],
result.day = buf[index + 3],
result.hour = buf[index + 4],
result.minute = buf[index + 5],
result.second = buf[index + 6],
index += 7;
}
if (flags & 0x04) {
result.hr = buf[index];
index += 2;
}
if (flags & 0x08) {
index += 1;
}
if (flags & 0x10) {
const ms = buf[index];
result.moved = (ms & 0b1) ? 1 : 0;
result.cuffLoose = (ms & 0b10) ? 1 : 0;
result.irregularPulse = (ms & 0b100) ? 1 : 0;
result.improperMeasure = (ms & 0b100000) ? 1 : 0;
index += 1;
}
return result;
}
function getBP(id) {
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "6x8:2", label: "Blood Pressure", fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "6x8:2", label: "Waiting...", fillx: 1 },
]
}
]
});
g.clear();
layout.render();
var device;
var service;
log("connecting to ", id);
NRF.connect(id).then(function (d) {
device = d;
return new Promise(resolve => setTimeout(resolve, 1000));
}).then(function () {
log("connected");
if (device.getSecurityStatus().bonded) {
log("Already bonded");
return true;
} else {
log("Start bonding");
return device.startBonding();
}
}).then(function () {
device.device.on('gattserverdisconnected', function (reason) {
Bangle.load();
log("Disconnected ", reason);
});
return device.getPrimaryService("1810");
}).then(function (s) {
service = s;
return service.getCharacteristic("2A08");
}).then(function (characteristic) {
//set time on device during pairing
var date = new Date();
var b = new ArrayBuffer(7);
var v = new DataView(b);
v.setUint16(0, date.getFullYear(), true);
v.setUint8(2, date.getMonth() + 1);
v.setUint8(3, date.getDate());
v.setUint8(4, date.getHours());
v.setUint8(5, date.getMinutes());
v.setUint8(5, date.getSeconds());
var arr = [];
for (let i = 0; i < v.buffer.length; i++) {
arr[i] = v.buffer[i];
}
return characteristic.writeValue(arr);
}).then(function () {
return service.getCharacteristic("2A35");
}).then(function (c) {
c.on('characteristicvaluechanged', function (event) {
//log("-> "); // this is a DataView
//log(event.target.value);
const receivedData = analyzeBPData(event.target.value);
modHS.saveDataToFile('bpres', 'bloodPressure', receivedData);
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: receivedData.sbp, fillx: 1 },
{ type: "txt", font: "12x20:2", label: "/", fillx: 1 },
{ type: "txt", font: "12x20:2", label: receivedData.dbp, fillx: 1 }
]
},
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: receivedData.hr, fillx: 1 },
{ type: "txt", font: "12x20:2", label: "BPM", fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Saved!", fillx: 1 }
]
},
]
});
g.clear();
layout.render();
});
return c.startNotifications();
}).then(function (d) {
log("Setting Notification Interval");
log("Waiting for notifications");
}).catch(function (e) {
log("GATT ", device);
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "6x8:2", label: "ERROR!", fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "6x8:1", label: e, fillx: 1 },
]
}
]
});
g.clear();
layout.render();
if (!device.connected) {
getBP(id);
}
});
}
var macID = settings.bt_bloodPressure_id.split(" ");
setTimeout(() => { getBP(macID[0]) }, 2000);

View File

@ -0,0 +1,11 @@
{
"DEBUG" : false,
"SAVE_DEBUG": false,
"notifications" : true,
"record" : ["bat","steps","hrm","baro","acc"],
"filePrefix": "htst",
"GPS" : true,
"GPSAdaptiveTime" : 2,
"GPSInterval" : 30,
"GPSScanTime" : 5
}

View File

@ -0,0 +1,70 @@
var Layout = require("Layout");
var modHS = require("HSModule");
var layout;
/** --------- MI SCALE --------------------------- */
function getMass(service) {
var datareceived = [];
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Body Mass", fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Waiting...", fillx: 1 },
]
}
]
});
g.clear();
layout.render();
NRF.setScan();//clear other scans
NRF.setScan(function (devices) {
var data = devices.serviceData[service];
datareceived.push(data);
var ctlByte = data[1];
var stabilized = ctlByte & (1 << 5);
var weight = ((data[12] << 8) + data[11]) / 200;
var impedance = (data[10] << 8) + data[9];
if (stabilized && datareceived.length > 1 && impedance > 0 && impedance < 65534) {
NRF.setScan();
datareceived = [];
var dataOut ={
'mass' : weight,
'impedance' : impedance
};
modHS.saveDataToFile('mass', 'bodyMass', dataOut);
layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: weight, fillx: 1 },
{ type: "txt", font: "12x20:2", label: "kg", fillx: 1 }
]
},
{
type: "h", c: [
{ type: "txt", font: "6x8:2", label: impedance, fillx: 1 },
]
},
{
type: "h", c: [
{ type: "txt", font: "12x20:2", label: "Saved!", fillx: 1 }
]
},
]
});
g.clear();
layout.render();
setTimeout(function () { Bangle.load(); }, 3000);
}
}, { timeout: 2000, filters: [{ services: [service] }] });
}
//init
getMass('181b');

View File

@ -0,0 +1,216 @@
function _getSettings() {
var out = Object.assign(
require('Storage').readJSON("heatsuite.default.json", true) || {},
require('Storage').readJSON("heatsuite.settings.json", true) || {}
);
out.StudyTasks = require('Storage').readJSON("heatsuite.tasks.json", true) || {};
return out;
}
function _checkFileHeaders(filename,header){
var storageFile = require("Storage").open(filename, "r");
var headers = storageFile.readLine().trim();
var headerString = header.join(",");
if(headers === headerString){
return true;
}else{
return false;
}
}
function _renameOldFile(file){
var rename = false;
var i = 1;
while(!rename){
var filename = file+"_"+String(i);
if(require('Storage').list(filename).length == 0){
var newFile = require("Storage").open(filename, "w");
var oldFile = require("Storage").open(file, "r");
var l = oldFile.readLine();
while (l!==undefined) {
newFile.write(l);
l = oldFile.readLine();
}
oldFile.erase(); //erase old file
require("Storage").compact(); //compact memory
rename = true;
}else{
i++;
}
}
}
function _getRecordFile(type, headers) {
var settings = _getSettings();
var dt = new Date();
var hour = dt.getHours();
if (hour < 10) hour = '0' + hour;
var month = dt.getMonth() + 1;
if (month < 10) month = '0' + month;
var day = dt.getDate();
if (day < 10) day = '0' + day;
var date = dt.getFullYear() + "" + month + "" + day;
var fileName = settings.filePrefix + "_" + type + "_";
fileName = fileName + date;
if (require('Storage').list(fileName).length > 0 && type !== "accel") {
if(_checkFileHeaders(fileName,headers)){
return require('Storage').open(fileName, 'a');
}else{ // need to rename the old file as headers have changed
_renameOldFile(fileName);
}
}
if (type !== "accel") {
var storageFile = require("Storage").open(fileName, "w");
storageFile.write(headers.join(",") + "\n");
}
return require("Storage").open(fileName, "a");
}
function _checkStorageFree(type) {
var settings = _getSettings();
var freeSpace = require("Storage").getFree();
var filePrefix = settings.filePrefix + type;
var csvList = require("Storage").list(filePrefix);
if (freeSpace < 500000) {
if(csvList.length > 0){
require("Storage").open(csvList[0],"r").erase();
}
require("Storage").compact();
}
}
function _saveDataToFile(type, task, arr) {
var newArr = {
'unix' : parseInt((getTime()).toFixed(0)),
'tz' : (new Date()).getTimezoneOffset() * -60
}
for (var key in arr) {
newArr[key] = arr[key];
}
var data = [];
var headers = [];
for (var key in newArr) {
if(Array.isArray(newArr[key])){
newArr[key] = newArr[key].join(';');
}
data.push(newArr[key]);
headers.push(key);
}
var currFile = _getRecordFile(type, headers);
if (currFile) {
var String = data.join(',') + '\n';
currFile.write(String);
_updateTaskQueue(task, newArr);
return true;
}
}
function _updateTaskQueue(task, arr) {
var appCache = _getCache();
var taskQueue = appCache.taskQueue;
var tasktime = parseInt((getTime()).toFixed(0));
if (taskQueue !== undefined) {
var newTaskQueue = taskQueue.filter(function (taskQueue) {
return taskQueue.id !== task;
});
appCache.taskQueue = newTaskQueue;
}
if (appCache[task] === undefined) appCache[task] = {};
if (task === 'survey') { //we will refactor the value to be an object with keys
var key = arr.key;
if(appCache.survey[key] === undefined) appCache.survey[key] = {};
appCache.survey[key] = {
unix: tasktime,
resp: arr.value
};
}else{
appCache[task] = arr;
}
appCache[task].unix = tasktime;
//lets always store cache so we can restore values if needed
_writeCache(appCache);
}
function _getCache() {
return require('Storage').readJSON("heatsuite.cache.json", true) || {};
}
function _writeCache(cache) {
var oldCache = _getCache();
if (oldCache !== cache) require('Storage').writeJSON("heatsuite.cache.json", cache);
return _getCache();
}
function _clearCache() {
require('Storage').writeJSON("heatsuite.cache.json", {});
return _getCache();
}
function _parseBLEData(buffer, dataSchema) {
let offset = 0;
let result = {};
for (let field in dataSchema) {
const dataType = dataSchema[field];
let value;
switch (dataType) {
case 'uint8':
value = buffer.getUint8(offset,true);
offset += 1; // 1 byte for uint8
break;
case 'uint16':
value = buffer.getUint16(offset,true); // Assuming little-endian format
offset += 4; // 2 bytes for uint16
break;
case 'int32':
value = buffer.getInt32(offset,true); // Assuming little-endian format
offset += 4; // 4 bytes for int32
break;
case 'float32':
value = buffer.getFloat32(offset,true); // Assuming little-endian format
offset += 4; // 4 bytes for float32
break;
case 'float64':
value = buffer.getFloat64(offset,true); // Assuming little-endian format
offset += 8; // 8 bytes for float64
break;
case 'array':
value = [];
for (let i = 0; i < 6; i++) {
value.push(buffer.getUint8(offset,true));
offset += 1; // 1 byte for each uint8
}
break;
case 'float16':{
const b0 = buffer.getUint8(offset, true);
const b1 = buffer.getUint8(offset + 1, true);
const mantissa = (b1 << 8) | b0;
const sign = mantissa & 0x8000 ? -1 : 1;
const exponent = (mantissa >> 11) & 0x0F;
const fraction = mantissa & 0x7FF;
value = sign * (1 + fraction / 2048) * Math.pow(2, exponent - 15);
offset += 2;
break;
}
default:
throw new Error(`Unknown data type: ${dataType}`);
}
result[field] = value;
}
return result;
}
function _log(msg) {
var settings = _getSettings();
if(settings.SAVE_DEBUG){
var file = require('Storage').open('heatsuite.log', 'a');
var string = String(parseInt((new Date().getTime() / 1000).toFixed(0)))+": "+msg+"\n";
file.write(string);
return;
}
else if (!settings.DEBUG) {
return;
} else {
console.log(msg);
}
}
exports = {
getSettings: _getSettings,
getRecordFile: _getRecordFile,
saveDataToFile: _saveDataToFile,
checkStorageFree : _checkStorageFree,
getCache: _getCache,
writeCache: _writeCache,
clearCache: _clearCache,
updateTaskQueue: _updateTaskQueue,
parseBLEData: _parseBLEData,
log: _log,
};

View File

@ -0,0 +1,359 @@
(function (back) {
var settingsJSON = "heatsuite.settings.json";
var studyTasksJSON = "heatsuite.tasks.json";
function log(msg) {
if (!settings.DEBUG) {
return;
} else {
console.log(msg);
}
}
function writeSettings(key, value) {
var s = require('Storage').readJSON(settingsJSON, true) || {};
s[key] = value;
require('Storage').writeJSON(settingsJSON, s);
settings = readSettings();
if (global.WIDGETS && WIDGETS["heatsuite"]) WIDGETS["heatsuite"].changed(); //redraw widget on settings update if open
}
function readSettings() {
var out = Object.assign(
require('Storage').readJSON("heatsuite.default.json", true) || {},
require('Storage').readJSON(settingsJSON, true) || {}
);
out.StudyTasks = require('Storage').readJSON(studyTasksJSON, true) || {};
return out;
}
var settings = readSettings();
/*---- PAIRING FUNCTIONS FOR DEVICES ----*/
function BPPair(id) {
var device;
E.showMessage(`Pairing /n ${id}`, "Bluetooth");
NRF.connect(id).then(function (d) {
device = d;
return new Promise(resolve => setTimeout(resolve, 2000));
}).then(function () {
log("connected");
if (device.getSecurityStatus().bonded) {
log("Already bonded");
return true;
} else {
log("Start bonding");
return device.startBonding();
}
}).then(function () {
device.device.on('gattserverdisconnected', function (reason) {
log("Disconnected ", reason);
});
return device.getPrimaryService("1810");
}).then(function (service) {
log(service);
return service.getCharacteristic("2A08");
}).then(function (characteristic) {
//set time on device during pairing
var date = new Date();
var b = new ArrayBuffer(7);
var v = new DataView(b);
v.setUint16(0, date.getFullYear(), true);
v.setUint8(2, date.getMonth() + 1);
v.setUint8(3, date.getDate());
v.setUint8(4, date.getHours());
v.setUint8(5, date.getMinutes());
v.setUint8(5, date.getSeconds());
var arr = [];
for (let i = 0; i < v.buffer.length; i++) {
arr[i] = v.buffer[i];
}
return characteristic.writeValue(arr);
}).then(function () {
writeSettings("bt_bloodPressure_id", id);
// Store the name for displaying later. Will connect by ID
if (device.name) {
writeSettings("bt_bloodPressure_name", device.name);
}
E.showAlert("Paired!").then(function () { E.showMenu(deviceSettings()) });
log("Device ID paired, time set, Done!");
return device.disconnect();
}).catch(function (e) {
log(e);
E.showAlert("error! " + e).then(function () { E.showMenu(deviceSettings()) });
});
}
function PairTcore(id) {
E.showMessage(`Pairing /n ${id}`, "Bluetooth");
var gatt;
NRF.connect(id).then(function (g) {
gatt = g;
console.log("connected!!!");
// return gatt.startBonding();
//}).then(function() {
console.log("bonded", gatt.getSecurityStatus());
writeSettings("bt_coreTemperature_id", id);
E.showAlert("Paired!").then(function () { E.showMenu(deviceSettings()) });
log("Device ID paired, Done!");
gatt.disconnect();
}).catch(function (e) {
log("ERROR: " + e);
E.showAlert("error! " + e).then(function () { E.showMenu(deviceSettings()) });
});
}
function deviceSettings() {
var menu = { '< Back': function () { E.showMenu(mainMenuSettings()); } };
menu[''] = { 'title': 'Devices' };
settings.StudyTasks.forEach(task => {
if (task.btPair === undefined || !task.btPair) return;
let key = task.id; // Adjust based on how you identify tasks
let id = "bt_" + key + "_id";
if (settings[id] !== undefined) {
menu["Clear " + key] = function () {
E.showPrompt("Clear " + key + " device?").then((r) => {
if (r) {
writeSettings("bt_" + key + "_id", undefined);
writeSettings("bt_" + key + "_name", undefined);
}
E.showMenu(mainMenuSettings());
});
};
} else {
menu["Pair " + key] = () => createMenuFromScan(key, task.btInfo.service);
}
});
return menu;
}
function recordMenu(){
var updateRecorder = function(name,v){
var r = settings.record;
r = r.filter(item => item !== name);
if(v){
r.push(name);
}
writeSettings("record",r);
}
var menu = { '< Back': function () { E.showMenu(mainMenuSettings()); } };
menu[''] = { 'title': 'Recorder' };
var recorderOptions = {
'hrm' : 'Optical HR',
'steps' : "Steps",
'bat' : 'Battery',
'movement': 'Movement',
'acc':'Accelerometry',
'baro':'Temp/Pressure',
'bthrm': 'BT HRM',
'CORESensor':'CORE Sensor'
}
for (let key in recorderOptions) {
let name = recorderOptions[key];
menu[name] = {
value: settings.record.includes(key),
onchange: v => {updateRecorder(key,v);}
};
}
menu['High Acc'] = {
value: settings.highAcc || false,
onchange: v => {
settings.highAcc = v;
writeSettings("highAcc", v);
}
};
return menu;
}
function mainMenuSettings() {
var menu = {
'': { 'title': 'Main' },
'< Back': back
};
menu['Recorders'] = function () {E.showMenu(recordMenu()) };
menu['Devices'] = function () { E.showMenu(deviceSettings()) };
menu['GPS'] = function () { E.showMenu(gpsSettings()) };
menu['Language'] = function () { E.showMenu(languageMenu()) };
menu['Swipe Launch'] = {
value: settings.swipeOpen || false,
onchange: v => {
settings.swipeOpen = v;
writeSettings("swipeOpen", v);
}
};
menu['Survey Random'] = {
value: settings.surveyRandomize || false,
onchange: v => {
settings.GPS = v;
writeSettings("surveyRandomize", v);
}
};
menu['HRM Interval'] = {
value: settings.HRMInterval || 0,
min: 0, max: 60,
onchange: v => {
settings.HRMInterval = v;
writeSettings("HRMInterval", v);
}
};
menu['Restart BLE'] = function () {
E.showPrompt("Restart Bluetooth?").then((r) => {
if (r) {
NRF.disconnect()
NRF.restart();
}
E.showMenu(mainMenuSettings());
});
};
menu['Clear Cache'] = function () {
E.showPrompt("Clear Cache?").then((r) => {
if (r) {
require('Storage').writeJSON("heatsuite.cache.json", {});
}
E.showMenu(mainMenuSettings());
});
}
menu['Clear Study ID'] = function () {
E.showPrompt("Clear study ID (includes ignored)?").then((r) => {
if (r) {
writeSettings("studyID", undefined);
writeSettings("studyIDIgnore", []);
}
E.showMenu(mainMenuSettings());
});
}
menu['Notifications'] = {
value: settings.notifications || false,
onchange: v => {
settings.notifications = v;
writeSettings("notifications", v);
}
};
menu['Debug'] = function () { E.showMenu(debugMenu()) };
return menu;
}
function debugMenu(){
var menu = {
'': { 'title': 'Debug' },
'< Back': function () { E.showMenu(mainMenuSettings()); }
};
menu['Console'] = {
value: settings.DEBUG || false,
onchange: v => {
settings.DEBUG = v;
writeSettings("DEBUG", v);
}
};
menu['Log (file)'] = {
value: settings.SAVE_DEBUG || false,
onchange: v => {
settings.SAVE_DEBUG = v;
writeSettings("SAVE_DEBUG", v);
}
};
return menu;
}
function gpsSettings() {
var menu = {
'': { 'title': 'GPS' },
'< Back': function () { E.showMenu(mainMenuSettings()); }
};
menu['GPS'] = {
value: settings.GPS || false,
onchange: v => {
settings.GPS = v;
writeSettings("GPS", v);
}
};
menu['Scan Time (min)'] = {
value: settings.GPSScanTime || 1,
min: 0, max: 60,
onchange: v => {
settings.GPSScanTime = v;
writeSettings("GPSScanTime", v);
}
};
menu['Interval (min)'] = {
value: settings.GPSInterval || 10,
min: 0, max: 180,
onchange: v => {
settings.GPSinterval = v;
writeSettings("GPSInterval", v);
}
};
menu['Adaptive (min)'] = {
value: settings.GPSAdaptiveTime || 2,
min: 0, max: 60,
onchange: v => {
settings.GPSAdaptiveTime = v;
writeSettings("GPSAdaptiveTime", v);
}
};
return menu;
}
function languageMenu() {
var menu = { '< Back': function () { E.showMenu(mainMenuSettings()); } };
menu[''] = { 'title': 'Language' };
var surveySettings = require('Storage').readJSON("heatsuite.survey.json", true) || {};
Object.keys(surveySettings.supported).forEach(key => {
//var id = surveySettings.supported[key];
menu[key] = function () {
E.showPrompt("Set " + key + "?").then((r) => {
if (r) {
writeSettings('lang', key);
}
E.showMenu(mainMenuSettings());
});
};
});
return menu;
}
function createMenuFromScan(type, service) {
E.showMenu();
E.showMessage("Scanning for 4 seconds");
var submenu_scan = {
'< Back': function () { E.showMenu(deviceSettings()); }
};
NRF.findDevices(function (devices) {
submenu_scan[''] = { title: `Scan (${devices.length} found)` };
if (devices.length === 0) {
E.showAlert("No " + type + " devices found")
.then(() => E.showMenu(deviceSettings()));
return;
} else {
devices.forEach((d) => {
print("Found device", d);
var shown = (d.name || d.id.substr(0, 17));
submenu_scan[shown] = function () {
E.showPrompt("Set " + shown + "?").then((r) => {
if (r) {
switch (type) {
case "bloodPressure":
BPPair(d.id);
break;
case "coreTemperature":
PairTcore(d.id);
break;
case "bthrm":
break;
default:
E.showMenu(deviceSettings());
break;
}
} else {
E.showMenu(deviceSettings());
}
});
};
});
}
E.showMenu(submenu_scan);
}, { timeout: 4000, active: true, filters: [{ services: [service] }] });
}
E.showMenu(mainMenuSettings());
})

View File

@ -0,0 +1,228 @@
var surveyFileJSON = "heatsuite.survey.json";
var Layout = require("Layout");
const modHS = require('HSModule');
var layout;
var TaskScreenTimeout;
Bangle.setOptions({
'backlightTimeout':30000,
'lockTimeout':30000
});
var settings = modHS.getSettings();
//randomize question order
function shuffle(array) {
const result = [];
var itemsLeft = array;
while (itemsLeft.length) {
var Item;
if (itemsLeft[0].orderFix !== undefined && itemsLeft[0].orderFix == true) {
Item = itemsLeft.splice(0, 1)[0];
} else {
var randomIndex = Math.floor(Math.random() * itemsLeft.length);
Item = itemsLeft.splice(randomIndex, 1)[0];
}
result.push(Item); // ...and add it to the result
}
return result;
}
var surveyFile = require('Storage').readJSON(surveyFileJSON, true) || {"questions":[{"text":{"en_GB":"Thermal Comfort?"},"options":[{"text":{"en_GB":"Comfortable"},"value":0,"color":"#ffffff","btnColor":"#38ed35"},{"text":{"en_GB":"Uncomfortable"},"value":1,"color":"#ffffff","btnColor":"#ff0019"}],"tod":[[0,2359]],"key":"comfort"}],"supported":{"en_GB":"English (GB)"}};
var QArr = surveyFile.questions;
if (settings.surveyRandomize !== undefined && settings.surveyRandomize) {
QArr = shuffle(QArr);
}
function log(msg) {
if (!settings.DEBUG) {
return;
} else {
console.log(msg);
}
}
var appCache = modHS.getCache();
var lang = settings.lang || require("locale").name || "en_GB";
function queueTaskScreenTimeout() {
if (TaskScreenTimeout) clearTimeout(TaskScreenTimeout);
if (TaskScreenTimeout === undefined) {
TaskScreenTimeout = setTimeout(function () {
Bangle.load();
}, 180000);
}
}
/** -----------==== SURVEYS ====---------------- */
var scrollInterval;
function drawScrollingText(text,height) {
Bangle.appRect = { x: 0, y: height, w: g.getWidth(), h: g.getHeight() - height, x2: g.getWidth()-1, y2: g.getHeight()-1 };
let stringWidth = g.stringWidth(text);
let textX = (stringWidth > g.getWidth())? (stringWidth/2) : 0;
g.setColor("#000");
g.setBgColor("#FFF");
g.setFont("Vector:20", 2);
g.clearRect(0, 0, g.getWidth(), height);
function QuestionText() {
g.setColor("#000");
g.setBgColor("#FFF");
g.setFont("Vector:20", 2);
g.clearRect(0, 0, g.getWidth(), height);
g.drawString(text, textX, height/2);
textX -= 5;
if (textX < (-(stringWidth/2)+g.getWidth())) textX = (stringWidth/2);
g.flip();
}
if(scrollInterval) clearTimeout(scrollInterval);
if(stringWidth > g.getWidth()){
scrollInterval = setInterval(QuestionText, 60); //will need to scroll as its too long
}else{
QuestionText();
}
}
function drawResponseOpts(ind){
//force scrolling of question at the top
var question = QArr[ind];
drawScrollingText(question.text[lang].replace(/\\n/g, " "),30);
var height = 30;
var options = question.options;
if(options.length < 4){
height = Math.floor(Bangle.appRect.h / options.length);
}
let drawItem = function (idx,r){
var optionText = options[idx].value;
if (options[idx].text !== undefined) {
optionText = options[idx].text[lang];
}
g.setColor((options[idx].color)?options[idx].color:"#000");
g.setBgColor((options[idx].btnColor)?options[idx].btnColor:"#CCC").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1);
g.setFontAlign(0, 0, 0);
g.setFont("Vector:20").drawString(optionText,r.x+(g.getWidth()/2),r.y+(height/2));
};
let selectItem = function(id) {
var resp = (options[id] && options[id].text && lang in options[id].text) ? options[id].text[lang] : options[id].value;
const cbString = ind + "," + question.key + "," + resp + "," + options[id].value;
return surveyResponse(cbString);
};
E.showScroller({
h : height,
c : options.length,
draw : drawItem,
select : selectItem
});
}
function drawSurveyLayout(index) {
if(scrollInterval) clearTimeout(scrollInterval);
if (surveyFile === undefined) {
log('No Survey File');
E.showAlert("No Survey File Found.").then(function () {
Bangle.showClock();
});
return;
}
if (index == QArr.length) {
//at the end, so we can show a saved image and redirect to time screen
g.clear();
g.reset();
g.setBgColor("#FFF");
Bangle.buzz(150);
layout = new Layout({
type: "v",
c: [{
type: "img",
pad: 4,
src: require("heatshrink").decompress(atob("ikUwYFCgVJkgMDhMkyVJAwQFCAQNAgESAoQCBwEBBwlIgAFDpNkyAjDkm/5MEBwdf+gUEl/6AoVZkmX/oLClv6pf+DQn1/4+E3//0gFBkACBv/SBYI7D5JiDLJx9CBAR4CAoWQQ4Z9DgAA=="))
},
{
type: "txt",
font: "Vector:30",
label: "Done!"
}]
});
layout.render();
setTimeout(function () {
Bangle.load();
}, 500);
return;
}
var question = QArr[index];
var dateN = new Date();
if (question.tod !== undefined && question.tod.length > 0) {
//Now we need to see if we are in window of the day that we are eligible to ask the question
var windowOfDay = false;
var currMT = parseInt(dateN.getHours() + "" + dateN.getMinutes());
for (let d = 0; d < question.tod.length; d++) {
if (currMT > question.tod[d][0] && currMT < question.tod[d][1]) {
windowOfDay = true;
break;
}
}
if (!windowOfDay) {
drawSurveyLayout(index + 1);
return;
}
}
if (appCache.survey !== undefined){ //first time we are asking this question
if(appCache.survey[question.key] !== undefined) {
//lets just check if this survey question can be shown right now, otherwise we will skip it
var lastS = new Date(appCache.survey[question.key].unix * 1000);
if (question.oncePerDay !== undefined && question.oncePerDay) { // check if we can only show survey once a day and if we already have
//if (dateN.getFullYear() + dateN.getMonth() + dateN.getDate() === lastS.getFullYear() + lastS.getMonth() + lastS.getDate()) {
if(Math.floor(dateN.getTime() / 86400000) === Math.floor(lastS.getTime() / 86400000)){
drawSurveyLayout(index + 1);
return;
}
}
}
}
Bangle.buzz(100);
g.clear();
g.reset();
var out = {
type: "v",
c: []
};
//default to English if question isn't translated
if (!question.text[lang]) {
lang = "en_GB";
}
var questionText = question.text[lang].replace(/\\n/g, "\n");
var q = {
type: "txt",
wrap: true,
fillx: 1,
filly: 1,
font: "Vector:20",
label: questionText,
id: "label"
};
out.c.push(q);
var optFont = 'Vector:30';
if (question.optFont !== undefined) optFont = question.optFont;
var opt = {
type: "btn",
font: optFont,
label: ">>",
pad: 1,
btnFaceCol: "#0f0",
cb: l => drawResponseOpts(index)
};
out.c.push(opt);
layout = new Layout(out);
layout.render();
return;
}
function surveyResponse(text) {
var arr = text.split(',');
var nextSurvey = parseInt(arr[0]) + 1;
let newArr = {
"key": arr[1],
"resp": arr[2],
"value": arr[3]
}
modHS.saveDataToFile('survey', 'survey', newArr);
drawSurveyLayout(nextSurvey);
}
drawSurveyLayout(0);
queueTaskScreenTimeout();

View File

@ -0,0 +1,88 @@
var Layout = require("Layout");
const modHS = require('HSModule');
var layout;
//var settings = modHS.getSettings();
var appCache = modHS.getCache();
var results = {
color: 0,
volume: null,
colorAssessment: appCache.urine && appCache.urine.colorAssessment ? appCache.urine.colorAssessment : 0
}
function YMDInt(date) {
var year = date.getFullYear().toString();
var month = (date.getMonth() + 1).toString().padStart(2, '0');
var day = date.getDate().toString().padStart(2, '0');
var concatenatedDate = year + month + day;
var concatenatedInteger = parseInt(concatenatedDate);
return concatenatedInteger;
}
function saveUrineData(color) {
if (color > 0) {
var d = new Date();
results.color = color;
results.colorAssessment = YMDInt(d);
}
Bangle.buzz(150);
modHS.saveDataToFile('urine', 'urine', results);
g.clear();
g.reset();
layout = new Layout({
type: "v",
c: [{
type: "img",
pad: 4,
src: require("heatshrink").decompress(atob("ikUwYFCgVJkgMDhMkyVJAwQFCAQNAgESAoQCBwEBBwlIgAFDpNkyAjDkm/5MEBwdf+gUEl/6AoVZkmX/oLClv6pf+DQn1/4+E3//0gFBkACBv/SBYI7D5JiDLJx9CBAR4CAoWQQ4Z9DgAA=="))
},
{
type: "txt",
font: "Vector:30",
label: "Saved!"
}]
});
layout.render();
setTimeout(function () {
Bangle.load();
}, 500);
}
function drawColorAssessment(){
var dateNow = new Date();
var lastUrineColorDate = appCache.urine && appCache.urine.colorAssessment ? appCache.urine.colorAssessment : 0;
var hourCurrent = dateNow.getHours();
var currentDay = YMDInt(dateNow);
if (hourCurrent >= 16 && currentDay > lastUrineColorDate) {
var layout = new Layout({
type: "v", c: [
{
type: "h", c: [
{ type: "btn", font: "6x8:2", label: " ", btnFaceCol: E.HSBtoRGB(0.3, 0.99, 1), cb: l => saveUrineData(2), fillx: 1, filly: 1, pad: 1 },
{ type: "btn", font: "6x8:2", label: " ", btnFaceCol: E.HSBtoRGB(0.2, 1, 1), cb: l => saveUrineData(1), fillx: 1, filly: 1, pad: 1 }
]
},
{
type: "h", c: [
{ type: "btn", font: "6x8:2", label: " ", btnFaceCol: E.HSBtoRGB(0.38, 1, 1), cb: l => saveUrineData(3), fillx: 1, filly: 1, pad: 1 },
{ type: "btn", font: "6x8:2", label: " ", btnFaceCol: E.HSBtoRGB(0.44, 1, 0.9), cb: l => saveUrineData(4), fillx: 1, filly: 1, pad: 1 },
]
}
]
});
g.clear();
g.reset();
layout.render();
} else {
saveUrineData(0);
}
}
drawColorAssessment();
/*
//Urine Colours adapted NSW chart and from the Hillmen Urine Chart to includes blood presence
//https://www.health.nsw.gov.au/environment/beattheheat/Pages/urine-colour-chart.aspx
*/

View File

@ -0,0 +1,819 @@
(() => {
const modHS = require('HSModule');
var settings = modHS.getSettings();
var cache = modHS.getCache();
var hrmInterval = 0;
var appName = "heatsuite";
var bleAdvertGen = 0xE9D0;
var lastBLEAdvert = [];
var recorders;
var activeRecorders = [];
var dataLog = [];
var lastGPSFix = 0;
var gpsLog = [];
var connectionLock = false;
var processQueue = [];
var processQueueTimeout = null;
let initHandlerTimeout = null;
let BTHRM_ConnectCheck = null;
//high Accelerometry data
var perSecAccHandler = null;
var highAccTimeout = null;
var highAccWriteTimeout = null;
//Fall Detection
var fallTime = 0;
var fallDetected = false;
Bangle.setOptions({
"hrmSportMode": -1,
});
//function for setting timeouts to the nearest second or minute
function timeoutAligned(periodMs, callback) {
var now = new Date();
var millisPassed = (now.getSeconds() * 1000) + now.getMilliseconds();
if (periodMs < 1000) periodMs = 1000; //nothing less than a second is allowed
var millisLeft = periodMs - (millisPassed % periodMs);
return setTimeout(() => { callback(); }, millisLeft);
}
function secondsSinceMidnight() {//valuable for compact storage of time
let d = new Date();
return d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds();
}
function queueProcess(func, arg) {
processQueue.push((next) => func(next, arg));
if (!connectionLock) {
processNextInQueue();
} else {
if (!processQueueTimeout) {
processQueueTimeout = setTimeout(processNextInQueue, 1000);
}
}
}
function processNextInQueue() {
clearTimeout(processQueueTimeout); // Clear the timeout when processing starts
processQueueTimeout = null;
if (processQueue.length === 0) {
return;
}
if (!connectionLock && processQueue.length > 0) {
const task = processQueue.shift();
task(() => {
modHS.log("[ProcessQueue] Processing queued Task");
processNextInQueue();
});
} else {
if (!processQueueTimeout) {
processQueueTimeout = setTimeout(processNextInQueue, 1000);
}
}
}
function stringToBytes(str) {
const byteArray = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
byteArray[i] = str.charCodeAt(i);
}
return byteArray;
}
function xorEncryptWithSalt(payload, key, salt) {
const encrypted = [];
const keyLen = key.length;
const saltLen = salt.length;
for (let i = 0; i < payload.length; i++) {
encrypted[i] = payload[i] ^ key.charCodeAt(i % keyLen) ^ salt.charCodeAt(i % saltLen);
}
return encrypted;
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
let temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
function flattenArray(array) {
let flattened = [];
for (let i = 0; i < array.length; i++) {
if (Array.isArray(array[i])) {
// If the element is an array, concatenate it recursively
flattened = flattened.concat(flattenArray(array[i]));
} else {
// If it's not an array, add the element to the result
flattened.push(array[i]);
}
}
return flattened;
}
function createRandomizedPayload(studyid, battery, temperature, heartRate, hr_loc, movement, pingFlag) {
let textBytes = stringToBytes(studyid);
if (textBytes.length < 4) {
const paddedArray = new Uint8Array(4); // Create a new Uint8Array with 4 bytes (default 0x00)
paddedArray.set(textBytes); // Copy the textBytes into the paddedArray
textBytes = paddedArray; // Replace textBytes with the padded version
}
const dataBlocks = [];
dataBlocks.push([0x00, textBytes[0], textBytes[1], textBytes[2], textBytes[3]]); // Study Code
var alert = false;
if (cache.hasOwnProperty('alert') && Object.keys(cache.alert).length > 0) {
var HEALTH_EVENTS = {
fall: 1,
custom: 99
};
var HEALTH_EVENT_TYPE = HEALTH_EVENTS[cache.alert.type] || HEALTH_EVENTS.custom;
dataBlocks.push([0x07, HEALTH_EVENT_TYPE]);
}
if (studyid !== "####") {
if (battery != null) {
dataBlocks.push([0x01, battery]); // Battery level
}
if (temperature != null && !isNaN(temperature)) {
dataBlocks.push([0x02, temperature & 255, (temperature >> 8) & 255]); // Temperature
}
if (heartRate != null && !isNaN(heartRate)) {
dataBlocks.push([0x03, heartRate]);
}
if (hr_loc != null && !isNaN(hr_loc) && !alert) { //sub this for an alert flag if needed!
dataBlocks.push([0x04, hr_loc]);
}
if (movement != null && !isNaN(movement)) {
dataBlocks.push([
0x05,
movement & 255,
(movement >> 8) & 255,
(movement >> 16) & 255,
(movement >> 24) & 255
]);
}
}
if (!isNaN(pingFlag)) {
let statusByte = (+Bangle.isCharging() << 1) | pingFlag;
dataBlocks.push([0x06, statusByte]);
}
modHS.log(JSON.stringify(dataBlocks));
const randomizedDataBlocks = shuffleArray(dataBlocks);
const payload = flattenArray(randomizedDataBlocks);
return studyid !== "####" ? xorEncryptWithSalt(payload, "heatsuite", studyid) : payload;
}
//from: https://github.com/espruino/BangleApps/tree/master/apps/recorder
//adapted to minute average data
let getRecorders = function () {
var recorders = {
hrm: function () {
var bpm = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null }, bpmConfidence = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null }, src = "";
function onHRM(h) {
if (h.confidence !== 0) bpmConfidence = newValueHandler(bpmConfidence, h.confidence);
if (h.bpm !== 0) bpm = newValueHandler(bpm, h.bpm);
src = h.src;
}
return {
name: "HR",
fields: ["hr", "hr_conf", "hr_src"],
getValues: () => {
var r = [bpm.avg === null ? null : bpm.avg.toFixed(0), bpmConfidence.avg === null ? null : bpmConfidence.avg.toFixed(0), src];
bpm = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
bpmConfidence = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
src = "";
return r;
},
start: () => {
Bangle.on('HRM', onHRM);
Bangle.setHRMPower(1, appName);
},
stop: () => {
Bangle.removeListener('HRM', onHRM);
Bangle.setHRMPower(0, appName);
},
draw: (x, y) => g.setColor(Bangle.isHRMOn() ? "#f00" : "#f88").drawImage(atob("DAwBAAAAMMeef+f+f+P8H4DwBgAA"), x, y)
};
},
bthrm: function () {
var bt_bpm = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var bt_bat = "";
var bt_energy = "";
var bt_contact = "";
var bt_rr = [];
function onBTHRM(h) {
//modHS.log(JSON.stringify(h));
if (h.bpm === 0) return;
bt_bpm = newValueHandler(bt_bpm, h.bpm);
bt_bat = h.bat;
bt_energy = h.energy;
bt_contact = h.contact;
if (h.rr) {
h.rr.forEach(val => bt_rr.push(val));
}
}
return {
name: "BT HR",
fields: ["bt_bpm", "bt_bat", "bt_energy", "bt_contact", "bt_rr"],
getValues: () => {
const result = [bt_bpm.avg === null ? null : bt_bpm.avg.toFixed(0), bt_bat, bt_energy, bt_contact, bt_rr.join(";")];
bt_bpm = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
bt_rr = [];
bt_bat = "";
bt_energy = "";
bt_contact = "";
return result;
},
start: () => {
Bangle.on('BTHRM', onBTHRM);
if (Bangle.setBTHRMPower) Bangle.setBTHRMPower(1, appName);
},
stop: () => {
Bangle.removeListener('BTHRM', onBTHRM);
if (Bangle.setBTHRMPower) Bangle.setBTHRMPower(0, appName);
}
}
},
CORESensor: function () {
var core = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var skin = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var core_hr = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var heatflux = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var hsi = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var core_bat = null;
var unit = null;
function onCORE(h) {
core = newValueHandler(core, h.core);
skin = newValueHandler(skin, h.skin);
if (core_hr > 0) {
core_hr = newValueHandler(core_hr, h.hr);
}
heatflux = newValueHandler(heatflux, h.heatflux);
hsi = newValueHandler(hsi, h.hsi);
core_bat = h.battery;
unit = h.unit;
}
return {
name: "CORESensor",
fields: ["core", "skin", "unit", "core_hr", "hf","hsi", "core_bat"],
getValues: () => {
const result = [core.avg === null ? null : core.avg.toFixed(2), skin.avg === null ? null : skin.avg.toFixed(2), unit, core_hr.avg === null ? null : core_hr.avg.toFixed(0), heatflux.avg === null ? null : heatflux.avg.toFixed(2),hsi.avg === null ? null : hsi.avg.toFixed(1), core_bat];
core = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
skin = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
core_hr = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
heatflux = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
hsi = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
core_bat = null;
unit = null;
return result;
},
start: () => {
Bangle.on('CORESensor', onCORE);
if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(1, appName);
},
stop: () => {
Bangle.removeListener('CORESensor', onCORE);
if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, appName);
}
}
},
bat: function () {
return {
name: "BAT",
fields: ["batt_p", "batt_v", "charging"],
getValues: () => {
return [E.getBattery(), NRF.getBattery().toFixed(2), Bangle.isCharging()];
},
start: () => {
},
stop: () => {
},
draw: (x, y) => g.setColor(Bangle.isCharging() ? "#0f0" : "#ff0").drawImage(atob("DAwBAABgH4G4EYG4H4H4H4GIH4AA"), x, y)
};
},
steps: function () {
var lastSteps = 0;
return {
name: "steps",
fields: ["steps"],
getValues: () => {
var c = Bangle.getStepCount(), r = [c - lastSteps];
lastSteps = c;
return r;
},
start: () => { lastSteps = Bangle.getStepCount(); },
stop: () => { },
draw: (x, y) => g.reset().drawImage(atob("DAwBAAMMeeeeeeeecOMMAAMMMMAA"), x, y)
};
},
movement: function () {
return {
name: "movement",
fields: ["movement"],
getValues: () => {
return [Bangle.getHealthStatus().movement];
},
start: () => { },
stop: () => { },
draw: (x, y) => g.reset().drawImage(atob("DAwBAAMMeeeeeeeecOMMAAMMMMAA"), x, y)
};
},
acc: function () {
var accMagArray = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
function accelHandler(accel) {
// magnitude is computed as: sqrt(x*x + y*y + z*z)
// to compute Elucidean Norm Minus One, simply run: mag - 1
// (https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0061691)
accMagArray = newValueHandler(accMagArray, accel.mag);
}
return {
name: "Accelerometer",
fields: ["acc_min", "acc_max", "acc_avg", "acc_sum"],
getValues: () => {
var r = [accMagArray.min === null ? null : accMagArray.min.toFixed(4), accMagArray.max === null ? null : accMagArray.max.toFixed(4), accMagArray.avg === null ? null : accMagArray.avg.toFixed(4), accMagArray.sum === null ? null : accMagArray.sum.toFixed(4)];
accMagArray = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
return r;
},
start: () => {
//Bangle.setPollInterval(80); // This will allow it to be dynamic and save battery
Bangle.on('accel', accelHandler);
},
stop: () => {
Bangle.removeListener('accel', accelHandler);
},
draw: (x, y) => g.setColor(Bangle.isHRMOn() ? "#f00" : "#f88").drawImage(atob("DAwBAAAAMMeef+f+f+P8H4DwBgAA"), x, y)
};
},
};
if (Bangle.getPressure) {
recorders['baro'] = function () {
var temp = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var press = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var alt = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
function onPress(c) {
if (c.temperature !== 0) temp = newValueHandler(temp, c.temperature);
if (c.pressure !== 0) press = newValueHandler(press, c.pressure);
if (c.altitude !== 0) alt = newValueHandler(alt, c.altitude);
}
return {
name: "Baro",
fields: ["baro_temp", "baro_press", "baro_alt"],
getValues: () => {
var r = [temp.avg === null ? null : temp.avg.toFixed(2), press.avg === null ? null : press.avg.toFixed(2), alt.avg === null ? null : alt.avg.toFixed(2)];
var temp = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var press = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
var alt = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null };
return r;
},
start: () => {
Bangle.setBarometerPower(1, appName);
Bangle.on('pressure', onPress);
},
stop: () => {
Bangle.setBarometerPower(0, appName);
Bangle.removeListener('pressure', onPress);
},
draw: (x, y) => g.setColor("#0f0").drawImage(atob("DAwBAAH4EIHIEIHIEIHIEIEIH4AA"), x, y)
};
}
}
return recorders;
}
function newValueHandler(arr, value) {//a way to keep resource use down for each sensor since this could grow large!
arr.count = (arr.count === null) ? 1 : arr.count + 1;
if (arr.count === 1) arr.min = value;
arr.last = value;
arr.avg = (value + (arr.avg * (arr.count - 1))) / arr.count;
arr.sum = arr.sum + value;
arr.min = (value < arr.min) ? value : arr.min;
arr.max = (value > arr.max) ? value : arr.max;
return arr;
}
//increased accelerometer data storage for higher resolution activity tracking
function perSecAcc(status) {
if(!status){
if (perSecAccHandler) Bangle.removeListener('accel', perSecAccHandler);
if (highAccTimeout) clearTimeout(highAccTimeout);
if (highAccWriteTimeout) clearTimeout(highAccWriteTimeout);
return;
}
function arrayAcc() {
return { count: 0, sum: 0 };
}
function updateArray(acc, value) {
acc.sum += value;
acc.count++;
}
function getAvg(acc) {
return acc.count ? acc.sum / acc.count : 0;
}
let mag = arrayAcc();
let accTemp = [];
perSecAccHandler = function(accel){
updateArray(mag, accel.mag);
};
Bangle.on('accel', perSecAccHandler);
function writeAccLog(buf) {
if (!buf || !buf.length) return;
let f = modHS.getRecordFile("accel", []);
if (!f) return;
let line = "";
function processArrayChunk() {
let chunkSize = 10;
for (let i = 0; i < chunkSize && buf.length; i++) {
let data = buf.shift();
if (!data || data.length !== 8) continue;
let dv = new DataView(data.buffer);
let t = dv.getUint32(0, true);
let mag = dv.getUint16(4, true);
let sum = dv.getUint16(6, true);
line += t + "," + mag + "," + sum + "\n";
}
if (buf.length) {
setTimeout(processArrayChunk, 10);
} else {
f.write(line);
f = null;
}
}
processArrayChunk();
}
function writeHSAccelSetTimeout() {
if (accTemp.length > 0) {
queueProcess((next, buf) => {
writeAccLog(buf);
next();
},accTemp);
accTemp = [];
}
highAccWriteTimeout = timeoutAligned(10000, writeHSAccelSetTimeout); //check every 10 seconds
}
function tempAccLog() {
let secondsSM = secondsSinceMidnight();
let scaledMagAvg = Math.round(getAvg(mag) * 8192);
let scaledMagSum = Math.round(mag.sum * 1024);
let b = new Uint8Array(8);
let dv = new DataView(b.buffer);
dv.setUint32(0, secondsSM, true);
dv.setUint16(4, scaledMagAvg, true);
dv.setUint16(6, scaledMagSum, true);
accTemp.push(b); // Push Uint8Array
mag = arrayAcc();
highAccTimeout = timeoutAligned(1000, tempAccLog);
}
let rawAccLogInt = (settings.AccLogInt ? settings.AccLogInt * 1000 : 1000);
let accLogInt = Math.max(1000, Math.round(rawAccLogInt / 1000) * 1000);
highAccTimeout = timeoutAligned(accLogInt, tempAccLog);
highAccWriteTimeout = timeoutAligned(30000, writeHSAccelSetTimeout);
}
function updateBLEAdvert(data) {
var unix = parseInt((new Date().getTime() / 1000).toFixed(0));
var batt = null,
rawTemp = null,
temperature = null,
heartRate = null,
hr_loc = null,
rawMovement = null,
movement = null;
if (data.length > 0) {
var headers = ['unix', 'tz'];
activeRecorders.forEach(recorder => headers.push.apply(headers, recorder.fields));
const safeGet = (field) => {
const index = headers.indexOf(field);
return index !== -1 ? data[index] : null;
};
unix = data[0]; // Unix timestamp is always first
batt = safeGet('batt_p', data, headers);
rawTemp = safeGet('baro_temp', data, headers);
temperature = (rawTemp != null) ? Math.round(rawTemp * 100) : null;
heartRate = safeGet('hr', data, headers);
hr_loc = 1;
if (headers.includes('bt_hrm')) {
hr_loc = 2;
heartRate = safeGet('bt_hrm', data, headers);
}
rawMovement = safeGet('acc_sum', data, headers);
movement = (rawMovement != null) ? Math.round(rawMovement * 10000) : null;
}
var studyid = settings.studyID || "####";
if (studyid.length > 4) {
studyid = studyid.substring(0, 4);
}
var lastNodePing = cache.lastNodePing || 0;
var nodePing = (Math.abs(unix - lastNodePing) > 360) ? 1 : 0;
let advert = createRandomizedPayload(studyid, batt, temperature, heartRate, hr_loc, movement, nodePing);
modHS.log(advert);
require("ble_advert").set(bleAdvertGen, advert);
}
function fallDetectFunc(acc) {
if (!fallDetected) {
let d = new Date().getTime();
if (fallTime != 0) {
modHS.log("acc", acc.mag);
}
if (fallTime != 0 && d - fallTime > 200) {
fallTime = 0; fallDetected = false;
} else if (acc.mag < 0.3 && fallTime === 0) {
fallTime = d;
modHS.log("FALLING", fallTime);
} else if (acc.mag > 2.1 && d - fallTime < 200) {
//IMPACT
Bangle.buzz(400);
E.showPrompt("Did you fall?", { title: "FALL DETECTION", img: atob("FBQBAfgAf+Af/4P//D+fx/n+f5/v+f//n//5//+f//n////3//5/n+P//D//wf/4B/4AH4A=") }).then((r) => {
if (r) {
fallDetected = true;
modHS.saveDataToFile('alert', 'marker', { 'event': 'fall' });
Bangle.showClock();
} else {
Bangle.showClock(); //no fall, so just return to clock
}
});
}
}
}
function storeTempLog(unix) {
var fields = [unix, ((new Date()).getTimezoneOffset() * -60)];
activeRecorders.forEach(recorder => fields.push.apply(fields, recorder.getValues()));
dataLog.push(fields);
lastBLEAdvert = fields;
updateBLEAdvert(lastBLEAdvert);
}
function writeLog() {
var headers = ["unix", "tz"];
activeRecorders.forEach(recorder => headers.push.apply(headers, recorder.fields));
var storageFile = modHS.getRecordFile('minData', headers);
try {
if (storageFile) {
while (dataLog.length > 0) {
let item = dataLog.shift();
storageFile.write(item.join(',') + '\n');
}
}
} catch (e) {
modHS.log(e);
}
modHS.checkStorageFree('minData');
return true;
}
function toggleHRM() {
var recHRM = recorders['hrm'];
var hrm = recHRM();
if (Bangle.isHRMOn()) {
hrm.stop();
} else {
hrm.start();
}
}
function writeGPS() {
var storageFile = modHS.getRecordFile('gps', ["unix", "tz", 'lat', "lon", "alt", "speed", "course", "fix", "satellites"]);
if (storageFile) {
while (gpsLog.length > 0) { //store it
let item = gpsLog.shift();
storageFile.write(item.join(',') + '\n');
cache["lastGPSSignal"] = item[0];
queueProcess((next, cache) => {
modHS.writeCache(cache);
next();
}, cache);
}
}
}
function gpsHandler() {
if (settings.GPS) {
function logGPS(f) {
if (!isNaN(f.lat)) {
const unix = parseInt((new Date().getTime() / 1000).toFixed(0));
if (unix > lastGPSFix + 60) {
var fields = [unix, ((new Date()).getTimezoneOffset() * -60), f.lat, f.lon, f.alt, f.speed, f.course, f.fix, f.satellites];
gpsLog.push(fields);
lastGPSFix = unix;
}
}
}
Bangle.on('GPS', logGPS);
Bangle.setGPSPower(1, appName);
var updateTime = settings.GPSInterval !== undefined ? settings.GPSInterval * 60 : 600;
var searchTime = settings.GPSScanTime !== undefined ? settings.GPSScanTime * 60 : 60;
var adaptiveTime = settings.GPSAdaptiveTime !== undefined ? settings.GPSAdaptiveTime * 60 : 120;
require("gpssetup").setPowerMode({ power_mode: "PSMOO", update: updateTime, search: searchTime, adaptive: adaptiveTime, appName: appName });
} else {
Bangle.setGPSPower(0, appName); //just make sure its off
}
}
function studyTaskCheck(timenow) {
modHS.log("[StudyTask] Func init at " + timenow);
let notifications = false;
const tasks = settings.StudyTasks;
tasks.forEach(task => {
let key = task.id;
modHS.log(`[StudyTask] Processing task: ${JSON.stringify(task)}`);
if (!cache[key]) {
cache[key] = {};
}
const d = new Date(timenow * 1000);
const hours = d.getHours();
const minutes = d.getMinutes().toString().padStart(2, "0");
const tod = parseInt(`${hours}${minutes}`);
const lastTaskTime = cache[key].unix || 0;
const debounceTime = (timenow - lastTaskTime) >= task.debounce;
if (task.tod !== undefined && Array.isArray(task.tod) && task.tod.includes(tod) && debounceTime) {
modHS.log(`[StudyTask] Time to notify: ${task}`);
const taskID = { id: key, time: timenow };
cache.taskQueue = cache.taskQueue || []; // Ensure taskQueue exists
cache.taskQueue.push(taskID);
var seen = {};
var newTaskQueue = [];
for (var i = 0; i < cache.taskQueue.length; i++) {
var obj = cache.taskQueue[i];
if (!seen[obj.id]) {
seen[obj.id] = true;
newTaskQueue.push(obj);
}
}
cache.taskQueue = newTaskQueue;
modHS.log(`[StudyTask] ${JSON.stringify(cache)}`);
if (task.notify) {
notifications = true;
}
modHS.writeCache(cache);
WIDGETS["heatsuite"].draw();
}
});
if (notifications) {
Bangle.buzz(200);
setTimeout(() => Bangle.buzz(200), 300);
}
if (notifications && Bangle.CLOCK && settings.notifications) {
const desc = `Tasks: ${cache.taskQueue.length}`;
E.showPrompt(desc, {
title: "NOTIFICATION",
img: atob("FBQBAfgAf+Af/4P//D+fx/n+f5/v+f//n//5//+f//n////3//5/n+P//D//wf/4B/4AH4A="),
buttons: {
" X ": false,
" >> ": true
}
}).then(v => {
if (v) {
Bangle.load("heatsuite.app.js");
} else {
Bangle.load();
}
});
}
modHS.log("[StudyTask] Func end");
}
//heart beat of the backend
function init() {
cache = modHS.getCache(); //update cache each minute
var unix = parseInt((new Date().getTime() / 1000).toFixed(0));
if (storeTempLog(unix)) {
modHS.log("Data Logged to RAM");
}
queueProcess((next, unix) => {
modHS.log("[HRM + StudyTask]");
try {
// HRM interval check
if (settings.HRMInterval > 0) {
if (hrmInterval >= settings.HRMInterval) {
toggleHRM();
hrmInterval = 0;
}
hrmInterval++;
}
if (settings.StudyTasks.length > 0) {
studyTaskCheck(unix); // This might also need to be queued if async
}
} catch (error) {
modHS.log("Error in StudyTaskCheck:", error);
} finally {
next(); // Ensure next() is called even if an error occurs
}
}, unix);
}
function initHandler() {
function callback() {
init(); initHandler();
}
initHandlerTimeout = timeoutAligned(60000, callback);
}
function startRecorder() {
settings = modHS.getSettings();
if (initHandlerTimeout) clearTimeout(initHandlerTimeout);
if (BTHRM_ConnectCheck) clearInterval(BTHRM_ConnectCheck);
activeRecorders = []; //clear active recorders
recorders = getRecorders();
settings.record.forEach(r => {
var recorder = recorders[r];
if (!recorder) {
return;
}
var activeRecorder = recorder();
activeRecorder.start();
activeRecorders.push(activeRecorder);
});
if (settings.hasOwnProperty('fallDetect') && settings.fallDetect) {
Bangle.on('accel', fallDetectFunc);
} else {
Bangle.removeListener('accel', fallDetectFunc);
}
//BTHRM Additions
if (settings.record.includes('bthrm') && Bangle.hasOwnProperty("isBTHRMConnected")) {
var BTHRMStatus = 0;
BTHRM_ConnectCheck = setInterval(function () {
if (Bangle.isBTHRMConnected() != BTHRMStatus) {
BTHRMStatus = Bangle.isBTHRMConnected();
WIDGETS["heatsuite"].draw();
}
}, 10000); //runs every 10 seconds
}
updateBLEAdvert(lastBLEAdvert);
initHandler();
if (settings.highAcc !== undefined && settings.highAcc) {
perSecAcc(settings.highAcc);
}
if(settings.swipeOpen !== undefined && settings.swipeOpen){
Bangle.on('swipe', function(dir) {
if (dir === 1) { // 1 = right swipe
Bangle.buzz();
require("widget_utils").hide();
Bangle.load('heatsuite.app.js');
}
});
}
}
startRecorder();
gpsHandler();
function writeSetTimeout() {
if (dataLog.length > 0) {
queueProcess((next, unix) => {
writeLog();
next();
},0);
}
if (gpsLog.length > 0) {
//writeGPS();
queueProcess((next, unix) => {
writeGPS();
next();
},0);
}
setTimeout(writeSetTimeout, 5000);
}
//log writer/checker
writeSetTimeout();
//widget stuff
var iconWidth = 44;
function draw() {
g.reset();
g.setColor(cache.taskQueue === undefined ? "#fff" : cache.taskQueue.length > 0 ? "#f00" : "#0f0");
g.setFontAlign(0, 0);
g.fillRect({ x: this.x, y: this.y, w: this.x + iconWidth - 1, h: this.y + 23, r: 8 });
g.setColor(-1);
g.setFont("Vector", 12);
g.drawImage(atob("FBfCAP//AADk+kPKAAAoAAAAAKoAAAAAKAAAAFQoFQAAVTxVAFQVVVQVRABVABFVEBQEVQBUABUAAFUAVQCoFVVUKogAVQAiqBVVVCoAVQBVAABUABUAVRAUBFVEAFUAEVQVVVQVAFU8VQAAVCgVAAAAKAAAAACqAAAAACgAAA=="), this.x + 1, this.y + 1);
g.setColor((Bangle.hasOwnProperty("isBTHRMConnected") && Bangle.isBTHRMConnected()) ? "#00F" : "#0f0");
g.drawImage(atob("EhCCAAKoAqgCqqiqqCqqqqqqqqqqqqqqqqqqqqqqqqqqqqqiqqqqqAqqqqoAKqqqgACqqqAAAqqoAAAKqgAAACqAAAAAoAAAAAoAAA=="), this.x + 22, this.y + 3);
}
WIDGETS.heatsuite = {
area: 'tr',
width: iconWidth,
draw: draw,
changed: function () {
startRecorder();
WIDGETS["heatsuite"].draw();
}
};
//Diagnosing BLUETOOTH Connection Issues
//for managing memory issues - keeping code here for testing purposes in the future
if (NRF.getSecurityStatus().connected) { //if widget starts while a bluetooth connection exits, force connection flag - but this is
//connectionLock = true;
}
NRF.on('error', function (msg) {
modHS.log("[NRF][ERROR] " + msg);
});
NRF.on('connect', function (addr) {
//connectionLock = true;
modHS.log("[NRF][CONNECTED] " + JSON.stringify(addr));
});
NRF.on('disconnect', function (reason) {
connectionLock = false;
cache = modHS.getCache(); //update cache each disconnect
if (lastBLEAdvert) {
updateBLEAdvert(lastBLEAdvert); //update this if its changed with cache update
}
processNextInQueue();
modHS.log("[NRF][DISCONNECT] " + JSON.stringify(reason));
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
apps/heatsuite/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,30 @@
{ "id": "heatsuite",
"name": "HeatSuite",
"shortName":"HeatSuite",
"version":"0.10",
"description": "The smartwatch software to integrate with HeatSuite",
"icon": "icon.png",
"type": "app",
"tags": "health,tool",
"supports" : ["BANGLEJS2"],
"readme": "README.md",
"dependencies" : { "bthrm":"app", "gpssetup" : "app", "coretemp":"app"},
"customConnect": true,
"custom": "custom.html",
"storage": [
{"name":"heatsuite.img","url":"app-icon.js","evaluate":true},
{"name":"HSModule","url":"heatsuite.module.js"},
{"name":"heatsuite.app.js","url":"heatsuite.app.js"},
{"name":"heatsuite.settings.js","url":"heatsuite.settings.js"},
{"name":"heatsuite.wid.js","url":"heatsuite.wid.js"},
{"name":"heatsuite.survey.js","url":"heatsuite.survey.js"},
{"name":"heatsuite.bletemp.js","url":"heatsuite.bletemp.js"},
{"name":"heatsuite.bp.js","url":"heatsuite.bp.js"},
{"name":"heatsuite.mass.js","url":"heatsuite.mass.js"},
{"name":"heatsuite.urine.js","url":"heatsuite.urine.js"}
],
"data": [
{"name":"heatsuite.settings.json"},
{"name":"heatsuite.cache.json"}
]
}

2
core

@ -1 +1 @@
Subproject commit ef99424a9fbd01be504841a6c759ba4292a542f7
Subproject commit 76b887dd6fc5693786fc1e63b97f3e4ab4306ed7