Merge branch 'run_module'

pull/1364/head
Gordon Williams 2022-01-28 09:46:39 +00:00
commit 2f3a9c8c87
9 changed files with 418 additions and 176 deletions

View File

@ -2,3 +2,4 @@
0.02: Set pace format to mm:ss, time format to h:mm:ss,
added settings to opt out of GPS and HRM
0.03: Fixed distance calculation, tested against Garmin Etrex, Amazfit GTS 2
0.04: Use the exstats module, and make what is displayed configurable

View File

@ -28,7 +28,24 @@ 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 Marathon
* `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.
* Keep a log of each run's stats (distance/steps/etc)
## Development
This app uses the [`exstats` module](/modules/exstats.js). When uploaded via the
app loader, the module is automatically included in the app's source. However
when developing via the IDE the module won't get pulled in by default.
There are some options to fix this easily - please check out the [modules README.md file](/modules/README.md)

View File

@ -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 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 }
var lc = [];
// Load stats in pair by pair
for (var i=0;i<statIDs.length;i+=2) {
var sa = exs.stats[statIDs[i+0]];
var sb = exs.stats[statIDs[i+1]];
lc.push({ type:"h", filly:1, c:[
{type:"txt", font:fontHeading, label:sa.title.toUpperCase(), fillx:1, col:headingCol },
{type:"txt", font:fontHeading, label:sb.title.toUpperCase(), fillx:1, col:headingCol }
]}, { type:"h", filly:1, c:[
{type:"txt", font:fontValue, label:sa.getString(), id:sa.id, fillx:1 },
{type:"txt", font:fontValue, label:sb.getString(), id:sb.id, fillx:1 }
]});
sa.on('changed', e=>layout[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: 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);

View File

@ -1,6 +1,6 @@
{ "id": "run",
"name": "Run",
"version":"0.03",
"version":"0.04",
"description": "Displays distance, time, steps, cadence, pace and more for runners.",
"icon": "app.png",
"tags": "run,running,fitness,outdoors,gps",

View File

@ -1,44 +1,50 @@
(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]
}
function save() {
settings = s
let settings = Object.assign({
B1 : "dist",
B2 : "time",
B3 : "pacea",
B4 : "bpm",
B5 : "step",
B6 : "caden",
paceLength : 1000
}, storage.readJSON(SETTINGS_FILE, 1) || {});
function saveSettings() {
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;
save();
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];
saveSettings();
},
}
})
}
var menu = {
'': { 'title': 'Run' },
'< Back': back
};
ExStats.appendMenuItems(menu, settings, saveSettings);
Object.assign(menu,{
'Box 1': getBoxChooser("B1"),
'Box 2': getBoxChooser("B2"),
'Box 3': getBoxChooser("B3"),
'Box 4': getBoxChooser("B4"),
'Box 5': getBoxChooser("B5"),
'Box 6': getBoxChooser("B6"),
});
E.showMenu(menu);
})

View File

@ -1,5 +1,8 @@
/* Copyright (c) 2022 Bangle.js contibutors. See the file LICENSE for copying permission. */
/*
Take a look at README.md for hints on developing with this library.
Usage:
```

View File

@ -1,9 +1,54 @@
App Modules
===========
These are modules used by apps - you can use them with:
These are modules used by apps - you can use them from a Bangle.js app with:
```
var testmodule = require("testmodule");
testmodule.test()
```
Development
-----------
When apps that use these modules are uploaded via the
app loader, the module is automatically included in the app's source. However
when developing via the IDE the module won't get pulled in by default.
To fix this you have three options:
### Host your own App Loader and upload from that
This is reasonably easy to set up, but it's more difficult to make changes and upload:
* Follow the steps here to set up your own App Loader: https://www.espruino.com/Bangle.js+App+Loader
* Make changes to that repository
* Refresh and upload your app from the app loader (you can have the IDE connected
at the same time so you can see any error messages)
### Upload the module to the Bangle's internal storage
This allows you to develop both the app and module very quickly, but the app is
uploaded in a slightly different way to what you'd get when you use the App Loader
or the method below:
* Load the module's source file in the Web IDE
* Click the down-arrow below the upload button, then `Storage`
* Click `New File`, type `your_module_name` as the name (with no `.js` extension), click `Ok`
* Now Click the `Upload` icon.
You can now upload the app direct from the IDE. You can even leave a second Web IDE window open
(one for the app, one for the module) to allow you to change the module.
### Change the Web IDE search path to include Bangle.js modules
This is nice and easy (and the results are the same as if the app was
uploaded via the app loader), however you cannot then make/test changes
to the module.
* In the IDE, Click the `Settings` icon in the top right
* Click `Communications` and scroll down to `Module URL`
* Now change the module URL from the default of `https://www.espruino.com/modules`
to `https://banglejs.com/apps/modules|https://www.espruino.com/modules`
The next time you upload your app, the module will automatically be included.

View File

@ -1,6 +1,8 @@
/* Copyright (c) 2020 OmegaRogue. See the file LICENSE for copying permission. */
/*
Graphics Functions based on the React Sci-Fi UI Framework Arwes
Take a look at README.md for hints on developing with this library.
*/
var C = {

262
modules/exstats.js Normal file
View File

@ -0,0 +1,262 @@
/* Copyright (c) 2022 Bangle.js contibutors. See the file LICENSE for copying permission. */
/* Exercise Stats module
Take a look at README.md for hints on developing with this library.
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
}
/// Or you can display a menu where the settings can be configured - these are passed as the 'options' argument of getStats
var menu = { ... };
ExStats.appendMenuItems(menu, settings, saveSettingsFunction);
E.showMenu(menu);
*/
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;
}
};
};
exports.appendMenuItems = function(menu, settings, saveSettings) {
var paceNames = ["1000m","1 mile","1/2 Mthn", "Marathon",];
var paceAmts = [1000,1609,21098,42195];
menu['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];
saveSettings();
},
};
};