diff --git a/apps.json b/apps.json index 3a446edb7..f893deeac 100644 --- a/apps.json +++ b/apps.json @@ -2170,8 +2170,22 @@ {"name":"tetra.stl","url":"tetra.stl"}, {"name":"cube.stl","url":"cube.stl"}, {"name":"icosa.stl","url":"icosa.stl"} - ] - }, + ] + }, + { "id": "cscsensor", + "name": "Cycling speed sensor", + "shortName":"CSCSensor", + "icon": "icons8-cycling-48.png", + "version":"0.03", + "description": "Read BLE enabled cycling speed and cadence sensor and display readings on watch", + "tags": "outdoors,exercise,ble,bluetooth", + "readme": "README.md", + "storage": [ + {"name":"cscsensor.app.js","url":"cscsensor.app.js"}, + {"name":"cscsensor.settings.js","url":"settings.js"}, + {"name":"cscsensor.img","url":"cscsensor-icon.js","evaluate":true} + ] + }, { "id": "worldclock", "name": "World Clock - 4 time zones", "shortName":"World Clock", diff --git a/apps/cscsensor/ChangeLog b/apps/cscsensor/ChangeLog new file mode 100644 index 000000000..ae99905a6 --- /dev/null +++ b/apps/cscsensor/ChangeLog @@ -0,0 +1,4 @@ +0.01: New app! +0.02: Add wheel circumference settings dialog +0.03: Save total distance traveled + diff --git a/apps/cscsensor/README.md b/apps/cscsensor/README.md new file mode 100644 index 000000000..8ba862241 --- /dev/null +++ b/apps/cscsensor/README.md @@ -0,0 +1,16 @@ +# CSCSensor + +Simple app that can read a cycling speed and cadence (CSC) sensor and display the information on the watch. +Currently the app displays the following data: + +- moving time +- current speed +- average speed +- maximum speed +- trip distance traveled +- total distance traveled + +Button 1 resets all measurements except total distance traveled. The latter gets preserved by being written to storage every 0.1 miles and upon exiting the app. + +I do not have access to a cadence sensor at the moment, so only the speed part is currently implemented. Values displayed are imperial or metric (depending on locale), +the wheel circumference can be adjusted in the global settings app. diff --git a/apps/cscsensor/cscsensor-icon.js b/apps/cscsensor/cscsensor-icon.js new file mode 100644 index 000000000..12c597956 --- /dev/null +++ b/apps/cscsensor/cscsensor-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4A/AH/OAAIuuGFYuEGFQv/ADOlwV8wK/qwN8AAelGAguiFogACWsulFw6SERcwAFSISLnSMuAFZWCGENWllWLRSZC0vOAAovWmUslkyvbqJwIuHGC4uBAARiDdAwueL4YACMQLmfX5IAFqwwoMIowpMQ4wpGIcywDiYAA2IAAgwGq2kFwIvGC5YtPDJIuCF4gXPFxQHLF44XQFxAKOF4oXRBg4LOFwYvEEag7OBgReQNZzLNF5IXPBJlXq4vVC5Qv8R9TXQFwbvYJBgLlNbYXRBoYOEA44XfCAgAFCxgXYDI4VPC7IA/AH4A/AH4AWA")) diff --git a/apps/cscsensor/cscsensor.app.js b/apps/cscsensor/cscsensor.app.js new file mode 100644 index 000000000..3e6e54404 --- /dev/null +++ b/apps/cscsensor/cscsensor.app.js @@ -0,0 +1,137 @@ +var device; +var gatt; +var service; +var characteristic; + +const SETTINGS_FILE = 'cscsensor.json'; +const storage = require('Storage'); + +class CSCSensor { + constructor() { + this.movingTime = 0; + this.lastTime = 0; + this.lastBangleTime = Date.now(); + this.lastRevs = -1; + this.settings = storage.readJSON(SETTINGS_FILE, 1) || {}; + this.settings.totaldist = this.settings.totaldist || 0; + this.totaldist = this.settings.totaldist; + this.wheelCirc = (this.settings.wheelcirc || 2230)/25.4; + this.speedFailed = 0; + this.speed = 0; + this.maxSpeed = 0; + this.lastSpeed = 0; + this.qUpdateScreen = true; + this.lastRevsStart = -1; + this.qMetric = !require("locale").speed(1).toString().endsWith("mph"); + this.speedUnit = this.qMetric ? "km/h" : "mph"; + this.distUnit = this.qMetric ? "km" : "mi"; + this.distFactor = this.qMetric ? 1.609344 : 1; + } + reset() { + this.maxSpeed = 0; + this.movingTime = 0; + this.lastRevsStart = this.lastRevs; + this.maxSpeed = 0; + } + updateScreen() { + var dist = this.distFactor*(this.lastRevs-this.lastRevsStart)*this.wheelCirc/63360.0; + var ddist = Math.round(100*dist)/100; + var tdist = Math.round(this.distFactor*this.totaldist*10)/10; + var dspeed = Math.round(10*this.distFactor*this.speed)/10; + var dmins = Math.floor(this.movingTime/60).toString(); + if (dmins.length<2) dmins = "0"+dmins; + var dsecs = (Math.floor(this.movingTime) % 60).toString(); + if (dsecs.length<2) dsecs = "0"+dsecs; + var avespeed = (this.movingTime>2 ? Math.round(10*dist/(this.movingTime/3600))/10 : 0); + var maxspeed = Math.round(10*this.distFactor*this.maxSpeed)/10; + g.setFontAlign(1, -1, 0).setFontVector(18).setColor(1, 1, 0); + g.drawString("Time:", 86, 60); + g.drawString("Speed:", 86, 92); + g.drawString("Ave spd:", 86, 124); + g.drawString("Max spd:", 86, 156); + g.drawString("Trip dist:", 86, 188); + g.drawString("Total:", 86, 220); + g.setFontAlign(-1, -1, 0).setFontVector(24).setColor(1, 1, 1).clearRect(92, 60, 239, 239); + g.drawString(dmins+":"+dsecs, 92, 60); + g.drawString(dspeed+" "+this.speedUnit, 92, 92); + g.drawString(avespeed + " " + this.speedUnit, 92, 124); + g.drawString(maxspeed + " " + this.speedUnit, 92, 156); + g.drawString(ddist + " " + this.distUnit, 92, 188); + g.drawString(tdist + " " + this.distUnit, 92, 220); + } + updateSensor(event) { + var qChanged = false; + if (event.target.uuid == "0x2a5b") { + var wheelRevs = event.target.value.getUint32(1, true); + var dRevs = (this.lastRevs>0 ? wheelRevs-this.lastRevs : 0); + if (dRevs>0) { + qChanged = true; + this.totaldist += dRevs*this.wheelCirc/63360.0; + if ((this.totaldist-this.settings.totaldist)>0.1) { + this.settings.totaldist = this.totaldist; + storage.writeJSON(SETTINGS_FILE, this.settings); + } + } + this.lastRevs = wheelRevs; + if (this.lastRevsStart<0) this.lastRevsStart = wheelRevs; + var wheelTime = event.target.value.getUint16(5, true); + var dT = (wheelTime-this.lastTime)/1024; + var dBT = (Date.now()-this.lastBangleTime)/1000; + this.lastBangleTime = Date.now(); + if (dT<0) dT+=64; + this.lastTime = wheelTime; + this.speed = this.lastSpeed; + if (dRevs>0 && dT>0) { + this.speed = (dRevs*this.wheelCirc/63360.0)*3600/dT; + this.speedFailed = 0; + this.movingTime += dBT; + } + else { + this.speedFailed++; + qChanged = false; + if (this.speedFailed>3) { + this.speed = 0; + qChanged = (this.lastSpeed>0); + } + } + this.lastSpeed = this.speed; + if (this.speed > this.maxSpeed) this.maxSpeed = this.speed; + } + if (qChanged && this.qUpdateScreen) this.updateScreen(); + } +} + +var mySensor = new CSCSensor(); + +function parseDevice(d) { + device = d; + g.clearRect(0, 60, 239, 239).setFontAlign(0, 0, 0).setColor(0, 1, 0).drawString("Found device", 120, 120).flip(); + device.gatt.connect().then(function(ga) { + gatt = ga; + g.clearRect(0, 60, 239, 239).setFontAlign(0, 0, 0).setColor(0, 1, 0).drawString("Connected", 120, 120).flip(); + return gatt.getPrimaryService("1816"); +}).then(function(s) { + service = s; + return service.getCharacteristic("2a5b"); +}).then(function(c) { + characteristic = c; + characteristic.on('characteristicvaluechanged', (event)=>mySensor.updateSensor(event)); + return characteristic.startNotifications(); +}).then(function() { + console.log("Done!"); + g.clearRect(0, 60, 239, 239).setColor(1, 1, 1).flip(); + mySensor.updateScreen(); +}).catch(function(e) { + g.clearRect(0, 60, 239, 239).setColor(1, 0, 0).setFontAlign(0, 0, 0).drawString("ERROR"+e, 120, 120).flip(); +})} + +NRF.setScan(parseDevice, { filters: [{services:["1816"]}], timeout: 2000}); +g.clearRect(0, 60, 239, 239).setFontVector(18).setFontAlign(0, 0, 0).setColor(0, 1, 0); +g.drawString("Scanning for CSC sensor...", 120, 120); + +setWatch(function() { mySensor.reset(); g.clearRect(0, 60, 239, 239); mySensor.updateScreen(); }, BTN1, {repeat:true, debounce:20}); + +Bangle.on('kill',()=>{ if (gatt!=undefined) gatt.disconnect(); mySensor.settings.totaldist = mySensor.totaldist; storage.writeJSON(SETTINGS_FILE, mySensor.settings); }); + +Bangle.loadWidgets(); +Bangle.drawWidgets(); diff --git a/apps/cscsensor/icons8-cycling-48.png b/apps/cscsensor/icons8-cycling-48.png new file mode 100644 index 000000000..0bc83859f Binary files /dev/null and b/apps/cscsensor/icons8-cycling-48.png differ diff --git a/apps/cscsensor/settings.js b/apps/cscsensor/settings.js new file mode 100644 index 000000000..d7a7d565d --- /dev/null +++ b/apps/cscsensor/settings.js @@ -0,0 +1,45 @@ +// 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'), + }, + 'Reset total distance': function() { + E.showPrompt("Zero total distance?", {buttons: {"No":false, "Yes":true}}).then(function(v) { + if (v) { + s['totaldist'] = 0; + storage.write(SETTINGS_FILE, s); + } + }).then(back); + } + } + E.showMenu(menu); +})