diff --git a/apps/heatsuite/ChangeLog b/apps/heatsuite/ChangeLog index cc0d173b2f..679d6c004a 100644 --- a/apps/heatsuite/ChangeLog +++ b/apps/heatsuite/ChangeLog @@ -29,3 +29,8 @@ Normalized StudyTasks loading to an array Renamed high accelerometry max-records setting to `BinMaxRecords` Fixed `GPSInterval` setting persistence casing +0.16: Refactored blood pressure pairing and measurement flow + Added BP SFLOAT parsing, bonded-device validation, time sync, timeout/disconnect handling, and clearer pairing/security errors + Coordinated BP/scale/temp task launches with widget BLE stop/start to avoid recorder scan conflicts + Improved CORESensor recorder ownership, pause/resume lifecycle + Fixed CORE HR value updates and moved recorder start/stop logging diff --git a/apps/heatsuite/heatsuite.app.js b/apps/heatsuite/heatsuite.app.js index 312ede6b95..91c4bd6166 100644 --- a/apps/heatsuite/heatsuite.app.js +++ b/apps/heatsuite/heatsuite.app.js @@ -12,6 +12,24 @@ let settings = modHS.getSettings(); let appCache = modHS.getCache(); +function stopBLEDevices() { + if (global.WIDGETS && WIDGETS["heatsuite"] && WIDGETS["heatsuite"].stopBLEDevices) { + return Promise.resolve(WIDGETS["heatsuite"].stopBLEDevices()); + } + return Promise.resolve(); +} + +function loadTaskApp(appFile, label) { + NRF.setScan(); + stopBLEDevices().then(function () { + NRF.setScan(); + Bangle.load(appFile); + }).catch(function (e) { + modHS.log("Failed to stop BLE before " + label + " task: " + e); + Bangle.load(appFile); + }); +} + function queueNRFFindDeviceTimeout() { if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); NRFFindDeviceTimeout = setTimeout(function () { @@ -33,8 +51,7 @@ function findBtDevices() { if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); if (TaskScreenTimeout) clearTimeout(TaskScreenTimeout); NRF.setScan(); - WIDGETS['heatsuite'].stopBLEDevices(); - Bangle.load(appFile); + loadTaskApp(appFile, label); return true; } if (devices.length !== 0) { @@ -44,7 +61,14 @@ function findBtDevices() { modHS.log("Services: ", services); if (services !== undefined && services.includes('1810') && d.id === settings.bt_bloodPressure_id) { //Blood Pressure - return foundDevice("BP", 'heatsuite.bp.js'); + found = true; + if (layout && layout.msg) { + layout.msg.label = "BP Found"; + layout.render(); + } + if (NRFFindDeviceTimeout) clearTimeout(NRFFindDeviceTimeout); + loadTaskApp('heatsuite.bp.js', "BP"); + return true; } else if (services !== undefined && (services.includes('181b') || services.includes('181d')) && studyTasks.some(task => task.id === "bodyMass")) { if (services.includes('181b')) { if (!d.serviceData || !d.serviceData['181b'] || d.serviceData['181b'].length < 2) { diff --git a/apps/heatsuite/heatsuite.bp.js b/apps/heatsuite/heatsuite.bp.js index 2fba5af69a..3e9e0d8ee5 100644 --- a/apps/heatsuite/heatsuite.bp.js +++ b/apps/heatsuite/heatsuite.bp.js @@ -2,186 +2,473 @@ 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, peripheralId) { + requireBytes(data, 0, 1, "flags"); + var flags = data.getUint8(0); + var index = 1; + var result = { + "peripheral_id": peripheralId || null, + "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.settings.js b/apps/heatsuite/heatsuite.settings.js index 041ee73cbb..0f9648f2aa 100644 --- a/apps/heatsuite/heatsuite.settings.js +++ b/apps/heatsuite/heatsuite.settings.js @@ -1,16 +1,89 @@ (function (back) { + var modHS = require('HSModule'); var settingsJSON = "heatsuite.settings.json"; var studyTasksJSON = "heatsuite.tasks.json"; + 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,6 +92,43 @@ if (global.WIDGETS && WIDGETS["heatsuite"]) WIDGETS["heatsuite"].changed(); //redraw widget on settings update if open } + function writeSettingsBatch(values) { + var s = require('Storage').readJSON(settingsJSON, true) || {}; + Object.keys(values).forEach(function (key) { + s[key] = values[key]; + }); + require('Storage').writeJSON(settingsJSON, s); + settings = readSettings(); + } + + 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 disconnectDevice(device) { + if (!device || device.connected === false || !device.disconnect) return Promise.resolve(); + try { + return Promise.resolve(device.disconnect()).catch(function (e) { + log("[BLE] Disconnect failed", e); + }); + } catch (e) { + log("[BLE] Disconnect failed", e); + return Promise.resolve(); + } + } + function readSettings() { var out = Object.assign( require('Storage').readJSON("heatsuite.default.json", true) || {}, @@ -31,57 +141,80 @@ 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 () { + var pairedName; + 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)); + }); + } + function savePairing() { + var values = { "bt_bloodPressure_id": id }; + pairedName = name || device.name || (device.device && device.device.name); + if (pairedName) values.bt_bloodPressure_name = pairedName; + writeSettingsBatch(values); + log("[BP Pair] Saved device id", id, pairedName || ""); + } + function restoreBLEAndShowMenu(message, title, isError) { + return startBLEDevices().catch(function (e) { + log("[BLE] Restore failed", e); + if (!isError) { + message = message + "\nBLE restore failed: " + e; + title = title || "BP Device"; + } + }).then(function () { + if (isError) { + return E.showAlert(message, title).then(function () { E.showMenu(deviceSettings()); }); + } + return E.showPrompt(message, { title: title, buttons: { "OK": true } }).then(function () { E.showMenu(deviceSettings()); }); + }); + } + 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 () { - 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 () { WIDGETS['heatsuite'].startBLEDevices(); E.showMenu(deviceSettings()) }); - log("Device ID paired, time set, Done!"); - return device.disconnect(); + 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 () { + return disconnectDevice(device); + }).then(function () { + savePairing(); + return restoreBLEAndShowMenu("Paired!", "BP Device", false); }).catch(function (e) { - log(e); - E.showAlert("Error! " + e).then(() => {WIDGETS['heatsuite'].startBLEDevices();E.showMenu(deviceSettings())}); + log("[BP Pair] Error", e); + disconnectDevice(device).then(function () { + return restoreBLEAndShowMenu("Error! " + e, "BP Device", true); + }); }); } function PairTcore(id) { @@ -94,12 +227,12 @@ //}).then(function() { console.log("bonded", gatt.getSecurityStatus()); writeSettings("bt_coreTemperature_id", id); - E.showAlert("Paired!").then(function () { WIDGETS['heatsuite'].startBLEDevices(); E.showMenu(deviceSettings()) }); + E.showAlert("Paired!").then(function () { startBLEDevices().then(function () { 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().then(function () { E.showMenu(deviceSettings()); }); }); }); } @@ -348,42 +481,56 @@ 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); + startBLEDevices().catch(function (restoreError) { + log("[BLE] Restore after scan preflight failed", restoreError); + }).then(function () { + return E.showAlert("Bluetooth stop failed: " + e, "Scan Error"); + }) + .then(function () { E.showMenu(deviceSettings()); }); + }); } E.showMenu(mainMenuSettings()); diff --git a/apps/heatsuite/heatsuite.wid.js b/apps/heatsuite/heatsuite.wid.js index 60b83ef139..da9e0bf33f 100644 --- a/apps/heatsuite/heatsuite.wid.js +++ b/apps/heatsuite/heatsuite.wid.js @@ -8,6 +8,7 @@ var recorders; var activeRecorders = []; var recordersWithBLE = ['bthrm','CORESensor']; + var CORE_TASK_OWNER = "heatsuite.task"; var dataLog = []; var lastGPSFix = 0; var gpsLog = []; @@ -25,6 +26,69 @@ var fallTime = 0; var fallDetected = false; + function wait(ms) { + return new Promise(function(resolve) { + setTimeout(resolve, ms); + }); + } + + function ensureCoreTempRuntime() { + if (Bangle.setCORESensorPower) return true; + try { + require("CORESensor").enable(); + } catch (e) { + modHS.log("CORESensor runtime enable failed: " + e); + } + if (!Bangle.setCORESensorPower) { + modHS.log("CORESensor runtime unavailable"); + return false; + } + return true; + } + + function pauseCoreForTask() { + try { + if (!Bangle.CORESensorPause && !ensureCoreTempRuntime()) return Promise.resolve(); + 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 && !ensureCoreTempRuntime()) return Promise.resolve(); + 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); + } Bangle.setOptions({ "hrmSportMode": -1, }); @@ -264,10 +328,11 @@ 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) { + if (h.hr > 0) { core_hr = newValueHandler(core_hr, h.hr); } heatflux = newValueHandler(heatflux, h.heatflux); @@ -290,12 +355,13 @@ return result; }, start: () => { + ensureCoreTempRuntime(); Bangle.on('CORESensor', onCORE); - if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(1, appName); + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(1, owner); }, stop: () => { Bangle.removeListener('CORESensor', onCORE); - if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, appName); + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, owner); } } }, @@ -942,33 +1008,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(); + }); runScheduledInit(); gpsHandler(); @@ -1017,20 +1078,30 @@ 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) { + modHS.log("Stopping " + item); + if (activeRecorders.find(r => r.name === item)) { + stopRecorder(item); + } + }); + NRF.setScan(); + return hardStopBTHRM(); + }).then(function () { + NRF.setScan(); + return wait(500); }); }, startBLEDevices: function() { + settings = modHS.getSettings(); recordersWithBLE.forEach(function(item) { - modHS.log(`Starting ${item}`); + modHS.log("Starting " + item); if (settings.record.includes(item)) { - restartRecorder(item); + startRecorderByName(item); } }); + return resumeCoreAfterTask(); } }; diff --git a/apps/heatsuite/metadata.json b/apps/heatsuite/metadata.json index 8622e61661..7314c2ba4e 100644 --- a/apps/heatsuite/metadata.json +++ b/apps/heatsuite/metadata.json @@ -1,7 +1,7 @@ { "id": "heatsuite", "name": "HeatSuite", "shortName":"HeatSuite", - "version":"0.15", + "version":"0.16", "author": "nravanelli", "description": "The smartwatch software to integrate with HeatSuite", "icon": "icon.png",