new app: heatsuite

master
nravanelli 2025-04-25 10:57:17 +08:00
parent f6b8aa5942
commit 97e55d22cb
18 changed files with 2939 additions and 0 deletions

1
.gitignore vendored
View File

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

6
apps/heatsuite/ChangeLog Normal file
View File

@ -0,0 +1,6 @@
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)

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

@ -0,0 +1,75 @@
# 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)
## 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,z,y` 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/nravaneli)

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("ksqwcBkmSpICCmVCAoYCGyckyALIAQMmochDROSskmFJeZkmEBZEAhMmpFJkEABwtAgECpgaBMIMSBwmApEEyMJkkCoApFgGAhMiiVIAoILCAoIjCgFAggDBggIBEwICByEJB4UEDQJHBhAFCI4QaBAoZcCFIJHEAogjCI4QjBKYJoCgAODNAJKCBYQABRIY1BAoZTCNwhTBNARNEAQUBSQeQAoILC5LXBF4WAhADBXIVMEQJQBRgcJAoNkyUZhOQNAoFC5AaBiYpBI4cBAoWQeQOSoVJgmQFIQFByZJBBwK2CgEQKAMADoPJEAbdCa4ZuHkBuDXgiwCgS2FAoaqDgQFBWwQACiC/CEwSwDDoQyBI4i2DAoQsBEwIIBCgIFDJoIpBEYIFBGQQdCOISDBBQRWCJQjdFojdGAQUCoEkxBQBJoiSDFgLUBoApCBwhHBXgJxCBYgCDmVJNwYCGychmRNDAQ/IsgLJAQJJBDRWSsmEBAo"))

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

@ -0,0 +1,553 @@
<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
<link rel="stylesheet" href="../../css/spectre-icons.min.css">
<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
</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-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>
</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>
//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.value);
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){
Util.readStorageFile(filename, (c)=>{
let url;
const a = document.createElement('a');
fnArr = filename.split('_');
if(fnArr[1] !== 'accel'){
const blob = new Blob([c], { type: 'text/plain' });
url = URL.createObjectURL(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"; // Header
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; //scaled differently
let time = secondsToClock(seconds);
csv += `${time},${seconds},${avg},${sum}\n`;
}
const blob = new Blob([csv], { type: 'text/csv' });
url = URL.createObjectURL(blob);
}
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
return;
});
}
function downloadAllFiles(){
}
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 renderDownloadTabLogs(e){
var element = document.getElementById("downloadData-tab-content");
if(e.length > 0){
}else{
e.innerHTML = "No files to download.";
}
}
function renderDownloadTab(e){
var element = document.getElementById("downloadData-tab-content");
var tab_badge = document.getElementById("downloadData-tab-label");
if(e.length > 0){
tab_badge.classList.add("badge");
tab_badge.dataset.badge = e.length;
element.innerHTML = "";
e.forEach((file)=>{
var filename = file.replace(/\x01$/, '');
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">Download All <i class="icon icon-download" onclick="DownloadAllFiles(JSON.parse("${e}"));"></i></button></td><td><button class="btn">Delete All <i class="icon icon-delete" onclick=""></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,184 @@
{
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() {
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 => taskButtonInterpretter("Bangle.load('heatsuite.settings.js');");
}
}
//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
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();
queueNRFFindDeviceTimeout();
queueTaskScreenTimeout();
}
Bangle.setLocked(false); //unlock screen!
Bangle.loadWidgets();
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 @@
require("heatsuite").enable();

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,350 @@
(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();
}
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' };
Object.keys(settings.StudyTasks).forEach(key => {
var details = settings.StudyTasks[key];
if (details.btPair === undefined || !details.btPair) return;
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, settings.StudyTasks[key].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['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,811 @@
(() => {
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) bt_rr.push(h.rr);
}
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 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);
}
hsi = newValueHandler(hsi, h.hsi);
core_bat = h.battery;
unit = h.unit;
}
return {
name: "CORESensor",
fields: ["core", "skin", "unit", "core_hr", "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), 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 };
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', 'alert', { 'type': '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);
}
}
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();
if (cache.taskQueue !== undefined) (cache.taskQueue.length > 0 ? g.setColor("#f00") : g.setColor("#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();
}
};
Bangle.on('tap', function(data) {
if(data.double && data.dir === "front" && !Bangle.isCharging()){
require("widget_utils").hide();
Bangle.load('heatsuite.app.js');
}
});
//Diagnosing BLUETOOTH Connection Issues
if (NRF.getSecurityStatus().connected) { //if widget starts while a bluetooth connection exits, need to force connection flag
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));
});
})();

BIN
apps/heatsuite/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,31 @@
{ "id": "heatsuite",
"name": "HeatSuite",
"shortName":"HeatSuite",
"version":"0.09",
"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.boot.js","url":"heatsuite.boot.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"}
]
}