diff --git a/apps/cycling/ChangeLog b/apps/cycling/ChangeLog new file mode 100644 index 000000000..ec66c5568 --- /dev/null +++ b/apps/cycling/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/cycling/README.md b/apps/cycling/README.md new file mode 100644 index 000000000..61ba1d455 --- /dev/null +++ b/apps/cycling/README.md @@ -0,0 +1,26 @@ +# Cycling +> Displays data from a BLE Cycling Speed and Cadence sensor. + +*Fork of the CSCSensor app using the layout library and separate module for CSC functionality* + +The following data are displayed: +- curent speed +- moving time +- average speed +- maximum speed +- trip distance +- total distance + +Total distance is not stored on the Bangle, but instead is calculated from the CWR (cumulative wheel revolutions) reported by the sensor. This metric is, according to the BLE spec, a absolute value that persists throughout the lifetime of the sensor and never rolls over. + +**Cadence / Crank features are currently not implemented** + +# TODO +* Settings: imperial/metric +* Store circumference per device address +* Sensor battery status +* Implement crank events / show cadence +* Bangle.js 1 compatibility + +# Development +There is a "mock" version of the `blecsc` module, which can be used to test features in the emulator. Check `blecsc-emu.js` for usage. diff --git a/apps/cycling/blecsc-emu.js b/apps/cycling/blecsc-emu.js new file mode 100644 index 000000000..ca5058545 --- /dev/null +++ b/apps/cycling/blecsc-emu.js @@ -0,0 +1,111 @@ +// UUID of the Bluetooth CSC Service +const SERVICE_UUID = "1816"; +// UUID of the CSC measurement characteristic +const MEASUREMENT_UUID = "2a5b"; + +// Wheel revolution present bit mask +const FLAGS_WREV_BM = 0x01; +// Crank revolution present bit mask +const FLAGS_CREV_BM = 0x02; + +/** + * Fake BLECSC implementation for the emulator, where it's hard to test + * with actual hardware. Generates "random" wheel events (no crank). + * + * To upload as a module, paste the entire file in the console using this + * command: require("Storage").write("blecsc-emu",``); + */ +class BLECSCEmulator { + constructor() { + this.timeout = undefined; + this.interval = 500; + this.ccr = 0; + this.lwt = 0; + this.handlers = { + // value + // disconnect + // wheelEvent + // crankEvent + }; + } + + getDeviceAddress() { + return 'fa:ke:00:de:vi:ce'; + } + + /** + * Callback for the GATT characteristicvaluechanged event. + * Consumers must not call this method! + */ + onValue(event) { + // Not interested in non-CSC characteristics + if (event.target.uuid != "0x" + MEASUREMENT_UUID) return; + + // Notify the generic 'value' handler + if (this.handlers.value) this.handlers.value(event); + + const flags = event.target.value.getUint8(0, true); + // Notify the 'wheelEvent' handler + if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({ + cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions + lwet: event.target.value.getUint16(5, true), // last wheel event time + }); + + // Notify the 'crankEvent' handler + if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({ + ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions + lcet: event.target.value.getUint16(9, true), // last crank event time + }); + } + + /** + * Register an event handler. + * + * @param {string} event value|disconnect + * @param {function} handler handler function that receives the event as its first argument + */ + on(event, handler) { + this.handlers[event] = handler; + } + + fakeEvent() { + this.interval = Math.max(50, Math.min(1000, this.interval + Math.random()*40-20)); + this.lwt = (this.lwt + this.interval) % 0x10000; + this.ccr++; + + var buffer = new ArrayBuffer(8); + var view = new DataView(buffer); + view.setUint8(0, 0x01); // Wheel revolution data present bit + view.setUint32(1, this.ccr, true); // Cumulative crank revolutions + view.setUint16(5, this.lwt, true); // Last wheel event time + + this.onValue({ + target: { + uuid: "0x2a5b", + value: view, + }, + }); + + this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval); + } + + /** + * Find and connect to a device which exposes the CSC service. + * + * @return {Promise} + */ + connect() { + this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval); + return Promise.resolve(true); + } + + /** + * Disconnect the device. + */ + disconnect() { + if (!this.timeout) return; + clearTimeout(this.timeout); + } +} + +exports = BLECSCEmulator; diff --git a/apps/cycling/blecsc.js b/apps/cycling/blecsc.js new file mode 100644 index 000000000..7a47108e5 --- /dev/null +++ b/apps/cycling/blecsc.js @@ -0,0 +1,150 @@ +const SERVICE_UUID = "1816"; +// UUID of the CSC measurement characteristic +const MEASUREMENT_UUID = "2a5b"; + +// Wheel revolution present bit mask +const FLAGS_WREV_BM = 0x01; +// Crank revolution present bit mask +const FLAGS_CREV_BM = 0x02; + +/** + * This class communicates with a Bluetooth CSC peripherial using the Espruino NRF library. + * + * ## Usage: + * 1. Register event handlers using the \`on(eventName, handlerFunction)\` method + * You can subscribe to the \`wheelEvent\` and \`crankEvent\` events or you can + * have raw characteristic values passed through using the \`value\` event. + * 2. Search and connect to a BLE CSC peripherial by calling the \`connect()\` method + * 3. To tear down the connection, call the \`disconnect()\` method + * + * ## Events + * - \`wheelEvent\` - the peripharial sends a notification containing wheel event data + * - \`crankEvent\` - the peripharial sends a notification containing crank event data + * - \`value\` - the peripharial sends any CSC characteristic notification (including wheel & crank event) + * - \`disconnect\` - the peripherial ends the connection or the connection is lost + * + * Each event can only have one handler. Any call to \`on()\` will + * replace a previously registered handler for the same event. + */ +class BLECSC { + constructor() { + this.device = undefined; + this.ccInterval = undefined; + this.gatt = undefined; + this.handlers = { + // wheelEvent + // crankEvent + // value + // disconnect + }; + } + + getDeviceAddress() { + if (!this.device || !this.device.id) + return '00:00:00:00:00:00'; + return this.device.id.split(" ")[0]; + } + + checkConnection() { + if (!this.device) + console.log("no device"); + // else + // console.log("rssi: " + this.device.rssi); + } + + /** + * Callback for the GATT characteristicvaluechanged event. + * Consumers must not call this method! + */ + onValue(event) { + // Not interested in non-CSC characteristics + if (event.target.uuid != "0x" + MEASUREMENT_UUID) return; + + // Notify the generic 'value' handler + if (this.handlers.value) this.handlers.value(event); + + const flags = event.target.value.getUint8(0, true); + // Notify the 'wheelEvent' handler + if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({ + cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions + lwet: event.target.value.getUint16(5, true), // last wheel event time + }); + + // Notify the 'crankEvent' handler + if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({ + ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions + lcet: event.target.value.getUint16(9, true), // last crank event time + }); + } + + /** + * Callback for the NRF disconnect event. + * Consumers must not call this method! + */ + onDisconnect(event) { + console.log("disconnected"); + if (this.ccInterval) + clearInterval(this.ccInterval); + + if (!this.handlers.disconnect) return; + this.handlers.disconnect(event); + } + + /** + * Register an event handler. + * + * @param {string} event wheelEvent|crankEvent|value|disconnect + * @param {function} handler function that will receive the event as its first argument + */ + on(event, handler) { + this.handlers[event] = handler; + } + + /** + * Find and connect to a device which exposes the CSC service. + * + * @return {Promise} + */ + connect() { + // Register handler for the disconnect event to be passed throug + NRF.on('disconnect', this.onDisconnect.bind(this)); + + // Find a device, then get the CSC Service and subscribe to + // notifications on the CSC Measurement characteristic. + // NRF.setLowPowerConnection(true); + return NRF.requestDevice({ + timeout: 5000, + filters: [{ services: [SERVICE_UUID] }], + }).then(device => { + this.device = device; + this.device.on('gattserverdisconnected', this.onDisconnect.bind(this)); + this.ccInterval = setInterval(this.checkConnection.bind(this), 2000); + return device.gatt.connect(); + }).then(gatt => { + this.gatt = gatt; + return gatt.getPrimaryService(SERVICE_UUID); + }).then(service => { + return service.getCharacteristic(MEASUREMENT_UUID); + }).then(characteristic => { + characteristic.on('characteristicvaluechanged', this.onValue.bind(this)); + return characteristic.startNotifications(); + }); + } + + /** + * Disconnect the device. + */ + disconnect() { + if (this.ccInterval) + clearInterval(this.ccInterval); + + if (!this.gatt) return; + try { + this.gatt.disconnect(); + } catch { + // + } + } +} + +exports = BLECSC; diff --git a/apps/cycling/cscsensor.app.js b/apps/cycling/cscsensor.app.js new file mode 100644 index 000000000..9de3f5a3e --- /dev/null +++ b/apps/cycling/cscsensor.app.js @@ -0,0 +1,420 @@ +const Layout = require('Layout'); + +const SETTINGS_FILE = 'cscsensor.json'; +const storage = require('Storage'); + +const RECONNECT_TIMEOUT = 4000; +const MAX_CONN_ATTEMPTS = 2; + +class CSCSensor { + constructor(blecsc, display) { + // Dependency injection + this.blecsc = blecsc; + this.display = display; + + // Load settings + this.settings = storage.readJSON(SETTINGS_FILE, 1) || {}; + this.wheelCirc = (this.settings.wheelcirc || 2230) / 1000; // unit: m + + // CSC runtime variables + this.movingTime = 0; // unit: s + this.lastBangleTime = Date.now(); // unit: ms + this.lwet = 0; // last wheel event time (unit: s/1024) + this.cwr = -1; // cumulative wheel revolutions + this.cwrTrip = 0; // wheel revolutions since trip start + this.speed = 0; // unit: m/s + this.maxSpeed = 0; // unit: m/s + this.speedFailed = 0; + + // Other runtime variables + this.connected = false; + this.failedAttempts = 0; + this.failed = false; + + // Layout configuration + this.layout = 0; + this.display.useMetricUnits(true); + // this.display.useMetricUnits(!require("locale").speed(1).toString().endsWith("mph")); + } + + onDisconnect(event) { + console.log("disconnected ", event); + + this.connected = false; + this.setLayout(0); + this.display.setDeviceAddress("unknown"); + + if (this.failedAttempts >= MAX_CONN_ATTEMPTS) { + this.failed = true; + this.display.setStatus("Connection failed after " + MAX_CONN_ATTEMPTS + " attempts."); + } else { + this.display.setStatus("Disconnected"); + setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT); + } + + } + + connect() { + this.connected = false; + this.setLayout(0); + this.display.setStatus("Connecting..."); + console.log("Trying to connect to BLE CSC"); + + // Hook up events + this.blecsc.on('wheelEvent', this.onWheelEvent.bind(this)); + this.blecsc.on('disconnect', this.onDisconnect.bind(this)); + + // Scan for BLE device and connect + this.blecsc.connect() + .then(function() { + this.failedAttempts = 0; + this.failed = false; + this.connected = true; + var addr = this.blecsc.getDeviceAddress(); + console.log("Connected to " + addr); + + this.display.setDeviceAddress(addr); + this.display.setStatus("Connected"); + + // Switch to speed screen in 2s + setTimeout(function() { + this.setLayout(1); + this.updateScreen(); + }.bind(this), 2000); + }.bind(this)) + .catch(function(e) { + this.failedAttempts++; + this.onDisconnect(e); + }.bind(this)); + } + + disconnect() { + this.blecsc.disconnect(); + this.connected = false; + this.setLayout(0); + this.display.setStatus("Disconnected") + } + + setLayout(num) { + this.layout = num; + if (this.layout == 0) { + this.display.updateLayout("status"); + } else if (this.layout == 1) { + this.display.updateLayout("speed"); + } else if (this.layout == 2) { + this.display.updateLayout("distance"); + } + } + + reset() { + this.connected = false; + this.failed = false; + this.failedAttempts = 0; + } + + interact(d) { + // Only interested in tap / center button + if (d) return; + + // Reconnect in failed state + if (this.failed) { + this.reset(); + this.connect(); + } else if (this.connected) { + this.setLayout((this.layout + 1) % 3); + } + } + + updateScreen() { + var tripDist = this.cwrTrip * this.wheelCirc; + var avgSpeed = this.movingTime > 3 ? tripDist / this.movingTime : 0 + + this.display.setTotalDistance(this.cwr * this.wheelCirc); + this.display.setTripDistance(tripDist); + this.display.setSpeed(this.speed); + this.display.setAvg(avgSpeed); + this.display.setMax(this.maxSpeed); + this.display.setTime(Math.floor(this.movingTime)); + } + + onWheelEvent(event) { + // Calculate number of revolutions since last wheel event + var dRevs = (this.cwr > 0 ? event.cwr - this.cwr : 0); + this.cwr = event.cwr; + + // Increment the trip revolutions counter + this.cwrTrip += dRevs; + + // Calculate time delta since last wheel event + var dT = (event.lwet - this.lwet)/1024; + var now = Date.now(); + var dBT = (now-this.lastBangleTime)/1000; + this.lastBangleTime = now; + if (dT<0) dT+=64; // wheel event time wraps every 64s + if (Math.abs(dT-dBT)>3) dT = dBT; // not sure about the reason for this + this.lwet = event.lwet; + + // Recalculate current speed + if (dRevs>0 && dT>0) { + this.speed = dRevs * this.wheelCirc / dT; + this.speedFailed = 0; + this.movingTime += dT; + } else { + this.speedFailed++; + if (this.speedFailed>3) { + this.speed = 0; + } + } + + // Update max speed + if (this.speed>this.maxSpeed + && (this.movingTime>3 || this.speed<20) + && this.speed<50 + ) this.maxSpeed = this.speed; + + this.updateScreen(); + } +} + +class CSCDisplay { + constructor() { + this.metric = true; + this.fontLabel = "6x8"; + this.fontMed = "15%"; + this.fontLarge = "32%"; + this.currentLayout = "status"; + this.layouts = {}; + this.layouts.speed = new Layout({ + type: "v", + c: [ + { + type: "h", + id: "speed_g", + fillx: 1, + filly: 1, + pad: 4, + bgCol: "#fff", + c: [ + {type: undefined, width: 32, halign: -1}, + {type: "txt", id: "speed", label: "00.0", font: this.fontLarge, bgCol: "#fff", col: "#000", width: 122}, + {type: "txt", id: "speed_u", label: " km/h", font: this.fontLabel, col: "#000", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "time_g", + fillx: 1, + pad: 4, + bgCol: "#000", + height: 32, + c: [ + {type: undefined, width: 32, halign: -1}, + {type: "txt", id: "time", label: "00:00", font: this.fontMed, bgCol: "#000", col: "#fff", width: 122}, + {type: "txt", id: "time_u", label: "mins", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "stats_g", + fillx: 1, + bgCol: "#fff", + height: 32, + c: [ + { + type: "v", + pad: 4, + bgCol: "#fff", + c: [ + {type: "txt", id: "max_l", label: "MAX", font: this.fontLabel, col: "#000"}, + {type: "txt", id: "max", label: "00.0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 69}, + ], + }, + { + type: "v", + pad: 4, + bgCol: "#fff", + c: [ + {type: "txt", id: "avg_l", label: "AVG", font: this.fontLabel, col: "#000"}, + {type: "txt", id: "avg", label: "00.0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 69}, + ], + }, + {type: "txt", id: "stats_u", label: " km/h", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, + ] + }, + ], + }); + this.layouts.distance = new Layout({ + type: "v", + c: [ + { + type: "h", + id: "tripd_g", + fillx: 1, + pad: 4, + bgCol: "#fff", + height: 32, + c: [ + {type: "txt", id: "tripd_l", label: "TRP", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36}, + {type: "txt", id: "tripd", label: "0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 118}, + {type: "txt", id: "tripd_u", label: "km", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "totald_g", + fillx: 1, + pad: 4, + bgCol: "#000", + height: 32, + c: [ + {type: "txt", id: "totald_l", label: "TTL", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 36}, + {type: "txt", id: "totald", label: "0", font: this.fontMed, bgCol: "#000", col: "#fff", width: 118}, + {type: "txt", id: "totald_u", label: "km", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 22, r: 90}, + ] + }, + ], + }); + this.layouts.status = new Layout({ + type: "v", + c: [ + { + type: "h", + id: "status_g", + fillx: 1, + bgCol: "#fff", + height: 100, + c: [ + {type: "txt", id: "status", label: "Bangle Cycling", font: this.fontMed, bgCol: "#fff", col: "#000", width: 176, wrap: 1}, + ] + }, + { + type: "h", + id: "addr_g", + fillx: 1, + pad: 4, + bgCol: "#fff", + height: 32, + c: [ + { type: "txt", id: "addr_l", label: "MAC", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36 }, + { type: "txt", id: "addr", label: "unknown", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 140 }, + ] + }, + ], + }); + } + + updateLayout(layout) { + this.currentLayout = layout; + + g.clear(); + this.layouts[layout].update(); + this.layouts[layout].render(); + } + + renderIfLayoutActive(layout, node) { + if (layout != this.currentLayout) return; + this.layouts[layout].render(node) + } + + useMetricUnits(metric) { + this.metric = metric; + + console.log("using " + (metric ? "metric" : "imperial") + " units"); + + var speedUnit = metric ? "km/h" : "mph"; + this.layouts.speed.speed_u.label = speedUnit; + this.layouts.speed.stats_u.label = speedUnit; + + var distanceUnit = metric ? "km" : "mi"; + this.layouts.distance.tripd_u.label = distanceUnit; + this.layouts.distance.totald_u.label = distanceUnit; + + this.updateLayout(this.currentLayout); + } + + convertDistance(meters) { + if (this.metric) return meters / 1000; + return meters / 1609.344; + } + + convertSpeed(mps) { + if (this.metric) return mps * 3.6; + return mps * 2.23694; + } + + setSpeed(speed) { + this.layouts.speed.speed.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.speed_g); + } + + setAvg(speed) { + this.layouts.speed.avg.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.stats_g); + } + + setMax(speed) { + this.layouts.speed.max.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.stats_g); + } + + setTime(seconds) { + var time = ''; + var hours = Math.floor(seconds/3600); + if (hours) { + time += hours + ":"; + this.layouts.speed.time_u.label = " hrs"; + } else { + this.layouts.speed.time_u.label = "mins"; + } + + time += String(Math.floor((seconds%3600)/60)).padStart(2, '0') + ":"; + time += String(seconds % 60).padStart(2, '0'); + + this.layouts.speed.time.label = time; + this.renderIfLayoutActive("speed", this.layouts.speed.time_g); + } + + setTripDistance(distance) { + this.layouts.distance.tripd.label = this.convertDistance(distance).toFixed(1) + this.renderIfLayoutActive("distance", this.layouts.distance.tripd_g); + } + + setTotalDistance(distance) { + this.layouts.distance.totald.label = this.convertDistance(distance).toFixed(1) + this.renderIfLayoutActive("distance", this.layouts.distance.totald_g); + } + + setDeviceAddress(address) { + this.layouts.status.addr.label = address + this.renderIfLayoutActive("status", this.layouts.status.addr_g); + } + + setStatus(status) { + this.layouts.status.status.label = status + this.renderIfLayoutActive("status", this.layouts.status.status_g); + } +} + +var BLECSC; +if (process.env.BOARD === "EMSCRIPTEN" || process.env.BOARD === "EMSCRIPTEN2") { + // Emulator + BLECSC = require("blecsc-emu"); +} else { + // Actual hardware + BLECSC = require("blecsc"); +} +var blecsc = new BLECSC(); +var display = new CSCDisplay(); +var sensor = new CSCSensor(blecsc, display); + +E.on('kill',()=>{ + sensor.disconnect(); +}); + +Bangle.setUI("updown", d => { + sensor.interact(d); +}); + +sensor.connect(); +// Bangle.loadWidgets(); +// Bangle.drawWidgets(); diff --git a/apps/cycling/icons8-cycling-48.png b/apps/cycling/icons8-cycling-48.png new file mode 100644 index 000000000..0bc83859f Binary files /dev/null and b/apps/cycling/icons8-cycling-48.png differ diff --git a/apps/cycling/metadata.json b/apps/cycling/metadata.json new file mode 100644 index 000000000..917658fad --- /dev/null +++ b/apps/cycling/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "cycling", + "name": "Bangle Cycling", + "shortName": "Cycling", + "version": "0.01", + "description": "Display live values from a BLE CSC sensor", + "icon": "icons8-cycling-48.png", + "tags": "outdoors,exercise,ble,bluetooth", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"cycling.app.js","url":"cycling.app.js"}, + {"name":"cycling.settings.js","url":"settings.js"}, + {"name":"blecsc","url":"blecsc.js"} + ] +} diff --git a/apps/cycling/settings.js b/apps/cycling/settings.js new file mode 100644 index 000000000..810d8afc0 --- /dev/null +++ b/apps/cycling/settings.js @@ -0,0 +1,37 @@ +// 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) { + const SETTINGS_FILE = 'cscsensor.json' + // initialize with default settings... + let s = { + 'wheelcirc': 2230, + } + // ...and overwrite them with any saved values + // This way saved values are preserved if a new version adds more settings + const storage = require('Storage') + const saved = storage.readJSON(SETTINGS_FILE, 1) || {} + for (const key in saved) { + s[key] = saved[key]; + } + // creates a function to safe a specific setting, e.g. save('color')(1) + function save(key) { + return function (value) { + s[key] = value; + storage.write(SETTINGS_FILE, s); + } + } + const menu = { + '': { 'title': 'Cycle speed sensor' }, + '< Back': back, + 'Wheel circ.(mm)': { + value: s.wheelcirc, + min: 800, + max: 2400, + step: 5, + onchange: save('wheelcirc'), + }, + } + E.showMenu(menu); +})