diff --git a/apps/run/README.md b/apps/run/README.md index c094d4873..c455f70e5 100644 --- a/apps/run/README.md +++ b/apps/run/README.md @@ -28,6 +28,15 @@ so if you have no GPS lock you just need to wait. However you can just install the `Recorder` app, turn recording on in that, and then start the `Run` app. +## Settings + +Under `Settings` -> `App` -> `Run` you can change settings for this app. + +* `Pace` is the distance that pace should be shown over - 1km, 1 mile, 1/2 Marathon or 1 Maraton +* `Box 1/2/3/4/5/6` are what should be shown in each of the 6 boxes on the display. From the top left, down. + If you set it to `-` nothing will be displayed, so you can display only 4 boxes of information + if you wish by setting the last 2 boxes to `-`. + ## TODO * Allow this app to trigger the `Recorder` app on and off directly. diff --git a/apps/run/app.js b/apps/run/app.js index a92bbe387..9e313ea2a 100644 --- a/apps/run/app.js +++ b/apps/run/app.js @@ -1,68 +1,37 @@ +var ExStats = require("exstats"); var B2 = process.env.HWVERSION==2; var Layout = require("Layout"); var locale = require("locale"); var fontHeading = "6x8:2"; var fontValue = B2 ? "6x15:2" : "6x8:3"; var headingCol = "#888"; -var running = false; var fixCount = 0; -var startTime; -var startSteps; -// This & previous GPS readings -var lastGPS, thisGPS; -var distance = 0; ///< distance in meters -var startSteps = Bangle.getStepCount(); ///< number of steps when we started -var lastStepCount = startSteps; // last time 'step' was called -var stepHistory = new Uint8Array(60); // steps each second for the last minute (0 = current minute) g.clear(); Bangle.loadWidgets(); Bangle.drawWidgets(); // --------------------------- - -function formatTime(ms) { - let hrs = Math.floor(ms/3600000).toString(); - let mins = (Math.floor(ms/60000)%60).toString(); - let secs = (Math.floor(ms/1000)%60).toString(); - - if (hrs === '0') - return mins.padStart(2,0)+":"+secs.padStart(2,0); - else - return hrs+":"+mins.padStart(2,0)+":"+secs.padStart(2,0); // dont pad hours -} - -// Format speed in meters/second -function formatPace(speed) { - if (speed < 0.1667) { - return `__:__`; - } - const pace = Math.round(1000 / speed); // seconds for 1km - const min = Math.floor(pace / 60); // minutes for 1km - const sec = pace % 60; - return ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2); -} - +let settings = Object.assign({ + B1 : "dist", + B2 : "time", + B3 : "pacea", + B4 : "bpm", + B5 : "step", + B6 : "caden", + paceLength : 1000 +}, require("Storage").readJSON("run.json", 1) || {}); +var statIDs = [settings.B1,settings.B2,settings.B3,settings.B4,settings.B5,settings.B6].filter(s=>s!=""); +var exs = ExStats.getStats(statIDs, settings); // --------------------------- -function clearState() { - distance = 0; - startSteps = Bangle.getStepCount(); - stepHistory.fill(0); - layout.dist.label=locale.distance(distance); - layout.time.label="00:00"; - layout.pace.label=formatPace(0); - layout.hrm.label="--"; - layout.steps.label=0; - layout.cadence.label= "0"; - layout.status.bgCol = "#f00"; -} - +// Called to start/stop running function onStartStop() { - running = !running; + var running = !exs.state.active; if (running) { - clearState(); - startTime = Date.now(); + exs.start(); + } else { + exs.stop(); } layout.button.label = running ? "STOP" : "START"; layout.status.label = running ? "RUN" : "STOP"; @@ -72,107 +41,44 @@ function onStartStop() { layout.render(); } +var lc = []; +// Load stats in pair by pair +for (var i=0;ilayout[e.id].label = e.getString()); + sb.on('changed', e=>layout[e.id].label = e.getString()); +} +// At the bottom put time/GPS state/etc +lc.push({ type:"h", filly:1, c:[ + {type:"txt", font:fontHeading, label:"GPS", id:"gps", fillx:1, bgCol:"#f00" }, + {type:"txt", font:fontHeading, label:"00:00", id:"clock", fillx:1, bgCol:g.theme.fg, col:g.theme.bg }, + {type:"txt", font:fontHeading, label:"STOP", id:"status", fillx:1 } +]}); +// Now calculate the layout var layout = new Layout( { - type:"v", c: [ - { type:"h", filly:1, c:[ - {type:"txt", font:fontHeading, label:"DIST", fillx:1, col:headingCol }, - {type:"txt", font:fontHeading, label:"TIME", fillx:1, col:headingCol } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontValue, label:"0.00", id:"dist", fillx:1 }, - {type:"txt", font:fontValue, label:"00:00", id:"time", fillx:1 } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontHeading, label:"PACE", fillx:1, col:headingCol }, - {type:"txt", font:fontHeading, label:"HEART", fillx:1, col:headingCol } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontValue, label:`__'__"`, id:"pace", fillx:1 }, - {type:"txt", font:fontValue, label:"--", id:"hrm", fillx:1 } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontHeading, label:"STEPS", fillx:1, col:headingCol }, - {type:"txt", font:fontHeading, label:"CADENCE", fillx:1, col:headingCol } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontValue, label:"0", id:"steps", fillx:1 }, - {type:"txt", font:fontValue, label:"0", id:"cadence", fillx:1 } - ]}, { type:"h", filly:1, c:[ - {type:"txt", font:fontHeading, label:"GPS", id:"gps", fillx:1, bgCol:"#f00" }, - {type:"txt", font:fontHeading, label:"00:00", id:"clock", fillx:1, bgCol:g.theme.fg, col:g.theme.bg }, - {type:"txt", font:fontHeading, label:"STOP", id:"status", fillx:1 } - ]}, - - ] + type:"v", c: lc },{lazy:true, btns:[{ label:"START", cb: onStartStop, id:"button"}]}); -clearState(); +delete lc; layout.render(); -function onTimer() { - layout.clock.label = locale.time(new Date(),1); - if (!running) { - layout.render(); - return; - } - // called once a second - var duration = Date.now() - startTime; // in ms - // set cadence based on steps over last minute - var stepsInMinute = E.sum(stepHistory); - var cadence = 60000 * stepsInMinute / Math.min(duration,60000); - // update layout - layout.time.label = formatTime(duration); - layout.steps.label = Bangle.getStepCount()-startSteps; - layout.cadence.label = Math.round(cadence); - layout.render(); - // move step history onwards - stepHistory.set(stepHistory,1); - stepHistory[0]=0; -} - -function radians(a) { - return a*Math.PI/180; -} - -// distance between 2 lat and lons, in meters, Mean Earth Radius = 6371km -// https://www.movable-type.co.uk/scripts/latlong.html -function calcDistance(a,b) { - var x = radians(a.lon-b.lon) * Math.cos(radians((a.lat+b.lat)/2)); - var y = radians(b.lat-a.lat); - return Math.round(Math.sqrt(x*x + y*y) * 6371000); -} - +// Handle GPS state change for icon Bangle.on("GPS", function(fix) { layout.gps.bgCol = fix.fix ? "#0f0" : "#f00"; - if (!fix.fix) { return; } // only process actual fixes + if (!fix.fix) return; // only process actual fixes if (fixCount++ == 0) { Bangle.buzz(); // first fix, does not need to respect quiet mode - lastGPS = fix; // initialise on first fix - } - - thisGPS = fix; - - if (running) { - var d = calcDistance(lastGPS, thisGPS); - distance += d; - layout.dist.label=locale.distance(distance); - var duration = Date.now() - startTime; // in ms - var speed = distance * 1000 / duration; // meters/sec - layout.pace.label = formatPace(speed); - lastGPS = fix; } }); -Bangle.on("HRM", function(h) { - layout.hrm.label = h.bpm; -}); -Bangle.on("step", function(steps) { - if (running) { - layout.steps.label = steps-Bangle.getStepCount(); - stepHistory[0] += steps-lastStepCount; - } - lastStepCount = steps; -}); - -let settings = require("Storage").readJSON('run.json',1)||{"use_gps":true,"use_hrm":true}; - -// We always call ourselves once a second, if only to update the time -setInterval(onTimer, 1000); - -/* Turn GPS and HRM on right at the start to ensure -we get the highest chance of a lock. */ -if (settings.use_hrm) Bangle.setHRMPower(true,"app"); -if (settings.use_gps) Bangle.setGPSPower(true,"app"); +// We always call ourselves once a second to update +setInterval(function() { + layout.clock.label = locale.time(new Date(),1); + layout.render(); +}, 1000); diff --git a/apps/run/settings.js b/apps/run/settings.js index 882b15c71..82735df15 100644 --- a/apps/run/settings.js +++ b/apps/run/settings.js @@ -1,44 +1,58 @@ (function(back) { const SETTINGS_FILE = "run.json"; - - // initialize with default settings... - let s = { - 'use_gps': true, - 'use_hrm': true - } + var ExStats = require("exstats"); + var statsList = ExStats.getList(); + statsList.unshift({name:"-",id:""}); // add blank menu item + var statsIDs = statsList.map(s=>s.id); // ...and overwrite them with any saved values // This way saved values are preserved if a new version adds more settings const storage = require('Storage') - let settings = storage.readJSON(SETTINGS_FILE, 1) || {} - const saved = settings || {} - for (const key in saved) { - s[key] = saved[key] - } - + let settings = Object.assign({ + B1 : "dist", + B2 : "time", + B3 : "pacea", + B4 : "bpm", + B5 : "step", + B6 : "caden", + paceLength : 1000 + }, storage.readJSON(SETTINGS_FILE, 1) || {}); function save() { - settings = s storage.write(SETTINGS_FILE, settings) } - E.showMenu({ - '': { 'title': 'Run' }, - '< Back': back, - 'Use GPS': { - value: s.use_gps, - format: () => (s.use_gps ? 'Yes' : 'No'), - onchange: () => { - s.use_gps = !s.use_gps; - save(); - }, - }, - 'Use HRM': { - value: s.use_hrm, - format: () => (s.use_hrm ? 'Yes' : 'No'), - onchange: () => { - s.use_hrm = !s.use_hrm; + function getBoxChooser(boxID) { + return { + min :0, max: statsIDs.length-1, + value: Math.max(statsIDs.indexOf(settings[boxID]),0), + format: v => statsList[v].name, + onchange: v => { + settings[boxID] = statsIDs[v]; save(); }, } + } + + var paceNames = ["1000m","1 mile","1/2 Mthn", "Marathon",]; + var paceAmts = [1000,1609,21098,42195]; + E.showMenu({ + '': { 'title': 'Run' }, + '< Back': back, + 'Pace': { + min :0, max: paceNames.length-1, + value: Math.max(paceAmts.indexOf(settings.paceLength),0), + format: v => paceNames[v], + onchange: v => { + settings.paceLength = paceAmts[v]; + print(settings); + save(); + }, + }, + 'Box 1': getBoxChooser("B1"), + 'Box 2': getBoxChooser("B2"), + 'Box 3': getBoxChooser("B3"), + 'Box 4': getBoxChooser("B4"), + 'Box 5': getBoxChooser("B5"), + 'Box 6': getBoxChooser("B6"), }) }) diff --git a/modules/exstats.js b/modules/exstats.js new file mode 100644 index 000000000..7e4dd6573 --- /dev/null +++ b/modules/exstats.js @@ -0,0 +1,239 @@ +/* Exercise Stats module + +Usage +----- + +var ExStats = require("exstats"); +// Get a list of available types of run statistic +print(ExStats.getList()); +// returns list of available stat IDs like +[ + {name: "Time", id:"time"}, + {name: "Distance", id:"dist"}, + {name: "Steps", id:"step"}, + {name: "Heart (BPM)", id:"bpm"}, + {name: "Pace (avr)", id:"pacea"}, + {name: "Pace (current)", id:"pacec"}, + {name: "Cadence", id:"caden"}, +] + +// Setup and load all statistic types +var exs = ExStats.getStats(["dist", "time", "pacea","bpm","step","caden"], options); +// exs contains +{ + stats : { time : { + id : "time" + title : "Time" // title to use when rendering + getValue : function // get a floating point value for this stat + getString : function // get a formatted string for this stat + // also fires a 'changed' event + }, + dist : { ... }, + pacea : { ... }, + ... + }, + state : { active : bool, + .. other internal-ish state info + }, + start : function, // call to start exercise and reset state + stop : function, // call to stop exercise +} + +*/ +var state = { + active : false, // are we working or not? + // startTime, // time exercise started + lastGPS:{}, thisGPS:{}, // This & previous GPS readings + // distance : 0, ///< distance in meters + // avrSpeed : 0, ///< in m/sec + startSteps : Bangle.getStepCount(), ///< number of steps when we started + lastSteps : Bangle.getStepCount(), // last time 'step' was called + stepHistory : new Uint8Array(60), // steps each second for the last minute (0 = current minute) + // stepsInMinute // steps over the last minute + // cadence // steps per minute adjusted if <1 minute + // BPM // beats per minute + // BPMage // how many seconds was BPM set? +}; +// list of active stats (indexed by ID) +var stats = {}; + +// distance between 2 lat and lons, in meters, Mean Earth Radius = 6371km +// https://www.movable-type.co.uk/scripts/latlong.html +function calcDistance(a,b) { + function radians(a) { return a*Math.PI/180; } + var x = radians(a.lon-b.lon) * Math.cos(radians((a.lat+b.lat)/2)); + var y = radians(b.lat-a.lat); + return Math.round(Math.sqrt(x*x + y*y) * 6371000); +} + +// Given milliseconds, return a time +function formatTime(ms) { + let hrs = Math.floor(ms/3600000).toString(); + let mins = (Math.floor(ms/60000)%60).toString(); + let secs = (Math.floor(ms/1000)%60).toString(); + + if (hrs === '0') + return mins.padStart(2,0)+":"+secs.padStart(2,0); + else + return hrs+":"+mins.padStart(2,0)+":"+secs.padStart(2,0); // dont pad hours +} + +// Format speed in meters/second, paceLength=length in m for pace over +function formatPace(speed, paceLength) { + if (speed < 0.1667) { + return `__:__`; + } + const pace = Math.round(paceLength / speed); // seconds for paceLength (1000=1km) + const min = Math.floor(pace / 60); // minutes for paceLength + const sec = pace % 60; + return ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2); +} + +Bangle.on("GPS", function(fix) { + if (!fix.fix) return; // only process actual fixes + + if (!state.active) return; + if( state.lastGPS.fix) + state.distance += calcDistance(state.lastGPS, fix); + var duration = Date.now() - state.startTime; // in ms + state.avrSpeed = state.distance * 1000 / duration; // meters/sec + if (stats["pacea"]) stats["pacea"].emit("changed",stats["pacea"]); + state.lastGPS = state.thisGPS; + state.thisGPS = fix; + if (stats["pacec"]) stats["pacec"].emit("changed",stats["pacec"]); +}); + +Bangle.on("step", function(steps) { + if (!state.active) return; + if (stats["step"]) stats["step"].emit("changed",stats["step"]); + state.lastStepCount = steps; +}); +Bangle.on("HRM", function(h) { + if (h.confidence>=60) { + state.BPM = h.bpm; + state.BPMage = 0; + stats["bpm"].emit("changed",stats["bpm"]); + } +}); + +/** Get list of available statistic types */ +exports.getList = function() { + return [ + {name: "Time", id:"time"}, + {name: "Distance", id:"dist"}, + {name: "Steps", id:"step"}, + {name: "Heart (BPM)", id:"bpm"}, + {name: "Pace (avr)", id:"pacea"}, + {name: "Pace (current)", id:"pacec"}, + {name: "Cadence", id:"caden"}, + ]; +}; +/** Instatiate the given list of statistic IDs (see comments at top) + options = { + paceLength : meters to measure pace over + } +*/ +exports.getStats = function(statIDs, options) { + options = options||{}; + options.paceLength = options.paceLength||1000; + var needGPS,needHRM; + // ====================== + if (statIDs.includes("time")) { + stats["time"]={ + title : "Time", + getValue : function() { return Date.now()-state.startTime; }, + getString : function() { return formatTime(this.getValue()) }, + }; + }; + if (statIDs.includes("dist")) { + needGPS = true; + stats["dist"]={ + title : "Dist", + getValue : function() { return state.distance; }, + getString : function() { return require("locale").distance(state.distance); }, + }; + } + if (statIDs.includes("step")) { + stats["step"]={ + title : "Steps", + getValue : function() { return Bangle.getStepCount() - state.startSteps; }, + getString : function() { return this.getValue().toString() }, + }; + } + if (statIDs.includes("bpm")) { + needHRM = true; + stats["bpm"]={ + title : "BPM", + getValue : function() { return state.BPM; }, + getString : function() { return state.BPM||"--" }, + }; + } + if (statIDs.includes("pacea")) { + needGPS = true; + stats["pacea"]={ + title : "Pace(avr)", + getValue : function() { return state.avrSpeed; }, // in m/sec + getString : function() { return formatPace(state.avrSpeed, options.paceLength); }, + }; + } + if (statIDs.includes("pacec")) { + needGPS = true; + stats["pacec"]={ + title : "Pace(now)", + getValue : function() { return (state.thisGPS.speed||0)/3.6; }, // in m/sec + getString : function() { return formatPace(this.getValue(), options.paceLength); }, + }; + } + if (statIDs.includes("caden")) { + needGPS = true; + stats["caden"]={ + title : "Cadence", + getValue : function() { return state.stepsPerMin; }, + getString : function() { return state.stepsPerMin; }, + }; + } + // ====================== + for (var i in stats) stats[i].id=i; // set up ID field + if (needGPS) Bangle.setGPSPower(true,"exs"); + if (needHRM) Bangle.setHRMPower(true,"exs"); + setInterval(function() { // run once a second.... + if (!state.active) return; + // called once a second + var duration = Date.now() - state.startTime; // in ms + // set cadence -> steps over last minute + state.stepsPerMin = Math.round(60000 * E.sum(state.stepHistory) / Math.min(duration,60000)); + if (stats["caden"]) stats["caden"].emit("changed",stats["caden"]); + // move step history onwards + state.stepHistory.set(state.stepHistory,1); + state.stepHistory[0]=0; + if (stats["time"]) stats["time"].emit("changed",stats["time"]); + // update BPM - if nothing valid in 60s remove the reading + state.BPMage++; + if (state.BPM && state.BPMage>60) { + state.BPM = 0; + if (stats["bpm"]) stats["bpm"].emit("changed",stats["bpm"]); + } + }, 1000); + function reset() { + state.startTime = Date.now(); + state.startSteps = state.lastSteps = Bangle.getStepCount(); + state.lastSteps = 0; + state.stepHistory.fill(0); + state.stepsPerMin = 0; + state.distance = 0; + state.avrSpeed = 0; + state.BPM = 0; + state.BPMage = 0; + } + reset(); + return { + stats : stats, state : state, + start : function() { + state.active = true; + reset(); + }, + stop : function() { + state.active = false; + } + }; +};