diff --git a/apps/heatsuite/ChangeLog b/apps/heatsuite/ChangeLog index 32b6dbd091..a6240a5cd2 100644 --- a/apps/heatsuite/ChangeLog +++ b/apps/heatsuite/ChangeLog @@ -18,4 +18,7 @@ `heatsuite.app.js` made scanning faster for devices (1s scan time) `heatsuite.surveylib.js` changed to be more standalone, with more UI options (see docs) 0.13: `heatsuite.surveylib.js` added a right hand scroll bar for questions where the responses exceed the screen space - `heatsuite.wid.js` fixed charging notification issue \ No newline at end of file + `heatsuite.wid.js` fixed charging notification issue +0.14: Package starter (empty) tasks/survey data + Harden blood pressure pairing, measurement parsing, and result handling + Improve recorder and BLE lifecycle coordination diff --git a/apps/heatsuite/README.md b/apps/heatsuite/README.md index 6a88e0df9f..4edf638f6a 100644 --- a/apps/heatsuite/README.md +++ b/apps/heatsuite/README.md @@ -51,6 +51,13 @@ The `Searching...` text at the bottom of the screen shows that the watch is sear _Note: This will only show when a bluetooth device is associated with a task._ +## CORE Sensor recording + +HeatSuite installs CoreTemp as the CORE BLE runtime provider, but CORE recording +is off by default. Enable the `CORE Sensor` recorder in HeatSuite settings when +CORE samples should be included in HeatSuite minute data. HeatSuite does not +turn on CoreTemp's standalone background `Enable` setting. + ## 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. diff --git a/apps/heatsuite/custom.html b/apps/heatsuite/custom.html index b11cfc4111..9622d0f95f 100644 --- a/apps/heatsuite/custom.html +++ b/apps/heatsuite/custom.html @@ -180,6 +180,17 @@ const STORAGEFILE_SUFFIX = ' (SF)'; let HeatSuiteFileList = []; //default Schema + let heatsuite__settings_defaultSchema = { + "DEBUG": false, + "SAVE_DEBUG": false, + "notifications": true, + "record": ["bat", "steps", "hrm", "baro", "acc"], + "filePrefix": "htst", + "GPS": true, + "GPSAdaptiveTime": 2, + "GPSInterval": 30, + "GPSScanTime": 5 + }; let heatsuite__taskFile_defaultSchema = [ { "id": "survey", @@ -609,14 +620,11 @@ } } function onInit(device) { - let settings = {}; + let settings = Object.assign({}, heatsuite__settings_defaultSchema); let promise = Promise.resolve(); promise = promise.then(() => { - return readStorageJSONAsync("heatsuite.default.json"); //retaining to keep it compatible with older versions - }); - promise = promise.then(defaults => { return readStorageJSONAsync("heatsuite.settings.json").then(user => { - settings = Object.assign({}, defaults, user); + settings = Object.assign({}, heatsuite__settings_defaultSchema, user); }); }); promise = promise.then(() => { diff --git a/apps/heatsuite/heatsuite.app.js b/apps/heatsuite/heatsuite.app.js index 8422734808..0543ffdb47 100644 --- a/apps/heatsuite/heatsuite.app.js +++ b/apps/heatsuite/heatsuite.app.js @@ -1,6 +1,7 @@ { let studyTasksJSON = "heatsuite.tasks.json"; -let studyTasks = require('Storage').readJSON(studyTasksJSON, true) || {}; +let studyTasks = require('Storage').readJSON(studyTasksJSON, true) || []; +if (!Array.isArray(studyTasks)) studyTasks = []; let Layout = require("Layout"); let modHS = require("HSModule"); @@ -11,6 +12,71 @@ let settings = modHS.getSettings(); let appCache = modHS.getCache(); +function safeStringify(value) { + try { + return JSON.stringify(value); + } catch (e) { + return String(value); + } +} + +function byteToHex(value) { + let out = (value & 0xFF).toString(16); + return out.length < 2 ? "0" + out : out; +} + +function bufferToHex(buffer) { + if (!buffer) return ""; + let arr = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let bytes = []; + for (let i = 0; i < arr.length; i++) bytes.push(byteToHex(arr[i])); + return bytes.join(" "); +} + +function serviceDataToHex(serviceData) { + if (!serviceData) return "{}"; + let out = {}; + Object.keys(serviceData).forEach(k => { + out[k] = bufferToHex(serviceData[k]); + }); + return safeStringify(out); +} + +function log() { + let parts = []; + for (let i = 0; i < arguments.length; i++) parts.push(String(arguments[i])); + modHS.log(parts.join(" ")); +} + +function logScanDevice(d) { + log("[Scan] device", + "id=" + d.id, + "name=" + (d.name || ""), + "rssi=" + d.rssi, + "services=" + safeStringify(d.services || []), + d.data ? "payload=" + bufferToHex(d.data) : "", + d.serviceData ? "serviceData=" + serviceDataToHex(d.serviceData) : ""); +} + +function stopBLEDevices() { + if (global.WIDGETS && WIDGETS["heatsuite"] && WIDGETS["heatsuite"].stopBLEDevices) { + return Promise.resolve(WIDGETS["heatsuite"].stopBLEDevices()); + } + return Promise.resolve(); +} + +function loadTaskApp(app) { + NRF.setScan(); + + stopBLEDevices().then(function () { + NRF.setScan(); + Bangle.load(app); + }).catch(function (e) { + modHS.log("Failed to stop BLE before task: " + e); + Bangle.load(app); + }); +} + function queueNRFFindDeviceTimeout() { if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); NRFFindDeviceTimeout = setTimeout(function () { @@ -25,17 +91,15 @@ function findBtDevices() { let found = false; if (devices.length !== 0) { devices.some((d) => { - modHS.log("Found device", d); + logScanDevice(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); - WIDGETS['heatsuite'].stopBLEDevices(); - Bangle.load('heatsuite.bp.js'); + loadTaskApp('heatsuite.bp.js'); return true; } else if (services !== undefined && (services.includes('181b') || services.includes('181d')) && studyTasks.some(task => task.id === "bodyMass")) { if (services.includes('181b')) { @@ -76,8 +140,7 @@ function findBtDevices() { layout.msg.label = "Scale Found"; layout.render(); if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); - WIDGETS['heatsuite'].stopBLEDevices(); - Bangle.load('heatsuite.mass.js'); + loadTaskApp('heatsuite.mass.js'); return true; } else if (services !== undefined && services.includes('1809') && d.id === settings.bt_coreTemperature_id) { //Core Temperature @@ -85,8 +148,7 @@ function findBtDevices() { layout.msg.label = "Temp Found"; layout.render(); if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); - WIDGETS['heatsuite'].stopBLEDevices(); - Bangle.load('heatsuite.bletemp.js'); + loadTaskApp('heatsuite.bletemp.js'); return true; } }); @@ -193,16 +255,28 @@ function draw() { if (row.c.length > 0) { layoutOut.c.push(row); } - //Final + //Final if(btRequired) layoutOut.c.push({ type: "txt", font: "6x8:2", label: "Searching...", id: "msg", fillx: 1 }); - let options = { + let options = { lazy: true, btns:[{label:"Exit", cb: l=>Bangle.showClock() }] }; - + layout = new Layout(layoutOut, options); layout.render(); - if(btRequired) queueNRFFindDeviceTimeout(); + + if (btRequired) { + log("[BLE preflight] stopping BLE before task scan"); + + stopBLEDevices().then(function () { + log("[BLE preflight] complete, starting scan loop"); + queueNRFFindDeviceTimeout(); + }).catch(function (e) { + modHS.log("[BLE preflight] failed: " + e); + queueNRFFindDeviceTimeout(); + }); + } + queueTaskScreenTimeout(); } diff --git a/apps/heatsuite/heatsuite.bp.js b/apps/heatsuite/heatsuite.bp.js index 2fba5af69a..532a9c7076 100644 --- a/apps/heatsuite/heatsuite.bp.js +++ b/apps/heatsuite/heatsuite.bp.js @@ -2,186 +2,474 @@ 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; + +var BP_SERVICE_UUID = "1810"; +var BP_DATE_TIME_UUID = "2A08"; +var BP_MEASUREMENT_UUID = "2A35"; +var BP_CONNECT_SETTLE_MS = 2500; +var BP_MEASUREMENT_TIMEOUT_MS = 120000; +var BP_INDICATION_IDLE_EXIT_MS = 2000; +var BP_EXIT_DELAY_MS = 3000; +var BP_PAIRING_ERROR = "BP cuff is not paired. Pair in Settings with START held until PR."; + +function isBPSecurityError(e) { + var msg = (e && e.message) ? e.message : String(e); + msg = msg.toLowerCase(); + return msg.indexOf("security") >= 0 || + msg.indexOf("auth") >= 0 || + msg.indexOf("encrypt") >= 0 || + msg.indexOf("bond") >= 0 || + msg.indexOf("pair") >= 0 || + msg.indexOf("insufficient") >= 0; +} + +function debugEnabled() { + return !!(settings.DEBUG || settings.SAVE_DEBUG); +} + +function log() { + if (!debugEnabled()) return; + var parts = []; + for (var i = 0; i < arguments.length; i++) { + parts.push(String(arguments[i])); } - if (flags & 0x04) { - result.hr = buf[index]; - index += 2; + if (modHS.log) modHS.log(parts.join(" ")); + else console.log(parts.join(" ")); +} + +function safeStringify(value) { + try { + return JSON.stringify(value); + } catch (e) { + return String(value); } - if (flags & 0x08) { - index += 1; +} + +function byteToHex(value) { + var out = (value & 0xFF).toString(16); + return out.length < 2 ? "0" + out : out; +} + +function dataViewToHex(data) { + var bytes = []; + for (var i = 0; i < data.byteLength; i++) { + bytes.push(byteToHex(data.getUint8(i))); } - 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 bytes.join(" "); +} + +function getSecurityStatus(device) { + if (!device || !device.getSecurityStatus) return {}; + try { + return device.getSecurityStatus() || {}; + } catch (e) { + log("BP security status failed", e); + return {}; } - return result; } -function getBP(id) { +function logSecurityStatus(label, device) { + log(label, safeStringify(getSecurityStatus(device))); +} + +function showMessage(title, msg) { layout = new Layout({ type: "v", c: [ { type: "h", c: [ - { type: "txt", font: "6x8:2", label: "Blood Pressure", fillx: 1 }, + { type: "txt", font: "6x8:2", label: title, fillx: 1, wrap: true }, ] }, { type: "h", c: [ - { type: "txt", font: "6x8:2", label: "Waiting...", fillx: 1 }, + { type: "txt", font: "6x8:1", label: msg || "", fillx: 1, wrap: true }, ] } ] }); 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"); +} + +function showWaiting() { + showMessage("Blood Pressure", "Waiting..."); +} + +function showSavedResult(receivedData, savedCount) { + var savedLabel = savedCount > 1 ? "Saved x" + savedCount : "Saved!"; + 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 === null ? "--" : receivedData.hr, fillx: 1 }, + { type: "txt", font: "12x20:2", label: "BPM", fillx: 1 }, + ] + }, + { + type: "h", c: [ + { type: "txt", font: "12x20:2", label: savedLabel, fillx: 1 } + ] + }, + ] + }); + g.clear(); + layout.render(); +} + +function decodeSFloat16(raw) { + raw = raw & 0xFFFF; + if (raw === 0x07FE) return Infinity; + if (raw === 0x0802) return -Infinity; + if (raw === 0x07FF || raw === 0x0800 || raw === 0x0801) return null; + + var mantissa = raw & 0x0FFF; + var exponent = raw >> 12; + if (mantissa >= 0x0800) mantissa -= 0x1000; + if (exponent >= 0x08) exponent -= 0x10; + var value = mantissa * Math.pow(10, exponent); + return Math.round(value * 1000000) / 1000000; +} + +function requireBytes(data, index, count, label) { + if (index + count > data.byteLength) { + throw new Error("Truncated BP measurement: " + label); + } +} + +function readSFloat(data, index, label) { + requireBytes(data, index, 2, label); + return decodeSFloat16(data.getUint16(index, true)); +} + +function parseBPMeasurement(data, deviceId) { + requireBytes(data, 0, 1, "flags"); + var flags = data.getUint8(0); + var index = 1; + var result = { + "deviceId": deviceId || null, + "rawFlags": flags, + "unit": (flags & 0x01) ? "kPa" : "mmHg", + "sbp": null, + "dbp": null, + "map": null, + "hr": null, + "year": null, + "month": null, + "day": null, + "hour": null, + "minute": null, + "second": null, + "userId": null, + "moved": null, + "cuffLoose": null, + "irregularPulse": null, + "improperMeasure": null, + "bodyMovementDetected": null, + "measurementPositionImproper": null + }; + + result.sbp = readSFloat(data, index, "systolic"); + index += 2; + result.dbp = readSFloat(data, index, "diastolic"); + index += 2; + result.map = readSFloat(data, index, "mean arterial pressure"); + index += 2; + + if (flags & 0x02) { + requireBytes(data, index, 7, "timestamp"); + result.year = data.getUint16(index, true); + result.month = data.getUint8(index + 2); + result.day = data.getUint8(index + 3); + result.hour = data.getUint8(index + 4); + result.minute = data.getUint8(index + 5); + result.second = data.getUint8(index + 6); + index += 7; + } + if (flags & 0x04) { + result.hr = readSFloat(data, index, "pulse rate"); + index += 2; + } + if (flags & 0x08) { + requireBytes(data, index, 1, "user id"); + result.userId = data.getUint8(index); + index += 1; + } + if (flags & 0x10) { + requireBytes(data, index, 2, "measurement status"); + var status = data.getUint16(index, true); + result.moved = (status & 0x0001) ? 1 : 0; + result.bodyMovementDetected = result.moved; + result.cuffLoose = (status & 0x0002) ? 1 : 0; + result.irregularPulse = (status & 0x0004) ? 1 : 0; + result.improperMeasure = (status & 0x0020) ? 1 : 0; + result.measurementPositionImproper = result.improperMeasure; + index += 2; + } + + return result; +} + +function buildDateTimePayload(date) { + var arr = new Uint8Array(7); + var v = new DataView(arr.buffer); + 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(6, date.getSeconds()); + return arr; +} + +function trySyncDeviceTime(service) { + return service.getCharacteristic(BP_DATE_TIME_UUID).then(function (characteristic) { + return characteristic.writeValue(buildDateTimePayload(new Date())).then(function () { + log("BP time sync complete"); return true; - } else { - log("Start bonding"); - return device.startBonding(); - } - }).then(function () { - device.device.on('gattserverdisconnected', function (reason) { + }); + }).catch(function (e) { + log("BP time sync skipped", e); + return false; + }); +} + +function disconnectDevice(device) { + if (!device || !device.disconnect) return; + if (device.connected === false) return; + try { + device.disconnect(); + } catch (e) { + log("BP disconnect failed", e); + } +} + +function exitSoon(delay) { + setTimeout(function () { + Bangle.load(); + }, delay || BP_EXIT_DELAY_MS); +} + +function getBP(id) { + if (!id) { + log("BP start failed: no paired device id"); + showMessage("ERROR!", "No BP device paired"); + exitSoon(); + return Promise.resolve(false); + } + + showWaiting(); + log("BP start", id); + var device; + var measurementTimeout; + var indicationIdleTimeout; + var finished = false; + var savedCount = 0; + var measurementReady = false; + var disconnectedBeforeReady = false; + var lastReceivedData = null; + var resultPromptTimeout = null; + + function showResultPrompt(text) { + if (resultPromptTimeout) clearTimeout(resultPromptTimeout); + resultPromptTimeout = setTimeout(function () { + resultPromptTimeout = null; + Bangle.load(); + }, 10000); + E.showPrompt(text, { title: "BP Result", buttons: { "OK": true } }).then(function () { + if (resultPromptTimeout) { + clearTimeout(resultPromptTimeout); + resultPromptTimeout = null; + } 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]; + } + + function clearMeasurementTimeout() { + if (measurementTimeout) { + clearTimeout(measurementTimeout); + measurementTimeout = undefined; } - 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 } - ] - }, - ] + } + + function clearIndicationIdleTimeout() { + if (indicationIdleTimeout) { + clearTimeout(indicationIdleTimeout); + indicationIdleTimeout = undefined; + } + } + + function clearTimeouts() { + clearMeasurementTimeout(); + clearIndicationIdleTimeout(); + } + + function finishSuccess() { + if (finished) return; + finished = true; + clearTimeouts(); + log("BP finish success", "saved=" + savedCount); + disconnectDevice(device); + var resultText = "Saved!"; + if (lastReceivedData) { + resultText = lastReceivedData.sbp + "/" + lastReceivedData.dbp + " mmHg\n" + + (lastReceivedData.hr !== null ? lastReceivedData.hr + " BPM" : "") + + (savedCount > 1 ? "\nSaved x" + savedCount : ""); + } + showResultPrompt(resultText); + } + + function scheduleFinishAfterIdle() { + clearIndicationIdleTimeout(); + log("BP idle exit scheduled", BP_INDICATION_IDLE_EXIT_MS); + indicationIdleTimeout = setTimeout(finishSuccess, BP_INDICATION_IDLE_EXIT_MS); + } + + function fail(e) { + clearTimeouts(); + disconnectDevice(device); + var msg = e && e.message ? e.message : String(e); + log("BP failed", msg); + showMessage("ERROR!", msg); + exitSoon(); + } + + function attachDisconnectHandler() { + if (device.device && device.device.on) { + device.device.on('gattserverdisconnected', function (reason) { + if (device) device.connected = false; + log("BP disconnected", reason); + if (!finished) { + if (!measurementReady && savedCount === 0) { + disconnectedBeforeReady = true; + return; + } + finished = true; + clearTimeouts(); + if (savedCount > 0) { + disconnectDevice(device); + var dcText = lastReceivedData + ? lastReceivedData.sbp + "/" + lastReceivedData.dbp + " mmHg\n" + + (lastReceivedData.hr !== null ? lastReceivedData.hr + " BPM" : "") + + (savedCount > 1 ? "\nSaved x" + savedCount : "") + : "Saved!"; + showResultPrompt(dcText); + return; + } + showMessage("ERROR!", "BP disconnected"); + exitSoon(); + } }); - g.clear(); - layout.render(); + } + } + + function connectDevice() { + disconnectedBeforeReady = false; + if (NRF.setScan) { + log("BP stop active scan before connect"); + NRF.setScan(); + } + log("BP connect start", id); + return NRF.connect(id).then(function (d) { + device = d; + attachDisconnectHandler(); + log("BP connected", id); + logSecurityStatus("BP security after connect", device); + log("BP settle", BP_CONNECT_SETTLE_MS); + return new Promise(function (resolve) { + setTimeout(resolve, BP_CONNECT_SETTLE_MS); + }); + }).then(function () { + if (disconnectedBeforeReady || (device && device.connected === false)) { + throw new Error("Disconnected"); + } + var security = getSecurityStatus(device); + log("BP security after settle", safeStringify(security)); + if (security && security.bonded === false) { + throw new Error(BP_PAIRING_ERROR); + } + if (security && security.bonded) log("BP already bonded"); + return device; }); - 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 }, - ] + } + + function subscribeToMeasurement() { + if (!device || device.connected === false || disconnectedBeforeReady) { + throw new Error("Disconnected"); + } + log("BP get service", BP_SERVICE_UUID); + return device.getPrimaryService(BP_SERVICE_UUID); + } + + function setupMeasurement() { + return subscribeToMeasurement().then(function (s) { + log("BP service ready", BP_SERVICE_UUID); + return trySyncDeviceTime(s).then(function () { + return s; + }); + }).then(function (s) { + log("BP get characteristic", BP_MEASUREMENT_UUID); + return s.getCharacteristic(BP_MEASUREMENT_UUID); + }).then(function (c) { + c.on('characteristicvaluechanged', function (event) { + if (finished) return; + try { + if (debugEnabled()) log("BP payload raw", dataViewToHex(event.target.value)); + var receivedData = parseBPMeasurement(event.target.value, id); + log("BP payload parsed", safeStringify(receivedData)); + modHS.saveDataToFile('bpres', 'bloodPressure', receivedData); + savedCount++; + lastReceivedData = receivedData; + log("BP saved", "count=" + savedCount); + clearMeasurementTimeout(); + showSavedResult(receivedData, savedCount); + scheduleFinishAfterIdle(); + } catch (e) { + finished = true; + fail(e); } - ] + }); + log("BP start notifications", BP_MEASUREMENT_UUID); + return c.startNotifications().then(function () { + log("BP notifications started", BP_MEASUREMENT_UUID); + }); }); - g.clear(); - layout.render(); - if (!device.connected) { - getBP(id); - } + } + + function normalizeSetupError(e) { + if (!isBPSecurityError(e)) throw e; + throw new Error(BP_PAIRING_ERROR); + } + + return connectDevice().then(setupMeasurement).catch(normalizeSetupError).then(function () { + if (finished) return false; + measurementReady = true; + log("BP waiting for measurement notifications"); + log("BP measurement timeout scheduled", BP_MEASUREMENT_TIMEOUT_MS); + measurementTimeout = setTimeout(function () { + if (finished) return; + finished = true; + fail(new Error("BP measurement timeout")); + }, BP_MEASUREMENT_TIMEOUT_MS); + return true; + }).catch(function (e) { + if (finished) return false; + finished = true; + fail(e); + return false; }); } -var macID = settings.bt_bloodPressure_id.split(" "); -setTimeout(() => { getBP(macID[0]) }, 2000); \ No newline at end of file + +function startBP() { + settings = modHS.getSettings(); + var id = settings.bt_bloodPressure_id; + return getBP(id); +} + +setTimeout(startBP, 2000); diff --git a/apps/heatsuite/heatsuite.module.js b/apps/heatsuite/heatsuite.module.js index e9a413afb6..6baf7184f1 100644 --- a/apps/heatsuite/heatsuite.module.js +++ b/apps/heatsuite/heatsuite.module.js @@ -1,14 +1,40 @@ +var DEFAULT_SETTINGS = { + DEBUG: false, + SAVE_DEBUG: false, + notifications: true, + record: ["bat", "steps", "hrm", "baro", "acc"], + filePrefix: "htst", + GPS: true, + GPSAdaptiveTime: 2, + GPSInterval: 30, + GPSScanTime: 5 +}; + +function _copySettings(settings) { + var out = Object.assign({}, settings); + if (Array.isArray(out.record)) out.record = out.record.slice(); + return out; +} + +function _getDefaultSettings() { + return _copySettings(DEFAULT_SETTINGS); +} + function _getSettings() { var out = Object.assign( - //require('Storage').readJSON("heatsuite.default.json", true) || {}, + _getDefaultSettings(), require('Storage').readJSON("heatsuite.settings.json", true) || {} ); - out.StudyTasks = require('Storage').readJSON("heatsuite.tasks.json", true) || {}; + out.StudyTasks = require('Storage').readJSON("heatsuite.tasks.json", true) || []; + if (!Array.isArray(out.record)) out.record = []; + if (!Array.isArray(out.StudyTasks)) out.StudyTasks = []; return out; } function _checkFileHeaders(filename,header){ var storageFile = require("Storage").open(filename, "r"); - var headers = storageFile.readLine().trim(); + var headers = storageFile.readLine(); + if (!headers) return false; + headers = headers.trim(); var headerString = header.join(","); if(headers === headerString){ return true; @@ -129,7 +155,7 @@ function _getBinaryFile(type, header, requestedOffset, requestedBytes){ const recordSize = dv.getUint16(3, true); const intervalSec = dv.getUint16(5, true); let maxRecords = settings.AccelBinMaxRecords|0; - if (maxRecords <= 0) maxRecords = 6000; + if (maxRecords <= 0) maxRecords = 6000; const capacity = headerLen + (maxRecords * recordSize); let fileName = c.binFiles[type]; if (!(fileName && Storage.read(fileName) !== undefined && _validateExistingBinary(fileName, header))) { @@ -139,7 +165,7 @@ function _getBinaryFile(type, header, requestedOffset, requestedBytes){ for (let i=1; Storage.read(fileName) !== undefined; i++) { fileName = `${settings.filePrefix}_${type}_${startUnix}_${i}.raw`; } - Storage.write(fileName, header, 0, capacity); + Storage.write(fileName, header, 0, capacity); c.binFiles[type] = fileName; _writeCache(c); } @@ -154,7 +180,7 @@ function _getBinaryFile(type, header, requestedOffset, requestedBytes){ c.binFiles[type] = newName; _writeCache(c); fileName = newName; - requestedOffset = headerLen; + requestedOffset = headerLen; } // --- clamp write size to remaining capacity --- const remainingBytes = Math.max(0, capacity - requestedOffset); @@ -292,7 +318,7 @@ function _parseBLEData(buffer, dataSchema) { const exponent = (mantissa >> 11) & 0x0F; const fraction = mantissa & 0x7FF; value = sign * (1 + fraction / 2048) * Math.pow(2, exponent - 15); - offset += 2; + offset += 2; break; } default: @@ -317,6 +343,7 @@ function _log(msg) { } } exports = { + getDefaultSettings: _getDefaultSettings, getSettings: _getSettings, getRecordFile: _getRecordFile, saveDataToFile: _saveDataToFile, @@ -330,4 +357,4 @@ exports = { updateTaskQueue: _updateTaskQueue, parseBLEData: _parseBLEData, log: _log, -}; \ No newline at end of file +}; diff --git a/apps/heatsuite/heatsuite.settings.js b/apps/heatsuite/heatsuite.settings.js index e17b31091f..8de6818bad 100644 --- a/apps/heatsuite/heatsuite.settings.js +++ b/apps/heatsuite/heatsuite.settings.js @@ -1,16 +1,90 @@ (function (back) { + var modHS = require('HSModule'); var settingsJSON = "heatsuite.settings.json"; var studyTasksJSON = "heatsuite.tasks.json"; + var defaultSettings = modHS.getDefaultSettings ? modHS.getDefaultSettings() : {}; + var BP_SERVICE_UUID = "1810"; + var BP_DATE_TIME_UUID = "2A08"; - function log(msg) { - if (!settings.DEBUG) { - return; + function log() { + if (!settings.DEBUG && !settings.SAVE_DEBUG) return; + var parts = []; + for (var i = 0; i < arguments.length; i++) parts.push(String(arguments[i])); + if (modHS.log) { + modHS.log(parts.join(" ")); } else { - console.log(msg); + console.log(parts.join(" ")); } } + function safeStringify(value) { + try { + return JSON.stringify(value); + } catch (e) { + return String(value); + } + } + + function byteToHex(value) { + var out = (value & 0xFF).toString(16); + return out.length < 2 ? "0" + out : out; + } + + function bufferToHex(buffer) { + if (!buffer) return ""; + var arr = new Uint8Array(buffer); + var bytes = []; + for (var i = 0; i < arr.length; i++) bytes.push(byteToHex(arr[i])); + return bytes.join(" "); + } + + function getSecurityStatus(device) { + if (!device || !device.getSecurityStatus) return {}; + try { + return device.getSecurityStatus() || {}; + } catch (e) { + log("[BP Pair] Security status failed", e); + return {}; + } + } + + function logSecurityStatus(label, device) { + log(label, safeStringify(getSecurityStatus(device))); + } + + function logScanDevice(type, device) { + log("[Scan]", type, "id=" + device.id, "name=" + (device.name || ""), "rssi=" + device.rssi, + "services=" + safeStringify(device.services || []), + device.data ? "payload=" + bufferToHex(device.data) : ""); + } + + function buildDateTimePayload(date) { + var arr = new Uint8Array(7); + var v = new DataView(arr.buffer); + 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(6, date.getSeconds()); + return arr; + } + + function trySyncBPDeviceTime(device) { + return device.getPrimaryService(BP_SERVICE_UUID).then(function (service) { + return service.getCharacteristic(BP_DATE_TIME_UUID); + }).then(function (characteristic) { + return characteristic.writeValue(buildDateTimePayload(new Date())).then(function () { + log("[BP Pair] Time sync complete"); + return true; + }); + }).catch(function (e) { + log("[BP Pair] Time sync skipped", e); + return false; + }); + } + function writeSettings(key, value) { var s = require('Storage').readJSON(settingsJSON, true) || {}; s[key] = value; @@ -19,86 +93,108 @@ if (global.WIDGETS && WIDGETS["heatsuite"]) WIDGETS["heatsuite"].changed(); //redraw widget on settings update if open } + function getWidget() { + return global.WIDGETS && WIDGETS["heatsuite"]; + } + + function stopBLEDevices() { + var widget = getWidget(); + if (widget && widget.stopBLEDevices) return Promise.resolve(widget.stopBLEDevices()); + return Promise.resolve(); + } + + function startBLEDevices() { + var widget = getWidget(); + if (widget && widget.startBLEDevices) return Promise.resolve(widget.startBLEDevices()); + return Promise.resolve(); + } + function readSettings() { var out = Object.assign( - //require('Storage').readJSON("heatsuite.default.json", true) || {}, + {}, + defaultSettings, require('Storage').readJSON(settingsJSON, true) || {} ); - out.StudyTasks = require('Storage').readJSON(studyTasksJSON, true) || {}; + out.StudyTasks = require('Storage').readJSON(studyTasksJSON, true) || []; + if (!Array.isArray(out.record)) out.record = []; + if (!Array.isArray(out.StudyTasks)) out.StudyTasks = []; return out; } var settings = readSettings(); /*---- PAIRING FUNCTIONS FOR DEVICES ----*/ - function BPPair(id) { + function BPPair(id, name) { 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 () { + function attachDisconnectLog() { + if (!device || !device.device || device.device._hsBPDisconnectLog) return; + device.device._hsBPDisconnectLog = true; device.device.on('gattserverdisconnected', function (reason) { - log("Disconnected ", reason); + log("[BP Pair] 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]; + } + function isBonded() { + var security = getSecurityStatus(device); + return !!(security && security.bonded); + } + function connect() { + log("[BP Pair] Connect start", id, name || ""); + return NRF.connect(id).then(function (d) { + device = d; + attachDisconnectLog(); + log("[BP Pair] Connected", id); + logSecurityStatus("[BP Pair] Security after connect", device); + return new Promise(resolve => setTimeout(resolve, 2000)); + }); + } + E.showMessage(`Pairing with\n${id}`, "Pair BP"); + connect().then(function () { + logSecurityStatus("[BP Pair] Security after settle", device); + if (isBonded()) { + log("[BP Pair] Already bonded"); + return true; + } else if (device.startBonding) { + log("[BP Pair] Start bonding"); + return device.startBonding().then(function (result) { + log("[BP Pair] Bonding resolved", result); + return result; + }); } - return characteristic.writeValue(arr); + throw new Error("Bonding is unavailable"); + }).then(function () { + logSecurityStatus("[BP Pair] Security after bonding", device); + if (!isBonded()) throw new Error("Pairing incomplete. Hold START until PR and try again."); + }).then(function () { + return trySyncBPDeviceTime(device); }).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); + if (name || (device && device.device && device.device.name)) { + writeSettings("bt_bloodPressure_name", name || device.device.name); } - E.showAlert("Paired!").then(function () { WIDGETS['heatsuite'].startBLEDevices(); E.showMenu(deviceSettings()) }); - log("Device ID paired, time set, Done!"); - return device.disconnect(); + E.showPrompt("Paired!", { title: "BP Device", buttons: { "OK": true } }).then(function () { startBLEDevices(); E.showMenu(deviceSettings()) }); + log("[BP Pair] Saved device id", id, name || ""); + if (device && device.connected !== false && device.disconnect) return device.disconnect(); }).catch(function (e) { - log(e); - E.showAlert("Error! " + e).then(() => {WIDGETS['heatsuite'].startBLEDevices();E.showMenu(deviceSettings())}); + log("[BP Pair] Error", e); + E.showAlert("Error! " + e).then(() => { startBLEDevices(); E.showMenu(deviceSettings()) }); }); } function PairTcore(id) { - E.showMessage(`Pairing /n ${id}`, "Bluetooth"); + E.showMessage(`Pairing with\n${id}`, "Bluetooth"); var gatt; NRF.connect(id).then(function (g) { gatt = g; - console.log("connected!!!"); + log("[CORE Pair] Connected", id); // return gatt.startBonding(); //}).then(function() { - console.log("bonded", gatt.getSecurityStatus()); + log("[CORE Pair] Security", safeStringify(gatt.getSecurityStatus ? gatt.getSecurityStatus() : {})); writeSettings("bt_coreTemperature_id", id); - E.showAlert("Paired!").then(function () { WIDGETS['heatsuite'].startBLEDevices(); E.showMenu(deviceSettings()) }); + E.showAlert("Paired!").then(function () { startBLEDevices(); E.showMenu(deviceSettings()) }); log("Device ID paired, Done!"); return gatt.disconnect(); }).catch(function (e) { log("ERROR: " + e); - E.showAlert("error! " + e).then(function () { WIDGETS['heatsuite'].startBLEDevices();E.showMenu(deviceSettings()) }); + E.showAlert("error! " + e).then(function () { startBLEDevices(); E.showMenu(deviceSettings()) }); }); } @@ -175,7 +271,7 @@ '': { 'title': 'Main' }, '< Back': back }; - + menu['Recorders'] = function () {E.showMenu(recordMenu()) }; menu['Devices'] = function () { E.showMenu(deviceSettings()) }; menu['GPS'] = function () { E.showMenu(gpsSettings()) }; @@ -245,7 +341,7 @@ var menu = { '': { 'title': 'Debug' }, '< Back': function () { E.showMenu(mainMenuSettings()); } - }; + }; menu['Console'] = { value: settings.DEBUG || false, onchange: v => { @@ -266,7 +362,7 @@ var menu = { '': { 'title': 'High Acc' }, '< Back': function () { E.showMenu(mainMenuSettings()); } - }; + }; menu['Interval'] = { value: settings.AccLogInt || 5, min: 1, max: 60, @@ -347,43 +443,52 @@ E.showMenu(); E.showMessage("Scanning for 4 seconds"); var submenu_scan = { - '< Back': function () {WIDGETS['heatsuite'].startBLEDevices(); E.showMenu(deviceSettings()); } + '< Back': function () { startBLEDevices(); E.showMenu(deviceSettings()); } }; - WIDGETS['heatsuite'].stopBLEDevices(); - 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; - default: - E.showMenu(deviceSettings()); - break; + function startScan() { + NRF.findDevices(function (devices) { + submenu_scan[''] = { title: `Scan (${devices.length} found)` }; + if (devices.length === 0) { + E.showAlert("No " + type + " devices found") + .then(() => { startBLEDevices(); E.showMenu(deviceSettings()); }); + return; + } else { + devices.forEach((d) => { + logScanDevice(type, 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, d.name); + break; + case "coreTemperature": + PairTcore(d.id); + break; + default: + startBLEDevices(); + E.showMenu(deviceSettings()); + break; + } + } else { + startBLEDevices(); + E.showMenu(deviceSettings()); } - } else { - E.showMenu(deviceSettings()); - } - }); - }; - }); - } - E.showMenu(submenu_scan); - }, { timeout: 4000, active: true, filters: [{ services: [service] }] }); + }); + }; + }); + } + E.showMenu(submenu_scan); + }, { timeout: 4000, active: true, filters: [{ services: [service] }] }); + } + stopBLEDevices().then(function () { + startScan(); + }).catch(function (e) { + log("[BLE preflight] failed before settings scan", e); + startScan(); + }); } - + E.showMenu(mainMenuSettings()); -}) \ No newline at end of file +}) diff --git a/apps/heatsuite/heatsuite.settings.json b/apps/heatsuite/heatsuite.settings.json new file mode 100644 index 0000000000..881b8ffb44 --- /dev/null +++ b/apps/heatsuite/heatsuite.settings.json @@ -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 +} diff --git a/apps/heatsuite/heatsuite.survey.json b/apps/heatsuite/heatsuite.survey.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/apps/heatsuite/heatsuite.survey.json @@ -0,0 +1 @@ +{} diff --git a/apps/heatsuite/heatsuite.surveylib.js b/apps/heatsuite/heatsuite.surveylib.js index e7480b1d1d..3eca17e260 100644 --- a/apps/heatsuite/heatsuite.surveylib.js +++ b/apps/heatsuite/heatsuite.surveylib.js @@ -261,7 +261,7 @@ var height = ds.responseHeight; var opt = question.options; var resStyle = opt.type || undefined; - + switch (resStyle){ case "number": { // ranged/step input @@ -272,13 +272,13 @@ var units = (opt.units != undefined) ? opt.units : undefined; var nextMap = (opt.next != undefined) ? opt.next : {}; - function nextFor(val) { + function nextFor(val) { if (!nextMap) return 0; if (typeof nextMap === "string" || typeof nextMap === "number") return nextMap; //added so we can have a fixed followup for all questions if (typeof nextMap === "object") { if(val in nextMap) return nextMap[val] | 0; } - return 0; + return 0; } function handleResponse() { diff --git a/apps/heatsuite/heatsuite.tasks.json b/apps/heatsuite/heatsuite.tasks.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/heatsuite/heatsuite.tasks.json @@ -0,0 +1 @@ +[] diff --git a/apps/heatsuite/heatsuite.wid.js b/apps/heatsuite/heatsuite.wid.js index 875e4eea10..1a177cd175 100644 --- a/apps/heatsuite/heatsuite.wid.js +++ b/apps/heatsuite/heatsuite.wid.js @@ -8,6 +8,59 @@ var recorders; var activeRecorders = []; var recordersWithBLE = ['bthrm','CORESensor']; + var CORE_TASK_OWNER = "heatsuite.task"; + + function wait(ms) { + return new Promise(function(resolve) { + setTimeout(resolve, ms); + }); + } + + function pauseCoreForTask() { + try { + if (Bangle.CORESensorPause) { + modHS.log("Pausing CORESensor for HeatSuite task BLE"); + return Bangle.CORESensorPause(CORE_TASK_OWNER); + } + } catch (e) { + modHS.log("CORESensor pause failed: " + e); + } + return Promise.resolve(); + } + + function resumeCoreAfterTask() { + try { + if (Bangle.CORESensorResume) { + modHS.log("Resuming CORESensor after HeatSuite task BLE"); + return Bangle.CORESensorResume(CORE_TASK_OWNER); + } + } catch (e) { + modHS.log("CORESensor resume failed: " + e); + } + return Promise.resolve(); + } + + function hardStopBTHRM() { + try { + if (!Bangle.setBTHRMPower) return wait(1000); + + modHS.log("Hard stopping BTHRM"); + + if (Bangle._PWR && Bangle._PWR.BTHRM && Bangle._PWR.BTHRM.length) { + Bangle._PWR.BTHRM.slice().forEach(function(owner) { + modHS.log("Releasing BTHRM owner " + owner); + Bangle.setBTHRMPower(0, owner); + }); + } + + Bangle.setBTHRMPower(0, appName); + } catch (e) { + modHS.log("Hard stop BTHRM failed: " + e); + } + + return wait(1500); + } + var dataLog = []; var lastGPSFix = 0; var gpsLog = []; @@ -254,22 +307,32 @@ var hsi = { "count": null, "avg": null, "min": null, "max": null, "sum": null, "last": null }; var core_bat = null; var unit = null; + var OWNER = "heatsuite.recorder.CORESensor"; + function onCORE(h) { core = newValueHandler(core, h.core); skin = newValueHandler(skin, h.skin); - if (core_hr > 0) { - core_hr = newValueHandler(core_hr, h.hr); - } + if (h.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"], + 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]; + 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 }; @@ -277,17 +340,25 @@ 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); + try { require("CORESensor").enable(); } catch (e) {} + if (Bangle.setCORESensorPower) { + Bangle.setCORESensorPower(1, OWNER); + } + Bangle.on("CORESensor", onCORE); + modHS.log("CORESensor recorder started and requested CORE power"); }, stop: () => { - Bangle.removeListener('CORESensor', onCORE); - if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, appName); + Bangle.removeListener("CORESensor", onCORE); + if (Bangle.setCORESensorPower) { + Bangle.setCORESensorPower(0, OWNER); + } + modHS.log("CORESensor recorder stopped and released CORE power"); } - } + }; }, bat: function () { return { @@ -335,7 +406,7 @@ 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) + // (https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0061691) accMagArray = newValueHandler(accMagArray, accel.mag); } return { @@ -401,7 +472,7 @@ arr.max = (value > arr.max) ? value : arr.max; return arr; } - //increased accelerometer data storage for higher resolution activity tracking + //increased accelerometer data storage for higher resolution activity tracking function perSecAcc(status) { if(!status){ if (perSecAccHandler) Bangle.removeListener('accel', perSecAccHandler); @@ -492,7 +563,7 @@ var currentOffset = HDR_LEN + startIndex * RECORD_SIZE; var toWrite = combined; var safetyIters = 0, SAFETY_MAX = 64; - var maxRecords = modHS.getSettings().BinMaxRecords|0; + var maxRecords = modHS.getSettings().BinMaxRecords|0; if (maxRecords <= 0) maxRecords = 6000; var capacity = HDR_LEN + maxRecords * RECORD_SIZE; while (toWrite.length > 0) { @@ -584,7 +655,7 @@ highAccTimeout = timeoutAligned(accLogInt, tempAccLog); highAccWriteTimeout = timeoutAligned(30000, writeHSAccelSetTimeout); } - + function updateBLEAdvert(data) { //var unix = parseInt((new Date().getTime() / 1000).toFixed(0)); var batt = null, @@ -827,9 +898,16 @@ function startRecorder() { settings = modHS.getSettings(); + if (!Array.isArray(settings.record)) settings.record = []; + if (!Array.isArray(settings.StudyTasks)) settings.StudyTasks = []; if (initHandlerTimeout) clearTimeout(initHandlerTimeout); if (BTHRM_ConnectCheck) clearInterval(BTHRM_ConnectCheck); - activeRecorders = []; //clear active recorders + activeRecorders.forEach(function (r) { + if (r && typeof r.stop === "function") { + try { r.stop(); } catch (e) { modHS.log("Recorder stop failed: " + e); } + } + }); + activeRecorders = []; recorders = getRecorders(); settings.record.forEach(r => { var recorder = recorders[r]; @@ -889,33 +967,28 @@ } } - function restartRecorder(name) { + function startRecorderByName(name) { if (!name || typeof recorders[name] !== "function") { modHS.log(`Recorder ${name} not found`); return; } - const index = activeRecorders.findIndex(r => r.name === name); - if (index === -1) { - modHS.log(`Recorder ${name} is not active, skipping restart`); + if (activeRecorders.find(r => r.name === name)) { + modHS.log(`Recorder ${name} already active`); return; } - const existing = activeRecorders[index]; - if (typeof existing.stop === "function") { - existing.stop(); - modHS.log(`Stopped existing ${name}`); - } - activeRecorders.splice(index, 1); const newRecorder = recorders[name](); if (typeof newRecorder.start === "function") { newRecorder.start(); activeRecorders.push(newRecorder); - modHS.log(`Restarted ${name}`); + modHS.log(`Started ${name}`); } else { - modHS.log(`Failed to restart ${name}: no start()`); + modHS.log(`Failed to start ${name}: no start()`); } } - startRecorder(); + resumeCoreAfterTask().then(function () { + startRecorder(); + }); gpsHandler(); @@ -941,17 +1014,21 @@ //widget stuff var iconWidth = 44; + var iconHeight = 24; function draw() { + var x = this.x, y = this.y; g.reset(); + g.setClipRect(x, y, x + iconWidth - 1, y + iconHeight - 1); 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.fillRect({ x: x, y: y, w: iconWidth, h: iconHeight, 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); - + g.drawImage(atob("FBfCAP//AADk+kPKAAAoAAAAAKoAAAAAKAAAAFQoFQAAVTxVAFQVVVQVRABVABFVEBQEVQBUABUAAFUAVQCoFVVUKogAVQAiqBVVVCoAVQBVAABUABUAVRAUBFVEAFUAEVQVVVQVAFU8VQAAVCgVAAAAKAAAAACqAAAAACgAAA=="), x + 1, y + 1); + g.setColor((Bangle.hasOwnProperty("isBTHRMConnected") && Bangle.isBTHRMConnected()) ? "#00F" : "#888"); + g.drawImage(atob("EhCCAAKoAqgCqqiqqCqqqqqqqqqqqqqqqqqqqqqqqqqqqqqiqqqqqAqqqqoAKqqqgACqqqAAAqqoAAAKqgAAACqAAAAAoAAAAAoAAA=="), x + 22, y + 3); + g.setClipRect(0, 0, g.getWidth() - 1, g.getHeight() - 1); + } WIDGETS.heatsuite = { area: 'tr', @@ -961,34 +1038,45 @@ startRecorder(); WIDGETS["heatsuite"].draw(); }, + stopBLEDevices: function() { - recordersWithBLE.forEach(function(item) { - modHS.log(`Stopping ${item}`); - if (activeRecorders.find(r => r.name === item)) { - stopRecorder(item); - } + settings = modHS.getSettings(); + + return pauseCoreForTask().then(function () { + recordersWithBLE.forEach(function(item) { + if (activeRecorders.find(r => r.name === item)) { + modHS.log("Stopping " + item); + stopRecorder(item); + } + }); + + NRF.setScan(); // clear active scans + + return hardStopBTHRM(); + }).then(function () { + NRF.setScan(); // clear again after BLE settles + return wait(500); }); }, + startBLEDevices: function() { + settings = modHS.getSettings(); + recordersWithBLE.forEach(function(item) { - modHS.log(`Starting ${item}`); if (settings.record.includes(item)) { - restartRecorder(item); + modHS.log("Starting " + item); + startRecorderByName(item); } }); + + return resumeCoreAfterTask(); } }; - //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) { diff --git a/apps/heatsuite/metadata.json b/apps/heatsuite/metadata.json index 5a9ba91143..860d8156a4 100644 --- a/apps/heatsuite/metadata.json +++ b/apps/heatsuite/metadata.json @@ -1,12 +1,12 @@ { "id": "heatsuite", "name": "HeatSuite", "shortName":"HeatSuite", - "version":"0.13", + "version":"0.14", "description": "The smartwatch software to integrate with HeatSuite", "icon": "icon.png", "type": "app", "tags": "health,tool", - "supports" : ["BANGLEJS2"], + "supports" : ["BANGLEJS2"], "readme": "README.md", "dependencies" : { "bthrm":"app", "gpssetup" : "app", "coretemp":"app"}, "customConnect": true, @@ -27,7 +27,20 @@ {"name":"heatsuite.urine.js","url":"heatsuite.urine.js"} ], "data": [ - {"name":"heatsuite.settings.json"}, - {"name":"heatsuite.cache.json"} + { + "name": "heatsuite.settings.json", + "url": "heatsuite.settings.json" + }, + { + "name": "heatsuite.tasks.json", + "url": "heatsuite.tasks.json" + }, + { + "name": "heatsuite.survey.json", + "url": "heatsuite.survey.json" + }, + { + "name": "heatsuite.cache.json" + } ] }