diff --git a/apps/coretemp/.gitignore b/apps/coretemp/.gitignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/coretemp/ChangeLog b/apps/coretemp/ChangeLog index 141a2bfe549..93f1835e3d2 100644 --- a/apps/coretemp/ChangeLog +++ b/apps/coretemp/ChangeLog @@ -1,6 +1,10 @@ 0.01: New app -0.02: Cleanup interface and add settings, widget, add skin temp reporting. +0.02: Cleanup interface and add settings, widget, add skin temp reporting. 0.03: Move code for recording to this app 0.04: Use default Bangle formatter for booleans 0.05: Minor code improvements -0.06: Added advanced settings, adapted code to mirror bthrm funcitonality, and will work with latest firmware CORE Sensor Firmware (V0.87) +0.06: Added advanced settings and updated compatibility with recent CORE Sensor firmware (V0.87) +0.7: Improve CORE BLE connection handling and recorder/widget integration +0.8: Modularize CoreTemp runtime and add ANT+ HRM management with tests +0.9: Improved BLE lifecycle management and added more debug options +0.10: Add app-loader configurator, default ANT+ HRM pairing shortcut diff --git a/apps/coretemp/README.md b/apps/coretemp/README.md index 0ee967b3142..0eef7df6022 100644 --- a/apps/coretemp/README.md +++ b/apps/coretemp/README.md @@ -1,43 +1,416 @@ -# CoreTemp display +# CoreTemp -Application to connect to the [CORE](https://corebodytemp.com/) or [calera](https://info.greenteg.com/calera-research) devices from greenteg and display the current skin and body core temperature readings. +Connect a Bangle.js watch to a [CORE](https://corebodytemp.com/) or +[calera](https://info.greenteg.com/calera-research) sensor from greenteg and +display live body temperature data. -This also includes a module (heavily influenced by the BTHRM app) so you can integrate the core sensor into your own apps/widgets. You can also pair an ANT+ heart rate strap to the CORE/calera sensor as well in the App Settings so that you can leverage the exertional algorthim for estimating core temperature. +CoreTemp also installs a `CORESensor` module so other apps, widgets, and +recorders can subscribe to the same sensor readings. -## Usage +## What It Provides -Background task connects to a paired and emits a CORESensor signal value for each reading. -Application contains three components, one is a background task that monitors the sensor and emits a 'CORESensor' signal on activity if activated in settings. -The widget shows when the sensor is enabled and connected (green) or disconnected (grey). -The app listens for 'CORESensor' signals and shows the current data. +- A foreground app that temporarily powers a paired CORE sensor while open and + shows CORE temperature, skin temperature, Heat Strain Index, and battery + level. +- A background runtime that keeps a paired CORE sensor connected when enabled. +- A widget that is visible when the feature is enabled and changes color when + the CORE sensor is connected. +- A recorder integration that logs core temperature data into Recorder. +- An ANT+ HRM manager for pairing a heart-rate monitor through the CORE + Control Point characteristic. -## CORESensor Module +## Setup -With the module, you can add the CORE Sensor to your own app. Simply power on the module and listen to CORESensor: +1. Install CoreTemp from the Bangle.js app loader. +2. Open `Settings > Apps > CoreTemp`. +3. Use `Scan for CORE` to find and pair your CORE/calera sensor. +4. Enable `Enable` only if you want CoreTemp itself to keep CORE connected in + the background. +5. Enable `Widget` if you want connection status on the clock screen. +By default, CoreTemp installs disabled and does not keep CORE connected in the +background. Opening the CoreTemp app starts an active foreground session for a +paired CORE sensor without changing the background `Enable` setting. When +background mode is enabled, the boot task loads the runtime automatically. The +runtime connects to the paired CORE sensor, subscribes to measurements, and +emits a `CORESensor` event for each reading. + +## Settings + +The main settings menu contains: + +- `Enable`: turns the background CORE runtime on or off. +- `Widget`: shows or hides the CoreTemp widget. +- `Scan for CORE`: scans for CORE sensors when no CORE device is paired. +- `Test `: connects to the currently paired CORE sensor when it is not + already connected. +- `Forget `: removes the saved CORE sensor, disables background CORE + connection, and clears cached BLE characteristic handles without erasing + global Bangle BLE bonds. +- `HRM (ANT+)`: opens heart-rate monitor management for the paired CORE sensor. +- `Debug`: contains disconnect warning, debug logging, status, cache rebuild, + and `Reset CoreTemp` actions. +- `Full log`: records all debug lines, including every measurement event. +- `Partial log`: records connection/discovery/control logs but skips measurement + `data` lines to reduce log volume. +- `Custom CORE only`: requires CORE's custom temperature characteristic and + disables the standard Health Thermometer fallback. + +## ANT+ HRM Pairing + +CORE can pair with a heart-rate monitor and use that heart-rate data for its own +accuracy. CoreTemp manages ANT+ HRMs through CORE's Control Point +characteristic. + +Open `Settings > Apps > CoreTemp > HRM (ANT+)`. + +Available actions: + +- `Status`: queries the CORE sensor for currently paired ANT+ HRMs. +- `Scan ANT+`: starts an ANT+ scan on CORE, waits 5 seconds locally, then + reads the found HRM IDs. Scan results may include HRMs already paired on + CORE. +- `Recent HRMs`: shows HRMs previously paired through CoreTemp. +- `Clear Paired HRM`: clears ANT+ HRMs paired on CORE and verifies the result. + +Pairing flow: + +1. Choose `Scan ANT+`. +2. Wait for the 5 second scan window. +3. Select a found ANT+ HRM ID. +4. Choose `Pair`. +5. CoreTemp verifies pairing by reading CORE's paired HRM status. + +CoreTemp treats pairing as a single-HRM app policy: + +- If no HRM is paired, the selected HRM is paired. +- If one HRM is already paired, CoreTemp asks whether to replace or re-pair it. + Replacement clears CORE's paired HRM, waits 2 seconds, then pairs the + selected HRM. +- If multiple HRMs are paired, clear paired HRMs before pairing another one. + +There is no manual ANT ID entry in the on-watch settings UI. The watch pairing +path is scan, select, pair. Previously paired HRMs can be paired again from +`Recent HRMs`. + +## BLE Lifecycle + +`ble.js` serializes connect, reconnect, pause, pair, unpair, and cache rebuild +work through one lifecycle queue. The state machine below shows the runtime +states, the main transitions between them, and the recovery branches that feed +back into reconnect. + +```mermaid +flowchart TD + A[idle] -->|connect / power_on / resume / pair / rebuild| B{paused?} + B -->|yes| A + B -->|no| C{paired device id present?} + C -->|no| A + C -->|yes| D{pending mode switch?} + + D -->|pair target| E[disconnecting: pair target] + D -->|rebuild cache| F[disconnecting: rebuild cache] + D -->|reconnect requested| G[disconnecting: reconnect requested] + D -->|explicit disconnect| H[disconnecting: requested disconnect] + D -->|unpair| I[disconnecting: unpair] + D -->|none| J{have reusable device?} + + E -->|BLE settle| K[connectWithBusyRetry] + F -->|delete cache + rebuild settle| K + G -->|BLE settle| K + H -->|BLE settle| A + I -->|BLE settle + clear pairing/cache| A + + J -->|no| L[scanning] + J -->|yes| M[connecting] + L -->|requestDevice success| M + L -->|requestDevice failure| X[error] + + M -->|gatt.connect + security log| N{transport already ready?} + M -->|connect failure| X + + N -->|yes| O[connected] + N -->|no| P{cached chars available?} + + P -->|no| Q[discovering] + P -->|yes| R[attaching] + + R -->|attach succeeds| O + R -->|cached attach fails and gatt still connected| S[delete cache] + R -->|cached attach fails after disconnect| X + S --> Q + + Q -->|services + characteristics found| R + Q -->|missing required characteristics| X + Q -->|disconnect before fallback discovery| X + + O -->|pause| T[disconnecting: paused] + O -->|disconnect / transport error| U[reset transport] + O -->|profile upgrade timer| F + O -->|explicit disconnect| H + O -->|explicit unpair| I + + T -->|BLE settle| A + U -->|should stay connected| V[reconnect_wait] + U -->|power released / paused / unpaired| A + + X -->|busy error and retries remain| W[error: stack busy] + X -->|other failure| Y[cleanupGatt + BLE settle] + W --> K + Y -->|should stay connected and paired| V + Y -->|pair flow / pause / power off / no pairing| A + + V -->|timer fires| G ``` -Bangle.setCORESensorPower(1,appName); -Bangle.on('CORESensor', (x) =>{ ... }); + +Key points: + +- Cached characteristic fallback is only allowed to rebuild/discover while the + current GATT is still connected. If the transport drops first, the lifecycle + aborts, cleans up, settles, and lets the reconnect loop retry. +- The standard Health Thermometer fallback can reach `connected`, but a profile + upgrade timer later forces a controlled reconnect and cache rebuild so the + runtime can switch to the custom CORE service when it becomes available. +- Transport disconnects from notifications or Control Point traffic do not try + to recover inline. They request a queued reconnect so all BLE transitions + still pass through the same serialized lifecycle. + +## HRM ANT+ Lifecycle + +All HRM actions run through `hrm.js`, which uses `controlpoint.js` as a strict +single-request actor on top of the connected CORE Control Point characteristic. +Unexpected or stale indications are discarded by the Control Point layer and do +not change the HRM workflow. + +```mermaid +flowchart TD + A[HRM action requested] --> B{hrmState.busy?} + B -->|yes| Z[reject: HRM operation already in progress] + B -->|no| C[runOperation
set busy=true
operation=name] + + C --> D{action} + + D -->|Status| E[request HRM_PAIRED_COUNT] + E --> F[request each HRM_PAIRED_ANT_ENTRY] + F --> G[update pairedSensors/currentSource] + G --> Y[success
busy=false] + + D -->|Scan ANT+| H[request HRM_SCAN_ANT_START] + H --> I[wait 5s local scan window] + I --> J[request HRM_SCAN_ANT_COUNT] + J --> K[request each HRM_SCAN_ANT_ENTRY] + K --> L[store lastScan entries] + L --> Y + + D -->|Pair ANT+| M[normalize entry/id] + M -->|invalid| X[error] + M -->|valid| N[queryPairedEntries] + N --> O{paired count} + O -->|more than 1| X + O -->|0| P[pairNormalizedEntry] + O -->|1 same ANT id + replaceExisting=false| Q[remember selected/recent] + O -->|1 different + replaceExisting=false| X + O -->|1 paired + replaceExisting=true| R[request HRM_CLEAR_ANT] + R --> R2[wait 2s settle] + R2 --> P + Q --> Y + + P --> S[request HRM_PAIR_ANT] + S --> T[queryPairedEntries again] + T --> U{new entry present?} + U -->|yes| V[remember selected/recent] + U -->|no| X + V --> Y + + D -->|Clear Paired HRM| AA[request HRM_CLEAR_ANT] + AA --> AB[queryPairedEntries again] + AB --> AC{entries remain?} + AC -->|no| AD[clear selected + persist config] + AC -->|yes| X + AD --> Y + + X --> AE[set lastError
busy=false] ``` -The CORESensor emits an object with following keys: +HRM notes: + +- `Status`, `Scan ANT+`, `Pair ANT+`, and `Clear Paired HRM` are all verified + reads or write-then-read flows. The module does not trust an ACK alone. +- `pairANT` enforces a single-HRM app policy: same ID is idempotent only when + `replaceExisting` is false. With `replaceExisting`, the flow clears, waits + 2 seconds, and pairs the selected HRM. Multiple paired HRMs are treated as a + manual cleanup case. +- Control Point transport errors still come from the BLE layer. If the CORE + connection drops, the HRM operation fails, `lastError` is recorded, and BLE + recovery continues through the reconnect lifecycle above. -* **core**: Estimated/Predicted core temperature -* **skin**: Measured skin temperature -* **unit**: "F" or "C" -* **hr**: Heart Rate (only when ANT+ heart rate monitor is paired) -* **heatflux**: (calera device only - needs encryption level b released by greenteg) -* **hsi**: Heat Strain Index ([read more here](https://help.corebodytemp.com/en/articles/10447107-heat-strain-index), exertional algorithm only) -* **battery**: battery level -* **quality**: Used to indicate the quality or trust level of the current measurement values +## CORESensor Events -## TODO +Apps can enable the runtime and listen for readings: + +```js +require("CORESensor").enable(); +Bangle.setCORESensorPower(1, "myapp"); +Bangle.on("CORESensor", function (data) { + // Use CORE sensor data here. +}); +``` -* Integrate with other tracking/sports apps to log data. -* Emit Bangle.js heart rate to device as a heart rate for internal algorthim +Release power when your app no longer needs the sensor: + +```js +Bangle.setCORESensorPower(0, "myapp"); +``` + +Each `CORESensor` event contains: + +- `core`: estimated/predicted core temperature, or the CORE invalid sentinel + when unavailable. +- `skin`: measured skin temperature. +- `unit`: `"C"` or `"F"`. +- `hr`: heart-rate value from CORE measurements when provided, otherwise `0`. +- `hrState`: CORE heart-rate state when provided by the measurement flags. +- `heatflux`: heat flux value. +- `hsiValid`: whether `hsi` is valid. +- `hsi`: Heat Strain Index value from CORE's exertional algorithm. +- `battery`: CORE battery level. +- `dataQuality`: measurement quality/trust level from the low bits of CORE's + quality/state byte. +- `flags`: raw measurement flags. + +## Runtime APIs + +After `require("CORESensor").enable()`, CoreTemp exposes these helpers on +`Bangle`: + +```js +Bangle.isCORESensorOn(); +Bangle.isCORESensorConnected(); +Bangle.CORESensorConnect(); +Bangle.CORESensorDisconnect(); +Bangle.CORESensorPair(deviceOrId, name); +Bangle.CORESensorUnpair(); +Bangle.CORESensorRebuildCache(); +Bangle.CORESensorGetStatus(); +Bangle.setCORESensorPower(on, owner); +Bangle.CORESensorPause(owner); +Bangle.CORESensorResume(owner); +Bangle.CORESensorIsPaused(); +``` + +`setCORESensorPower` records whether an owner wants CORE connected. +`CORESensorPause` temporarily yields the BLE stack without changing any stored +settings, pairing, or power owner. + +Debug helpers: + +```js +Bangle.enableCORESensorLog(); +Bangle.disableCORESensorLog(); +Bangle.CORESensorSetDebugLog(true); +``` + +HRM helpers: + +```js +Bangle.CORESensorHRMGetState(); +Bangle.CORESensorHRMGetStatus(); +Bangle.CORESensorHRMScanANT(); +Bangle.CORESensorHRMPairANT(entryOrId, replaceExisting); +Bangle.CORESensorHRMClearANT(); +``` + +`CORESensorHRMPairANT` remains available for scripts or other apps, but the +watch settings UI does not expose manual ID input. + +## Control Point Model + +CoreTemp's HRM support is split into small modules: + +- `protocol.js`: CORE UUIDs, opcodes, measurement parsing, Control Point + response parsing, and ANT entry parsing. +- `controlpoint.js`: one strict Control Point request actor. It writes one + opcode at a time and only settles on a matching `[0x80, opcode, ...]` + indication. +- `hrm.js`: user-level HRM workflows such as scan, status, pair, recent HRMs, + and clear. +- `ble.js`: CORE BLE connection, service discovery, characteristic caching, and + notification forwarding. + +Unexpected, stale, or mismatched Control Point indications are logged and +discarded. They are not surfaced as HRM workflow errors. + +## BLE Compatibility + +CoreTemp supports CORE's custom Core Body Temperature Service: + +- Service: `00002100-5b1e-4347-b07c-97b514dae121` +- Temperature characteristic: `00002101-5b1e-4347-b07c-97b514dae121` +- Control Point characteristic: `00002102-5b1e-4347-b07c-97b514dae121` + +For older/basic compatibility it also accepts the standard BLE Health +Thermometer profile: + +- Service: `0x1809` / `00001809-0000-1000-8000-00805f9b34fb` +- Temperature Measurement characteristic: `0x2a1c` / + `00002a1c-0000-1000-8000-00805f9b34fb` + +The standard Health Thermometer path provides temperature readings only. CORE +Control Point and ANT+ HRM management require the custom Control Point +characteristic. + +When CoreTemp falls back to Health Thermometer mode, it periodically performs a +background profile upgrade attempt. This disconnects briefly, rebuilds the +characteristic cache, and switches to the custom CORE profile if it is available. + +Enable `Custom CORE only` in Debug if you want CoreTemp to reject the standard +Health Thermometer fallback and keep reconnecting until the custom CORE +temperature characteristic is available. + +## Storage + +CoreTemp stores: + +- `coretemp.json`: app settings, paired CORE device ID/name, debug flag, and BLE + characteristic cache. +- `coretemp.hrm.json`: selected and recent ANT+ HRMs. +- `coretemp.log`: debug log output when debug logging is enabled. + +The repository also includes `apps/coretemp/tests/` for local regression tests. +That folder is committed for development but is not listed in `metadata.json`, +so it is not installed on the watch. + +## Recorder + +The Recorder integration is named `Core` and records: + +- Core +- Skin +- Unit +- HeatFlux +- HeatStrainIndex +- Battery +- Quality + +Recorder powers the CORE runtime while recording and releases it when recording +stops. + +## Development Tests + +From the repository root: + +```sh +node apps/coretemp/tests/run.js +``` + +The tests cover protocol parsing, the strict Control Point actor, HRM scan, +pair/status/clear behavior, BLE Control Point forwarding, runtime exports, +settings menu behavior, and manifest packaging. ## Creators/Contributors Ivor Hewitt [Nicholas Ravanelli](https://github.com/nravanelli) + +[Zheng Yifei](https://github.com/zyf0717) diff --git a/apps/coretemp/app-settings.json b/apps/coretemp/app-settings.json index 121c18b1af9..506c26d737f 100644 --- a/apps/coretemp/app-settings.json +++ b/apps/coretemp/app-settings.json @@ -1,4 +1,7 @@ { "enabled":false, - "debuglog": false + "widget": true, + "customprofileonly": false, + "debugMode": false, + "antScanWindowSec": 5 } diff --git a/apps/coretemp/ble.js b/apps/coretemp/ble.js new file mode 100644 index 00000000000..f34b8de8892 --- /dev/null +++ b/apps/coretemp/ble.js @@ -0,0 +1,1265 @@ +var store = require("coretemp.store"); +var protocol = require("coretemp.protocol"); +var controlpoint = require("coretemp.controlpoint"); + +var CORE_STATE = { + IDLE: "idle", + SCANNING: "scanning", + CONNECTING: "connecting", + DISCOVERING: "discovering", + ATTACHING: "attaching", + CONNECTED: "connected", + RECONNECT_WAIT: "reconnect_wait", + DISCONNECTING: "disconnecting", + ERROR: "error" +}; + +var RECONNECT_DELAY_MIN_MS = 5000; +var RECONNECT_DELAY_MAX_MS = 30000; +var BLE_SETTLE_DELAY_MS = 2000; +var BLE_REBUILD_SETTLE_DELAY_MS = 4000; +var BLE_BUSY_RETRY_LIMIT = 3; +var PROFILE_UPGRADE_RETRY_MS = 60000; + +var initialized; +var gatt; +var device; +var characteristics = []; +var controlPointChar; +var reconnectTimer; +var reconnectDelayMs = RECONNECT_DELAY_MIN_MS; +var expectedDisconnectDevice; +var batteryLevel = 0; +var coreState = CORE_STATE.IDLE; +var lastError; +var lifecycleQueue = Promise.resolve(); +var activeLifecycleTask; +var shouldBeConnected = false; +var pendingReconnect = false; +var pendingRebuildCache = false; +var pendingPairTarget; +var activePairTarget; +var pendingUnpair = false; +var pendingDisconnect = false; +var connectedHandlers = []; +var connectionSessionId = 0; +var activeProfile; +var profileUpgradeTimer; +var pauseOwners = []; + +// BLE lifecycle is driven by desired state plus one-shot pending flags. Public +// actions enqueue a reconciliation pass instead of directly mutating transport. +function log(text, param) { + store.log(text, param); +} + +function notifyConnectedHandlers(sessionId) { + var promise = Promise.resolve(); + connectedHandlers.forEach(function (handler) { + promise = promise.then(function () { + return handler(sessionId); + }).catch(function (err) { + log("CORE connected handler failed", err); + }); + }); + return promise; +} + +function readSettings() { + return store.read(); +} + +function writeSettings(mutator) { + return store.write(mutator); +} + +function setCoreState(nextState, reason) { + coreState = nextState; + if (reason !== undefined) log("CORE state -> " + nextState, reason); + else log("CORE state -> " + nextState); + emitStatus(); +} + +function emitStatus() { + if (typeof Bangle === "undefined" || typeof Bangle.emit !== "function") return; + try { + Bangle.emit("CORESensorStatus", getStatus()); + } catch (e) { + log("CORESensorStatus emit failed", e); + } +} + +function waitingPromise(timeout) { + return new Promise(function (resolve) { + log("Start waiting for " + timeout); + setTimeout(function () { + log("Done waiting for " + timeout); + resolve(); + }, timeout); + }); +} + +function waitForBleSettle(reason) { + log("Waiting for BLE settle", reason); + return waitingPromise(BLE_SETTLE_DELAY_MS); +} + +function waitForBleRebuildSettle(reason) { + log("Waiting for BLE rebuild settle", reason); + return waitingPromise(BLE_REBUILD_SETTLE_DELAY_MS); +} + +function clearPowerOwners() { + if (typeof Bangle === "undefined" || !Bangle._PWR) return; + // setCORESensorPower follows Bangle's reference-counted owner convention. + Bangle._PWR.CORESensor = []; +} + +function standDownCoreRuntime() { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + shouldBeConnected = false; + lastError = undefined; + clearPowerOwners(); +} + +function eraseStoredPairing(disableBackground) { + writeSettings(function (nextSettings) { + if (disableBackground) nextSettings.enabled = false; + delete nextSettings.btid; + delete nextSettings.btname; + delete nextSettings.cache; + }); +} + +function resetReconnectBackoff() { + reconnectDelayMs = RECONNECT_DELAY_MIN_MS; +} + +function increaseReconnectBackoff() { + reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS); +} + +function clearReconnectTimer() { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = undefined; + if (!shouldBeConnected) setCoreState(CORE_STATE.IDLE, "reconnect cancelled"); +} + +function clearProfileUpgradeTimer() { + if (!profileUpgradeTimer) return; + clearTimeout(profileUpgradeTimer); + profileUpgradeTimer = undefined; +} + +function isPaused() { + return pauseOwners.length > 0; +} + +function shouldAttemptProfileUpgrade() { + // Some devices initially expose only the standard thermometer profile. Keep + // that connection alive, then periodically rebuild discovery to pick up the + // richer custom CORE profile when it becomes visible. + return activeProfile === "health_thermometer" && + !isCustomProfileOnly() && + shouldBeConnected && + !isPaused() && + !pendingDisconnect && + !pendingUnpair && + isOn(); +} + +function scheduleProfileUpgrade() { + if (profileUpgradeTimer || !shouldAttemptProfileUpgrade()) return; + log("Scheduling CORE profile upgrade", PROFILE_UPGRADE_RETRY_MS); + profileUpgradeTimer = setTimeout(function () { + profileUpgradeTimer = undefined; + if (!shouldAttemptProfileUpgrade()) return; + enqueueLifecycle("profile_upgrade", function () { + pendingReconnect = false; + pendingRebuildCache = true; + }).catch(function (err) { + log("CORE profile upgrade failed", err); + }); + }, PROFILE_UPGRADE_RETRY_MS); +} + +function logSecurityStatus(currentGatt) { + var status; + if (!currentGatt || !currentGatt.getSecurityStatus) return Promise.resolve(); + try { + status = currentGatt.getSecurityStatus(); + } catch (e) { + log("Unable to read CORE security status", e); + return Promise.resolve(); + } + log("CORE security status", status); + return Promise.resolve(); +} + +function isBleTransportError(err) { + var msg = String(err); + return msg.indexOf("GATT") >= 0 || + msg.indexOf("Disconnected") >= 0 || + msg.indexOf("disconnected") >= 0 || + msg.indexOf("not connected") >= 0; +} + +function isBleBusyError(err) { + var msg = String(err).toLowerCase(); + return msg.indexOf("in progress") >= 0; +} + +function normalizeUuid(uuid) { + var normalized = uuid === undefined || uuid === null ? "" : String(uuid).toLowerCase(); + if (normalized.length === 36 && + normalized.indexOf("0000") === 0 && + normalized.indexOf("-0000-1000-8000-00805f9b34fb") === 8) { + return "0x" + normalized.substr(4, 4); + } + return normalized; +} + +function isCustomProfileOnly() { + return store.get().customprofileonly === true; +} + +function isSupportedService(uuid) { + // customprofileonly is a diagnostic escape hatch: ignore the fallback + // Health Thermometer profile so discovery failures expose custom-profile bugs. + if (isCustomProfileOnly() && normalizeUuid(uuid) === protocol.HEALTH_THERMOMETER_SERVICE_UUID) return false; + return protocol.SUPPORTED_SERVICES.indexOf(normalizeUuid(uuid)) >= 0; +} + +function isSupportedCharacteristic(uuid) { + if (isCustomProfileOnly() && normalizeUuid(uuid) === protocol.TEMPERATURE_MEASUREMENT_UUID) return false; + return protocol.SUPPORTED_CHARACTERISTIC_UUIDS.indexOf(normalizeUuid(uuid)) >= 0; +} + +function characteristicProperties(characteristic) { + var properties = characteristic.properties || {}; + return { + notify: !!properties.notify, + indicate: !!properties.indicate, + read: !!properties.read, + write: !!properties.write + }; +} + +function describeCharacteristicRole(uuid) { + uuid = normalizeUuid(uuid); + if (uuid === protocol.CORE_TEMP_UUID) return "custom_core_temperature"; + if (uuid === protocol.TEMPERATURE_MEASUREMENT_UUID) return "health_thermometer_temperature"; + if (uuid === protocol.CORE_CONTROL_POINT_UUID) return "custom_core_control_point"; + if (uuid === protocol.BATTERY_LEVEL_UUID) return "battery_level"; + return "unknown"; +} + +function missingRequiredCoreCharacteristics(chars) { + var hasTemp = false; + var customOnly = isCustomProfileOnly(); + chars.forEach(function (characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + if (uuid === protocol.CORE_TEMP_UUID || (!customOnly && uuid === protocol.TEMPERATURE_MEASUREMENT_UUID)) hasTemp = true; + }); + var missing = []; + if (!hasTemp) missing.push(customOnly ? protocol.CORE_TEMP_UUID : protocol.CORE_TEMP_UUID + " or " + protocol.TEMPERATURE_MEASUREMENT_UUID); + return missing; +} + +function getCharacteristicsProfile(chars) { + var hasCustomTemperature = false; + var hasHealthThermometer = false; + var hasControlPoint = false; + chars.forEach(function (characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + if (uuid === protocol.CORE_TEMP_UUID) hasCustomTemperature = true; + if (uuid === protocol.TEMPERATURE_MEASUREMENT_UUID) hasHealthThermometer = true; + if (uuid === protocol.CORE_CONTROL_POINT_UUID) hasControlPoint = true; + }); + if (hasCustomTemperature && hasControlPoint) return "custom_core"; + if (hasCustomTemperature) return "custom_core_temperature"; + if (hasHealthThermometer) return "health_thermometer"; + return undefined; +} + +function makeDiscoveryMismatchError(prefix, chars) { + var missing = missingRequiredCoreCharacteristics(chars); + var err = new Error(prefix + ": missing " + missing.join(", ")); + err.coreContext = "discover"; + err.coreDiscoveryMismatch = true; + err.missingCharacteristics = missing; + return err; +} + +function setControlPointCharacteristic(characteristic) { + controlPointChar = characteristic; + if (!controlPointChar) { + controlpoint.setAdapter(undefined); + return; + } + controlpoint.setAdapter({ + write: function (bytes) { + return controlPointChar.writeValue(new Uint8Array(bytes)); + }, + log: log + }); +} + +function addNotificationHandler(characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + // Cached characteristics can be reattached across reconnects; mark the object + // so repeated attach attempts do not register duplicate notification handlers. + if (characteristic._coretempHandlerAdded) return; + characteristic._coretempHandlerAdded = true; + characteristic.on("characteristicvaluechanged", function (ev) { + if (uuid === protocol.CORE_TEMP_UUID) { + var data = protocol.parseMeasurement(ev.target.value, batteryLevel); + log("data", data); + Bangle.emit("CORESensor", data); + } else if (uuid === protocol.TEMPERATURE_MEASUREMENT_UUID) { + var tempData = protocol.parseTemperatureMeasurement(ev.target.value, batteryLevel); + log("data", tempData); + Bangle.emit("CORESensor", tempData); + } else if (uuid === protocol.CORE_CONTROL_POINT_UUID) { + log("Control point response", protocol.dataViewToArray(ev.target.value)); + controlpoint.onNotification(ev.target.value); + } else if (uuid === protocol.BATTERY_LEVEL_UUID) { + batteryLevel = protocol.parseBattery(ev.target.value); + log("Got battery", batteryLevel); + } + }); +} + +function characteristicsFromCache(currentDevice) { + var cache = store.get().cache; + var restored = []; + var service = { device: currentDevice }; + var cached; + var characteristic; + var uuid; + if (!cache || !cache.characteristics) return restored; + log("Read cached characteristics"); + // Espruino exposes BluetoothRemoteGATTCharacteristic enough for handle-based + // reconstruction, avoiding full service discovery on every reconnect. + for (uuid in cache.characteristics) { + if (!cache.characteristics.hasOwnProperty(uuid)) continue; + cached = cache.characteristics[uuid]; + characteristic = new BluetoothRemoteGATTCharacteristic(); + characteristic.handle_value = cached.handle; + characteristic.uuid = normalizeUuid(cached.uuid); + characteristic.properties = { + notify: cached.notify, + indicate: cached.indicate, + read: cached.read, + write: cached.write + }; + characteristic.service = service; + addNotificationHandler(characteristic); + restored.push(characteristic); + } + restored = preferCustomCoreTemperature(restored); + if (!hasRequiredCoreCharacteristics(restored)) { + log("Cached characteristics do not satisfy current profile settings", { + profile: getCharacteristicsProfile(restored), + customOnly: isCustomProfileOnly() + }); + return []; + } + return restored; +} + +function saveCache(chars) { + writeSettings(function (nextSettings) { + var cache = { characteristics: {} }; + chars.forEach(function (characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + cache.characteristics[uuid] = { + handle: characteristic.handle_value, + uuid: uuid, + notify: characteristic.properties.notify, + indicate: characteristic.properties.indicate, + read: characteristic.properties.read, + write: characteristic.properties.write + }; + }); + nextSettings.cache = cache; + }); +} + +function deleteCache() { + writeSettings(function (nextSettings) { + delete nextSettings.cache; + }); +} + +function hasRequiredCoreCharacteristics(chars) { + return missingRequiredCoreCharacteristics(chars).length === 0; +} + +function preferCustomCoreTemperature(chars) { + var hasCustomTemperature = false; + var customOnly = isCustomProfileOnly(); + chars.forEach(function (characteristic) { + if (normalizeUuid(characteristic.uuid) === protocol.CORE_TEMP_UUID) hasCustomTemperature = true; + }); + if (!customOnly && !hasCustomTemperature) return chars; + // Prefer the custom CORE stream whenever present; the standard Health + // Thermometer value lacks skin temp, HR, heat flux, HSI, and quality bits. + return chars.filter(function (characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + if (customOnly && uuid === protocol.TEMPERATURE_MEASUREMENT_UUID) { + log("Skipping standard temperature measurement because custom CORE profile is required"); + return false; + } + if (hasCustomTemperature && uuid === protocol.TEMPERATURE_MEASUREMENT_UUID) { + log("Skipping standard temperature measurement because custom CORE temperature is available"); + return false; + } + return true; + }); +} + +function isTransportReady() { + return !!(gatt && gatt.connected && hasRequiredCoreCharacteristics(characteristics)); +} + +function createCharacteristicPromise(characteristic) { + var result = Promise.resolve(); + var supportsUpdates; + var uuid = normalizeUuid(characteristic.uuid); + if (uuid === protocol.CORE_CONTROL_POINT_UUID) setControlPointCharacteristic(characteristic); + supportsUpdates = uuid === protocol.CORE_CONTROL_POINT_UUID || + (characteristic.properties && + (characteristic.properties.notify || characteristic.properties.indicate)); + if (characteristic.properties && characteristic.properties.read) { + result = result.then(function () { + log("Reading characteristic", { uuid: uuid, role: describeCharacteristicRole(uuid) }); + return characteristic.readValue().then(function (data) { + if (uuid === protocol.BATTERY_LEVEL_UUID) batteryLevel = protocol.parseBattery(data); + }); + }); + } + if (supportsUpdates) { + result = result.then(function () { + log("Starting notifications", { uuid: uuid, role: describeCharacteristicRole(uuid) }); + return characteristic.startNotifications() + .then(function () { + log("Notifications started", { uuid: uuid, role: describeCharacteristicRole(uuid) }); + }) + .then(function () { + // Give the watch BLE stack time to finish enabling CCCDs before the + // next characteristic operation; without this, writes can race setup. + return waitingPromise(3000); + }); + }); + } + return result; +} + +function attachCharacteristics() { + var promise = Promise.resolve(); + setCoreState(CORE_STATE.ATTACHING); + activeProfile = getCharacteristicsProfile(characteristics); + log("Attaching CORE profile", activeProfile); + characteristics.forEach(function (characteristic) { + promise = promise.then(function () { + addNotificationHandler(characteristic); + return createCharacteristicPromise(characteristic); + }); + }); + return promise.then(function () { + if (!hasRequiredCoreCharacteristics(characteristics)) { + throw makeDiscoveryMismatchError("Missing required CORE characteristics", characteristics); + } + }); +} + +function assertGattConnectedForDiscovery(currentGatt) { + if (!currentGatt || !currentGatt.connected) { + var err = new Error("Disconnected before discovery fallback"); + err.coreContext = "connect"; + throw err; + } + return currentGatt; +} + +function discoverCharacteristics(currentGatt) { + currentGatt = assertGattConnectedForDiscovery(currentGatt); + setCoreState(CORE_STATE.DISCOVERING); + characteristics = []; + setControlPointCharacteristic(undefined); + log("Runtime discovery: getting services"); + return currentGatt.getPrimaryServices().then(function (services) { + var promise = Promise.resolve(); + log("Runtime discovery: got services", services.length); + services.forEach(function (service) { + var serviceUuid = normalizeUuid(service.uuid); + var serviceSupported = isSupportedService(serviceUuid); + log("Runtime discovery service", { uuid: serviceUuid, supported: serviceSupported }); + if (!serviceSupported) return; + promise = promise.then(function () { + return service.getCharacteristics().then(function (chars) { + chars.forEach(function (characteristic) { + var uuid = normalizeUuid(characteristic.uuid); + var accepted = isSupportedCharacteristic(uuid); + log("Runtime discovery characteristic", { + service: serviceUuid, + uuid: uuid, + role: describeCharacteristicRole(uuid), + accepted: accepted, + properties: characteristicProperties(characteristic) + }); + if (!accepted) return; + characteristics.push(characteristic); + }); + }); + }); + }); + return promise; + }).then(function () { + characteristics = preferCustomCoreTemperature(characteristics); + if (!hasRequiredCoreCharacteristics(characteristics)) { + throw makeDiscoveryMismatchError("Runtime discovery missing required CORE characteristics", characteristics); + } + return attachCharacteristics(); + }).then(function () { + saveCache(characteristics); + }); +} + +function attachCachedOrDiscover() { + var usedCache = false; + var currentGatt = gatt; + // Pairing deliberately bypasses saved handles because the target device may + // differ from the previously paired CORE. + if (activePairTarget) return discoverCharacteristics(currentGatt); + if (!characteristics.length) { + characteristics = characteristicsFromCache(device); + usedCache = characteristics.length > 0; + } + if (!characteristics.length) return discoverCharacteristics(currentGatt); + return attachCharacteristics().catch(function (err) { + if (!usedCache) throw err; + log("Cached characteristics failed, evaluating discovery fallback", err); + assertGattConnectedForDiscovery(currentGatt); + log("Cached characteristics failed, rebuilding cache", err); + deleteCache(); + characteristics = []; + setControlPointCharacteristic(undefined); + return discoverCharacteristics(currentGatt); + }); +} + +function resetTransportState(reason) { + log("resetTransportState", reason); + store.flush(); + clearProfileUpgradeTimer(); + controlpoint.cancelActive("CORE transport closed: " + reason); + setControlPointCharacteristic(undefined); + characteristics = []; + batteryLevel = 0; + activeProfile = undefined; + // A future requestDevice call may return the same object; allow its disconnect + // handler to be installed again after transport state is rebuilt. + if (device) device._coretempDisconnectHandlerAdded = false; + gatt = undefined; + device = undefined; +} + +function cleanupGatt(reason) { + var currentGatt = gatt; + var currentDevice = device; + log("cleanupGatt", reason); + resetTransportState(reason); + if (currentGatt && currentGatt.connected) { + expectedDisconnectDevice = currentDevice; + try { + currentGatt.disconnect(); + } catch (e) { + log("cleanup disconnect error", e); + try { + NRF.disconnect(); + } catch (e2) { + log("cleanup NRF.disconnect error", e2); + } + } + } +} + +function scheduleReconnect(reason) { + // Capture the current delay before increasing it so the first retry is quick + // while subsequent failures back off up to RECONNECT_DELAY_MAX_MS. + var delay = reconnectDelayMs; + if (isPaused()) { + clearReconnectTimer(); + pendingReconnect = false; + setCoreState(CORE_STATE.IDLE, "paused"); + return; + } + if (reconnectTimer || !shouldBeConnected || pendingDisconnect || pendingUnpair) return; + increaseReconnectBackoff(); + pendingReconnect = true; + setCoreState(CORE_STATE.RECONNECT_WAIT, delay); + reconnectTimer = setTimeout(function () { + reconnectTimer = undefined; + if (shouldBeConnected && !isPaused() && !pendingDisconnect && !pendingUnpair) { + enqueueLifecycle("reconnect", function () { + pendingReconnect = true; + }).catch(function (e) { + log("Reconnect task failed", e); + }); + } else { + setCoreState(CORE_STATE.IDLE, reason || "power released before reconnect"); + } + }, delay); +} + +function enqueueLifecycle(kind, mutator) { + if (mutator) mutator(); + // Serialize lifecycle work. BLE connect/disconnect/discovery operations are + // fragile when overlapped, and each queued pass reconciles the latest flags. + lifecycleQueue = lifecycleQueue.then(function () { + activeLifecycleTask = { kind: kind }; + log("Lifecycle task start", kind); + return reconcileLifecycle(kind).then(function (result) { + activeLifecycleTask = undefined; + log("Lifecycle task done", kind); + return result; + }, function (err) { + activeLifecycleTask = undefined; + log("Lifecycle task error", { kind: kind, error: String(err) }); + throw err; + }); + }, function () { + activeLifecycleTask = { kind: kind }; + return reconcileLifecycle(kind).then(function (result) { + activeLifecycleTask = undefined; + return result; + }, function (err) { + activeLifecycleTask = undefined; + throw err; + }); + }); + return lifecycleQueue; +} + +function ensureConnectionDesiredOrThrow(stage) { + var powerErr; + if (isPaused()) { + powerErr = new Error("CORESensor paused before " + stage); + powerErr.coreContext = "paused"; + throw powerErr; + } + if (!shouldBeConnected || pendingDisconnect || pendingUnpair) { + powerErr = new Error("CORESensor power off before " + stage); + powerErr.coreContext = "power_off"; + throw powerErr; + } +} + +function ensureDisconnectHandler(bleDevice) { + if (!bleDevice || bleDevice._coretempDisconnectHandlerAdded) return bleDevice; + bleDevice._coretempDisconnectHandlerAdded = true; + bleDevice.on("gattserverdisconnected", function (reason) { + onDisconnect(bleDevice, reason); + }); + return bleDevice; +} + +function ensureDeviceAvailable() { + var filters; + var targetId; + ensureConnectionDesiredOrThrow("connect"); + if (device) { + ensureDisconnectHandler(device); + log("Reuse device", device); + return Promise.resolve(device); + } + setCoreState(CORE_STATE.SCANNING); + NRF.setScan(); + targetId = activePairTarget && activePairTarget.id ? activePairTarget.id : store.get().btid; + filters = [{ id: targetId }]; + return NRF.requestDevice({ filters: filters, active: true }) + .then(function (foundDevice) { + return waitingPromise(2000).then(function () { + return foundDevice; + }); + }) + .then(function (foundDevice) { + device = ensureDisconnectHandler(foundDevice); + return foundDevice; + }, function (err) { + err.coreContext = "request_device"; + throw err; + }); +} + +function ensureGattConnected() { + ensureConnectionDesiredOrThrow("connect"); + if (!device) { + var err = new Error("CORE device is unavailable"); + err.coreContext = "connect"; + throw err; + } + gatt = device.gatt; + if (gatt.connected) { + if (expectedDisconnectDevice === device) expectedDisconnectDevice = undefined; + return logSecurityStatus(gatt); + } + setCoreState(CORE_STATE.CONNECTING); + return gatt.connect() + .then(function () { + if (expectedDisconnectDevice === device) expectedDisconnectDevice = undefined; + return waitingPromise(2000); + }) + .then(function () { + return logSecurityStatus(gatt); + }, function (err) { + if (!err.coreContext) err.coreContext = "connect"; + throw err; + }); +} + +function ensureTransportReady() { + ensureConnectionDesiredOrThrow("attach"); + if (isTransportReady()) { + setCoreState(CORE_STATE.CONNECTED, "transport already ready"); + return Promise.resolve(); + } + return attachCachedOrDiscover(); +} + +function performConnectSequence() { + return ensureDeviceAvailable() + .then(ensureGattConnected) + .then(ensureTransportReady) + .then(function () { + lastError = undefined; + pendingReconnect = false; + resetReconnectBackoff(); + connectionSessionId++; + setCoreState(CORE_STATE.CONNECTED); + scheduleProfileUpgrade(); + return notifyConnectedHandlers(connectionSessionId); + }) + .catch(function (err) { + if (String(err).indexOf("power off") >= 0) err.coreContext = "power_off"; + else if (!err.coreContext && coreState === CORE_STATE.DISCOVERING) err.coreContext = "discover"; + else if (!err.coreContext && coreState === CORE_STATE.ATTACHING) err.coreContext = "attach"; + else if (!err.coreContext) err.coreContext = "connect"; + throw err; + }); +} + +function handleLifecycleFailure(err) { + var context = err.coreContext || "connect"; + var isPairAttempt = !!activePairTarget || !!(activeLifecycleTask && activeLifecycleTask.kind === "pair"); + var settings = store.get(); + lastError = String(err); + log("BLE failure", { context: context, error: lastError }); + setCoreState(CORE_STATE.ERROR, context); + if (isPairAttempt) { + shouldBeConnected = false; + pendingReconnect = false; + clearReconnectTimer(); + } + if (context === "no_pairing") { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + setCoreState(CORE_STATE.IDLE, context); + throw err; + } + if (context === "power_off") { + clearReconnectTimer(); + cleanupGatt(context); + return waitForBleSettle(context).then(function () { + setCoreState(CORE_STATE.IDLE, context); + throw err; + }); + } + if (context === "paused") { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + cleanupGatt(context); + return waitForBleSettle(context).then(function () { + setCoreState(CORE_STATE.IDLE, context); + throw err; + }); + } + cleanupGatt(context); + return waitForBleSettle(context).then(function () { + if (isPairAttempt) { + pendingReconnect = false; + clearReconnectTimer(); + setCoreState(CORE_STATE.IDLE, context); + } else if (shouldBeConnected && !isPaused() && !pendingDisconnect && !pendingUnpair && settings.btid) { + scheduleReconnect(context); + } else { + pendingReconnect = false; + clearReconnectTimer(); + setCoreState(CORE_STATE.IDLE, context); + } + throw err; + }); +} + +function performBusyRetry(attempt, err) { + lastError = String(err); + log("BLE stack busy", { attempt: attempt, error: lastError }); + setCoreState(CORE_STATE.ERROR, "stack busy"); + cleanupGatt("stack busy"); + return waitForBleSettle("stack busy"); +} + +function reconcileLifecycle(kind) { + var settings = readSettings(); + if (pendingUnpair) { + standDownCoreRuntime(); + setCoreState(CORE_STATE.DISCONNECTING, "unpair"); + cleanupGatt("unpair"); + return waitForBleSettle("unpair").then(function () { + eraseStoredPairing(true); + standDownCoreRuntime(); + pendingUnpair = false; + pendingDisconnect = false; + pendingPairTarget = undefined; + pendingRebuildCache = false; + setCoreState(CORE_STATE.IDLE, "unpaired"); + }); + } + if (pendingDisconnect) { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + shouldBeConnected = false; + setCoreState(CORE_STATE.DISCONNECTING, "requested disconnect"); + cleanupGatt("requested disconnect"); + return waitForBleSettle("requested disconnect").then(function () { + pendingDisconnect = false; + setCoreState(CORE_STATE.IDLE, "requested disconnect"); + }); + } + if (isPaused()) { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + if (gatt || device) { + setCoreState(CORE_STATE.DISCONNECTING, "paused"); + cleanupGatt("paused"); + return waitForBleSettle("paused").then(function () { + setCoreState(CORE_STATE.IDLE, "paused"); + }); + } + setCoreState(CORE_STATE.IDLE, "paused"); + return Promise.resolve(); + } + if (pendingPairTarget) { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + shouldBeConnected = true; + setCoreState(CORE_STATE.DISCONNECTING, "pair target"); + cleanupGatt("pair target"); + return waitForBleSettle("pair target").then(function () { + // Move the pending target into activePairTarget for the duration of + // discovery so cache use and status handling know this is a pair attempt. + var pairTarget = pendingPairTarget; + pendingPairTarget = undefined; + activePairTarget = pairTarget; + if (pairTarget && pairTarget.device) device = pairTarget.device; + pendingRebuildCache = false; + return connectWithBusyRetry().then(function (result) { + writeSettings(function (nextSettings) { + nextSettings.btid = pairTarget.id; + if (pairTarget.name) nextSettings.btname = pairTarget.name; + else delete nextSettings.btname; + }); + activePairTarget = undefined; + return result; + }, function (err) { + activePairTarget = undefined; + throw err; + }); + }); + } + if (pendingRebuildCache) { + clearReconnectTimer(); + pendingReconnect = false; + shouldBeConnected = true; + setCoreState(CORE_STATE.DISCONNECTING, "rebuild cache"); + cleanupGatt("rebuild cache"); + deleteCache(); + return waitForBleRebuildSettle("rebuild cache").then(function () { + pendingRebuildCache = false; + return connectWithBusyRetry(); + }); + } + if (pendingReconnect) { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + setCoreState(CORE_STATE.DISCONNECTING, "reconnect requested"); + cleanupGatt("reconnect requested"); + return waitForBleSettle("reconnect requested").then(function () { + return connectWithBusyRetry(); + }).then(function (result) { + pendingReconnect = false; + return result; + }); + } + if (!shouldBeConnected) { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + if (gatt || device) { + setCoreState(CORE_STATE.DISCONNECTING, "no connection requested"); + cleanupGatt("no connection requested"); + return waitForBleSettle("no connection requested").then(function () { + setCoreState(CORE_STATE.IDLE, "no connection requested"); + }); + } + setCoreState(CORE_STATE.IDLE, "no connection requested"); + return Promise.resolve(); + } + if (!settings.btid) { + pendingReconnect = false; + clearReconnectTimer(); + clearProfileUpgradeTimer(); + lastError = "CORE device is not paired"; + setCoreState(CORE_STATE.IDLE, "no_pairing"); + if (kind === "connect" || kind === "power_on" || kind === "reconnect") { + var pairErr = new Error("CORE device is not paired"); + pairErr.coreContext = "no_pairing"; + throw pairErr; + } + return Promise.resolve(); + } + return connectWithBusyRetry(); +} + +function connectWithBusyRetry() { + var attempts = 0; + function attemptConnect() { + attempts++; + return performConnectSequence().catch(function (err) { + if (isBleBusyError(err) && shouldBeConnected && !isPaused() && attempts < BLE_BUSY_RETRY_LIMIT) { + return performBusyRetry(attempts, err).then(attemptConnect); + } + return handleLifecycleFailure(err); + }); + } + return attemptConnect(); +} + +function isTransientOwner(owner) { + return owner === "coretemp.settings" || + owner === "coretemp.pair" || + owner === "coretemp.rebuild"; +} + +function isOn() { + return !!(Bangle._PWR && Bangle._PWR.CORESensor && Bangle._PWR.CORESensor.length); +} + +function isConnected() { + return !!(gatt && gatt.connected); +} + +function runWithTemporaryPower(owner, fn) { + var acquiredPower = false; + var promise; + // Settings/debug actions need a live connection but should not leave the + // sensor powered after they finish unless another owner already held power. + if (!isOn()) { + setPower(1, owner); + acquiredPower = true; + } + try { + promise = Promise.resolve(fn()); + } catch (e) { + promise = Promise.reject(e); + } + return promise.then(function (result) { + if (acquiredPower) setPower(0, owner); + return result; + }, function (err) { + if (acquiredPower) setPower(0, owner); + throw err; + }); +} + +function onDisconnect(disconnectedDevice, reason) { + // cleanupGatt intentionally disconnects; ignore that event so requested + // disconnects do not schedule a reconnect. + if (expectedDisconnectDevice && expectedDisconnectDevice === disconnectedDevice) { + expectedDisconnectDevice = undefined; + log("Ignoring expected disconnect", reason); + return; + } + log("Disconnect", reason); + lastError = "Disconnected: " + reason; + resetTransportState("disconnect"); + if (shouldBeConnected && !isPaused() && !pendingDisconnect && !pendingUnpair && isOn()) { + scheduleReconnect("disconnect"); + } else { + pendingReconnect = false; + clearReconnectTimer(); + setCoreState(CORE_STATE.IDLE, "disconnect while off"); + } +} + +function requestTransportReconnect(reason, err) { + log("Request transport reconnect", { reason: reason, error: String(err) }); + lastError = String(err); + if (!shouldBeConnected || isPaused() || pendingDisconnect || pendingUnpair || !isOn()) return; + pendingReconnect = true; + enqueueLifecycle("transport_recovery", function () { + pendingReconnect = true; + }).catch(function (queueErr) { + log("Transport recovery failed", queueErr); + }); +} + +function connect() { + readSettings(); + if (!isOn()) return Promise.reject(new Error("CORESensor has no power owner")); + if (!store.get().btid) return Promise.reject(new Error("CORE device is not paired")); + if (isPaused()) { + clearReconnectTimer(); + pendingReconnect = false; + setCoreState(CORE_STATE.IDLE, "paused"); + return Promise.resolve(); + } + return enqueueLifecycle("connect", function () { + pendingDisconnect = false; + pendingUnpair = false; + pendingReconnect = false; + shouldBeConnected = true; + }); +} + +function disconnect() { + return enqueueLifecycle("disconnect", function () { + clearReconnectTimer(); + pendingReconnect = false; + pendingDisconnect = true; + shouldBeConnected = false; + }); +} + +function pairDevice(deviceOrId, deviceName) { + var pairTarget; + if (deviceOrId && typeof deviceOrId === "object") { + if (!deviceOrId.id) return Promise.reject(new Error("Missing CORE device id")); + pairTarget = { + id: deviceOrId.id, + name: deviceOrId.name, + device: deviceOrId + }; + } else { + if (!deviceOrId) return Promise.reject(new Error("Missing CORE device id")); + pairTarget = { + id: deviceOrId, + name: deviceName + }; + } + return runWithTemporaryPower("coretemp.pair", function () { + return enqueueLifecycle("pair", function () { + clearReconnectTimer(); + pendingReconnect = false; + pendingDisconnect = false; + pendingUnpair = false; + pendingRebuildCache = false; + pendingPairTarget = pairTarget; + shouldBeConnected = true; + }); + }); +} + +function unpairDevice() { + return enqueueLifecycle("unpair", function () { + clearReconnectTimer(); + pendingReconnect = false; + pendingDisconnect = false; + pendingUnpair = true; + shouldBeConnected = false; + }); +} + +function rebuildCache() { + readSettings(); + if (!store.get().btid) return Promise.reject(new Error("CORE device is not paired")); + return runWithTemporaryPower("coretemp.rebuild", function () { + return enqueueLifecycle("rebuild", function () { + clearReconnectTimer(); + pendingReconnect = false; + pendingDisconnect = false; + pendingRebuildCache = true; + shouldBeConnected = true; + }); + }); +} + +function writeControlPoint(opCode, params, options) { + if (!controlPointChar || !gatt || !gatt.connected) { + return Promise.reject(new Error("CORE control point is not connected")); + } + return controlpoint.request(opCode, params, options).catch(function (err) { + if (isBleTransportError(err)) requestTransportReconnect("control_point_transport", err); + throw err; + }); +} + +function getStatus() { + readSettings(); + return { + enabled: store.get().enabled === true, + paired: !!store.get().btid, + deviceId: store.get().btid, + deviceName: store.get().btname, + state: coreState, + connected: !!(gatt && gatt.connected), + reconnectScheduled: !!reconnectTimer, + profileUpgradeScheduled: !!profileUpgradeTimer, + hasCache: !!(store.get().cache && store.get().cache.characteristics), + profile: activeProfile, + customProfileOnly: store.get().customprofileonly === true, + lastError: lastError, + activeTask: activeLifecycleTask && activeLifecycleTask.kind, + desiredConnected: !!shouldBeConnected, + pendingReconnect: !!pendingReconnect, + paused: isPaused(), + pauseOwners: pauseOwners.slice() + }; +} + +function pause(owner) { + if (!owner) owner = "?"; + if (pauseOwners.indexOf(owner) < 0) pauseOwners.push(owner); + log("CORESensor pause ->", { owner: owner, owners: pauseOwners.slice() }); + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + return enqueueLifecycle("pause", function () { + clearReconnectTimer(); + clearProfileUpgradeTimer(); + pendingReconnect = false; + }); +} + +function resume(owner) { + if (!owner) owner = "?"; + if (pauseOwners.indexOf(owner) >= 0) { + pauseOwners = pauseOwners.filter(function (activeOwner) { + return activeOwner !== owner; + }); + } + log("CORESensor resume ->", { owner: owner, owners: pauseOwners.slice() }); + if (isPaused()) return Promise.resolve(); + if (!isOn()) { + pendingReconnect = false; + clearReconnectTimer(); + clearProfileUpgradeTimer(); + setCoreState(CORE_STATE.IDLE, "resume without power"); + return Promise.resolve(); + } + return enqueueLifecycle("resume", function () { + pendingDisconnect = false; + pendingUnpair = false; + pendingReconnect = false; + shouldBeConnected = true; + }); +} + +function setPower(isOnValue, app) { + if (!app) app = "?"; + if (Bangle._PWR === undefined) Bangle._PWR = {}; + if (Bangle._PWR.CORESensor === undefined) Bangle._PWR.CORESensor = []; + log("setCORESensorPower ->", { on: !!isOnValue, owner: app }); + if (isOnValue && Bangle._PWR.CORESensor.indexOf(app) < 0) Bangle._PWR.CORESensor.push(app); + if (!isOnValue && Bangle._PWR.CORESensor.indexOf(app) >= 0) { + Bangle._PWR.CORESensor = Bangle._PWR.CORESensor.filter(function (owner) { + return owner !== app; + }); + } + if (Bangle._PWR.CORESensor.length > 0) { + // Transient owners borrow power for settings operations. They should not + // override a pending disconnect/unpair or create background desire alone. + if (isTransientOwner(app)) return; + if (!pendingDisconnect && !pendingUnpair) shouldBeConnected = true; + if (isPaused()) { + clearReconnectTimer(); + pendingReconnect = false; + setCoreState(CORE_STATE.IDLE, "paused"); + return; + } + enqueueLifecycle("power_on", function () { + if (!pendingDisconnect && !pendingUnpair) shouldBeConnected = true; + }).catch(function (e) { + log("Auto connect failed", e); + }); + } else { + shouldBeConnected = false; + enqueueLifecycle("power_off", function () { + clearReconnectTimer(); + pendingReconnect = false; + pendingDisconnect = true; + shouldBeConnected = false; + }).catch(function (e) { + log("CORESensor disconnect error", e); + }); + } +} + +function runWithConnectedSession(owner, fn) { + return runWithTemporaryPower(owner, function () { + return connect().then(function () { + return fn(); + }); + }); +} + +exports.init = function () { + if (initialized) return; + initialized = true; +}; + +exports.isOn = isOn; +exports.isConnected = isConnected; +exports.connect = connect; +exports.disconnect = disconnect; +exports.pairDevice = pairDevice; +exports.unpairDevice = unpairDevice; +exports.rebuildCache = rebuildCache; +exports.writeControlPoint = writeControlPoint; +exports.getStatus = getStatus; +exports.setPower = setPower; +exports.pause = pause; +exports.resume = resume; +exports.isPaused = isPaused; +exports.runWithConnectedSession = runWithConnectedSession; +exports.onConnected = function (handler) { + if (typeof handler !== "function") return; + connectedHandlers.push(handler); +}; + +exports.shutdown = function () { + store.flush(); + clearReconnectTimer(); + clearProfileUpgradeTimer(); + shouldBeConnected = false; + pendingReconnect = false; + pendingDisconnect = false; + pendingUnpair = false; + pendingPairTarget = undefined; + activePairTarget = undefined; + pendingRebuildCache = false; + pauseOwners = []; + if (gatt || device) { + setCoreState(CORE_STATE.DISCONNECTING, "kill"); + cleanupGatt("kill"); + } +}; diff --git a/apps/coretemp/boot.js b/apps/coretemp/boot.js index 647a0c4bcf3..f451d0e00e6 100644 --- a/apps/coretemp/boot.js +++ b/apps/coretemp/boot.js @@ -1 +1,5 @@ -if ((require('Storage').readJSON("coretemp.json", true) || {}).enabled != false) require("CORESensor").enable(); \ No newline at end of file +var s = require("Storage").readJSON("coretemp.json", true) || {}; + +if (s.enabled === true) { + require("CORESensor").enable(); +} diff --git a/apps/coretemp/controlpoint.js b/apps/coretemp/controlpoint.js new file mode 100644 index 00000000000..697cf4a63fd --- /dev/null +++ b/apps/coretemp/controlpoint.js @@ -0,0 +1,116 @@ +var protocol = require("coretemp.protocol"); + +var adapter; +var activeRequest; +var requestQueue = []; + +function log(text, value) { + if (adapter && adapter.log) adapter.log(text, value); +} + +function normalizeOptions(options) { + if (typeof options === "number") return { timeoutMs: options }; + return options || {}; +} + +function clearRequest(req) { + if (req.timeout) clearTimeout(req.timeout); + if (activeRequest === req) activeRequest = undefined; +} + +function writeBytes(bytes) { + if (!adapter || !adapter.write) { + return Promise.reject(new Error("CORE control point is not connected")); + } + return Promise.resolve(adapter.write(bytes)); +} + +function pumpQueue() { + var req; + var bytes; + // CORE accepts one control-point procedure at a time; keep later commands + // queued until the matching response or timeout resolves the active one. + if (activeRequest || !requestQueue.length) return; + req = requestQueue.shift(); + bytes = [req.opcode].concat(req.params); + activeRequest = req; + req.timeout = setTimeout(function () { + if (activeRequest !== req) return; + clearRequest(req); + req.reject(new Error("CORE control point timeout for opcode " + req.opcode)); + pumpQueue(); + }, req.timeoutMs); + + writeBytes(bytes).then(function () { + log("Sent control point opcode", req.opcode); + }).catch(function (err) { + if (activeRequest !== req) return; + clearRequest(req); + req.reject(err); + pumpQueue(); + }); +} + +exports.setAdapter = function (nextAdapter) { + adapter = nextAdapter; + if (!adapter) exports.cancelActive("CORE control point is not connected"); +}; + +exports.isBusy = function () { + return !!activeRequest; +}; + +exports.cancelActive = function (reason) { + var req = activeRequest; + if (!req) return; + clearRequest(req); + req.reject(new Error(reason || "CORE control point request cancelled")); + pumpQueue(); +}; + +exports.request = function (opcode, params, options) { + params = params || []; + options = normalizeOptions(options); + return new Promise(function (resolve, reject) { + requestQueue.push({ + opcode: opcode, + params: params.slice ? params.slice() : [], + timeoutMs: options.timeoutMs || 10000, + resolve: resolve, + reject: reject + }); + pumpQueue(); + }); +}; + +exports.onNotification = function (dv) { + var response = protocol.parseResponse(dv); + var req; + + // Responses are async BLE notifications, so verify both the response opcode + // and the original request opcode before resolving the active request. + if (response.opCode !== protocol.OPCODES.RESPONSE) { + log("Ignoring non-control point response", response.bytes); + return; + } + if (!activeRequest) { + log("Discarding stale control point response", response.bytes); + return; + } + if (response.requestOpCode !== activeRequest.opcode) { + log("Discarding mismatched control point response", { + expected: activeRequest.opcode, + bytes: response.bytes + }); + return; + } + + req = activeRequest; + clearRequest(req); + if (response.resultCode === 0x01) { + req.resolve(response); + } else { + req.reject(new Error("Control point error code " + response.resultCode)); + } + pumpQueue(); +}; diff --git a/apps/coretemp/coretemp.js b/apps/coretemp/coretemp.js index 26806cac338..c1f7f3ef53f 100644 --- a/apps/coretemp/coretemp.js +++ b/apps/coretemp/coretemp.js @@ -1,4 +1,6 @@ var settings = require("Storage").readJSON("coretemp.json", 1) || {}; +var OWNER = "COREAPP"; +var coreStarted = false; // Simply listen for core events and show data //var btm = g.getHeight() - 1; var px = g.getWidth() / 2; @@ -19,32 +21,58 @@ var corelogo = { }; function onCore(c) { + if (!c) return; // Large or small font var sz = ((process.env.HWVERSION == 1) ? 3 : 2); + g.reset(); + if (g.setBgColor) g.setBgColor(g.theme.bg); g.setFontAlign(0, 0); g.clearRect(0, 32 + 48, g.getWidth(), 32 + 48 + 24 * 4); g.setColor(g.theme.dark ? "#CCC" : "#333"); // gray g.setFont("6x8", sz).drawString("Core: " + ((c.core < 327) ? (c.core + c.unit) : 'n/a'), px, 48 + 48); g.setFont("6x8", sz).drawString("Skin: " + c.skin + c.unit, px, 48 + 48 + 14); - g.setFont("6x8", sz).drawString("HR: " + c.hr + " BPM", px, 48 + 48 + 28); - g.setFont("6x8", sz).drawString("HSI: " + c.hsi+ "/10", px, 48 + 48 + 42); + var hrText = "n/a"; + if (c.hr && c.hr > 0) { + hrText = c.hr + " BPM"; + } + g.setFont("6x8", sz).drawString("HR: " + hrText, px, 48 + 48 + 28); + g.setFont("6x8", sz).drawString("HSI: " + (c.hsiValid ? (c.hsi + "/10") : "n/a"), px, 48 + 48 + 42); g.setFont("6x8", sz).drawString("BATT: " + c.battery+ "%", px, 48 + 48 + 56); } function drawBackground(message) { + g.reset(); + if (g.setBgColor) g.setBgColor(g.theme.bg); g.clear(); Bangle.loadWidgets(); Bangle.drawWidgets(); - g.reset().setFont("6x8", 2).setFontAlign(0, 0); + g.reset(); + if (g.setBgColor) g.setBgColor(g.theme.bg); + g.setFont("6x8", 2).setFontAlign(0, 0); g.drawImage(corelogo, px - 146 / 2, 30); g.drawString(message, g.getWidth() / 2, g.getHeight() / 2 + 16); } -if (!settings.enabled) { - drawBackground("Sensor off\nEnable in Settings"); -} else { - Bangle.setCORESensorPower(1,"COREAPP"); +function stopCore() { + if (coreStarted) { + Bangle.removeListener('CORESensor', onCore); + coreStarted = false; + } + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, OWNER); +} + +function startCore() { + if (!settings.btid) { + drawBackground("Pair CORE\nin Settings"); + return; + } + try { require("CORESensor").enable(); } catch (e) {} + coreStarted = true; Bangle.on('CORESensor', onCore); + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(1, OWNER); drawBackground("Waiting for\ndata..."); } + +E.on("kill", stopCore); +startCore(); diff --git a/apps/coretemp/custom.html b/apps/coretemp/custom.html new file mode 100644 index 00000000000..858034807ca --- /dev/null +++ b/apps/coretemp/custom.html @@ -0,0 +1,167 @@ + + + + + + + + +
+

CoreTemp Settings

+
+
+ + +
+ +
+ + +
+ +
+ + +

Allowed range: 5-30 seconds.

+
+ +
+ + +

ANT+ device ID. Leave blank to remove the stored preset ID only. Does not affect HRMs already paired with CORE.

+
+
+ +

+

+
+ + + + + + diff --git a/apps/coretemp/hrm.js b/apps/coretemp/hrm.js new file mode 100644 index 00000000000..9c8aa317273 --- /dev/null +++ b/apps/coretemp/hrm.js @@ -0,0 +1,343 @@ +var cp = require("coretemp.controlpoint"); +var protocol = require("coretemp.protocol"); +var store = require("coretemp.store"); + +var HRM_FILE = "coretemp.hrm.json"; +var RECENT_LIMIT = 5; + +var SCAN_START_ACK_TIMEOUT_MS = 3000; +var SCAN_WINDOW_DEFAULT_SEC = 5; +var SCAN_WINDOW_MIN_SEC = 5; +var SCAN_WINDOW_MAX_SEC = 30; +var SCAN_COUNT_TIMEOUT_MS = 5000; +var SCAN_ENTRY_TIMEOUT_MS = 5000; +var STATUS_TIMEOUT_MS = 5000; +var PAIR_TIMEOUT_MS = 10000; +var CLEAR_TIMEOUT_MS = 10000; +var REPLACE_CLEAR_SETTLE_MS = 2000; + +var hrmState = { + busy: false, + operation: null, + lastError: null, + lastScan: [], + paired: [], + selected: null, + recent: [] +}; + +function cloneEntry(entry) { + if (!entry) return null; + return { + index: entry.index || 0, + antId: entry.antId, + txType: entry.txType || 0, + transport: entry.transport || "ANT+" + }; +} + +function serializeEntry(entry) { + var serialized; + if (!entry) return null; + serialized = { + antId: entry.antId, + transport: entry.transport || "ANT+" + }; + if (entry.txType) serialized.txType = entry.txType; + return serialized; +} + +function getScanWindowMs() { + var value = store.get().antScanWindowSec; + if (typeof value === "string" && /^\d+$/.test(value.trim())) value = parseInt(value, 10); + if (typeof value !== "number" || isNaN(value) || (value | 0) !== value) value = SCAN_WINDOW_DEFAULT_SEC; + if (value < SCAN_WINDOW_MIN_SEC || value > SCAN_WINDOW_MAX_SEC) value = SCAN_WINDOW_DEFAULT_SEC; + return value * 1000; +} + +function normalizeAntId(id) { + // Accept UI/persisted decimal strings, but keep the ANT device id bounded to + // the 24 bits CORE accepts for pair requests. + if (typeof id === "string" && /^\d+$/.test(id.trim())) id = parseInt(id, 10); + if (typeof id !== "number" || isNaN(id) || (id | 0) !== id) return undefined; + if (id <= 0 || id > 0xFFFFFF) return undefined; + return id; +} + +function normalizeEntry(entry) { + var id; + if (typeof entry === "number" || typeof entry === "string") { + id = normalizeAntId(entry); + if (id === undefined) return null; + return { + index: 0, + antId: id, + txType: (id >> 16) & 0xFF, + transport: "ANT+" + }; + } + if (!entry) return null; + id = normalizeAntId(entry.antId); + if (id === undefined) return null; + return { + index: entry.index || 0, + antId: id, + txType: entry.txType || ((id >> 16) & 0xFF), + transport: entry.transport || "ANT+" + }; +} + +function readConfig() { + var config = require("Storage").readJSON(HRM_FILE, 1) || {}; + // HRM choices live outside coretemp.json so resetting BLE pairing/cache can + // be handled independently from the user's recent ANT+ sensor list. + hrmState.selected = normalizeEntry(config.selected); + hrmState.recent = (config.recent || []).map(normalizeEntry).filter(function (entry) { + return !!entry; + }); +} + +function persistConfig() { + require("Storage").writeJSON(HRM_FILE, { + selected: serializeEntry(hrmState.selected), + recent: hrmState.recent.map(serializeEntry) + }); +} + +function formatError(err) { + if (err === undefined || err === null) return String(err); + if (err instanceof Error) return err.message || String(err); + if (typeof err === "string") return err; + if (typeof err === "object" && err.message) return String(err.message); + try { + return JSON.stringify(err); + } catch (e) { + return String(err); + } +} + +function waitMs(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); +} + +function decorateEntry(entry) { + entry.transport = entry.transport || "ANT+"; + return entry; +} + +function buildStatus() { + return { + busy: hrmState.busy, + operation: hrmState.operation, + lastError: hrmState.lastError, + lastScan: hrmState.lastScan.map(cloneEntry), + pairedSensors: hrmState.paired.map(cloneEntry), + pairedCount: hrmState.paired.length, + paired: hrmState.paired.length > 0, + multiplePaired: hrmState.paired.length > 1, + currentSource: hrmState.paired[0] ? cloneEntry(hrmState.paired[0]) : null, + syncState: hrmState.paired.length ? "paired" : "none", + selected: cloneEntry(hrmState.selected), + recent: hrmState.recent.map(cloneEntry) + }; +} + +function setPaired(entries) { + hrmState.paired = entries.map(function (entry, index) { + entry.index = index; + return decorateEntry(entry); + }); +} + +function queryPairedEntries() { + return cp.request(protocol.OPCODES.HRM_PAIRED_COUNT, [], STATUS_TIMEOUT_MS) + .then(function (response) { + var count = protocol.parseCount(response); + var entries = []; + var promise = Promise.resolve(); + var i; + // Control-point requests must stay serialized; build an explicit promise + // chain rather than issuing all entry reads concurrently. + for (i = 0; i < count; i++) { + (function (index) { + promise = promise.then(function () { + return cp.request( + protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, + [index], + STATUS_TIMEOUT_MS + ).then(function (entryResponse) { + entries.push(decorateEntry(protocol.parseAntEntry(entryResponse, index))); + }); + }); + })(i); + } + return promise.then(function () { + entries.sort(function (a, b) { return a.index - b.index; }); + setPaired(entries); + return entries; + }); + }); +} + +function remember(entry) { + var normalized = normalizeEntry(entry); + var next = []; + var i; + if (!normalized) return; + hrmState.selected = normalized; + next.push(normalized); + for (i = 0; i < hrmState.recent.length && next.length < RECENT_LIMIT; i++) { + if (hrmState.recent[i].antId !== normalized.antId) next.push(hrmState.recent[i]); + } + hrmState.recent = next; + persistConfig(); +} + +function runOperation(name, fn) { + if (hrmState.busy) { + return Promise.reject(new Error("HRM operation already in progress")); + } + // Scan, pair, clear, and status all share the CORE control point. A module + // level operation lock keeps UI actions from interleaving multi-step flows. + hrmState.busy = true; + hrmState.operation = name; + hrmState.lastError = null; + store.log("HRM operation -> " + name); + return Promise.resolve().then(fn).then(function (result) { + hrmState.busy = false; + hrmState.operation = null; + hrmState.lastError = null; + return result; + }, function (err) { + hrmState.busy = false; + hrmState.operation = null; + hrmState.lastError = formatError(err); + store.log("HRM error", hrmState.lastError); + throw err; + }); +} + +function pairNormalizedEntry(entry) { + return cp.request( + protocol.OPCODES.HRM_PAIR_ANT, + protocol.makeAntPairParams(entry.antId, entry.txType), + PAIR_TIMEOUT_MS + ).then(function () { + return queryPairedEntries(); + }).then(function (entries) { + var i; + for (i = 0; i < entries.length; i++) { + if (entries[i].antId === entry.antId) { + remember(entry); + return buildStatus(); + } + } + throw new Error("HRM pair verification failed"); + }); +} + +function replacePairedEntry(entry) { + return cp.request(protocol.OPCODES.HRM_CLEAR_ANT, [], CLEAR_TIMEOUT_MS) + .then(function () { + return waitMs(REPLACE_CLEAR_SETTLE_MS); + }) + .then(function () { + return pairNormalizedEntry(entry); + }); +} + +exports.init = function () { + readConfig(); +}; + +exports.getState = function () { + return buildStatus(); +}; + +exports.getManagerState = exports.getState; + +exports.getStatus = function () { + return runOperation("status", function () { + return queryPairedEntries().then(buildStatus); + }); +}; + +exports.scanANT = function () { + return runOperation("scan_ant", function () { + // CORE scan results may include HRMs that are already paired. + return cp.request( + protocol.OPCODES.HRM_SCAN_ANT_START, + [0xFF], + SCAN_START_ACK_TIMEOUT_MS + ).then(function () { + return waitMs(getScanWindowMs()); + }).then(function () { + return cp.request(protocol.OPCODES.HRM_SCAN_ANT_COUNT, [], SCAN_COUNT_TIMEOUT_MS); + }).then(function (response) { + var count = protocol.parseCount(response); + var found = []; + var promise = Promise.resolve(); + var i; + // As with paired-entry reads, CORE returns scan entries by index through + // the single control point instead of in the count response. + for (i = 0; i < count; i++) { + (function (index) { + promise = promise.then(function () { + return cp.request( + protocol.OPCODES.HRM_SCAN_ANT_ENTRY, + [index], + SCAN_ENTRY_TIMEOUT_MS + ).then(function (entryResponse) { + found.push(decorateEntry(protocol.parseAntEntry(entryResponse, index))); + }); + }); + })(i); + } + return promise.then(function () { + found.sort(function (a, b) { return a.index - b.index; }); + hrmState.lastScan = found; + return found.map(cloneEntry); + }); + }); + }); +}; + +exports.pairANT = function (entry, replaceExisting) { + var normalized = normalizeEntry(entry); + if (!normalized) return Promise.reject(new Error("Invalid ANT+ HRM id")); + return runOperation("pair_ant", function () { + return queryPairedEntries().then(function (entries) { + if (entries.length > 1) { + throw new Error("Multiple HRMs are paired on CORE. Clear paired HRMs before pairing one ANT+ sensor."); + } + if (entries.length === 1) { + if (entries[0].antId === normalized.antId && !replaceExisting) { + remember(normalized); + return buildStatus(); + } + if (!replaceExisting) { + throw new Error("A different HRM is already paired on CORE"); + } + return replacePairedEntry(normalized); + } + return pairNormalizedEntry(normalized); + }); + }); +}; + +exports.clearANT = function () { + return runOperation("clear_ant", function () { + return cp.request(protocol.OPCODES.HRM_CLEAR_ANT, [], CLEAR_TIMEOUT_MS) + .then(queryPairedEntries) + .then(function (entries) { + if (entries.length) throw new Error("HRM clear verification failed"); + hrmState.selected = null; + persistConfig(); + return buildStatus(); + }); + }); +}; + +exports.clear = exports.clearANT; diff --git a/apps/coretemp/hrm.json b/apps/coretemp/hrm.json new file mode 100644 index 00000000000..ed372644f67 --- /dev/null +++ b/apps/coretemp/hrm.json @@ -0,0 +1,4 @@ +{ + "selected": null, + "recent": [] +} diff --git a/apps/coretemp/lib.js b/apps/coretemp/lib.js index 88526106100..da738b4ce51 100644 --- a/apps/coretemp/lib.js +++ b/apps/coretemp/lib.js @@ -1,279 +1,3 @@ -exports.enable = () => { - var settings = require("Storage").readJSON("coretemp.json", 1) || {}; - let log = function () { };//print - Bangle.enableCORESensorLog = function () { - log = function (text, param) { - let logline = new Date().toISOString() + " - " + text; - if (param) logline += ": " + JSON.stringify(param); - print(logline); - }; - }; - let gatt; - let device; - let characteristics; - let blockInit = false; - let waitingPromise = function (timeout) { - return new Promise(function (resolve) { - log("Start waiting for " + timeout); - setTimeout(() => { - log("Done waiting for " + timeout); - resolve(); - }, timeout); - }); - }; - - if (settings.enabled && settings.cache) { - let addNotificationHandler = function (characteristic) { - log("Setting notification handler"/*supportedCharacteristics[characteristic.uuid].handler*/); - characteristic.on('characteristicvaluechanged', (ev) => supportedCharacteristics[characteristic.uuid].handler(ev.target.value)); - }; - let characteristicsFromCache = function (device) { - let service = { device: device }; // fake a BluetoothRemoteGATTService - log("Read cached characteristics"); - let cache = settings.cache; - if (!cache.characteristics) return []; - let restored = []; - for (let c in cache.characteristics) { - let cached = cache.characteristics[c]; - let r = new BluetoothRemoteGATTCharacteristic(); - log("Restoring characteristic ", cached); - r.handle_value = cached.handle; - r.uuid = cached.uuid; - r.properties = {}; - r.properties.notify = cached.notify; - r.properties.read = cached.read; - r.service = service; - addNotificationHandler(r); - log("Restored characteristic: ", r); - restored.push(r); - } - return restored; - }; - let supportedCharacteristics = { - "00002101-5b1e-4347-b07c-97b514dae121": { - handler: function (dv) { - log(dv); - let index = 0; - let flags = dv.getUint8(index++); - let coreTemp = dv.getInt16(index, true) / 100.0; - index += 2; - let skinTemp = dv.getInt16(index, true) / 100.0; - index += 2; - let coreReserved = dv.getInt16(index, true); //caleraGT only with firmware decryption provided by Greenteg - index += 2; - let qualityAndState = dv.getUint8(index++); - let heartRate = dv.getUint8(index++); - let heatStrainIndex = dv.getUint8(index) / 10.0; - let dataQuality = qualityAndState & 0x07; - let hrState = (qualityAndState >> 4) & 0x03; - let data = { - core: coreTemp, - skin: skinTemp, - unit: (flags & 0b00001000) ? "F" : "C", - hr: heartRate, - heatflux: coreReserved, - hsi: heatStrainIndex, - battery: 0, - dataQuality: dataQuality, - hrState: hrState - }; - if (lastReceivedData.hasOwnProperty("0x180f")) { - data.battery = lastReceivedData["0x180f"]["0x2a19"]; - } - log("data", data); - Bangle.emit("CORESensor", data); - } - }, - "00002102-5b1e-4347-b07c-97b514dae121": { - handler: function (dv) { - log(dv);//just log the response, handle write and responses in another Promise Function (Bangle.CORESensorSendOpCode) - } - }, - "0x2a19": { - //Battery - handler: function (dv) { - if (!lastReceivedData["0x180f"]) lastReceivedData["0x180f"] = {}; - log("Got battery", dv); - lastReceivedData["0x180f"]["0x2a19"] = dv.getUint8(0); - } - } - }; - let lastReceivedData = { - }; - - Bangle.isCORESensorOn = function () { - return (Bangle._PWR && Bangle._PWR.CORESensor && Bangle._PWR.CORESensor.length > 0); - }; - - Bangle.isCORESensorConnected = function () { - return gatt && gatt.connected; - }; - - let onDisconnect = function (reason) { - blockInit = false; - log("Disconnect: " + reason); - if (Bangle.isCORESensorOn()) { - setTimeout(initCORESensor, 5000); - } - }; - let createCharacteristicPromise = function (newCharacteristic) { - log("Create characteristic promise", newCharacteristic); - let result = Promise.resolve(); - if (newCharacteristic.readValue) { - result = result.then(() => { - log("Reading data", newCharacteristic); - return newCharacteristic.readValue().then((data) => { - if (supportedCharacteristics[newCharacteristic.uuid] && supportedCharacteristics[newCharacteristic.uuid].handler) { - supportedCharacteristics[newCharacteristic.uuid].handler(data); - } - }); - }); - } - if (newCharacteristic.properties.notify) { - result = result.then(() => { - log("Starting notifications", newCharacteristic); - let startPromise = newCharacteristic.startNotifications().then(() => log("Notifications started", newCharacteristic)); - startPromise = startPromise.then(() => { - return waitingPromise(3000); - }); - return startPromise; - }); - } - return result.then(() => log("Handled characteristic", newCharacteristic)); - }; - - let attachCharacteristicPromise = function (promise, characteristic) { - return promise.then(() => { - log("Handling characteristic:", characteristic); - return createCharacteristicPromise(characteristic); - }); - }; - let initCORESensor = function () { - if (!settings.btname) { - log("CORESensor not paired, quitting"); - return; - } - if (blockInit) { - log("CORESensor already turned on by another app, quitting"); - return; - } - blockInit = true; - NRF.setScan(); - let promise; - let filters; - - if (!device) { - if (settings.btname) { - log("Configured device name ", settings.btname); - filters = [{ name: settings.btname }]; - } else { - return; - } - log("Requesting device with filters", filters); - try { - promise = NRF.requestDevice({ filters: filters, active: true }); - } catch (e) { - log("Error during initial request:", e); - onDisconnect(e); - return; - } - promise = promise.then((d) => { - log("Wait after request"); - return waitingPromise(2000).then(() => Promise.resolve(d)); - }); - - promise = promise.then((d) => { - log("Got device", d); - d.on('gattserverdisconnected', onDisconnect); - device = d; - }); - } else { - promise = Promise.resolve(); - log("Reuse device", device); - } - - promise = promise.then(() => { - gatt = device.gatt; - return Promise.resolve(gatt); - }); - - promise = promise.then((gatt) => { - if (!gatt.connected) { - log("Connecting..."); - let connectPromise = gatt.connect().then(function () { - log("Connected."); - }); - connectPromise = connectPromise.then(() => { - log("Wait after connect"); - return waitingPromise(2000); - }); - return connectPromise; - } else { - return Promise.resolve(); - } - }); - - promise = promise.then(() => { - if (!characteristics || characteristics.length == 0) { - characteristics = characteristicsFromCache(device); - } - let characteristicsPromise = Promise.resolve(); - for (let characteristic of characteristics) { - characteristicsPromise = attachCharacteristicPromise(characteristicsPromise, characteristic, true); - } - - return characteristicsPromise; - }); - - return promise.then(() => { - log("Connection established, waiting for notifications"); - }).catch((e) => { - characteristics = []; - log("Error:", e); - onDisconnect(e); - }); - }; - Bangle.setCORESensorPower = function (isOn, app) { - // Do app power handling - if (!app) app = "?"; - log("setCORESensorPower ->", isOn, app); - if (Bangle._PWR === undefined) Bangle._PWR = {}; - if (Bangle._PWR.CORESensor === undefined) Bangle._PWR.CORESensor = []; - if (isOn && !Bangle._PWR.CORESensor.includes(app)) Bangle._PWR.CORESensor.push(app); - if (!isOn && Bangle._PWR.CORESensor.includes(app)) Bangle._PWR.CORESensor = Bangle._PWR.CORESensor.filter(a => a != app); - isOn = Bangle._PWR.CORESensor.length; - // so now we know if we're really on - if (isOn) { - log("setCORESensorPower on" + app); - if (!Bangle.isCORESensorConnected()) initCORESensor(); - } else { // being turned off! - log("setCORESensorPower turning off ", app); - if (gatt) { - if (gatt.connected) { - log("CORESensor: Disconnect with gatt", gatt); - try { - gatt.disconnect().then(() => { - log("CORESensor: Successful disconnect"); - }).catch((e) => { - log("CORESensor: Error during disconnect promise", e); - }); - } catch (e) { - log("CORESensor: Error during disconnect attempt", e); - } - } - } - } - }; - - // disconnect when swapping apps - E.on("kill", function () { - if (gatt) { - log("CORESensor connected - disconnecting"); - try { gatt.disconnect(); } catch (e) { - log("CORESensor disconnect error", e); - } - gatt = undefined; - } - }); - } -}; \ No newline at end of file +exports.enable = function () { + require("coretemp.runtime").enable(); +}; diff --git a/apps/coretemp/metadata.json b/apps/coretemp/metadata.json index c9847decb43..562cc00d5d3 100644 --- a/apps/coretemp/metadata.json +++ b/apps/coretemp/metadata.json @@ -1,23 +1,35 @@ { "id": "coretemp", "name": "CoreTemp", - "version": "0.06", + "version": "0.10", "author": "ihewitt", "description": "Display CoreTemp device sensor data", "icon": "coretemp.png", "type": "app", "tags": "health", "readme": "README.md", + "customConnect": true, + "custom": "custom.html", "supports": ["BANGLEJS","BANGLEJS2"], "storage": [ {"name":"coretemp.wid.js","url":"widget.js"}, {"name":"coretemp.app.js","url":"coretemp.js"}, + {"name":"coretemp.runtime","url":"runtime.js"}, + {"name":"coretemp.ble","url":"ble.js"}, + {"name":"coretemp.protocol","url":"protocol.js"}, + {"name":"coretemp.controlpoint","url":"controlpoint.js"}, + {"name":"coretemp.hrm","url":"hrm.js"}, + {"name":"coretemp.store","url":"store.js"}, {"name":"CORESensor","url":"lib.js"}, {"name":"coretemp.recorder.js","url":"recorder.js"}, {"name":"coretemp.settings.js","url":"settings.js"}, + {"name":"coretemp.settingsui","url":"settingsui.js"}, {"name":"coretemp.img","url":"coretemp-icon.js","evaluate":true}, {"name":"coretemp.0.boot.js","url":"boot.js"} ], - "data": [{"name":"coretemp.json","url":"app-settings.json"}], + "data": [ + {"name":"coretemp.json","url":"app-settings.json"}, + {"name":"coretemp.hrm.json","url":"hrm.json"} + ], "screenshots": [{"url":"screenshot.png"}] } diff --git a/apps/coretemp/protocol.js b/apps/coretemp/protocol.js new file mode 100644 index 00000000000..577c3247d19 --- /dev/null +++ b/apps/coretemp/protocol.js @@ -0,0 +1,168 @@ +exports.CORE_SERVICE_UUID = "00002100-5b1e-4347-b07c-97b514dae121"; +exports.CORE_TEMP_UUID = "00002101-5b1e-4347-b07c-97b514dae121"; +exports.CORE_CONTROL_POINT_UUID = "00002102-5b1e-4347-b07c-97b514dae121"; +exports.HEALTH_THERMOMETER_SERVICE_UUID = "0x1809"; +exports.TEMPERATURE_MEASUREMENT_UUID = "0x2a1c"; +exports.BATTERY_SERVICE_UUID = "0x180f"; +exports.BATTERY_LEVEL_UUID = "0x2a19"; + +exports.SUPPORTED_SERVICES = [ + exports.CORE_SERVICE_UUID, + exports.BATTERY_SERVICE_UUID, + exports.HEALTH_THERMOMETER_SERVICE_UUID +]; + +exports.SUPPORTED_CHARACTERISTIC_UUIDS = [ + exports.CORE_TEMP_UUID, + exports.CORE_CONTROL_POINT_UUID, + exports.TEMPERATURE_MEASUREMENT_UUID, + exports.BATTERY_LEVEL_UUID +]; + +exports.OPCODES = { + RESPONSE: 0x80, + + HRM_CLEAR_ANT: 0x01, + HRM_PAIR_ANT: 0x02, + HRM_PAIRED_COUNT: 0x04, + HRM_PAIRED_ANT_ENTRY: 0x05, + + HRM_SCAN_ANT_START: 0x0A, + HRM_SCAN_ANT_COUNT: 0x0B, + HRM_SCAN_ANT_ENTRY: 0x0C, + + HRM_SEND_EXTERNAL_HR: 0x13 +}; + +exports.dataViewToArray = function (dv) { + var response = []; + var i; + if (!dv) return response; + if (dv.length !== undefined && !dv.getUint8) { + for (i = 0; i < dv.length; i++) response.push(dv[i]); + return response; + } + for (i = 0; i < dv.byteLength; i++) response.push(dv.getUint8(i)); + return response; +}; + +exports.parseMeasurement = function (dv, batteryLevel) { + var index = 0; + var flags = dv.byteLength > index ? dv.getUint8(index++) : 0; + var dataQuality; + var hrState; + var qualityAndState; + function hasBytes(count) { + return index + count <= dv.byteLength; + } + var data = { + flags: flags, + core: undefined, + skin: undefined, + unit: (flags & 0x08) ? "F" : "C", + hr: 0, + heatflux: undefined, + hsiValid: !!(flags & 0x20), + hsi: undefined, + battery: batteryLevel || 0, + dataQuality: undefined, + hrState: undefined, + qualityAndStateRaw: undefined + }; + + // CORE's custom characteristic is a compact little-endian packet: + // flags, core temp, skin temp, heat flux, quality/state, HR, optional HSI. + // Truncated packets still return partial data so callers can log/debug them. + if (hasBytes(2)) data.core = dv.getInt16(index, true) / 100; + index += 2; + if (hasBytes(2)) data.skin = dv.getInt16(index, true) / 100; + index += 2; + if (hasBytes(2)) data.heatflux = dv.getInt16(index, true); + index += 2; + + if (hasBytes(1)) { + qualityAndState = dv.getUint8(index++); + data.qualityAndStateRaw = qualityAndState; + } + if (hasBytes(1)) data.hr = dv.getUint8(index++); + if (data.hsiValid && index < dv.byteLength) { + data.hsi = dv.getUint8(index) / 10; + } + if (qualityAndState !== undefined) { + // Low bits are CORE data quality; upper nibble carries external HR state. + dataQuality = qualityAndState & 0x07; + hrState = (qualityAndState >> 4) & 0x03; + data.dataQuality = dataQuality; + data.hrState = hrState; + } + return data; +}; + +exports.parseTemperatureMeasurement = function (dv, batteryLevel) { + var flags = dv.byteLength > 0 ? dv.getUint8(0) : 0; + var mantissa; + var exponent; + var raw; + var core; + if (dv.byteLength >= 5) { + // Bluetooth Health Thermometer uses IEEE-11073 FLOAT: signed 24-bit + // mantissa plus signed 8-bit base-10 exponent. + raw = dv.getUint8(1) | (dv.getUint8(2) << 8) | (dv.getUint8(3) << 16); + exponent = dv.getUint8(4); + if (raw === 0x7FFFFF || raw === 0x7FFFFE || raw === 0x800002 || raw === 0x800000) { + // Reserved NaN/+INF/-INF/NRes values map to the app's existing n/a sentinel. + core = 327.67; + } else { + mantissa = raw & 0x800000 ? raw - 0x1000000 : raw; + if (exponent & 0x80) exponent = exponent - 0x100; + core = mantissa * Math.pow(10, exponent); + } + } + return { + flags: flags, + core: core, + skin: undefined, + unit: (flags & 0x01) ? "F" : "C", + hr: 0, + heatflux: undefined, + hsiValid: false, + hsi: undefined, + battery: batteryLevel || 0, + dataQuality: undefined, + hrState: undefined, + qualityAndStateRaw: undefined, + profile: "health_thermometer" + }; +}; + +exports.parseBattery = function (dv) { + return dv.getUint8(0); +}; + +exports.parseResponse = function (dv) { + var bytes = exports.dataViewToArray(dv); + return { + bytes: bytes, + opCode: bytes[0], + requestOpCode: bytes[1], + resultCode: bytes[2], + payload: bytes.slice(3) + }; +}; + +exports.parseCount = function (response) { + return response && response.payload ? response.payload[0] || 0 : 0; +}; + +exports.parseAntEntry = function (response, index) { + var payload = response && response.payload ? response.payload : []; + return { + index: index || 0, + antId: (payload[0] || 0) | ((payload[1] || 0) << 8), + txType: payload[2] || 0 + }; +}; + +exports.makeAntPairParams = function (id, txType) { + return [id & 0xFF, (id >> 8) & 0xFF, txType || ((id >> 16) & 0xFF)]; +}; diff --git a/apps/coretemp/recorder.js b/apps/coretemp/recorder.js index fb0352fb6cf..44cae25158f 100644 --- a/apps/coretemp/recorder.js +++ b/apps/coretemp/recorder.js @@ -7,9 +7,9 @@ skin=c.skin; hasCore = true; unit = c.unit; - hr = c.hr; - heatflux = c.heatflux; - hsi = c.hsi; + hr = (c.hr && c.hr > 0) ? c.hr : ""; + heatflux = c.heatflux; + hsi = c.hsiValid ? c.hsi : ""; battery= c.battery; quality = c.dataQuality; } @@ -18,10 +18,12 @@ fields : ["Core","Skin","Unit","HeartRate","HeatFlux","HeatStrainIndex","Battery","Quality"], getValues : () => { var r = [core,skin,unit,hr,heatflux,hsi,battery,quality]; + // Recorder polls independently from BLE notifications; clear values + // after each sample so stale temperatures are not duplicated in logs. core = ""; skin = ""; unit=""; - hr=""; + hr = ""; heatflux=""; hsi=""; battery=""; @@ -31,13 +33,14 @@ start : () => { hasCore = false; Bangle.on('CORESensor', onCore); + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(1, "coretemp.recorder"); }, stop : () => { hasCore = false; Bangle.removeListener('CORESensor', onCore); + if (Bangle.setCORESensorPower) Bangle.setCORESensorPower(0, "coretemp.recorder"); }, draw : (x,y) => g.setColor(hasCore?"#0f0":"#8f8").drawImage(atob("DAyBAAHh0js3EuDMA8A8AWBnDj9A8A=="),x,y) }; } }) - diff --git a/apps/coretemp/runtime.js b/apps/coretemp/runtime.js new file mode 100644 index 00000000000..26ded9a11b8 --- /dev/null +++ b/apps/coretemp/runtime.js @@ -0,0 +1,68 @@ +var store = require("coretemp.store"); +var ble = require("coretemp.ble"); +var hrm = require("coretemp.hrm"); + +var enabled; +var killHandler; + +function getStatus() { + var status = ble.getStatus(); + status.hrm = hrm.getState(); + return status; +} + +exports.enable = function () { + if (!enabled) { + enabled = true; + store.init(); + ble.init(); + hrm.init(); + + killHandler = function () { + ble.shutdown(); + store.shutdown(); + }; + E.on("kill", killHandler); + } + + Bangle.enableCORESensorLog = function () { + store.setDebug(true); + }; + + Bangle.disableCORESensorLog = function () { + store.setDebug(false); + }; + + Bangle.CORESensorSetDebugLog = function (isEnabled) { + store.setDebug(!!isEnabled); + }; + + Bangle.CORESensorSetLogMode = function (mode) { + store.setDebug(mode === "full" || mode === "partial", mode === "partial"); + }; + + Bangle.isCORESensorOn = ble.isOn; + Bangle.isCORESensorConnected = ble.isConnected; + Bangle.CORESensorConnect = ble.connect; + Bangle.CORESensorDisconnect = ble.disconnect; + Bangle.CORESensorPair = ble.pairDevice; + Bangle.CORESensorUnpair = ble.unpairDevice; + Bangle.CORESensorRebuildCache = ble.rebuildCache; + Bangle.CORESensorWriteControlPoint = ble.writeControlPoint; + Bangle.CORESensorGetStatus = getStatus; + Bangle.CORESensorPause = ble.pause; + Bangle.CORESensorResume = ble.resume; + Bangle.CORESensorIsPaused = ble.isPaused; + Bangle.CORESensorHRMGetState = hrm.getState; + Bangle.CORESensorHRMGetManagerState = hrm.getState; + Bangle.CORESensorHRMGetStatus = hrm.getStatus; + Bangle.CORESensorHRMScanANT = hrm.scanANT; + Bangle.CORESensorHRMPairANT = hrm.pairANT; + Bangle.CORESensorHRMClearANT = hrm.clearANT; + Bangle.CORESensorHRMClear = hrm.clearANT; + Bangle.setCORESensorPower = ble.setPower; + + if (store.get().enabled === true && Bangle.setCORESensorPower) { + Bangle.setCORESensorPower(1, "coretemp.enabled"); + } +}; diff --git a/apps/coretemp/settings.js b/apps/coretemp/settings.js index f79a11a4d22..c17587a9f51 100644 --- a/apps/coretemp/settings.js +++ b/apps/coretemp/settings.js @@ -1,533 +1,3 @@ -// This file should contain exactly one function, which shows the app's settings -/** - * @param {function} back Use back() to return to settings menu - */ (function (back) { - var settings = {}; - const SETTINGS_FILE = 'coretemp.json' - var CORECONNECTED = false; - // creates a function to safe a specific setting, e.g. save('color')(1) - function writeSettings(key, value) { - let s = require('Storage').readJSON(SETTINGS_FILE, true) || {}; - s[key] = value; - require('Storage').writeJSON(SETTINGS_FILE, s); - readSettings(); - } - - function readSettings() { - settings = Object.assign( - require('Storage').readJSON(SETTINGS_FILE, true) || {} - ); - } - - readSettings(); - let log = () => { }; - if (settings.debuglog) - log = print; - - let supportedServices = [ - "00002100-5b1e-4347-b07c-97b514dae121", // Core Body Temperature Service - "0x180f", // Battery - "0x1809", // Health Thermometer Service - ]; - - let supportedCharacteristics = [ - "00002101-5b1e-4347-b07c-97b514dae121", // Core Body Temperature Characteristic - "00002102-5b1e-4347-b07c-97b514dae121", //Core Temp Control Point (opCode for extra function) - //"0x2a1c", //Thermometer - //"0x2a1d", //Sensor Location (CORE) - "0x2a19", // Battery - ]; - - var characteristicsToCache = function (characteristics) { - log("Cache characteristics"); - let cache = {}; - if (!cache.characteristics) cache.characteristics = {}; - for (var c of characteristics) { - log("Saving handle " + c.handle_value + " for characteristic: ", c.uuid); - cache.characteristics[c.uuid] = { - "handle": c.handle_value, - "uuid": c.uuid, - "notify": c.properties.notify, - "read": c.properties.read, - "write": c.properties.write - }; - } - writeSettings("cache", cache); - }; - - var controlPointChar; - - let createCharacteristicPromise = function (newCharacteristic) { - log("Create characteristic promise", newCharacteristic.uuid); - if (newCharacteristic.uuid === "00002102-5b1e-4347-b07c-97b514dae121") { - log("Subscribing to CoreTemp Control Point Indications."); - controlPointChar = newCharacteristic; - return controlPointChar.writeValue(new Uint8Array([0x02]), { - type: "command", - handle: true - }) - .then(() => { - log("Indications enabled! Listening for responses..."); - return controlPointChar.startNotifications(); //now we can send opCodes - }) - .then(() => log("Finished handling CoreTemp Control Point.")) - .catch(error => { - log("Error enabling indications:", error); - }); - } - return Promise.resolve().then(() => log("Handled characteristic", newCharacteristic.uuid)); - }; - - let attachCharacteristicPromise = function (promise, characteristic) { - return promise.then(() => { - log("Handling characteristic:", characteristic.uuid); - return createCharacteristicPromise(characteristic); - }); - }; - - let characteristics; - let createCharacteristicsPromise = function (newCharacteristics) { - log("Create characteristics promise ", newCharacteristics.length); - let result = Promise.resolve(); - for (let c of newCharacteristics) { - if (!supportedCharacteristics.includes(c.uuid)) continue; - log("Supporting characteristic", c.uuid); - characteristics.push(c); - - result = attachCharacteristicPromise(result, c); - } - return result.then(() => log("Handled characteristics")); - }; - - let createServicePromise = function (service) { - log("Create service promise", service.uuid); - let result = Promise.resolve(); - result = result.then(() => { - log("Handling service", service.uuid); - return service.getCharacteristics().then((c) => createCharacteristicsPromise(c)); - }); - return result.then(() => log("Handled service", service.uuid)); - }; - - let attachServicePromise = function (promise, service) { - return promise.then(() => createServicePromise(service)); - }; - - function writeToControlPoint(opCode, params) { - return new Promise((resolve, reject) => { - let data = new Uint8Array([opCode].concat(params)); - - if (!controlPointChar) { - log("Control Point characteristic not found! Reconnecting..."); - return; - } - // Temporary handler to capture the response - function handleResponse(event) { - let response = new Uint8Array(event.target.value.buffer); - //let responseOpCode = response[0]; - let requestOpCode = response[1]; // Matches the sent OpCode - let resultCode = response[2]; // 0x01 = Success - controlPointChar.removeListener("characteristicvaluechanged", handleResponse); - if (requestOpCode === opCode) { - if (resultCode === 0x01) { //successful - resolve(response); - } else { - reject("Error Code: " + resultCode); - } - } - } - - controlPointChar.on("characteristicvaluechanged", handleResponse); - controlPointChar.writeValue(data) - .then(() => log("Sent OpCode:", opCode.toString(16), "Params:", data)) - .catch(error => { - log("Write error:", error); - reject(error); - }); - }); - } - let gatt; - function cacheDevice(deviceName) { - let promise; - let filters; - characteristics = []; - filters = [{ name: deviceName }]; - log("Requesting device with filters", filters); - promise = NRF.requestDevice({ filters: filters, active: settings.active }); - promise = promise.then((d) => { - E.showMessage("Found!!\n" + deviceName + "\nConnecting..."); - log("Got device", d); - gatt = d.gatt; - log("Connecting..."); - d.on('gattserverdisconnected', function () { - CORECONNECTED = false; - log("Disconnected! "); - gatt = null; - //setTimeout(() => cacheDevice(deviceName), 5000); // Retry in 5 seconds - }); - return gatt.connect().then(function () { - log("Connected."); - }); - }); - promise = promise.then(() => { - log(JSON.stringify(gatt.getSecurityStatus())); - if (gatt.getSecurityStatus().bonded) { - log("Already bonded"); - return Promise.resolve(); - } else { - log("Start bonding"); - return gatt.startBonding() - .then(() => log("Security status after bonding" + gatt.getSecurityStatus())); - } - }); - promise = promise.then(() => { - log("Getting services"); - return gatt.getPrimaryServices(); - }); - - promise = promise.then((services) => { - log("Got services", services.length); - let result = Promise.resolve(); - for (let service of services) { - if (!(supportedServices.includes(service.uuid))) continue; - log("Supporting service", service.uuid); - result = attachServicePromise(result, service); - } - return result; - }); - - return promise.then(() => { - log("Connection established, saving cache"); - E.showMessage("Found " + deviceName + "\nConnected!"); - CORECONNECTED = true; - characteristicsToCache(characteristics); - }); - } - - function ConnectToDevice(d) { - E.showMessage("Connecting..."); - let count = 0; - const successHandler = () => { - E.showMenu(buildMainMenu()); - }; - const errorHandler = (e) => { - count++; - log("ERROR", e); - if (count <= 10) { - E.showMessage("Error during caching\nRetry " + count + "/10", e); - return cacheDevice(d).then(successHandler).catch(errorHandler); - } else { - E.showAlert("Error during caching", e).then(() => { - E.showMenu(buildMainMenu()); - }); - } - }; - return cacheDevice(d).then(successHandler).catch(errorHandler); - } - /* - function getPairedAntHRM() { - writeToControlPoint(0x04) // Get paired HRMs - .then(response => { - let totalHRMs = response[3]; // HRM count at index 3 - log("📡 PAIRED ANT+:", totalHRMs); - let promises = []; - let hrmFound = []; - for (let i = 0; i < totalHRMs; i++) { - promises.push( - writeToControlPoint(0x05, [i]) // Get HRM ID from paired list - .then(hrmResponse => { - log("🔍 Response 0x05:", hrmResponse); - - let byte1 = hrmResponse[3]; // LSB - let byte2 = hrmResponse[4]; // Middle Byte - let byte3 = hrmResponse[5]; // MSB - let txType = hrmResponse[5]; // Transmission Type - let hrmState = hrmResponse[6]; // Connection State - let pairedAntId = (byte1) | (byte2 << 8) | (byte3 << 16); // ✅ Corrected parsing - let stateText = ["Closed", "Searching", "Synchronized", "Reserved"][hrmState & 0x03]; - log(`🔗 HRM ${i}: ANT ID = ${pairedAntId}, Tx-Type = ${txType}, State = ${stateText}`); - hrmFound.push({ index: i, antId: pairedAntId, txType: txType, stateText: stateText }); - }) - .catch(e => log(`❌ Error fetching HRM ${i} ID:`, e)) - ); - } - return Promise.all(promises).then(() => hrmFound); - }) - .then(allHRMs => { - log("Retrieved all paired HRMs:", allHRMs); - return // Modified start scanning command - }) - } - */ - function clearPairedHRM_ANT() { - return writeToControlPoint(0x01) // Send OpCode 0x01 to clear list - .then(response => { - let resultCode = response[2]; // Check the success flag - if (resultCode === 0x01) { - log("ANT+ HRM list cleared successfully."); - return Promise.resolve(); - } else { - log("Failed to clear ANT+ HRM list. Error code:", resultCode); - return Promise.reject(new Error(`Error code: ${resultCode}`)); - } - }) - .catch(error => { - log("Error clearing ANT+ HRM list:", error); - return Promise.reject(error); - }); - } - - function scanUntilSynchronized(maxRetries, delay) { - let attempts = 0; - function checkHRMState() { - if (attempts >= maxRetries) { - log("Max scan attempts reached. HRM did not synchronize."); - E.showAlert("Max scan attempts reached. HRM did not synchronize.").then(() => E.showMenu(HRM_MENU())); - return; - } - log(`Attempt ${attempts + 1}/${maxRetries}: Checking HRM state...`); - writeToControlPoint(0x05, [0]) // Check paired HRM state - .then(hrmResponse => { - log("Sent OpCode: 0x05, response: ", hrmResponse); - let byte1 = hrmResponse[3]; // LSB of ANT ID - let byte2 = hrmResponse[4]; // MSB of ANT ID - let txType = hrmResponse[5]; // Transmission Type - let hrmState = hrmResponse[6]; // HRM State - let retrievedAntId = (byte1) | (byte2 << 8) | (txType << 16); - let stateText = ["Closed", "Searching", "Synchronized", "Reserved"][hrmState & 0x03]; - log(`HRM Status: ANT ID = ${retrievedAntId}, Tx-Type = ${txType}, State = ${stateText}`); - E.showAlert(`HRM Status\nANT ID = ${retrievedAntId}\nState = ${stateText}`).then(() => E.showMenu(HRM_MENU())); - if (stateText === "Synchronized") { - return; - } else { - log(`HRM ${retrievedAntId} is not yet synchronized. Scanning again...`); - // Start scan again - writeToControlPoint(0x0D) - .then(() => writeToControlPoint(0x0A, [0xFF])) - .then(() => { - attempts++; - setTimeout(checkHRMState, delay); // Wait and retry - }) - .catch(error => { - log("Error restarting scan:", error); - }); - } - }) - .catch(error => { - log("Error checking HRM state:", error); - }); - } - log("Starting scan to synchronize HRM..."); - writeToControlPoint(0x0A, [0xFF]) // Start initial scan - .then(() => { - setTimeout(checkHRMState, delay); // Wait and check state - }) - .catch(error => { - log("Error starting initial scan:", error); - }); - } - - function scanHRM_ANT() { - E.showMenu(); - E.showMessage("Scanning for 10 seconds"); // Increased scan time - writeToControlPoint(0x0A, [0xFF]) - .then(response => { - log("Received Response for 0x0A:", response); - return new Promise(resolve => setTimeout(resolve, 10000)); // Extended scan time to 10 seconds - }) - .then(() => { - return writeToControlPoint(0x0B); // Get HRM count - }) - .then(response => { - let HRMCount = response[3]; - log("HRM Count Response:", HRMCount); - let hrmFound = []; - let promises = []; - for (let i = 0; i < HRMCount; i++) { - promises.push( - writeToControlPoint(0x0C, [i]) // Get Scanned HRM IDs - .then(hrmResponse => { - log("Response 0x0C:", hrmResponse); - let byte1 = hrmResponse[3]; // LSB - let byte2 = hrmResponse[4]; // MSB - let txType = hrmResponse[5]; // Transmission Type - let scannedAntId = (byte1) | (byte2 << 8) | (txType << 16); //3 byte ANT+ ID - log(`HRM ${i} ID Response: ${scannedAntId}`); - hrmFound.push({ antId: scannedAntId }); - }) - .catch(e => log(`Error fetching HRM ${i} ID:`, e)) - ); - } - return Promise.all(promises).then(() => { - if (hrmFound > 0) { - let submenu_scan = { - '< Back': function () { E.showMenu(buildMainMenu()); } - }; - hrmFound.forEach((hrm) => { - let id = hrm.antId; - submenu_scan[id] = function () { - E.showPrompt("Connect to\n" + id + "?", { title: "ANT+ Pairing" }).then((r) => { - if (r) { - E.showMessage("Connecting..."); - let byte1 = id & 0xFF; // LSB - let byte2 = (id >> 8) & 0xFF; // Middle byte - let byte3 = (id >> 16) & 0xFF; // Transmission Type - return clearPairedHRM_ANT(). //FIRST CLEAR ALL ANT+ HRM - then(() => { writeToControlPoint(0x02, [byte1, byte2, byte3]) }) // Pair the HRM - .then(() => { - log(`HRM ${id} added to paired list.`); - writeSettings("ANT_HRM", hrm); - E.showMenu(HRM_MENU()); - }) - .catch(e => log(`Error adding HRM ${id} to paired list:`, e)); - } - }); - }; - }); - E.showMenu(submenu_scan); - } else { - E.showAlert("No ANT+ HRM found.").then(() => E.showMenu(HRM_MENU())); - } - }); - }) - .catch(e => log("ERROR:", e)); - } - - function buildMainMenu() { - let mainmenu = { - '': { 'title': 'CORE Sensor' }, - '< Back': back, - 'Enable': { - value: !!settings.enabled, - onchange: v => { - writeSettings("enabled", v); - }, - }, - 'Widget': { - value: !!settings.widget, - onchange: v => { - writeSettings("widget", v); - }, - } - }; - if (settings.btname || settings.btid) { - let name = "Clear " + (settings.btname || settings.btid); - mainmenu[name] = function () { - E.showPrompt("Clear current device?").then((r) => { - if (r) { - writeSettings("btname", undefined); - writeSettings("btid", undefined); - writeSettings("cache", undefined); - if(gatt) gatt.disconnect(); - } - E.showMenu(buildMainMenu()); - }); - }; - if(!CORECONNECTED){ - let connect = "Connect " + (settings.btname || settings.btid); - mainmenu[connect] = function () {ConnectToDevice(settings.btname)}; - }else{ - mainmenu['HRM Settings'] = function () { E.showMenu(HRM_MENU()); }; - } - } else { - mainmenu['Scan for CORE'] = function () { ScanForCORESensor(); }; - } - mainmenu['Debug'] = function () { E.showMenu(submenu_debug); }; - return mainmenu; - } - let submenu_debug = { - '': { title: "Debug" }, - '< Back': function () { E.showMenu(buildMainMenu()); }, - 'Alert on disconnect': { - value: !!settings.warnDisconnect, - onchange: v => { - writeSettings("warnDisconnect", v); - } - }, - 'Debug log': { - value: !!settings.debuglog, - onchange: v => { - writeSettings("debuglog", v); - } - } - }; - - function HRM_MENU() { - let menu = { - '': { 'title': 'CORE: HR' }, - '< Back': function () { E.showMenu(buildMainMenu()); }, - 'Scan for ANT+': function () { scanHRM_ANT(); } - } - if (settings.btname) { - menu['ANT+ Status'] = function () { scanUntilSynchronized(10, 3000); }, - menu['Clear ANT+'] = function () { - E.showPrompt("Clear ANT+ HRs?", { title: "CLear ANT+" }).then((r) => { - if (r) { - clearPairedHRM_ANT(); - } - E.showMenu(HRM_MENU()); - }); - } - } - return menu; - } - - function ScanForCORESensor() { - E.showMenu(); - E.showMessage("Scanning for 5 seconds"); - let submenu_scan = { - '< Back': function () { E.showMenu(buildMainMenu()); } - }; - NRF.findDevices(function (devices) { - submenu_scan[''] = { title: `Scan (${devices.length} found)` }; - if (devices.length === 0) { - E.showAlert("No devices found") - .then(() => E.showMenu(buildMainMenu())); - return; - } else { - devices.forEach((d) => { - log("Found device", d); - let shown = (d.name || d.id.substr(0, 17)); - submenu_scan[shown] = function () { - E.showPrompt("Connect to\n" + shown + "?", { title: "Pairing" }).then((r) => { - if (r) { - E.showMessage("Connecting..."); - let count = 0; - const successHandler = () => { - E.showPrompt("Success!", { - buttons: { "OK": true } - }).then(() => { - writeSettings("btid", d.id); - writeSettings("btname", d.name); //Seems to only like to connect by name - E.showMenu(HRM_MENU()); - }); - }; - const errorHandler = (e) => { - count++; - log("ERROR", e); - if (count <= 10) { - E.showMessage("Error during caching\nRetry " + count + "/10", e); - return cacheDevice(d.name).then(successHandler).catch(errorHandler); - } else { - E.showAlert("Error during caching", e).then(() => { - E.showMenu(buildMainMenu()); - }); - } - }; - return cacheDevice(d.name).then(successHandler).catch(errorHandler); - } - }); - }; - }); - } - E.showMenu(submenu_scan); - }, { timeout: 5000, active: true, filters: [{ services: ["00002100-5b1e-4347-b07c-97b514dae121"] }] }); - } - - function init() { - E.showMenu(); - E.showMenu(buildMainMenu()); - } - init(); -}) \ No newline at end of file + require("coretemp.settingsui").open(back); +}) diff --git a/apps/coretemp/settingsui.js b/apps/coretemp/settingsui.js new file mode 100644 index 00000000000..9cd54a4e178 --- /dev/null +++ b/apps/coretemp/settingsui.js @@ -0,0 +1,514 @@ +exports.open = function (back) { + var store = require("coretemp.store"); + var settings = {}; + var OWNER = "coretemp.settings"; + var BACKGROUND_OWNER = "coretemp.enabled"; + + function readSettings() { + settings = store.read(); + } + + function writeSetting(key, value) { + store.write(function (nextSettings) { + // Full and partial logs are mutually exclusive settings. + if (key === "debuglog" && value) delete nextSettings.debugpartiallog; + if (key === "debugpartiallog" && value) delete nextSettings.debuglog; + if (value === undefined || value === false) delete nextSettings[key]; + else nextSettings[key] = value; + }); + readSettings(); + if (key === "enabled" && Bangle.setCORESensorPower) { + Bangle.setCORESensorPower(!!value, BACKGROUND_OWNER); + } + if (key === "debuglog" && Bangle.CORESensorSetDebugLog) { + Bangle.CORESensorSetDebugLog(!!value); + } + if ((key === "debuglog" || key === "debugpartiallog") && Bangle.CORESensorSetLogMode) { + Bangle.CORESensorSetLogMode(settings.debuglog ? "full" : (settings.debugpartiallog ? "partial" : "off")); + } + if (key === "customprofileonly" && value && Bangle.CORESensorRebuildCache) { + Promise.resolve(Bangle.CORESensorRebuildCache()).catch(function () {}); + } + } + + function showNext(next) { + var result; + if (!next) return; + result = next(); + if (result && typeof result === "object" && result[""]) E.showMenu(result); + return result; + } + + function ensureRuntime() { + if (!Bangle.CORESensorPair) { + try { + require("CORESensor").enable(); + } catch (e) { + return false; + } + } + return !!Bangle.CORESensorPair; + } + + function runWithCoreConnection(fn, skipConnect) { + var acquiredPower = false; + var promise = Promise.resolve(); + if (!ensureRuntime()) return Promise.reject(new Error("CORESensor runtime is unavailable")); + // Settings actions borrow sensor power and release it afterward when no + // app/background owner already had the CORE runtime active. + if (Bangle.setCORESensorPower && Bangle.isCORESensorOn && !Bangle.isCORESensorOn()) { + Bangle.setCORESensorPower(1, OWNER); + acquiredPower = true; + } + if (!skipConnect) promise = promise.then(function () { return Bangle.CORESensorConnect(); }); + promise = promise.then(fn); + return promise.then(function (result) { + if (acquiredPower) Bangle.setCORESensorPower(0, OWNER); + return result; + }, function (err) { + if (acquiredPower) Bangle.setCORESensorPower(0, OWNER); + throw err; + }); + } + + function formatError(err) { + if (err === undefined || err === null) return String(err); + if (err instanceof Error) return err.message || String(err); + if (typeof err === "string") return err; + if (typeof err === "object" && err.message) return String(err.message); + if (err && err.length !== undefined && typeof err !== "function") return String(err); + try { + return JSON.stringify(err); + } catch (e) { + return String(err); + } + } + + function showError(title, err, next) { + return E.showAlert(title + "\n" + formatError(err)).then(function () { + return showNext(next); + }); + } + + + + function normalizeHRMStatus(status) { + // Older runtime builds and tests may omit derived fields; normalize at the + // UI boundary so menu rendering stays tolerant of partial status objects. + if (!status) status = {}; + if (!status.pairedSensors) status.pairedSensors = []; + if (!status.lastScan) status.lastScan = []; + if (!status.recent) status.recent = []; + if (status.pairedCount === undefined) status.pairedCount = status.pairedSensors.length; + if (status.paired === undefined) status.paired = !!status.pairedCount; + if (status.multiplePaired === undefined) status.multiplePaired = status.pairedCount > 1; + if (status.currentSource === undefined) status.currentSource = status.pairedSensors[0] || null; + if (status.syncState === undefined) status.syncState = status.paired ? "paired" : "none"; + return status; + } + + function formatAntId(id) { + return id !== undefined && id !== null ? String(id) : "Unknown"; + } + + function describeEntry(entry) { + return "ANT ID: " + formatAntId(entry.antId) + "\n" + + "Transport: " + (entry.transport || "ANT+"); + } + + function describeHRMStatus(status) { + var lines = []; + status = normalizeHRMStatus(status); + lines.push("Paired: " + status.pairedCount); + lines.push("State: " + status.syncState); + if (status.currentSource) lines.push("ANT ID: " + formatAntId(status.currentSource.antId)); + if (status.selected) lines.push("Selected: " + formatAntId(status.selected.antId)); + if (status.lastError) lines.push("Error: " + status.lastError); + return lines.join("\n"); + } + + function showStatus(title, text, isFlagged, next) { + if (isFlagged) { + return E.showAlert(title + "\n" + text).then(function () { + return showNext(next); + }); + } + return E.showPrompt(text, { + title: title, + buttons: { "OK": true } + }).then(function () { + return showNext(next); + }); + } + + function showCoreStatus() { + var status; + var text; + if (!ensureRuntime() || !Bangle.CORESensorGetStatus) { + return E.showAlert("Runtime unavailable").then(function () { + E.showMenu(debugMenu()); + }); + } + status = Bangle.CORESensorGetStatus(); + text = "State: " + status.state + "\n" + + "Task: " + (status.activeTask || "") + "\n" + + "Profile: " + (status.profile || "") + "\n" + + "Custom only: " + status.customProfileOnly + "\n" + + "Upgrade: " + status.profileUpgradeScheduled + "\n" + + "HRM: " + (status.hrm ? status.hrm.operation || "" : "") + "\n" + + "Paired: " + status.paired + "\n" + + "Connected: " + status.connected + "\n" + + "Error: " + (status.lastError || ""); + return showStatus("CORE status", text, status.state === "error" || !!status.lastError, debugMenu); + } + + function rebuildCache() { + E.showMenu(); + E.showMessage("Rebuilding Cache..."); + return runWithCoreConnection(function () { + return Bangle.CORESensorRebuildCache(); + }, true).then(function () { + return E.showPrompt("Cache rebuilt", { + title: "Success", + buttons: { "OK": true } + }).then(function () { + E.showMenu(debugMenu()); + }); + }).catch(function (err) { + return showError("Error rebuilding cache", err, debugMenu); + }); + } + + function connectToDevice() { + E.showMenu(); + E.showMessage("Connecting..."); + return runWithCoreConnection(function () { + return Promise.resolve(); + }).then(function () { + readSettings(); + E.showMenu(buildMainMenu()); + }).catch(function (err) { + return showError("Error during connect", err, buildMainMenu); + }); + } + + function formatCoreName() { + return settings.btname || settings.btid; + } + + function showHRMStatus() { + E.showMenu(); + E.showMessage("Refreshing..."); + return runWithCoreConnection(function () { + return Bangle.CORESensorHRMGetStatus(); + }).then(function (status) { + status = normalizeHRMStatus(status); + return showStatus("HRM status", describeHRMStatus(status), !!status.lastError || !!status.multiplePaired, openHRMMenu); + }).catch(function (err) { + return showError("Error loading HRM status", err, openHRMMenu); + }); + } + + function showPairResult(status) { + return E.showPrompt(describeHRMStatus(status), { + title: "Success", + buttons: { "OK": true } + }).then(openHRMMenu); + } + + function pairEntryWithReplace(entry, replaceExisting) { + E.showMenu(); + E.showMessage("Pairing ANT+\n" + formatAntId(entry.antId) + "\n..."); + return runWithCoreConnection(function () { + return Bangle.CORESensorHRMPairANT(entry, replaceExisting); + }).then(showPairResult).catch(function (err) { + return showError("Error pairing HRM", err, openHRMMenu); + }); + } + + function confirmPairEntry(entry, parentMenu) { + return runWithCoreConnection(function () { + return Bangle.CORESensorHRMGetStatus(); + }).then(function (status) { + status = normalizeHRMStatus(status); + if (status.multiplePaired) { + return E.showAlert("Multiple HRMs paired\nClear paired HRMs\nbefore pairing.").then(function () { + if (parentMenu) E.showMenu(parentMenu); + else openHRMMenu(); + }); + } + if (status.pairedSensors.length === 1) { + return E.showPrompt( + status.pairedSensors[0].antId === entry.antId ? + "Re-pair existing\nHRM?" : + "Replace existing\nHRM?" + ).then(function (confirmed) { + if (!confirmed) { + if (parentMenu) E.showMenu(parentMenu); + else openHRMMenu(); + return; + } + return pairEntryWithReplace(entry, true); + }); + } + return E.showPrompt("Pair ANT+\n" + formatAntId(entry.antId) + "?").then(function (confirmed) { + if (!confirmed) { + if (parentMenu) E.showMenu(parentMenu); + else openHRMMenu(); + return; + } + return pairEntryWithReplace(entry, false); + }); + }).catch(function (err) { + return showError("Error checking HRM", err, openHRMMenu); + }); + } + + function openEntryMenu(entry, parentMenu) { + E.showMenu({ + "": { title: "ANT+ " + formatAntId(entry.antId) }, + "< Back": function () { E.showMenu(parentMenu); }, + "Details": function () { + E.showPrompt(describeEntry(entry), { + title: "Details", + buttons: { "OK": true } + }).then(function () { + openEntryMenu(entry, parentMenu); + }); + }, + "Pair": function () { + return confirmPairEntry(entry, parentMenu); + } + }); + } + + function scanANT() { + var menu; + E.showMenu(); + E.showMessage("Scanning\n5s"); + return runWithCoreConnection(function () { + return Bangle.CORESensorHRMScanANT(); + }).then(function (found) { + if (!found.length) return E.showAlert("No ANT+ HRM found").then(openHRMMenu); + menu = { + "": { title: "Scan ANT+" }, + "< Back": openHRMMenu + }; + found.forEach(function (entry) { + menu[(entry.index + 1) + ") " + formatAntId(entry.antId)] = function () { + openEntryMenu(entry, menu); + }; + }); + E.showMenu(menu); + }).catch(function (err) { + return showError("Error scanning HRM", err, openHRMMenu); + }); + } + + function openRecentHRMs(status) { + var menu = { + "": { title: "Recent HRMs" }, + "< Back": openHRMMenu + }; + status = normalizeHRMStatus(status); + if (!status.recent.length) return E.showAlert("No recent HRMs").then(openHRMMenu); + status.recent.forEach(function (entry) { + menu[formatAntId(entry.antId)] = function () { + openEntryMenu(entry, menu); + }; + }); + E.showMenu(menu); + } + + function clearHRM() { + return E.showPrompt("Clear paired HRM?").then(function (confirmed) { + if (!confirmed) return openHRMMenu(); + E.showMenu(); + E.showMessage("Clearing..."); + return runWithCoreConnection(function () { + return Bangle.CORESensorHRMClearANT(); + }).then(function (status) { + return E.showPrompt("Clear complete\n" + describeHRMStatus(status), { + title: "Success", + buttons: { "OK": true } + }).then(openHRMMenu); + }).catch(function (err) { + return showError("Error clearing HRM", err, openHRMMenu); + }); + }); + } + + function buildHRMMenu(status) { + var menu; + status = normalizeHRMStatus(status); + menu = { + "": { title: "HRM (ANT+)" }, + "< Back": function () { E.showMenu(buildMainMenu()); }, + "Status": showHRMStatus + }; + if (status.selected) { + menu["Preset HRM"] = function () { + openEntryMenu(status.selected, menu); + }; + } + menu["Scan ANT+"] = scanANT; + menu["Recent HRMs"] = function () { openRecentHRMs(status); }; + menu["Clear Paired HRM"] = clearHRM; + return menu; + } + + function openHRMMenu() { + var state; + if (!ensureRuntime() || !Bangle.CORESensorHRMGetStatus) { + return E.showAlert("Runtime unavailable").then(function () { + E.showMenu(buildMainMenu()); + }); + } + state = Bangle.CORESensorHRMGetState ? Bangle.CORESensorHRMGetState() : {}; + E.showMenu(buildHRMMenu(state)); + } + + function debugMenu() { + return { + "": { title: "Debug" }, + "< Back": function () { E.showMenu(buildMainMenu()); }, + "Full log": { + value: !!settings.debuglog, + onchange: function (v) { + writeSetting("debuglog", v); + E.showMenu(debugMenu()); + } + }, + "Partial log": { + value: !!settings.debugpartiallog, + onchange: function (v) { + writeSetting("debugpartiallog", v); + E.showMenu(debugMenu()); + } + }, + "Custom CORE only": { + value: !!settings.customprofileonly, + onchange: function (v) { writeSetting("customprofileonly", v); } + }, + "Status": showCoreStatus, + "Rebuild cache": rebuildCache, + "Reset CoreTemp": function () { + return E.showPrompt("Clear CORE app data?\nRemoves pairing, cache,\nHRM config, settings,\nand logs. BLE bonds\nare not erased.", { + title: "Reset CoreTemp" + }).then(function (confirmed) { + if (!confirmed) return E.showMenu(debugMenu()); + E.showMenu(); + E.showMessage("Resetting..."); + (Bangle.CORESensorUnpair ? Bangle.CORESensorUnpair() : Promise.resolve()).then(function () { + try { require("Storage").open("coretemp.log", "r").erase(); } catch (e) {} + try { require("Storage").open("coretemp.hrm.json", "r").erase(); } catch (e) {} + require("Storage").writeJSON("coretemp.json", { enabled: false, widget: true }); + require("Storage").compact(); + readSettings(); + E.showPrompt("CORE reset complete.", { + title: "Reset", + buttons: { "OK": true } + }).then(function () { + E.showMenu(buildMainMenu()); + }); + }).catch(function (err) { + showError("Reset failed", err, buildMainMenu); + }); + }); + } + }; + } + + function scanForCoreSensor() { + var menu = { "< Back": function () { E.showMenu(buildMainMenu()); } }; + E.showMenu(); + E.showMessage("Scanning for\n5 seconds"); + NRF.findDevices(function (devices) { + if (!devices) devices = []; + menu[""] = { title: "Scan (" + devices.length + ")" }; + if (!devices.length) { + return E.showAlert("No devices found").then(function () { + E.showMenu(buildMainMenu()); + }); + } + devices.forEach(function (device) { + var shown = device.name || device.id.substr(0, 17); + menu[shown] = function () { + E.showPrompt("Pair with\n" + shown + "?").then(function (confirmed) { + if (!confirmed) return E.showMenu(menu); + E.showMenu(); + E.showMessage("Pairing..."); + runWithCoreConnection(function () { + return Bangle.CORESensorPair(device).then(function (result) { + readSettings(); + if (settings.enabled && Bangle.setCORESensorPower) { + Bangle.setCORESensorPower(1, BACKGROUND_OWNER); + } + return result; + }); + }, true).then(function () { + readSettings(); + return E.showPrompt("CORE paired", { + title: "Success", + buttons: { "OK": true } + }).then(function () { + E.showMenu(buildMainMenu()); + }); + }).catch(function (err) { + return showError("Error during pairing", err, buildMainMenu); + }); + }); + }; + }); + E.showMenu(menu); + }, { + timeout: 5000, + active: true, + // Pairing scans only for CORE's custom service; the fallback thermometer + // profile is too generic to identify the device safely. + filters: [{ services: ["00002100-5b1e-4347-b07c-97b514dae121"] }] + }); + } + + function buildMainMenu() { + var menu = { + "": { title: "CORE Sensor" }, + "< Back": back, + "Always On": { + value: !!settings.enabled, + onchange: function (v) { writeSetting("enabled", v); } + }, + "Widget": { + value: !!settings.widget, + onchange: function (v) { writeSetting("widget", v); } + } + }; + if (settings.btname || settings.btid) { + menu["Forget " + formatCoreName()] = function () { + return E.showPrompt("Forget current CORE?\nRemoves saved device\nand cache. BLE bonds\nare not erased.").then(function (confirmed) { + if (!confirmed) return E.showMenu(buildMainMenu()); + E.showMenu(); + E.showMessage("Forgetting..."); + Promise.resolve(Bangle.CORESensorUnpair()).then(function () { + readSettings(); + E.showMenu(buildMainMenu()); + }).catch(function (err) { + showError("Error during forget", err, buildMainMenu); + }); + }); + }; + if (!(Bangle.isCORESensorConnected && Bangle.isCORESensorConnected())) { + menu["Test " + formatCoreName()] = connectToDevice; + } + menu["HRM (ANT+)"] = openHRMMenu; + } else { + menu["Scan for CORE"] = scanForCoreSensor; + } + menu["Debug"] = function () { E.showMenu(debugMenu()); }; + return menu; + } + + readSettings(); + ensureRuntime(); + E.showMenu(buildMainMenu()); +}; diff --git a/apps/coretemp/store.js b/apps/coretemp/store.js new file mode 100644 index 00000000000..b0790f09235 --- /dev/null +++ b/apps/coretemp/store.js @@ -0,0 +1,82 @@ +var SETTINGS_FILE = "coretemp.json"; +var LOG_FILE = "coretemp.log"; +var LOG_MAX_LINES = 200; + +var settings; +var logEnabled = false; +var logPartial = false; +var logBuffer = []; +var logFlushInterval; + +function readSettings() { + settings = require("Storage").readJSON(SETTINGS_FILE, 1) || {}; + return settings; +} + +function flushLog() { + if (!logBuffer.length) return; + while (logBuffer.length > LOG_MAX_LINES) logBuffer.shift(); + try { + // Append in batches to reduce flash writes during frequent BLE notifications. + require("Storage").open(LOG_FILE, "a").write(logBuffer.join("\n") + "\n"); + } catch (e) { + // ignore storage write failures + } + logBuffer = []; +} + +function setDebug(enabled, partial) { + logEnabled = !!enabled; + logPartial = !!partial && logEnabled; + if (logEnabled) { + if (!logFlushInterval) logFlushInterval = setInterval(flushLog, 30000); + } else if (logFlushInterval) { + clearInterval(logFlushInterval); + logFlushInterval = undefined; + flushLog(); + } +} + +function log(text, param) { + if (!logEnabled) return; + // Partial logging keeps lifecycle/control-point diagnostics but omits the + // high-rate measurement stream. + if (logPartial && text === "data") return; + var line = new Date().toISOString() + " - " + text; + if (param !== undefined) line += ": " + JSON.stringify(param); + print(line); + logBuffer.push(line); +} + +exports.init = function () { + var currentSettings = readSettings(); + setDebug(!!(currentSettings.debuglog || currentSettings.debugpartiallog), !currentSettings.debuglog && !!currentSettings.debugpartiallog); +}; + +exports.read = function () { + return readSettings(); +}; + +exports.get = function () { + return settings || readSettings(); +}; + +exports.write = function (mutator) { + var nextSettings = require("Storage").readJSON(SETTINGS_FILE, 1) || {}; + mutator(nextSettings); + require("Storage").writeJSON(SETTINGS_FILE, nextSettings); + settings = nextSettings; + return nextSettings; +}; + +exports.setDebug = setDebug; +exports.log = log; +exports.flush = flushLog; + +exports.shutdown = function () { + if (logFlushInterval) { + clearInterval(logFlushInterval); + logFlushInterval = undefined; + } + flushLog(); +}; diff --git a/apps/coretemp/test.json b/apps/coretemp/test.json new file mode 100644 index 00000000000..0b79ba8bf5e --- /dev/null +++ b/apps/coretemp/test.json @@ -0,0 +1,56 @@ +{ + "app": "coretemp", + "allowCustomApp": true, + "tests": [ + { + "description": "CoreTemp installs packaged defaults and runtime modules", + "steps": [ + { + "t": "assert", + "js": "!!require('Storage').read('coretemp.runtime')", + "is": "true", + "text": "Runtime module is installed" + }, + { + "t": "assert", + "js": "!!require('Storage').read('coretemp.ble')", + "is": "true", + "text": "BLE module is installed" + }, + { + "t": "assert", + "js": "!!require('Storage').read('coretemp.settingsui')", + "is": "true", + "text": "Settings UI module is installed" + }, + { + "t": "assert", + "js": "require('Storage').readJSON('coretemp.json', 1).widget", + "is": "true", + "text": "Packaged settings enable the widget by default" + }, + { + "t": "assert", + "js": "require('Storage').readJSON('coretemp.hrm.json', 1).recent.length", + "is": "equal", + "to": "0", + "text": "Packaged HRM settings start with no recent sensors" + }, + { + "t": "assert", + "js": "require('coretemp.protocol').OPCODES.HRM_SCAN_ANT_START", + "is": "equal", + "to": "10", + "text": "Protocol module exposes ANT scan opcode" + }, + { + "t": "assert", + "js": "require('coretemp.protocol').makeAntPairParams(4660, 86).join(',')", + "is": "equal", + "to": "\"52,18,86\"", + "text": "Protocol module builds ANT pair params" + } + ] + } + ] +} diff --git a/apps/coretemp/tests/README.md b/apps/coretemp/tests/README.md new file mode 100644 index 00000000000..830e2f4d466 --- /dev/null +++ b/apps/coretemp/tests/README.md @@ -0,0 +1,10 @@ +Local-only tests for the CoreTemp module. + +Run: + +```sh +node apps/coretemp/tests/run.js +``` + +This directory is committed with the app but is not listed in `metadata.json`, +so it is not pushed to Bangle storage. diff --git a/apps/coretemp/tests/behaviour/hrm_pair.test.js b/apps/coretemp/tests/behaviour/hrm_pair.test.js new file mode 100644 index 00000000000..d980b7672d8 --- /dev/null +++ b/apps/coretemp/tests/behaviour/hrm_pair.test.js @@ -0,0 +1,170 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); +const fakeControlPoint = require("../helpers/fake_controlpoint"); +const protocol = loader.create().require("coretemp.protocol"); + +function loadHRM(cp, storage, globals) { + return loader.create({ + storage, + globals, + overrides: { + "coretemp.controlpoint": cp, + "coretemp.store": { log() {}, init() {}, flush() {} } + } + }).require("coretemp.hrm"); +} + +function immediateTimers(onTimeout) { + return { + setTimeout(fn, ms) { + if (onTimeout) onTimeout(ms); + fn(); + return 1; + }, + clearTimeout() {} + }; +} + +module.exports = [ + { + name: "pair verifies and persists selected plus recent", + async fn() { + const storage = fakeStorage.create(); + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [0]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0x56]); + const hrm = loadHRM(cp, storage); + hrm.init(); + const status = await hrm.pairANT({ antId: 0x1234, txType: 0x56 }); + assert.strictEqual(status.selected.antId, 0x1234); + assert.strictEqual(storage.files["coretemp.hrm.json"].selected.antId, 0x1234); + assert.strictEqual(storage.files["coretemp.hrm.json"].selected.txType, 0x56); + assert.strictEqual(storage.files["coretemp.hrm.json"].recent[0].txType, 0x56); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x04, 0x02, 0x04, 0x05]); + } + }, + { + name: "persisted txType is reused for exact pairing", + async fn() { + const storage = fakeStorage.create({ + "coretemp.hrm.json": { + selected: { antId: 0x1234, txType: 0x56 }, + recent: [{ antId: 0x1234, txType: 0x56 }] + } + }); + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [0]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0x56]); + const hrm = loadHRM(cp, storage); + hrm.init(); + const status = await hrm.pairANT(hrm.getState().selected); + assert.strictEqual(status.selected.antId, 0x1234); + assert.deepStrictEqual(JSON.parse(JSON.stringify(cp.calls[1].params)), [0x34, 0x12, 0x56]); + } + }, + { + name: "manual ANT id pairs without persisted txType", + async fn() { + const storage = fakeStorage.create({ + "coretemp.hrm.json": { + selected: { antId: 0x1234 }, + recent: [{ antId: 0x1234 }] + } + }); + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [0]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0]); + const hrm = loadHRM(cp, storage); + hrm.init(); + const status = await hrm.pairANT({ antId: 0x1234 }); + assert.strictEqual(status.selected.antId, 0x1234); + assert.deepStrictEqual(JSON.parse(JSON.stringify(cp.calls[1].params)), [0x34, 0x12, 0]); + assert.deepStrictEqual(storage.files["coretemp.hrm.json"], { + selected: { antId: 0x1234, transport: "ANT+" }, + recent: [{ antId: 0x1234, transport: "ANT+" }] + }); + } + }, + { + name: "same HRM is idempotent without replace flag", + async fn() { + const storage = fakeStorage.create(); + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0x56]); + const hrm = loadHRM(cp, storage); + hrm.init(); + const status = await hrm.pairANT({ antId: 0x1234, txType: 0x56 }); + assert.strictEqual(status.selected.antId, 0x1234); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x04, 0x05]); + } + }, + { + name: "different paired HRM requires replace flag", + async fn() { + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x11, 0x11, 0]); + const hrm = loadHRM(cp, fakeStorage.create()); + hrm.init(); + await assert.rejects(hrm.pairANT({ antId: 0x1234 }), /different HRM/); + } + }, + { + name: "replace clears before pairing", + async fn() { + const cp = fakeControlPoint.create(); + let settleMs; + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x11, 0x11, 0]); + cp.enqueueResponse(protocol.OPCODES.HRM_CLEAR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0]); + const hrm = loadHRM(cp, fakeStorage.create(), immediateTimers(ms => { settleMs = ms; })); + hrm.init(); + const status = await hrm.pairANT({ antId: 0x1234 }, true); + assert.strictEqual(status.selected.antId, 0x1234); + assert.strictEqual(settleMs, 2000); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x04, 0x05, 0x01, 0x02, 0x04, 0x05]); + } + }, + { + name: "replace clears before pairing the same HRM", + async fn() { + const cp = fakeControlPoint.create(); + let settleMs; + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0]); + cp.enqueueResponse(protocol.OPCODES.HRM_CLEAR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0]); + const hrm = loadHRM(cp, fakeStorage.create(), immediateTimers(ms => { settleMs = ms; })); + hrm.init(); + const status = await hrm.pairANT({ antId: 0x1234 }, true); + assert.strictEqual(status.selected.antId, 0x1234); + assert.strictEqual(settleMs, 2000); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x04, 0x05, 0x01, 0x02, 0x04, 0x05]); + } + }, + { + name: "multiple paired HRMs block pairing", + async fn() { + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [2]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [1, 0, 0]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [2, 0, 0]); + const hrm = loadHRM(cp, fakeStorage.create()); + hrm.init(); + await assert.rejects(hrm.pairANT({ antId: 0x1234 }, true), /Multiple HRMs/); + } + } +]; diff --git a/apps/coretemp/tests/behaviour/hrm_scan.test.js b/apps/coretemp/tests/behaviour/hrm_scan.test.js new file mode 100644 index 00000000000..8f79398ae88 --- /dev/null +++ b/apps/coretemp/tests/behaviour/hrm_scan.test.js @@ -0,0 +1,90 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); +const fakeControlPoint = require("../helpers/fake_controlpoint"); +const protocolLoader = loader.create(); +const protocol = protocolLoader.require("coretemp.protocol"); + +async function scanWithSettings(settings) { + const cp = fakeControlPoint.create(); + let scanWindowMs; + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_START, []); + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_COUNT, [0]); + const storage = fakeStorage.create({ + "coretemp.json": settings || {} + }); + const loaded = loader.create({ + storage, + overrides: { + "coretemp.controlpoint": cp, + "coretemp.store": { + get() { return storage.readJSON("coretemp.json", 1) || {}; }, + log() {}, + init() {}, + flush() {} + } + }, + globals: { + setTimeout(fn, ms) { + scanWindowMs = ms; + fn(); + return 1; + }, + clearTimeout() {} + } + }); + const hrm = loaded.require("coretemp.hrm"); + hrm.init(); + await hrm.scanANT(); + return scanWindowMs; +} + +module.exports = [ + { + name: "scan start ACK is separate from local scan window", + async fn() { + const cp = fakeControlPoint.create(); + let scanWindowMs; + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_START, []); + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_COUNT, [2]); + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_ENTRY, [0x34, 0x12, 0x56]); + cp.enqueueResponse(protocol.OPCODES.HRM_SCAN_ANT_ENTRY, [0x78, 0x56, 0x9A]); + const loaded = loader.create({ + storage: fakeStorage.create(), + overrides: { + "coretemp.controlpoint": cp, + "coretemp.store": { get() { return {}; }, log() {}, init() {}, flush() {} } + }, + globals: { + setTimeout(fn, ms) { + scanWindowMs = ms; + fn(); + return 1; + }, + clearTimeout() {} + } + }); + const hrm = loaded.require("coretemp.hrm"); + hrm.init(); + const found = await hrm.scanANT(); + assert.strictEqual(scanWindowMs, 5000); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x0A, 0x0B, 0x0C, 0x0C]); + assert.deepStrictEqual(JSON.parse(JSON.stringify(cp.calls[0].params)), [0xFF]); + assert.deepStrictEqual(JSON.parse(JSON.stringify(found.map(entry => entry.antId))), [0x1234, 0x5678]); + assert.strictEqual(hrm.getState().lastScan.length, 2); + } + }, + { + name: "scan window is configurable in seconds", + async fn() { + assert.strictEqual(await scanWithSettings({ antScanWindowSec: 10 }), 10000); + } + }, + { + name: "invalid scan window falls back to default", + async fn() { + assert.strictEqual(await scanWithSettings({ antScanWindowSec: 1 }), 5000); + assert.strictEqual(await scanWithSettings({ antScanWindowSec: "bad" }), 5000); + } + } +]; diff --git a/apps/coretemp/tests/behaviour/hrm_status_clear.test.js b/apps/coretemp/tests/behaviour/hrm_status_clear.test.js new file mode 100644 index 00000000000..ea5810e9ea4 --- /dev/null +++ b/apps/coretemp/tests/behaviour/hrm_status_clear.test.js @@ -0,0 +1,64 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); +const fakeControlPoint = require("../helpers/fake_controlpoint"); +const protocol = loader.create().require("coretemp.protocol"); + +function loadHRM(cp, storage) { + return loader.create({ + storage, + overrides: { + "coretemp.controlpoint": cp, + "coretemp.store": { log() {}, init() {}, flush() {} } + } + }).require("coretemp.hrm"); +} + +module.exports = [ + { + name: "status queries paired count and entries", + async fn() { + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [0x34, 0x12, 0x56]); + const hrm = loadHRM(cp, fakeStorage.create()); + hrm.init(); + const status = await hrm.getStatus(); + assert.strictEqual(status.pairedCount, 1); + assert.strictEqual(status.currentSource.antId, 0x1234); + assert.deepStrictEqual(cp.calls.map(call => call.opcode), [0x04, 0x05]); + } + }, + { + name: "clear verifies zero paired entries", + async fn() { + const storage = fakeStorage.create({ + "coretemp.hrm.json": { + selected: { antId: 0x1234, txType: 0 }, + recent: [{ antId: 0x1234, txType: 0 }] + } + }); + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_CLEAR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [0]); + const hrm = loadHRM(cp, storage); + hrm.init(); + const status = await hrm.clearANT(); + assert.strictEqual(status.pairedCount, 0); + assert.strictEqual(storage.files["coretemp.hrm.json"].selected, null); + } + }, + { + name: "clear rejects failed verification", + async fn() { + const cp = fakeControlPoint.create(); + cp.enqueueResponse(protocol.OPCODES.HRM_CLEAR_ANT, []); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_COUNT, [1]); + cp.enqueueResponse(protocol.OPCODES.HRM_PAIRED_ANT_ENTRY, [1, 0, 0]); + const hrm = loadHRM(cp, fakeStorage.create()); + hrm.init(); + await assert.rejects(hrm.clearANT(), /verification/); + assert.match(hrm.getState().lastError, /verification/); + } + } +]; diff --git a/apps/coretemp/tests/e2e/app_lifecycle.test.js b/apps/coretemp/tests/e2e/app_lifecycle.test.js new file mode 100644 index 00000000000..476dd128efd --- /dev/null +++ b/apps/coretemp/tests/e2e/app_lifecycle.test.js @@ -0,0 +1,129 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); + +function createGraphics() { + return { + theme: { bg: "#fff", dark: false }, + clears: [], + bgColor: undefined, + getWidth() { return 176; }, + getHeight() { return 176; }, + clear() { this.clears.push({ type: "clear", bgColor: this.bgColor }); return this; }, + reset() { return this; }, + setBgColor(color) { this.bgColor = color; return this; }, + setFont() { return this; }, + setFontAlign() { return this; }, + clearRect() { this.clears.push({ type: "clearRect", bgColor: this.bgColor }); return this; }, + setColor() { return this; }, + drawString() { return this; }, + drawImage() { return this; } + }; +} + +module.exports = [ + { + name: "boot loads runtime only when background is enabled", + fn() { + let enableCount = 0; + let powerCalls = []; + const loaded = loader.create({ + storage: fakeStorage.create({ + "coretemp.json": { enabled: true } + }), + globals: { + Bangle: { + setCORESensorPower(on, owner) { + powerCalls.push([on, owner]); + } + } + }, + overrides: { + CORESensor: { + enable() { + enableCount++; + } + } + } + }); + + loaded.require("coretemp.boot"); + + assert.strictEqual(enableCount, 1); + assert.deepStrictEqual(powerCalls, []); + } + }, + { + name: "foreground app uses temporary COREAPP owner without enabling background", + fn() { + let enableCount = 0; + let killHandler; + const powerCalls = []; + const listeners = {}; + const storage = fakeStorage.create({ + "coretemp.json": { enabled: false, btid: "core-1" } + }); + const Bangle = { + loadWidgets() {}, + drawWidgets() {}, + on(name, handler) { + listeners[name] = handler; + }, + removeListener(name, handler) { + if (listeners[name] === handler) delete listeners[name]; + }, + setCORESensorPower(on, owner) { + powerCalls.push([on, owner]); + } + }; + const graphics = createGraphics(); + const loaded = loader.create({ + storage, + globals: { + Bangle, + E: { + on(name, handler) { + if (name === "kill") killHandler = handler; + } + }, + g: graphics, + atob() { + return ""; + }, + process: { env: { HWVERSION: 2 } } + }, + overrides: { + heatshrink: { + decompress() { + return ""; + } + }, + CORESensor: { + enable() { + enableCount++; + } + } + } + }); + + loaded.require("coretemp.app"); + + assert.strictEqual(enableCount, 1); + assert.strictEqual(typeof listeners.CORESensor, "function"); + assert.deepStrictEqual(powerCalls, [[1, "COREAPP"]]); + assert.strictEqual(storage.readJSON("coretemp.json", 1).enabled, false); + assert.strictEqual(graphics.clears[0].bgColor, "#fff"); + + graphics.setBgColor("#0f0"); + listeners.CORESensor({ core: 37.1, skin: 35.2, unit: "C", hsiValid: false, battery: 80 }); + + assert.strictEqual(graphics.clears[graphics.clears.length - 1].bgColor, "#fff"); + + killHandler(); + + assert.strictEqual(listeners.CORESensor, undefined); + assert.deepStrictEqual(powerCalls, [[1, "COREAPP"], [0, "COREAPP"]]); + assert.strictEqual(storage.readJSON("coretemp.json", 1).enabled, false); + } + } +]; diff --git a/apps/coretemp/tests/e2e/ble_controlpoint.test.js b/apps/coretemp/tests/e2e/ble_controlpoint.test.js new file mode 100644 index 00000000000..7667b1be85e --- /dev/null +++ b/apps/coretemp/tests/e2e/ble_controlpoint.test.js @@ -0,0 +1,567 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); +const fakeBLE = require("../helpers/fake_ble"); + +function tick() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +function createTimers(options) { + const reconnectTimers = []; + const profileUpgradeTimers = []; + options = options || {}; + return { + setTimeout(fn, ms) { + if (ms === 2000 || ms === 3000 || ms === 4000) { + Promise.resolve().then(fn); + return -1; + } + if (options.manualReconnect && (ms === 5000 || ms === 10000 || ms === 30000)) { + const timer = { fn, active: true }; + reconnectTimers.push(timer); + return timer; + } + if (ms === 60000) { + const timer = { fn, active: true }; + profileUpgradeTimers.push(timer); + return timer; + } + return setTimeout(fn, ms); + }, + clearTimeout(id) { + if (id && id.fn) { + id.active = false; + return; + } + if (id !== -1) clearTimeout(id); + }, + runNextReconnect() { + const timer = reconnectTimers.shift(); + if (timer && timer.active) Promise.resolve().then(timer.fn); + }, + hasReconnect() { + return reconnectTimers.some(timer => timer.active); + }, + runNextProfileUpgrade() { + const timer = profileUpgradeTimers.shift(); + if (timer && timer.active) Promise.resolve().then(timer.fn); + }, + hasProfileUpgrade() { + return profileUpgradeTimers.some(timer => timer.active); + } + }; +} + +function createLoadedBLE(options) { + options = options || {}; + const protocol = loader.create().require("coretemp.protocol"); + const env = fakeBLE.create(protocol, options.fakeBLE); + let cachedAttachFailureMode = options.cachedAttachFailureMode; + const emitted = []; + const Bangle = { + _PWR: { + CORESensor: ["test"] + }, + emit(name, data) { + emitted.push({ name, data }); + } + }; + const timers = createTimers(options.timers); + const storage = fakeStorage.create({ + "coretemp.json": Object.assign({ + btid: "core-1" + }, options.settings || {}) + }); + const loaded = loader.create({ + storage, + globals: { + Bangle, + NRF: env.NRF, + BluetoothRemoteGATTCharacteristic: function () { + return { + on() {}, + readValue() { return Promise.resolve(); }, + startNotifications() { + if (cachedAttachFailureMode === "disconnect_once") { + cachedAttachFailureMode = undefined; + env.gatt.connected = false; + env.device.emitDisconnect("drop during cached attach"); + return Promise.reject(new Error("Disconnected")); + } + if (cachedAttachFailureMode === "busy_once") { + cachedAttachFailureMode = undefined; + return Promise.reject(new Error("ERR 0x11 (BUSY)")); + } + return Promise.resolve(); + }, + writeValue() { return Promise.resolve(); } + }; + }, + setTimeout: timers.setTimeout, + clearTimeout: timers.clearTimeout + } + }); + return { + ble: loaded.require("coretemp.ble"), + protocol, + env, + storage, + Bangle, + emitted, + timers + }; +} + +async function drain() { + for (let i = 0; i < 20; i++) await tick(); +} + +module.exports = [ + { + name: "unpair clears paired CORE state without erasing global BLE bonds", + async fn() { + const { ble, storage, Bangle } = createLoadedBLE({ + settings: { + enabled: true, + btname: "CORE", + cache: { + characteristics: { + "00002101-5b1e-4347-b07c-97b514dae121": { + handle: 1, + uuid: "00002101-5b1e-4347-b07c-97b514dae121", + notify: true, + indicate: false, + read: false, + write: false + } + } + } + } + }); + ble.init(); + + await ble.unpairDevice(); + + const settings = storage.readJSON("coretemp.json", 1); + assert.strictEqual(settings.btid, undefined); + assert.strictEqual(settings.btname, undefined); + assert.strictEqual(settings.cache, undefined); + assert.strictEqual(settings.enabled, false); + assert.deepStrictEqual(JSON.parse(JSON.stringify(Bangle._PWR.CORESensor)), []); + assert.strictEqual(ble.getStatus().paired, false); + assert.strictEqual(ble.getStatus().desiredConnected, false); + assert.strictEqual(ble.getStatus().reconnectScheduled, false); + assert.strictEqual(ble.getStatus().lastError, undefined); + } + }, + { + name: "connect discovers control point and write wrapper resolves indication", + async fn() { + const { ble, protocol, env } = createLoadedBLE(); + ble.init(); + await ble.connect(); + assert.strictEqual(env.controlPointChar.notificationsStarted, true); + + const response = ble.writeControlPoint(protocol.OPCODES.HRM_SCAN_ANT_COUNT, [], { + timeoutMs: 200 + }); + await tick(); + assert.deepStrictEqual(env.controlPointChar.writes, [[protocol.OPCODES.HRM_SCAN_ANT_COUNT]]); + env.controlPointChar.emitValue([0x80, protocol.OPCODES.HRM_SCAN_ANT_COUNT, 0x01, 3]); + const result = await response; + assert.strictEqual(result.requestOpCode, protocol.OPCODES.HRM_SCAN_ANT_COUNT); + assert.deepStrictEqual(JSON.parse(JSON.stringify(result.payload)), [3]); + } + }, + { + name: "connect accepts normalized uppercase CORE UUIDs", + async fn() { + const { ble, protocol, env } = createLoadedBLE({ + fakeBLE: { uppercaseUuids: true } + }); + ble.init(); + await ble.connect(); + assert.strictEqual(env.controlPointChar.notificationsStarted, true); + + const response = ble.writeControlPoint(protocol.OPCODES.HRM_PAIRED_COUNT, [], { + timeoutMs: 200 + }); + await tick(); + env.controlPointChar.emitValue([0x80, protocol.OPCODES.HRM_PAIRED_COUNT, 0x01, 0]); + assert.strictEqual((await response).requestOpCode, protocol.OPCODES.HRM_PAIRED_COUNT); + } + }, + { + name: "connect does not attempt implicit BLE bonding", + async fn() { + const { ble, env } = createLoadedBLE({ + fakeBLE: { + bonded: false, + bondReject: new Error("Bonding failed") + } + }); + ble.init(); + + await ble.connect(); + + assert.strictEqual(env.gatt.bondCalls, 0); + assert.strictEqual(ble.getStatus().connected, true); + } + }, + { + name: "pair stores unbonded CORE without BLE bonding", + async fn() { + const { ble, env, storage } = createLoadedBLE({ + fakeBLE: { + bonded: false, + bondReject: new Error("Bonding failed") + } + }); + ble.init(); + + await ble.pairDevice(env.device); + + const settings = storage.readJSON("coretemp.json", 1); + assert.strictEqual(env.gatt.bondCalls, 0); + assert.strictEqual(settings.btid, "core-1"); + assert.strictEqual(settings.btname, "CORE"); + assert.strictEqual(ble.getStatus().reconnectScheduled, false); + } + }, + { + name: "failed pair flow does not leave background reconnect scheduled", + async fn() { + const { ble, env, timers } = createLoadedBLE({ + fakeBLE: { includeCoreCharacteristics: false }, + timers: { manualReconnect: true } + }); + ble.init(); + + await assert.rejects( + ble.pairDevice(env.device), + /Runtime discovery missing required CORE characteristics/ + ); + + assert.strictEqual(ble.getStatus().reconnectScheduled, false); + assert.strictEqual(ble.getStatus().desiredConnected, false); + assert.strictEqual(timers.hasReconnect(), false); + } + }, + { + name: "owner pause disconnects transport and resume reconnects when power remains", + async fn() { + const { ble, env, timers } = createLoadedBLE({ + timers: { manualReconnect: true } + }); + ble.init(); + await ble.connect(); + assert.strictEqual(env.gatt.connected, true); + + await ble.pause("heatsuite.task"); + + assert.strictEqual(ble.isPaused(), true); + assert.strictEqual(env.gatt.connected, false); + assert.strictEqual(ble.getStatus().connected, false); + assert.strictEqual(ble.getStatus().desiredConnected, true); + assert.strictEqual(timers.hasReconnect(), false); + + await ble.resume("heatsuite.task"); + + assert.strictEqual(ble.isPaused(), false); + assert.strictEqual(env.gatt.connected, true); + assert.strictEqual(ble.getStatus().connected, true); + } + }, + { + name: "disconnect during cached attach aborts discovery fallback and reconnects cleanly", + async fn() { + const { ble, env, timers } = createLoadedBLE({ + cachedAttachFailureMode: "disconnect_once", + timers: { manualReconnect: true }, + settings: { + cache: { + characteristics: { + "00002101-5b1e-4347-b07c-97b514dae121": { + handle: 1, + uuid: "00002101-5b1e-4347-b07c-97b514dae121", + notify: true, + indicate: false, + read: false, + write: false + } + } + } + } + }); + ble.init(); + + await assert.rejects(ble.connect(), /Disconnected/); + assert.strictEqual(env.getPrimaryServicesCalls(), 0); + assert.strictEqual(ble.getStatus().reconnectScheduled, true); + assert.match(ble.getStatus().lastError, /Disconnected/); + + timers.runNextReconnect(); + await drain(); + + assert.strictEqual(ble.getStatus().connected, true); + assert.strictEqual(ble.getStatus().state, "connected"); + } + }, + { + name: "cached BUSY attach failure rebuilds cache while transport stays connected", + async fn() { + const { ble, env } = createLoadedBLE({ + cachedAttachFailureMode: "busy_once", + settings: { + cache: { + characteristics: { + "00002101-5b1e-4347-b07c-97b514dae121": { + handle: 1, + uuid: "00002101-5b1e-4347-b07c-97b514dae121", + notify: true, + indicate: false, + read: false, + write: false + } + } + } + } + }); + ble.init(); + + await ble.connect(); + + assert.strictEqual(env.getPrimaryServicesCalls(), 1); + assert.strictEqual(ble.getStatus().connected, true); + assert.strictEqual(ble.getStatus().hasCache, true); + } + }, + { + name: "power on while paused does not auto-connect until final pause owner resumes", + async fn() { + const { ble, env } = createLoadedBLE(); + ble.init(); + + await ble.pause("task-a"); + await ble.pause("task-b"); + ble.setPower(1, "background"); + await drain(); + + assert.strictEqual(env.gatt.connected, false); + assert.strictEqual(ble.getStatus().paused, true); + assert.deepStrictEqual(JSON.parse(JSON.stringify(ble.getStatus().pauseOwners)), ["task-a", "task-b"]); + + await ble.resume("task-a"); + await drain(); + assert.strictEqual(env.gatt.connected, false); + assert.strictEqual(ble.isPaused(), true); + + await ble.resume("task-b"); + + assert.strictEqual(ble.isPaused(), false); + assert.strictEqual(env.gatt.connected, true); + } + }, + { + name: "connect accepts standard health thermometer temperature without control point", + async fn() { + const { ble, protocol, env, emitted } = createLoadedBLE({ + fakeBLE: { healthThermometerOnly: true } + }); + ble.init(); + await ble.connect(); + assert.strictEqual(ble.getStatus().connected, true); + await assert.rejects( + ble.writeControlPoint(protocol.OPCODES.HRM_PAIRED_COUNT, [], { timeoutMs: 20 }), + /not connected/ + ); + + env.healthThermometerChar.emitValue([0x00, 0x77, 0x01, 0x00, 0xFF]); + + const measurements = emitted.filter(e => e.name === "CORESensor"); + assert.strictEqual(measurements.length, 1); + assert.strictEqual(measurements[0].data.core, 37.5); + assert.strictEqual(measurements[0].data.profile, "health_thermometer"); + } + }, + { + name: "state changes emit CORE status events", + async fn() { + const { ble, emitted } = createLoadedBLE({ + settings: { enabled: true } + }); + ble.init(); + await ble.connect(); + + const statusEvents = emitted.filter(e => e.name === "CORESensorStatus"); + const connectedStatus = statusEvents.find(e => e.data && e.data.state === "connected"); + assert(connectedStatus); + assert.strictEqual(connectedStatus.data.connected, true); + assert.strictEqual(connectedStatus.data.enabled, true); + assert.strictEqual(typeof connectedStatus.data.paused, "boolean"); + } + }, + { + name: "health thermometer fallback schedules profile upgrade discovery", + async fn() { + const { ble, timers } = createLoadedBLE({ + fakeBLE: { healthThermometerOnly: true }, + timers: { manualProfileUpgrade: true, manualReconnect: true } + }); + ble.init(); + await ble.connect(); + + assert.strictEqual(ble.getStatus().profile, "health_thermometer"); + assert.strictEqual(ble.getStatus().profileUpgradeScheduled, true); + assert.strictEqual(timers.hasProfileUpgrade(), true); + + timers.runNextProfileUpgrade(); + await drain(); + + assert.strictEqual(ble.getStatus().desiredConnected, true); + } + }, + { + name: "custom CORE profile does not schedule profile upgrade discovery", + async fn() { + const { ble, timers } = createLoadedBLE({ + timers: { manualProfileUpgrade: true } + }); + ble.init(); + await ble.connect(); + + assert.strictEqual(ble.getStatus().profile, "custom_core"); + assert.strictEqual(ble.getStatus().profileUpgradeScheduled, false); + assert.strictEqual(timers.hasProfileUpgrade(), false); + } + }, + { + name: "custom-only setting rejects standard health thermometer fallback", + async fn() { + const { ble, timers } = createLoadedBLE({ + fakeBLE: { healthThermometerOnly: true }, + settings: { customprofileonly: true }, + timers: { manualReconnect: true } + }); + ble.init(); + await assert.rejects( + ble.connect(), + /Runtime discovery missing required CORE characteristics: missing 00002101-5b1e-4347-b07c-97b514dae121/ + ); + assert.strictEqual(ble.getStatus().reconnectScheduled, true); + + timers.runNextReconnect(); + await drain(); + assert.strictEqual(ble.getStatus().state, "reconnect_wait"); + } + }, + { + name: "custom-only setting ignores cached standard temperature fallback", + async fn() { + const { ble } = createLoadedBLE({ + fakeBLE: { healthThermometerOnly: true }, + timers: { manualReconnect: true }, + settings: { + customprofileonly: true, + cache: { + characteristics: { + "0x2a1c": { + handle: 1, + uuid: "0x2a1c", + notify: true, + indicate: true, + read: true, + write: false + } + } + } + } + }); + ble.init(); + await assert.rejects( + ble.connect(), + /Runtime discovery missing required CORE characteristics: missing 00002101-5b1e-4347-b07c-97b514dae121/ + ); + assert.strictEqual(ble.getStatus().profile, undefined); + } + }, + { + name: "connect prefers custom CORE temperature when both profiles are present", + async fn() { + const { ble, env } = createLoadedBLE({ + fakeBLE: { includeHealthThermometer: true } + }); + ble.init(); + await ble.connect(); + + assert.strictEqual(env.tempChar.notificationsStarted, true); + assert.strictEqual(env.healthThermometerChar.notificationsStarted, undefined); + } + }, + { + name: "discovery mismatch keeps background reconnect scheduled", + async fn() { + const { ble, timers } = createLoadedBLE({ + fakeBLE: { includeCoreCharacteristics: false }, + timers: { manualReconnect: true } + }); + ble.init(); + await assert.rejects( + ble.connect(), + /Runtime discovery missing required CORE characteristics: missing/ + ); + assert.strictEqual(ble.getStatus().reconnectScheduled, true); + + timers.runNextReconnect(); + await drain(); + + const status = ble.getStatus(); + assert.strictEqual(status.reconnectScheduled, true); + assert.strictEqual(status.desiredConnected, true); + assert.strictEqual(status.state, "reconnect_wait"); + } + }, + { + name: "mismatched indication is discarded until matching opcode arrives", + async fn() { + const { ble, protocol, env } = createLoadedBLE(); + ble.init(); + await ble.connect(); + const response = ble.writeControlPoint(protocol.OPCODES.HRM_PAIRED_COUNT, [], { + timeoutMs: 200 + }); + await tick(); + env.controlPointChar.emitValue([0x80, protocol.OPCODES.HRM_SCAN_ANT_COUNT, 0x01, 7]); + await tick(); + env.controlPointChar.emitValue([0x80, protocol.OPCODES.HRM_PAIRED_COUNT, 0x01, 1]); + assert.strictEqual((await response).payload[0], 1); + } + }, + { + name: "disconnect cancels active control point request", + async fn() { + const { ble, protocol, env, Bangle } = createLoadedBLE(); + ble.init(); + await ble.connect(); + const response = ble.writeControlPoint(protocol.OPCODES.HRM_PAIRED_COUNT, [], { + timeoutMs: 500 + }); + await tick(); + Bangle._PWR.CORESensor = []; + env.device.emitDisconnect("drop"); + await assert.rejects(response, /CORE transport closed: disconnect/); + } + }, + { + name: "write wrapper rejects when control point is not connected", + async fn() { + const { ble, protocol } = createLoadedBLE(); + ble.init(); + await assert.rejects( + ble.writeControlPoint(protocol.OPCODES.HRM_PAIRED_COUNT, [], { timeoutMs: 20 }), + /not connected/ + ); + } + } +]; diff --git a/apps/coretemp/tests/e2e/coretemp_runtime.test.js b/apps/coretemp/tests/e2e/coretemp_runtime.test.js new file mode 100644 index 00000000000..bf0f3e2888e --- /dev/null +++ b/apps/coretemp/tests/e2e/coretemp_runtime.test.js @@ -0,0 +1,65 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); + +module.exports = [ + { + name: "runtime exposes HRM APIs on Bangle", + fn() { + const Bangle = { + _PWR: {}, + on() {} + }; + const hrm = { + init() { this.initialized = true; }, + getState() { return { busy: false, pairedSensors: [] }; }, + getStatus() {}, + scanANT() {}, + pairANT() {}, + clearANT() {} + }; + const loaded = loader.create({ + storage: fakeStorage.create(), + globals: { Bangle, E: { on() {} } }, + overrides: { + "coretemp.hrm": hrm, + "coretemp.ble": { + init() {}, + shutdown() {}, + getStatus() { return { state: "connected" }; }, + isOn() {}, + isConnected() {}, + connect() {}, + disconnect() {}, + pairDevice() {}, + unpairDevice() {}, + rebuildCache() {}, + writeControlPoint() {}, + setPower() {}, + pause() {}, + resume() {}, + isPaused() {} + }, + "coretemp.store": { + init() {}, + shutdown() {}, + get() { return {}; }, + setDebug() {}, + flush() {}, + log() {} + } + } + }); + loaded.require("coretemp.runtime").enable(); + assert.strictEqual(typeof Bangle.CORESensorHRMGetStatus, "function"); + assert.strictEqual(typeof Bangle.CORESensorHRMScanANT, "function"); + assert.strictEqual(typeof Bangle.CORESensorHRMPairANT, "function"); + assert.strictEqual(typeof Bangle.CORESensorHRMClearANT, "function"); + assert.strictEqual(typeof Bangle.CORESensorSetLogMode, "function"); + assert.strictEqual(typeof Bangle.CORESensorPause, "function"); + assert.strictEqual(typeof Bangle.CORESensorResume, "function"); + assert.strictEqual(typeof Bangle.CORESensorIsPaused, "function"); + assert.strictEqual(Bangle.CORESensorGetStatus().hrm.busy, false); + } + } +]; diff --git a/apps/coretemp/tests/e2e/manifest_packaging.test.js b/apps/coretemp/tests/e2e/manifest_packaging.test.js new file mode 100644 index 00000000000..1d7f9dce94b --- /dev/null +++ b/apps/coretemp/tests/e2e/manifest_packaging.test.js @@ -0,0 +1,35 @@ +/* eslint-env node */ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +module.exports = [ + { + name: "manifest includes deployable HRM modules but excludes tests", + fn() { + const root = path.resolve(__dirname, "../.."); + const metadata = JSON.parse(fs.readFileSync(path.join(root, "metadata.json"), "utf8")); + const urls = metadata.storage.concat(metadata.data || []).map(entry => entry.url); + assert.strictEqual(metadata.custom, "custom.html"); + assert.strictEqual(metadata.customConnect, true); + assert.ok(urls.includes("protocol.js")); + assert.ok(urls.includes("controlpoint.js")); + assert.ok(urls.includes("hrm.js")); + assert.ok(urls.includes("hrm.json")); + assert.strictEqual(urls.some(url => /(^|\/)tests\//.test(url)), false); + } + }, + { + name: "packaged defaults leave background runtime disabled", + fn() { + const root = path.resolve(__dirname, "../.."); + const settings = JSON.parse(fs.readFileSync(path.join(root, "app-settings.json"), "utf8")); + assert.strictEqual(settings.enabled, false); + assert.strictEqual(settings.widget, true); + assert.strictEqual(settings.customprofileonly, false); + assert.strictEqual(settings.debugMode, false); + assert.strictEqual(settings.antScanWindowSec, 5); + assert.strictEqual(Object.prototype.hasOwnProperty.call(settings, "warn" + "Disconnect"), false); + } + } +]; diff --git a/apps/coretemp/tests/e2e/settings_hrm_flow.test.js b/apps/coretemp/tests/e2e/settings_hrm_flow.test.js new file mode 100644 index 00000000000..dd7fa2f6279 --- /dev/null +++ b/apps/coretemp/tests/e2e/settings_hrm_flow.test.js @@ -0,0 +1,726 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); + +module.exports = [ + { + name: "settings menu shows HRM actions for paired CORE", + fn() { + let currentMenu; + let statusCalls = 0; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorHRMGetState() { + return { + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + statusCalls++; + return Promise.resolve({ + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + return Promise.resolve(); + }, + showPrompt() { + return Promise.resolve(false); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + loaded.require("coretemp.settingsui").open(function () {}); + assert.strictEqual(typeof currentMenu["Forget CORE"], "function"); + assert.strictEqual(currentMenu["Unpair CORE"], undefined); + assert.strictEqual(typeof currentMenu["HRM (ANT+)"], "function"); + assert.strictEqual(statusCalls, 0); + currentMenu["HRM (ANT+)"](); + assert.strictEqual(statusCalls, 0); + assert.strictEqual(typeof currentMenu["Status"], "function"); + assert.strictEqual(typeof currentMenu["Scan ANT+"], "function"); + assert.strictEqual(typeof currentMenu["Recent HRMs"], "function"); + assert.strictEqual(currentMenu["Manual ANT ID"], undefined); + assert.strictEqual(typeof currentMenu["Clear Paired HRM"], "function"); + currentMenu["< Back"](); + currentMenu["Debug"](); + assert.strictEqual(typeof currentMenu["Full log"], "object"); + assert.strictEqual(typeof currentMenu["Partial log"], "object"); + assert.strictEqual(typeof currentMenu["Custom CORE only"], "object"); + assert.strictEqual(currentMenu["Alert on disconnect"], undefined); + assert.strictEqual(typeof currentMenu["Reset CoreTemp"], "function"); + assert.strictEqual(currentMenu["Erase BLE Bonds"], undefined); + assert.strictEqual(currentMenu["Reset All"], undefined); + assert.strictEqual(currentMenu["Debug log"], undefined); + } + }, + { + name: "HRM entry details use neutral prompt", + async fn() { + let currentMenu; + let alertCalls = 0; + let promptText; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorHRMGetState() { + return { + selected: { antId: 0x1234, transport: "ANT+" }, + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + return Promise.resolve({ + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + alertCalls++; + return Promise.resolve(); + }, + showPrompt(text) { + promptText = text; + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["HRM (ANT+)"](); + currentMenu["Preset HRM"](); + await currentMenu["Details"](); + + assert.strictEqual(alertCalls, 0); + assert.match(promptText, /ANT ID: 4660/); + } + }, + { + name: "configured default HRM uses normal pairing flow", + async fn() { + let currentMenu; + let pairedEntry; + let replaceFlag; + const prompts = []; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorHRMGetState() { + return { + selected: { antId: 0x1234, transport: "ANT+" }, + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + return Promise.resolve({ + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false + }); + }, + CORESensorHRMPairANT(entry, replaceExisting) { + pairedEntry = entry; + replaceFlag = replaceExisting; + return Promise.resolve({ + pairedSensors: [entry], + pairedCount: 1, + paired: true, + syncState: "paired", + selected: entry + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + return Promise.resolve(); + }, + showPrompt(text) { + prompts.push(text); + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["HRM (ANT+)"](); + assert.strictEqual(typeof currentMenu["Preset HRM"], "function"); + currentMenu["Preset HRM"](); + assert.strictEqual(typeof currentMenu["Pair"], "function"); + await currentMenu["Pair"](); + + assert.strictEqual(pairedEntry.antId, 0x1234); + assert.strictEqual(replaceFlag, false); + assert.match(prompts[0], /Pair ANT\+\n4660/); + } + }, + { + name: "default HRM pairing confirms replacement for different paired HRM", + async fn() { + let currentMenu; + let replaceFlag; + const prompts = []; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorHRMGetState() { + return { + selected: { antId: 0x1234, transport: "ANT+" }, + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + return Promise.resolve({ + pairedSensors: [{ antId: 0x9999, transport: "ANT+" }], + recent: [], + pairedCount: 1, + paired: true + }); + }, + CORESensorHRMPairANT(entry, replaceExisting) { + replaceFlag = replaceExisting; + return Promise.resolve({ + pairedSensors: [entry], + pairedCount: 1, + paired: true, + syncState: "paired", + selected: entry + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + return Promise.resolve(); + }, + showPrompt(text) { + prompts.push(text); + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["HRM (ANT+)"](); + currentMenu["Preset HRM"](); + await currentMenu["Pair"](); + + assert.match(prompts[0], /Replace existing\nHRM/); + assert.strictEqual(replaceFlag, true); + } + }, + { + name: "default HRM pairing confirms re-pair for same paired HRM", + async fn() { + let currentMenu; + let replaceFlag; + const prompts = []; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorHRMGetState() { + return { + selected: { antId: 0x1234, transport: "ANT+" }, + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + return Promise.resolve({ + pairedSensors: [{ antId: 0x1234, transport: "ANT+" }], + recent: [], + pairedCount: 1, + paired: true + }); + }, + CORESensorHRMPairANT(entry, replaceExisting) { + replaceFlag = replaceExisting; + return Promise.resolve({ + pairedSensors: [entry], + pairedCount: 1, + paired: true, + syncState: "paired", + selected: entry + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + return Promise.resolve(); + }, + showPrompt(text) { + prompts.push(text); + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["HRM (ANT+)"](); + currentMenu["Preset HRM"](); + await currentMenu["Pair"](); + + assert.match(prompts[0], /Re-pair existing\nHRM/); + assert.strictEqual(replaceFlag, true); + } + }, + { + name: "normal CORE and HRM status use neutral prompt instead of alert", + async fn() { + let currentMenu; + let alertCalls = 0; + let promptCalls = 0; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorGetStatus() { + return { + state: "connected", + paired: true, + connected: true, + lastError: "", + hrm: {} + }; + }, + CORESensorHRMGetState() { + return { + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false, + busy: false + }; + }, + CORESensorHRMGetStatus() { + return Promise.resolve({ + pairedSensors: [], + recent: [], + pairedCount: 0, + paired: false + }); + }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + alertCalls++; + return Promise.resolve(); + }, + showPrompt() { + promptCalls++; + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["Debug"](); + await currentMenu["Status"](); + currentMenu["< Back"](); + currentMenu["HRM (ANT+)"](); + await currentMenu["Status"](); + + assert.strictEqual(alertCalls, 0); + assert.strictEqual(promptCalls, 2); + } + }, + { + name: "cache rebuild success uses neutral success prompt instead of alert", + async fn() { + let currentMenu; + let alertCalls = 0; + let promptCalls = 0; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorRebuildCache() { return Promise.resolve(); }, + CORESensorGetStatus() { return { state: "connected", paired: true, connected: true, hrm: {} }; }, + CORESensorHRMGetState() { return {}; }, + CORESensorHRMGetStatus() { return Promise.resolve({}); }, + isCORESensorOn() { return true; }, + setCORESensorPower() {}, + on() {}, + removeListener() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { + alertCalls++; + return Promise.resolve(); + }, + showPrompt() { + promptCalls++; + return Promise.resolve(true); + }, + showMessage() {} + }; + const g = { + theme: { dark: false }, + clear() { return this; }, + setFont() { return this; }, + setFontAlign() { return this; }, + drawString() { return this; }, + setColor() { return this; }, + getWidth() { return 176; } + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE" + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {}, g }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["Debug"](); + await currentMenu["Rebuild cache"](); + + assert.strictEqual(alertCalls, 0); + assert.strictEqual(promptCalls, 1); + } + }, + { + name: "reset CoreTemp copy says BLE bonds are not erased and does not erase bonds", + async fn() { + let currentMenu; + const prompts = []; + let unpairCalls = 0; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { + unpairCalls++; + Bangle._PWR.CORESensor = []; + return Promise.resolve(); + }, + CORESensorGetStatus() { return { state: "connected", paired: true, connected: true, hrm: {} }; }, + CORESensorHRMGetState() { return {}; }, + CORESensorHRMGetStatus() { return Promise.resolve({}); }, + isCORESensorOn() { return true; }, + setCORESensorPower() {}, + _PWR: { CORESensor: ["coretemp.enabled"] } + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { return Promise.resolve(); }, + showPrompt(text, options) { + prompts.push({ text, options }); + return Promise.resolve(true); + }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + enabled: true, + widget: false, + btid: "core-1", + btname: "CORE" + }, + "coretemp.log": "log", + "coretemp.hrm.json": { selected: { antId: 1 } } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["Debug"](); + await currentMenu["Reset CoreTemp"](); + + assert.strictEqual(unpairCalls, 1); + assert.match(prompts[0].text, /BLE bonds\nare not erased/); + assert.deepStrictEqual(storage.files["coretemp.json"], { enabled: false, widget: true }); + assert.strictEqual(storage.files["coretemp.log"], undefined); + assert.strictEqual(storage.files["coretemp.hrm.json"], undefined); + assert.deepStrictEqual(Bangle._PWR.CORESensor, []); + } + }, + { + name: "full and partial log toggles refresh mutually exclusive UI state", + fn() { + let currentMenu; + const Bangle = { + CORESensorPair() {}, + CORESensorConnect() { return Promise.resolve(); }, + CORESensorUnpair() { return Promise.resolve(); }, + CORESensorSetLogMode() {}, + CORESensorGetStatus() { return { state: "connected", paired: true, connected: true, hrm: {} }; }, + CORESensorHRMGetState() { return {}; }, + CORESensorHRMGetStatus() { return Promise.resolve({}); }, + isCORESensorOn() { return true; }, + setCORESensorPower() {} + }; + const E = { + showMenu(menu) { + currentMenu = menu; + }, + showAlert() { return Promise.resolve(); }, + showPrompt() { return Promise.resolve(true); }, + showMessage() {} + }; + const storage = fakeStorage.create({ + "coretemp.json": { + btid: "core-1", + btname: "CORE", + debugpartiallog: true + } + }); + const loaded = loader.create({ + storage, + globals: { Bangle, E, NRF: {} }, + overrides: { + "coretemp.store": { + read() { return storage.readJSON("coretemp.json", 1) || {}; }, + write(mutator) { + const next = storage.readJSON("coretemp.json", 1) || {}; + mutator(next); + storage.writeJSON("coretemp.json", next); + return next; + }, + log() {} + } + } + }); + + loaded.require("coretemp.settingsui").open(function () {}); + currentMenu["Debug"](); + assert.strictEqual(currentMenu["Full log"].value, false); + assert.strictEqual(currentMenu["Partial log"].value, true); + + currentMenu["Full log"].onchange(true); + assert.strictEqual(currentMenu["Full log"].value, true); + assert.strictEqual(currentMenu["Partial log"].value, false); + + currentMenu["Partial log"].onchange(true); + assert.strictEqual(currentMenu["Full log"].value, false); + assert.strictEqual(currentMenu["Partial log"].value, true); + } + } +]; diff --git a/apps/coretemp/tests/fixtures/packets.js b/apps/coretemp/tests/fixtures/packets.js new file mode 100644 index 00000000000..0a221f446a4 --- /dev/null +++ b/apps/coretemp/tests/fixtures/packets.js @@ -0,0 +1,7 @@ +exports.response = function response(opcode, payload, resultCode) { + return [0x80, opcode, resultCode === undefined ? 0x01 : resultCode].concat(payload || []); +}; + +exports.antEntryPayload = function antEntryPayload(id, txType) { + return [id & 0xFF, (id >> 8) & 0xFF, txType || ((id >> 16) & 0xFF)]; +}; diff --git a/apps/coretemp/tests/helpers/dataview.js b/apps/coretemp/tests/helpers/dataview.js new file mode 100644 index 00000000000..d160a28ec78 --- /dev/null +++ b/apps/coretemp/tests/helpers/dataview.js @@ -0,0 +1,3 @@ +exports.fromBytes = function fromBytes(bytes) { + return new DataView(Uint8Array.from(bytes).buffer); +}; diff --git a/apps/coretemp/tests/helpers/fake_ble.js b/apps/coretemp/tests/helpers/fake_ble.js new file mode 100644 index 00000000000..0bbc7f46743 --- /dev/null +++ b/apps/coretemp/tests/helpers/fake_ble.js @@ -0,0 +1,131 @@ +const dataview = require("./dataview"); + +function createCharacteristic(uuid, properties) { + const handlers = {}; + const writes = []; + return { + uuid, + properties: properties || {}, + writes, + on(name, handler) { + handlers[name] = handler; + }, + startNotifications() { + this.notificationsStarted = true; + return Promise.resolve(); + }, + readValue() { + return Promise.resolve(dataview.fromBytes([90])); + }, + writeValue(value) { + writes.push(Array.prototype.slice.call(value)); + return Promise.resolve(); + }, + emitValue(bytes) { + handlers.characteristicvaluechanged({ + target: { + value: dataview.fromBytes(bytes) + } + }); + } + }; +} + +exports.create = function createFakeBLE(protocol, options) { + options = options || {}; + const disconnectHandlers = []; + let getPrimaryServicesCalls = 0; + const coreServiceUuid = options.uppercaseUuids ? protocol.CORE_SERVICE_UUID.toUpperCase() : protocol.CORE_SERVICE_UUID; + const tempUuid = options.uppercaseUuids ? protocol.CORE_TEMP_UUID.toUpperCase() : protocol.CORE_TEMP_UUID; + const controlPointUuid = options.uppercaseUuids ? protocol.CORE_CONTROL_POINT_UUID.toUpperCase() : protocol.CORE_CONTROL_POINT_UUID; + const tempChar = createCharacteristic(tempUuid, { notify: true }); + const controlPointChar = createCharacteristic(controlPointUuid, { + indicate: true, + write: true + }); + const batteryChar = createCharacteristic("0x2a19", { read: true }); + const healthThermometerChar = createCharacteristic("00002a1c-0000-1000-8000-00805f9b34fb", { + indicate: true + }); + const coreCharacteristics = options.includeCoreCharacteristics === false ? + [batteryChar] : + [tempChar, controlPointChar]; + const services = options.healthThermometerOnly ? [{ + uuid: "00001809-0000-1000-8000-00805f9b34fb", + getCharacteristics() { + return Promise.resolve([healthThermometerChar, batteryChar]); + } + }] : options.includeHealthThermometer ? [{ + uuid: "00001809-0000-1000-8000-00805f9b34fb", + getCharacteristics() { + return Promise.resolve([healthThermometerChar]); + } + }, { + uuid: coreServiceUuid, + getCharacteristics() { + return Promise.resolve(coreCharacteristics); + } + }] : [{ + uuid: coreServiceUuid, + getCharacteristics() { + return Promise.resolve(coreCharacteristics); + } + }]; + const gatt = { + connected: false, + bondCalls: 0, + connect() { + this.connected = true; + return Promise.resolve(); + }, + disconnect() { + this.connected = false; + }, + getSecurityStatus() { + return { + connected: this.connected, + encrypted: !!options.encrypted, + bonded: !!options.bonded + }; + }, + startBonding() { + this.bondCalls++; + if (options.bondReject) return Promise.reject(options.bondReject); + options.bonded = true; + return Promise.resolve(); + }, + getPrimaryServices() { + getPrimaryServicesCalls++; + return Promise.resolve(services); + } + }; + const device = { + id: "core-1", + name: "CORE", + gatt, + on(name, handler) { + if (name === "gattserverdisconnected") disconnectHandlers.push(handler); + }, + emitDisconnect(reason) { + disconnectHandlers.forEach(handler => handler(reason)); + } + }; + const NRF = { + setScan() {}, + requestDevice() { + return Promise.resolve(device); + } + }; + + return { + NRF, + device, + gatt, + getPrimaryServicesCalls() { + return getPrimaryServicesCalls; + }, + tempChar, + controlPointChar, + healthThermometerChar + }; +}; diff --git a/apps/coretemp/tests/helpers/fake_controlpoint.js b/apps/coretemp/tests/helpers/fake_controlpoint.js new file mode 100644 index 00000000000..e5496978234 --- /dev/null +++ b/apps/coretemp/tests/helpers/fake_controlpoint.js @@ -0,0 +1,29 @@ +exports.create = function createControlPoint() { + const calls = []; + const handlers = []; + return { + calls, + request(opcode, params, options) { + calls.push({ opcode, params: params || [], options }); + if (!handlers.length) return Promise.reject(new Error("No fake response for opcode " + opcode)); + return Promise.resolve().then(() => handlers.shift()(opcode, params || [], options)); + }, + enqueue(handler) { + handlers.push(handler); + }, + enqueueResponse(opcode, payload) { + handlers.push(() => ({ + bytes: [0x80, opcode, 0x01].concat(payload || []), + opCode: 0x80, + requestOpCode: opcode, + resultCode: 0x01, + payload: payload || [] + })); + }, + onNotification() {}, + cancelActive() {}, + isBusy() { + return false; + } + }; +}; diff --git a/apps/coretemp/tests/helpers/fake_storage.js b/apps/coretemp/tests/helpers/fake_storage.js new file mode 100644 index 00000000000..27cb01fd2d9 --- /dev/null +++ b/apps/coretemp/tests/helpers/fake_storage.js @@ -0,0 +1,32 @@ +exports.create = function createStorage(seed) { + const files = Object.assign({}, seed || {}); + return { + files, + readJSON(name) { + if (files[name] === undefined) return undefined; + return JSON.parse(JSON.stringify(files[name])); + }, + writeJSON(name, value) { + files[name] = JSON.parse(JSON.stringify(value)); + return true; + }, + read(name) { + return files[name]; + }, + write(name, value) { + files[name] = value; + return true; + }, + compact() {}, + open(name) { + return { + erase() { + delete files[name]; + }, + write(value) { + files[name] = (files[name] || "") + value; + } + }; + } + }; +}; diff --git a/apps/coretemp/tests/helpers/module_loader.js b/apps/coretemp/tests/helpers/module_loader.js new file mode 100644 index 00000000000..f605e59ee46 --- /dev/null +++ b/apps/coretemp/tests/helpers/module_loader.js @@ -0,0 +1,63 @@ +/* eslint-env node */ +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); +const fakeStorage = require("./fake_storage"); + +const root = path.resolve(__dirname, "../.."); + +const sources = { + "coretemp.protocol": path.join(root, "protocol.js"), + "coretemp.controlpoint": path.join(root, "controlpoint.js"), + "coretemp.hrm": path.join(root, "hrm.js"), + "coretemp.store": path.join(root, "store.js"), + "coretemp.ble": path.join(root, "ble.js"), + "coretemp.runtime": path.join(root, "runtime.js"), + "coretemp.boot": path.join(root, "boot.js"), + "coretemp.app": path.join(root, "coretemp.js"), + "coretemp.settingsui": path.join(root, "settingsui.js"), + CORESensor: path.join(root, "lib.js") +}; + +exports.create = function createLoader(options) { + const cache = {}; + const overrides = (options && options.overrides) || {}; + const storage = (options && options.storage) || fakeStorage.create(); + const globals = Object.assign({ + console, + print() {}, + setTimeout, + clearTimeout, + Promise, + Uint8Array, + DataView, + Error, + Date, + JSON, + String, + Number, + parseInt, + isNaN + }, (options && options.globals) || {}); + + function localRequire(name) { + if (name === "Storage") return storage; + if (overrides[name]) return overrides[name]; + if (!sources[name]) return require(name); + if (cache[name]) return cache[name].exports; + + const module = { exports: {} }; + cache[name] = module; + const code = fs.readFileSync(sources[name], "utf8"); + const wrapped = `(function (exports, require, module) {\n${code}\n})`; + const context = Object.assign({}, globals, { require: localRequire }); + vm.runInNewContext(wrapped, context, { filename: sources[name] })(module.exports, localRequire, module); + return module.exports; + } + + return { + require: localRequire, + storage, + cache + }; +}; diff --git a/apps/coretemp/tests/run.js b/apps/coretemp/tests/run.js new file mode 100644 index 00000000000..689d5252827 --- /dev/null +++ b/apps/coretemp/tests/run.js @@ -0,0 +1,37 @@ +/* eslint-env node */ +const path = require("path"); + +const files = [ + "unit/protocol.test.js", + "unit/store.test.js", + "unit/controlpoint.test.js", + "behaviour/hrm_scan.test.js", + "behaviour/hrm_pair.test.js", + "behaviour/hrm_status_clear.test.js", + "e2e/app_lifecycle.test.js", + "e2e/ble_controlpoint.test.js", + "e2e/coretemp_runtime.test.js", + "e2e/settings_hrm_flow.test.js", + "e2e/manifest_packaging.test.js" +]; + +async function run() { + let failures = 0; + for (const file of files) { + const fullPath = path.join(__dirname, file); + const tests = require(fullPath); + for (const testCase of tests) { + try { + await testCase.fn(); + console.log("ok - " + file + " - " + testCase.name); + } catch (err) { + failures++; + console.error("not ok - " + file + " - " + testCase.name); + console.error(err && err.stack ? err.stack : err); + } + } + } + if (failures) process.exitCode = 1; +} + +run(); diff --git a/apps/coretemp/tests/unit/controlpoint.test.js b/apps/coretemp/tests/unit/controlpoint.test.js new file mode 100644 index 00000000000..becce46df6c --- /dev/null +++ b/apps/coretemp/tests/unit/controlpoint.test.js @@ -0,0 +1,111 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const dv = require("../helpers/dataview"); +const packets = require("../fixtures/packets"); + +function makeControlPoint() { + const writes = []; + const logs = []; + const cp = loader.create().require("coretemp.controlpoint"); + cp.setAdapter({ + write(bytes) { + writes.push(bytes.slice()); + return Promise.resolve(); + }, + log(text, value) { + logs.push({ text, value }); + } + }); + return { cp, writes, logs }; +} + +function tick() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +module.exports = [ + { + name: "resolves only matching success response", + async fn() { + const { cp, writes } = makeControlPoint(); + const promise = cp.request(0x0B, [], 50); + await tick(); + cp.onNotification(dv.fromBytes(packets.response(0x04, [1]))); + assert.strictEqual(cp.isBusy(), true); + cp.onNotification(dv.fromBytes(packets.response(0x0B, [2]))); + const res = await promise; + assert.deepStrictEqual(plain(writes), [[0x0B]]); + assert.deepStrictEqual(plain(res.payload), [2]); + assert.strictEqual(cp.isBusy(), false); + } + }, + { + name: "rejects matching error response once", + async fn() { + const { cp } = makeControlPoint(); + const promise = cp.request(0x02, [1, 2, 3], 50); + await tick(); + cp.onNotification(dv.fromBytes(packets.response(0x02, [], 0x05))); + await assert.rejects(promise, /Control point error code 5/); + assert.strictEqual(cp.isBusy(), false); + } + }, + { + name: "timeout is request identity bound", + async fn() { + const { cp } = makeControlPoint(); + const first = cp.request(0x0A, [0xFF], 5); + await assert.rejects(first, /timeout/); + const second = cp.request(0x0B, [], 50); + await tick(); + cp.onNotification(dv.fromBytes(packets.response(0x0B, [1]))); + assert.strictEqual((await second).payload[0], 1); + } + }, + { + name: "queued requests settle independently without leaking previous response", + async fn() { + const { cp, writes } = makeControlPoint(); + const first = cp.request(0x0A, [0xFF], 50); + const second = cp.request(0x0B, [], 50); + await tick(); + assert.deepStrictEqual(plain(writes), [[0x0A, 0xFF]]); + cp.onNotification(dv.fromBytes(packets.response(0x0A, []))); + assert.strictEqual((await first).requestOpCode, 0x0A); + await tick(); + assert.deepStrictEqual(plain(writes), [[0x0A, 0xFF], [0x0B]]); + cp.onNotification(dv.fromBytes(packets.response(0x0B, [4]))); + const res = await second; + assert.strictEqual(res.requestOpCode, 0x0B); + assert.deepStrictEqual(plain(res.payload), [4]); + } + }, + { + name: "cancel rejects active request", + async fn() { + const { cp } = makeControlPoint(); + const promise = cp.request(0x01, [], 50); + await tick(); + cp.cancelActive("manual cancel"); + await assert.rejects(promise, /manual cancel/); + assert.strictEqual(cp.isBusy(), false); + } + }, + { + name: "write failure clears active request", + async fn() { + const cp = loader.create().require("coretemp.controlpoint"); + cp.setAdapter({ + write() { + return Promise.reject(new Error("GATT failure")); + } + }); + await assert.rejects(cp.request(0x01, [], 50), /GATT failure/); + assert.strictEqual(cp.isBusy(), false); + } + } +]; diff --git a/apps/coretemp/tests/unit/protocol.test.js b/apps/coretemp/tests/unit/protocol.test.js new file mode 100644 index 00000000000..f4d4493c334 --- /dev/null +++ b/apps/coretemp/tests/unit/protocol.test.js @@ -0,0 +1,49 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const dv = require("../helpers/dataview"); +const packets = require("../fixtures/packets"); + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +module.exports = [ + { + name: "parses normalized control point response payload", + fn() { + const protocol = loader.create().require("coretemp.protocol"); + const res = protocol.parseResponse(dv.fromBytes(packets.response(protocol.OPCODES.HRM_SCAN_ANT_COUNT, [2]))); + assert.deepStrictEqual(plain(res.bytes), [0x80, 0x0B, 0x01, 2]); + assert.strictEqual(res.opCode, 0x80); + assert.strictEqual(res.requestOpCode, 0x0B); + assert.strictEqual(res.resultCode, 0x01); + assert.deepStrictEqual(plain(res.payload), [2]); + assert.strictEqual(protocol.parseCount(res), 2); + } + }, + { + name: "parses ANT entry and pair params", + fn() { + const protocol = loader.create().require("coretemp.protocol"); + const res = protocol.parseResponse(dv.fromBytes(packets.response(0x0C, packets.antEntryPayload(0x1234, 0x56)))); + assert.deepStrictEqual(plain(protocol.parseAntEntry(res, 3)), { + index: 3, + antId: 0x1234, + txType: 0x56 + }); + assert.deepStrictEqual(plain(protocol.makeAntPairParams(0x563412)), [0x12, 0x34, 0x56]); + assert.deepStrictEqual(plain(protocol.makeAntPairParams(0x563412, 0x22)), [0x12, 0x34, 0x22]); + } + }, + { + name: "parses standard health thermometer temperature measurement", + fn() { + const protocol = loader.create().require("coretemp.protocol"); + const data = protocol.parseTemperatureMeasurement(dv.fromBytes([0x00, 0x77, 0x01, 0x00, 0xFF]), 88); + assert.strictEqual(data.core, 37.5); + assert.strictEqual(data.unit, "C"); + assert.strictEqual(data.battery, 88); + assert.strictEqual(data.profile, "health_thermometer"); + } + } +]; diff --git a/apps/coretemp/tests/unit/store.test.js b/apps/coretemp/tests/unit/store.test.js new file mode 100644 index 00000000000..b7efa98740b --- /dev/null +++ b/apps/coretemp/tests/unit/store.test.js @@ -0,0 +1,54 @@ +const assert = require("assert"); +const loader = require("../helpers/module_loader"); +const fakeStorage = require("../helpers/fake_storage"); + +function createStore(seed) { + const storage = fakeStorage.create(seed); + const loaded = loader.create({ + storage, + globals: { + setInterval() { return 1; }, + clearInterval() {}, + print() {} + } + }); + return { + store: loaded.require("coretemp.store"), + storage + }; +} + +module.exports = [ + { + name: "partial debug log excludes measurement data lines", + fn() { + const { store, storage } = createStore({ + "coretemp.json": { + debugpartiallog: true + } + }); + store.init(); + store.log("data", { core: 37.5 }); + store.log("CORE state -> connected"); + store.flush(); + + assert.strictEqual(storage.files["coretemp.log"].indexOf("data:"), -1); + assert.notStrictEqual(storage.files["coretemp.log"].indexOf("CORE state -> connected"), -1); + } + }, + { + name: "full debug log includes measurement data lines", + fn() { + const { store, storage } = createStore({ + "coretemp.json": { + debuglog: true + } + }); + store.init(); + store.log("data", { core: 37.5 }); + store.flush(); + + assert.notStrictEqual(storage.files["coretemp.log"].indexOf("data:"), -1); + } + } +]; diff --git a/apps/coretemp/widget.js b/apps/coretemp/widget.js index 777d622ac47..2615b927d76 100644 --- a/apps/coretemp/widget.js +++ b/apps/coretemp/widget.js @@ -1,6 +1,35 @@ (() => { var settings = {}; - var CORESensorStatus = false; + var CORESensorStatus = "off"; + + function getCORESensorStatus(status) { + if (status) return status; + try { + if (Bangle.CORESensorGetStatus) return Bangle.CORESensorGetStatus(); + if (Bangle.isCORESensorConnected) { + return { + connected: Bangle.isCORESensorConnected(), + enabled: !!settings.enabled + }; + } + } catch (e) { + } + return { connected: false, enabled: false }; + } + + function getWidgetStatus(status) { + status = getCORESensorStatus(status); + if (status.connected) return status.enabled ? "background" : "connected"; + return "off"; + } + + function updateStatus(status) { + var nextStatus = getWidgetStatus(status); + if (nextStatus === CORESensorStatus) return; + CORESensorStatus = nextStatus; + if (WIDGETS["coretemp"] && WIDGETS["coretemp"].draw) WIDGETS["coretemp"].draw(); + } + // draw your widget function draw() { if (!settings.widget) @@ -10,7 +39,9 @@ g.setFontAlign(0, 0); g.clearRect(this.x, this.y, this.x + 23, this.y + 23); - if (CORESensorStatus) { + if (CORESensorStatus === "background") { + g.setColor("#00f"); // background connected = blue + } else if (CORESensorStatus === "connected") { g.setColor("#0f0"); // green } else { g.setColor(g.theme.dark ? "#333" : "#CCC"); // off = grey @@ -28,21 +59,12 @@ delete WIDGETS["coretemp"]; return; } - if (settings.enabled) { - WIDGETS["coretemp"].width = 24; - } else { - WIDGETS["CORESensor"].width = 0; - } + WIDGETS["coretemp"].width = 24; + updateStatus(); } - if (Bangle.hasOwnProperty("isCORESensorConnected")) { - setInterval(function () { - if (Bangle.isCORESensorConnected() != CORESensorStatus) { - CORESensorStatus = Bangle.isCORESensorConnected(); - WIDGETS["coretemp"].draw(); - } - }, 10000); //runs every 10 seconds - } + Bangle.on("CORESensorStatus", updateStatus); + setInterval(updateStatus, 10000); // fallback for missed events and late runtime loading // add the widget WIDGETS["coretemp"] = { area: "tl",