From 31d835dad59e7d5c9c6d404c95c85ee5332a40c2 Mon Sep 17 00:00:00 2001 From: Joseph Paul Date: Sun, 20 Mar 2022 09:25:43 +0100 Subject: [PATCH 1/5] Cycling: Initial commit --- apps/cycling/ChangeLog | 1 + apps/cycling/README.md | 26 ++ apps/cycling/blecsc-emu.js | 111 ++++++++ apps/cycling/blecsc.js | 150 +++++++++++ apps/cycling/cscsensor.app.js | 420 +++++++++++++++++++++++++++++ apps/cycling/icons8-cycling-48.png | Bin 0 -> 1487 bytes apps/cycling/metadata.json | 16 ++ apps/cycling/settings.js | 37 +++ 8 files changed, 761 insertions(+) create mode 100644 apps/cycling/ChangeLog create mode 100644 apps/cycling/README.md create mode 100644 apps/cycling/blecsc-emu.js create mode 100644 apps/cycling/blecsc.js create mode 100644 apps/cycling/cscsensor.app.js create mode 100644 apps/cycling/icons8-cycling-48.png create mode 100644 apps/cycling/metadata.json create mode 100644 apps/cycling/settings.js 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 0000000000000000000000000000000000000000..0bc83859f1ac8d5b1d40aa787ab3c8bf339913fd GIT binary patch literal 1487 zcmV;=1u*)FP)BH^< zyW9n~y*qr_|Cil8&-47wInUcYcLAS#^2w*s6pf~HE?fR2B+da?2cQbX+sIIO#vR6} zC14F+`r0W52Ju37k`R;+ZG@6I2ZJY&z?9<-X7myuo-S{P&K=C?CBSq=-i|7FFn)>z zGlWX>KF@8r7q(gy1B1o}w0CMluJ4M^%-R8xwu0#!IG+Jvn>&239pdkH7KGgqBYtRX z)lMB~7s!Iy(Le8ue>L-ME?t%irW_D-wwd2XDaI0a{CFjS^1Q>z4XxT!ZQUV(a_xcY zE{>a0k)&U{slroVil2d!T}O7;Hcu~g6+8fRnFFc%UtKZp$@2HqpJHdAXn#>i&uvRZ zZKHNXw@71aWbpT6W)}f4c;Z))6)4X;oZQ+NP~X@#vf%BiZqhIKW&a1Sc;egR1P<-k z{(fzXNHrJz#96RAa7J5=dQ<-qCQE z#sWaFzaLh`#6j$w3wFY4@B61VVsb-5+>0Ap0N~oE+XF5)&8&G~VolTxt;^u;5muAI z_>fl;n3a~n52Q6S+yfz- z48{XqNg(<8jIQPm1UTEr`Kz%iCf2MA719#3*1J_6yDN_lu|C{ll(V;sW_)P6sQ&EA z`GY5RFQ4d%kKyF$lbAMStthU%DJ&Fw5yKGPCguXz*q;DLIxetq zQ&GO1>TEb!UZJ-wtLOsWr^WLUFR55a1JFZ+%wuOS_P?kzO>*eW))i2U?0003g14xmMV5(D= zZ)+Reo$m(#vmg$-zE*C@{b}Wv+)8JAH_U(7zD^lnc3c6*O@;Zc5&+BCtRi9%4{8tU z3bbC@QYuHZ@;KzXthu_VPlk?lHvFL^Vv3A`CVBba!c>K#2 zh4}zvSAa?YfJv1h2a}T^AOir1(O)_<)&blI;0B0S5!q)?A-+9YXT$Nbb2jB0BV5p zhYQ{teA!y4z+_T~w~NKZFaYQQ_@E@wkzQ5L?*O2Y{AYqV9l*<0I7)5>`!8};uXvO1 zvY0C{nbhHJVlELf;rgAm7ohxG!J2nQ8obi{Vaw^`W#@D(s8-t^BGESUbz+>c_z9mZ pnU^5{H~9$VZN5)F`Q(##^&bl8rz8LGDIEX+002ovPDHLkV1j9?%0U1C literal 0 HcmV?d00001 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); +}) From 14aca8db0fc687e162ec59b735399f09c9c6bf68 Mon Sep 17 00:00:00 2001 From: Joseph Paul Date: Sun, 20 Mar 2022 09:55:00 +0100 Subject: [PATCH 2/5] Cycling: Re-enable widgets; optimise font sizes --- .../{cscsensor.app.js => cycling.app.js} | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) rename apps/cycling/{cscsensor.app.js => cycling.app.js} (93%) diff --git a/apps/cycling/cscsensor.app.js b/apps/cycling/cycling.app.js similarity index 93% rename from apps/cycling/cscsensor.app.js rename to apps/cycling/cycling.app.js index 9de3f5a3e..4b53046d9 100644 --- a/apps/cycling/cscsensor.app.js +++ b/apps/cycling/cycling.app.js @@ -1,6 +1,6 @@ const Layout = require('Layout'); -const SETTINGS_FILE = 'cscsensor.json'; +const SETTINGS_FILE = 'cycling.json'; const storage = require('Storage'); const RECONNECT_TIMEOUT = 4000; @@ -57,7 +57,7 @@ class CSCSensor { connect() { this.connected = false; this.setLayout(0); - this.display.setStatus("Connecting..."); + this.display.setStatus("Connecting"); console.log("Trying to connect to BLE CSC"); // Hook up events @@ -180,7 +180,8 @@ class CSCDisplay { constructor() { this.metric = true; this.fontLabel = "6x8"; - this.fontMed = "15%"; + this.fontSmall = "15%"; + this.fontMed = "18%"; this.fontLarge = "32%"; this.currentLayout = "status"; this.layouts = {}; @@ -206,7 +207,7 @@ class CSCDisplay { fillx: 1, pad: 4, bgCol: "#000", - height: 32, + height: 36, c: [ {type: undefined, width: 32, halign: -1}, {type: "txt", id: "time", label: "00:00", font: this.fontMed, bgCol: "#000", col: "#fff", width: 122}, @@ -218,7 +219,7 @@ class CSCDisplay { id: "stats_g", fillx: 1, bgCol: "#fff", - height: 32, + height: 36, c: [ { type: "v", @@ -226,7 +227,7 @@ class CSCDisplay { 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: "txt", id: "max", label: "00.0", font: this.fontSmall, bgCol: "#fff", col: "#000", width: 69}, ], }, { @@ -235,7 +236,7 @@ class CSCDisplay { 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: "avg", label: "00.0", font: this.fontSmall, 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}, @@ -245,6 +246,7 @@ class CSCDisplay { }); this.layouts.distance = new Layout({ type: "v", + bgCol: "#fff", c: [ { type: "h", @@ -264,12 +266,12 @@ class CSCDisplay { id: "totald_g", fillx: 1, pad: 4, - bgCol: "#000", + bgCol: "#fff", 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}, + {type: "txt", id: "totald_l", label: "TTL", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36}, + {type: "txt", id: "totald", label: "0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 118}, + {type: "txt", id: "totald_u", label: "km", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, ] }, ], @@ -284,7 +286,7 @@ class CSCDisplay { bgCol: "#fff", height: 100, c: [ - {type: "txt", id: "status", label: "Bangle Cycling", font: this.fontMed, bgCol: "#fff", col: "#000", width: 176, wrap: 1}, + {type: "txt", id: "status", label: "Bangle Cycling", font: this.fontSmall, bgCol: "#fff", col: "#000", width: 176, wrap: 1}, ] }, { @@ -309,6 +311,7 @@ class CSCDisplay { g.clear(); this.layouts[layout].update(); this.layouts[layout].render(); + Bangle.drawWidgets(); } renderIfLayoutActive(layout, node) { @@ -380,7 +383,12 @@ class CSCDisplay { } setTotalDistance(distance) { - this.layouts.distance.totald.label = this.convertDistance(distance).toFixed(1) + const distance = this.convertDistance(distance); + if (distance >= 1000) { + this.layouts.distance.totald.label = String(Math.round(distance)); + } else { + this.layouts.distance.totald.label = distance.toFixed(1); + } this.renderIfLayoutActive("distance", this.layouts.distance.totald_g); } @@ -415,6 +423,5 @@ Bangle.setUI("updown", d => { sensor.interact(d); }); +Bangle.loadWidgets(); sensor.connect(); -// Bangle.loadWidgets(); -// Bangle.drawWidgets(); From a44c62630a0149abd9324c1a00b5ac13319abeaa Mon Sep 17 00:00:00 2001 From: Joseph Paul Date: Sun, 20 Mar 2022 10:51:00 +0100 Subject: [PATCH 3/5] Cycling: Implement settings --- apps/cycling/README.md | 20 +++++++---- apps/cycling/cycling.app.js | 60 ++++++++++++++++++++++--------- apps/cycling/settings.js | 72 +++++++++++++++++++++++-------------- 3 files changed, 103 insertions(+), 49 deletions(-) diff --git a/apps/cycling/README.md b/apps/cycling/README.md index 61ba1d455..7ba8ee224 100644 --- a/apps/cycling/README.md +++ b/apps/cycling/README.md @@ -1,7 +1,7 @@ # 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* +*This is a fork of the CSCSensor app using the layout library and separate module for CSC functionality. It also drops persistence of total distance on the Bangle, as this information is also persisted on the sensor itself. Further, it allows configuration of display units (metric/imperial) independent of chosen locale. Finally, multiple sensors can be used and wheel circumference can be configured for each sensor individually.* The following data are displayed: - curent speed @@ -11,16 +11,24 @@ The following data are displayed: - 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. +Other than in the original version of the app, 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, an 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 +## Usage +Open the app and connect to a CSC sensor. + +Upon first connection, close the app afain and enter the settings app to configure the wheel circumference. The total circumference is (cm + mm) - it is split up into two values for ease of configuration. Check the status screen inside the Cycling app while connected to see the address of the currently connected sensor (if you need to differentiate between multiple sensors). + +Inside the Cycling app, use button / tap screen to: +- cycle through screens (if connected) +- reconnect (if connection aborted) + +## TODO * Sensor battery status * Implement crank events / show cadence * Bangle.js 1 compatibility +* Allow setting CWR on the sensor (this is a feature intended by the BLE CSC spec, in case the sensor is replaced or transferred to a different bike) -# Development +## 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/cycling.app.js b/apps/cycling/cycling.app.js index 4b53046d9..268284a29 100644 --- a/apps/cycling/cycling.app.js +++ b/apps/cycling/cycling.app.js @@ -1,7 +1,11 @@ const Layout = require('Layout'); +const storage = require('Storage'); const SETTINGS_FILE = 'cycling.json'; -const storage = require('Storage'); +const SETTINGS_DEFAULT = { + sensors: {}, + metric: true, +}; const RECONNECT_TIMEOUT = 4000; const MAX_CONN_ATTEMPTS = 2; @@ -13,8 +17,8 @@ class CSCSensor { this.display = display; // Load settings - this.settings = storage.readJSON(SETTINGS_FILE, 1) || {}; - this.wheelCirc = (this.settings.wheelcirc || 2230) / 1000; // unit: m + this.settings = storage.readJSON(SETTINGS_FILE, true) || SETTINGS_DEFAULT; + this.wheelCirc = undefined; // CSC runtime variables this.movingTime = 0; // unit: s @@ -34,13 +38,16 @@ class CSCSensor { // Layout configuration this.layout = 0; this.display.useMetricUnits(true); - // this.display.useMetricUnits(!require("locale").speed(1).toString().endsWith("mph")); + this.deviceAddress = undefined; + this.display.useMetricUnits((this.settings.metric)); } onDisconnect(event) { console.log("disconnected ", event); this.connected = false; + this.wheelCirc = undefined; + this.setLayout(0); this.display.setDeviceAddress("unknown"); @@ -51,7 +58,23 @@ class CSCSensor { this.display.setStatus("Disconnected"); setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT); } + } + loadCircumference() { + if (!this.deviceAddress) return; + + // Add sensor to settings if not present + if (!this.settings.sensors[this.deviceAddress]) { + this.settings.sensors[this.deviceAddress] = { + cm: 223, + mm: 0, + }; + storage.writeJSON(SETTINGS_FILE, this.settings); + } + + const high = this.settings.sensors[this.deviceAddress].cm || 223; + const low = this.settings.sensors[this.deviceAddress].mm || 0; + this.wheelCirc = (10*high + low) / 1000; } connect() { @@ -70,12 +93,14 @@ class CSCSensor { this.failedAttempts = 0; this.failed = false; this.connected = true; - var addr = this.blecsc.getDeviceAddress(); - console.log("Connected to " + addr); + this.deviceAddress = this.blecsc.getDeviceAddress(); + console.log("Connected to " + this.deviceAddress); - this.display.setDeviceAddress(addr); + this.display.setDeviceAddress(this.deviceAddress); this.display.setStatus("Connected"); + this.loadCircumference(); + // Switch to speed screen in 2s setTimeout(function() { this.setLayout(1); @@ -90,9 +115,9 @@ class CSCSensor { disconnect() { this.blecsc.disconnect(); - this.connected = false; + this.reset(); this.setLayout(0); - this.display.setStatus("Disconnected") + this.display.setStatus("Disconnected"); } setLayout(num) { @@ -110,6 +135,7 @@ class CSCSensor { this.connected = false; this.failed = false; this.failedAttempts = 0; + this.wheelCirc = undefined; } interact(d) { @@ -127,7 +153,7 @@ class CSCSensor { updateScreen() { var tripDist = this.cwrTrip * this.wheelCirc; - var avgSpeed = this.movingTime > 3 ? tripDist / this.movingTime : 0 + var avgSpeed = this.movingTime > 3 ? tripDist / this.movingTime : 0; this.display.setTotalDistance(this.cwr * this.wheelCirc); this.display.setTripDistance(tripDist); @@ -297,7 +323,7 @@ class CSCDisplay { 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_l", label: "ADDR", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36 }, { type: "txt", id: "addr", label: "unknown", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 140 }, ] }, @@ -316,13 +342,13 @@ class CSCDisplay { renderIfLayoutActive(layout, node) { if (layout != this.currentLayout) return; - this.layouts[layout].render(node) + this.layouts[layout].render(node); } useMetricUnits(metric) { this.metric = metric; - console.log("using " + (metric ? "metric" : "imperial") + " units"); + // console.log("using " + (metric ? "metric" : "imperial") + " units"); var speedUnit = metric ? "km/h" : "mph"; this.layouts.speed.speed_u.label = speedUnit; @@ -378,12 +404,12 @@ class CSCDisplay { } setTripDistance(distance) { - this.layouts.distance.tripd.label = this.convertDistance(distance).toFixed(1) + this.layouts.distance.tripd.label = this.convertDistance(distance).toFixed(1); this.renderIfLayoutActive("distance", this.layouts.distance.tripd_g); } setTotalDistance(distance) { - const distance = this.convertDistance(distance); + distance = this.convertDistance(distance); if (distance >= 1000) { this.layouts.distance.totald.label = String(Math.round(distance)); } else { @@ -393,12 +419,12 @@ class CSCDisplay { } setDeviceAddress(address) { - this.layouts.status.addr.label = address + this.layouts.status.addr.label = address; this.renderIfLayoutActive("status", this.layouts.status.addr_g); } setStatus(status) { - this.layouts.status.status.label = status + this.layouts.status.status.label = status; this.renderIfLayoutActive("status", this.layouts.status.status_g); } } diff --git a/apps/cycling/settings.js b/apps/cycling/settings.js index 810d8afc0..445c4ca33 100644 --- a/apps/cycling/settings.js +++ b/apps/cycling/settings.js @@ -3,35 +3,55 @@ * @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 SETTINGS_FILE = 'cycling.json' + + // Set default values and merge with stored values + let settings = Object.assign({ + metric: true, + sensors: {}, + }, (storage.readJSON(SETTINGS_FILE, true) || {})); + const menu = { - '': { 'title': 'Cycle speed sensor' }, + '': { 'title': 'Cycling' }, '< Back': back, - 'Wheel circ.(mm)': { - value: s.wheelcirc, - min: 800, - max: 2400, - step: 5, - onchange: save('wheelcirc'), + 'Units': { + value: settings.metric, + format: v => v ? 'metric' : 'imperial', + onchange: (metric) => { + settings.metric = metric; + storage.writeJSON(SETTINGS_FILE, settings); + }, }, } + + const sensorMenus = {}; + for (var addr of Object.keys(settings.sensors)) { + // Define sub menu + sensorMenus[addr] = { + '': { title: addr }, + '< Back': () => E.showMenu(menu), + 'cm': { + value: settings.sensors[addr].cm, + min: 80, max: 240, step: 1, + onchange: (v) => { + settings.sensors[addr].cm = v; + storage.writeJSON(SETTINGS_FILE, settings); + }, + }, + '+ mm': { + value: settings.sensors[addr].mm, + min: 0, max: 9, step: 1, + onchange: (v) => { + settings.sensors[addr].mm = v; + storage.writeJSON(SETTINGS_FILE, settings); + }, + }, + }; + + // Add entry to main menu + menu[addr] = () => E.showMenu(sensorMenus[addr]); + } + E.showMenu(menu); -}) +})(load) From 433e3a4967579e056f067675011ee3205491e00b Mon Sep 17 00:00:00 2001 From: Joseph Paul Date: Sun, 20 Mar 2022 10:59:24 +0100 Subject: [PATCH 4/5] Cycling: Add JS icon --- apps/cycling/cycling.icon.js | 1 + apps/cycling/metadata.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 apps/cycling/cycling.icon.js diff --git a/apps/cycling/cycling.icon.js b/apps/cycling/cycling.icon.js new file mode 100644 index 000000000..12c597956 --- /dev/null +++ b/apps/cycling/cycling.icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4A/AH/OAAIuuGFYuEGFQv/ADOlwV8wK/qwN8AAelGAguiFogACWsulFw6SERcwAFSISLnSMuAFZWCGENWllWLRSZC0vOAAovWmUslkyvbqJwIuHGC4uBAARiDdAwueL4YACMQLmfX5IAFqwwoMIowpMQ4wpGIcywDiYAA2IAAgwGq2kFwIvGC5YtPDJIuCF4gXPFxQHLF44XQFxAKOF4oXRBg4LOFwYvEEag7OBgReQNZzLNF5IXPBJlXq4vVC5Qv8R9TXQFwbvYJBgLlNbYXRBoYOEA44XfCAgAFCxgXYDI4VPC7IA/AH4A/AH4AWA")) diff --git a/apps/cycling/metadata.json b/apps/cycling/metadata.json index 917658fad..cb4260bb2 100644 --- a/apps/cycling/metadata.json +++ b/apps/cycling/metadata.json @@ -11,6 +11,7 @@ "storage": [ {"name":"cycling.app.js","url":"cycling.app.js"}, {"name":"cycling.settings.js","url":"settings.js"}, - {"name":"blecsc","url":"blecsc.js"} + {"name":"blecsc","url":"blecsc.js"}, + {"name":"cycling.img","url":"cycling.icon.js","evaluate": true} ] } From bda630ed0869bb74ce72c517773ee2caea216e55 Mon Sep 17 00:00:00 2001 From: Joseph Paul Date: Sun, 20 Mar 2022 11:05:54 +0100 Subject: [PATCH 5/5] Cycling: don't call settings function --- apps/cycling/settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cycling/settings.js b/apps/cycling/settings.js index 445c4ca33..76303379d 100644 --- a/apps/cycling/settings.js +++ b/apps/cycling/settings.js @@ -54,4 +54,4 @@ } E.showMenu(menu); -})(load) +})