2022-03-01 05:54:27 +00:00
|
|
|
/* Copyright (c) 2022 Bangle.js contributors. See the file LICENSE for copying permission. */
|
2022-01-27 14:05:47 +00:00
|
|
|
/* Exercise Stats module
|
|
|
|
|
2022-01-28 09:45:56 +00:00
|
|
|
Take a look at README.md for hints on developing with this library.
|
|
|
|
|
2022-01-27 14:05:47 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-01-28 09:45:56 +00:00
|
|
|
/// 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);
|
|
|
|
|
2022-01-27 14:05:47 +00:00
|
|
|
*/
|
|
|
|
var state = {
|
|
|
|
active : false, // are we working or not?
|
|
|
|
// startTime, // time exercise started
|
|
|
|
lastGPS:{}, thisGPS:{}, // This & previous GPS readings
|
|
|
|
// distance : 0, ///< distance in meters
|
2022-01-31 10:40:35 +00:00
|
|
|
// avrSpeed : 0, ///< speed over whole run in m/sec
|
2022-02-01 14:13:01 +00:00
|
|
|
// curSpeed : 0, ///< current (but averaged speed) in m/sec
|
2022-01-27 14:05:47 +00:00
|
|
|
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?
|
2022-03-05 06:15:35 +00:00
|
|
|
// Notifies: 0 for disabled, otherwise how often to notify in meters and seconds
|
2022-03-05 06:40:23 +00:00
|
|
|
notifyDist: 0, notifyTime: 0, notifySteps: 0,
|
|
|
|
nextNotifyDist: 0, nextNotifyTime: 0, nextNotifySteps: 0,
|
2022-01-27 14:05:47 +00:00
|
|
|
};
|
|
|
|
// 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
|
2022-03-01 05:54:27 +00:00
|
|
|
// (Equirectangular approximation)
|
2022-01-27 14:05:47 +00:00
|
|
|
function calcDistance(a,b) {
|
2022-03-05 06:15:35 +00:00
|
|
|
function radians(a) { return a*Math.PI/180; }
|
|
|
|
var x = radians(b.lon-a.lon) * Math.cos(radians((a.lat+b.lat)/2));
|
|
|
|
var y = radians(b.lat-a.lat);
|
2022-01-31 10:40:35 +00:00
|
|
|
return Math.sqrt(x*x + y*y) * 6371000;
|
2022-01-27 14:05:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
2022-02-01 14:13:01 +00:00
|
|
|
state.lastGPS = state.thisGPS;
|
|
|
|
state.thisGPS = fix;
|
|
|
|
if (state.lastGPS.fix)
|
2022-01-27 14:05:47 +00:00
|
|
|
state.distance += calcDistance(state.lastGPS, fix);
|
2022-01-31 10:40:35 +00:00
|
|
|
if (stats["dist"]) stats["dist"].emit("changed",stats["dist"]);
|
2022-01-27 14:05:47 +00:00
|
|
|
var duration = Date.now() - state.startTime; // in ms
|
|
|
|
state.avrSpeed = state.distance * 1000 / duration; // meters/sec
|
2022-02-01 17:26:43 +00:00
|
|
|
state.curSpeed = state.curSpeed*0.8 + fix.speed*0.2/3.6; // meters/sec
|
2022-01-27 14:05:47 +00:00
|
|
|
if (stats["pacea"]) stats["pacea"].emit("changed",stats["pacea"]);
|
|
|
|
if (stats["pacec"]) stats["pacec"].emit("changed",stats["pacec"]);
|
2022-01-31 10:40:35 +00:00
|
|
|
if (stats["speed"]) stats["speed"].emit("changed",stats["speed"]);
|
2022-03-05 06:40:23 +00:00
|
|
|
if (state.notifyDist > 0 && state.nextNotifyDist < stats["dist"]) {
|
2022-03-05 06:15:35 +00:00
|
|
|
stats["dist"].emit("notify",stats["dist"]);
|
2022-03-05 06:40:23 +00:00
|
|
|
state.nextNotifyDist = stats["dist"] + state.notifyDist;
|
2022-03-05 06:15:35 +00:00
|
|
|
}
|
2022-01-27 14:05:47 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
Bangle.on("step", function(steps) {
|
|
|
|
if (!state.active) return;
|
|
|
|
if (stats["step"]) stats["step"].emit("changed",stats["step"]);
|
2022-02-23 16:57:22 +00:00
|
|
|
state.stepHistory[0] += steps-state.lastStepCount;
|
2022-01-27 14:05:47 +00:00
|
|
|
state.lastStepCount = steps;
|
2022-03-05 06:15:35 +00:00
|
|
|
if (state.notifySteps > 0 && state.nextNotifySteps < steps) {
|
|
|
|
stats["step"].emit("notify",stats["step"]);
|
|
|
|
state.nextNotifySteps = steps + state.notifySteps;
|
|
|
|
}
|
2022-01-27 14:05:47 +00:00
|
|
|
});
|
|
|
|
Bangle.on("HRM", function(h) {
|
|
|
|
if (h.confidence>=60) {
|
|
|
|
state.BPM = h.bpm;
|
|
|
|
state.BPMage = 0;
|
2022-03-01 05:54:27 +00:00
|
|
|
if (stats["bpm"]) stats["bpm"].emit("changed",stats["bpm"]);
|
2022-01-27 14:05:47 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
/** 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"},
|
2022-03-01 05:54:27 +00:00
|
|
|
{name: "Pace (avg)", id:"pacea"},
|
|
|
|
{name: "Pace (curr)", id:"pacec"},
|
2022-01-31 10:40:35 +00:00
|
|
|
{name: "Speed", id:"speed"},
|
2022-01-27 14:05:47 +00:00
|
|
|
{name: "Cadence", id:"caden"},
|
|
|
|
];
|
|
|
|
};
|
2022-03-01 05:54:27 +00:00
|
|
|
/** Instantiate the given list of statistic IDs (see comments at top)
|
2022-01-27 14:05:47 +00:00
|
|
|
options = {
|
|
|
|
paceLength : meters to measure pace over
|
2022-03-05 06:40:23 +00:00
|
|
|
notifyDist : meters to notify have elapsed (repeats)
|
2022-03-05 06:15:35 +00:00
|
|
|
notifyTime : ms to notify have elapsed (repeats)
|
|
|
|
notifySteps : number of steps to notify have elapsed (repeats)
|
2022-01-27 14:05:47 +00:00
|
|
|
}
|
|
|
|
*/
|
|
|
|
exports.getStats = function(statIDs, options) {
|
|
|
|
options = options||{};
|
|
|
|
options.paceLength = options.paceLength||1000;
|
2022-03-05 06:40:23 +00:00
|
|
|
options.notifyDist = options.notifyDist||0;
|
2022-03-05 06:32:07 +00:00
|
|
|
options.notifyTime = options.notifyTime||0;
|
|
|
|
options.notifySteps = options.notifySteps||0;
|
2022-01-27 14:05:47 +00:00
|
|
|
var needGPS,needHRM;
|
|
|
|
// ======================
|
|
|
|
if (statIDs.includes("time")) {
|
|
|
|
stats["time"]={
|
|
|
|
title : "Time",
|
|
|
|
getValue : function() { return Date.now()-state.startTime; },
|
|
|
|
getString : function() { return formatTime(this.getValue()) },
|
|
|
|
};
|
2022-03-01 05:54:27 +00:00
|
|
|
}
|
2022-01-27 14:05:47 +00:00
|
|
|
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"]={
|
2022-01-31 10:40:35 +00:00
|
|
|
title : "A Pace",
|
2022-01-27 14:05:47 +00:00
|
|
|
getValue : function() { return state.avrSpeed; }, // in m/sec
|
|
|
|
getString : function() { return formatPace(state.avrSpeed, options.paceLength); },
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (statIDs.includes("pacec")) {
|
|
|
|
needGPS = true;
|
|
|
|
stats["pacec"]={
|
2022-01-31 10:40:35 +00:00
|
|
|
title : "C Pace",
|
2022-02-01 14:13:01 +00:00
|
|
|
getValue : function() { return state.curSpeed; }, // in m/sec
|
|
|
|
getString : function() { return formatPace(state.curSpeed, options.paceLength); },
|
2022-01-27 14:05:47 +00:00
|
|
|
};
|
|
|
|
}
|
2022-01-31 10:40:35 +00:00
|
|
|
if (statIDs.includes("speed")) {
|
|
|
|
needGPS = true;
|
|
|
|
stats["speed"]={
|
|
|
|
title : "Speed",
|
2022-02-01 17:26:43 +00:00
|
|
|
getValue : function() { return state.curSpeed*3.6; }, // in kph
|
|
|
|
getString : function() { return require("locale").speed(state.curSpeed*3.6); },
|
2022-01-31 10:40:35 +00:00
|
|
|
};
|
|
|
|
}
|
2022-01-27 14:05:47 +00:00
|
|
|
if (statIDs.includes("caden")) {
|
|
|
|
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"]);
|
|
|
|
}
|
2022-03-05 06:15:35 +00:00
|
|
|
if (state.notifyTime > 0 && state.nextNotifyTime < stats["time"]) {
|
|
|
|
stats["time"].emit("notify",stats["time"]);
|
2022-03-05 06:40:23 +00:00
|
|
|
state.nextNotifyTime = stats["time"] + state.notifyTime;
|
2022-03-05 06:15:35 +00:00
|
|
|
}
|
2022-01-27 14:05:47 +00:00
|
|
|
}, 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;
|
2022-02-01 14:13:01 +00:00
|
|
|
state.curSpeed = 0;
|
2022-01-27 14:05:47 +00:00
|
|
|
state.BPM = 0;
|
|
|
|
state.BPMage = 0;
|
2022-03-05 06:32:07 +00:00
|
|
|
state.notifyTime = options.notifyTime;
|
2022-03-05 06:40:23 +00:00
|
|
|
state.notifyDist = options.notifyDist;
|
2022-03-05 06:32:07 +00:00
|
|
|
state.notifySteps = options.notifySteps;
|
2022-03-05 07:11:42 +00:00
|
|
|
console.log("options:");
|
|
|
|
console.log(JSON.stringify(options));
|
2022-03-05 06:32:07 +00:00
|
|
|
if (options.notifyTime) {
|
|
|
|
state.nextNotifyTime = state.startTime + options.notifyTime;
|
|
|
|
}
|
2022-03-05 06:40:23 +00:00
|
|
|
if (options.notifyDist) {
|
|
|
|
state.nextNotifyDist = state.distance + options.notifyDist;
|
2022-03-05 06:32:07 +00:00
|
|
|
}
|
|
|
|
if (options.notifySteps) {
|
|
|
|
state.nextNotifySteps = state.lastSteps + options.notifySteps;
|
|
|
|
}
|
2022-03-05 07:11:42 +00:00
|
|
|
console.log("state:");
|
|
|
|
console.log(JSON.stringify(state));
|
2022-01-27 14:05:47 +00:00
|
|
|
}
|
|
|
|
reset();
|
|
|
|
return {
|
|
|
|
stats : stats, state : state,
|
|
|
|
start : function() {
|
|
|
|
state.active = true;
|
|
|
|
reset();
|
|
|
|
},
|
|
|
|
stop : function() {
|
|
|
|
state.active = false;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
};
|
2022-01-28 09:45:56 +00:00
|
|
|
|
|
|
|
exports.appendMenuItems = function(menu, settings, saveSettings) {
|
|
|
|
var paceNames = ["1000m","1 mile","1/2 Mthn", "Marathon",];
|
|
|
|
var paceAmts = [1000,1609,21098,42195];
|
|
|
|
menu['Pace'] = {
|
2022-03-05 06:52:46 +00:00
|
|
|
min: 0, max: paceNames.length-1,
|
2022-01-28 09:45:56 +00:00
|
|
|
value: Math.max(paceAmts.indexOf(settings.paceLength),0),
|
|
|
|
format: v => paceNames[v],
|
|
|
|
onchange: v => {
|
|
|
|
settings.paceLength = paceAmts[v];
|
|
|
|
saveSettings();
|
|
|
|
},
|
|
|
|
};
|
2022-03-05 07:02:00 +00:00
|
|
|
var distNames = ['Off', "1000m","1 mile","1/2 Mthn", "Marathon",];
|
|
|
|
var distAmts = [0, 1000,1609,21098,42195];
|
2022-03-05 06:15:35 +00:00
|
|
|
menu['Ntfy Dist'] = {
|
2022-03-05 06:52:46 +00:00
|
|
|
min: 0, max: distNames.length-1,
|
2022-03-05 06:40:23 +00:00
|
|
|
value: Math.max(distAmts.indexOf(settings.notifyDist),0),
|
2022-03-05 06:32:07 +00:00
|
|
|
format: v => distNames[v],
|
|
|
|
onchange: v => {
|
2022-03-05 06:40:23 +00:00
|
|
|
settings.notifyDist = distAmts[v];
|
2022-03-05 06:32:07 +00:00
|
|
|
saveSettings();
|
|
|
|
},
|
|
|
|
};
|
|
|
|
var timeNames = ['Off', '30s', '1min', '2min', '5min', '10min', '30min', '1hr'];
|
|
|
|
var timeAmts = [0, 30000, 60000, 120000, 300000, 600000, 1800000, 3600000];
|
|
|
|
menu['Ntfy Time'] = {
|
2022-03-05 06:52:46 +00:00
|
|
|
min: 0, max: timeNames.length-1,
|
2022-03-05 06:32:07 +00:00
|
|
|
value: Math.max(timeAmts.indexOf(settings.notifyTime),0),
|
|
|
|
format: v => timeNames[v],
|
|
|
|
onchange: v => {
|
|
|
|
settings.notifyTime = timeAmts[v];
|
|
|
|
saveSettings();
|
|
|
|
},
|
|
|
|
};
|
2022-03-05 07:11:42 +00:00
|
|
|
var stepNames = ['Off', '100', '500', '1000', '5000', '10000'];
|
2022-03-05 06:32:07 +00:00
|
|
|
var stepAmts = [0, 100, 500, 1000, 5000, 10000];
|
|
|
|
menu['Ntfy Steps'] = {
|
2022-03-05 07:11:42 +00:00
|
|
|
min: 0, max: stepNames.length-1,
|
2022-03-05 06:32:07 +00:00
|
|
|
value: Math.max(stepAmts.indexOf(settings.notifySteps),0),
|
2022-03-05 07:11:42 +00:00
|
|
|
format: v => stepNames[v],
|
2022-03-05 06:15:35 +00:00
|
|
|
onchange: v => {
|
2022-03-05 06:32:07 +00:00
|
|
|
settings.notifySteps = stepAmts[v];
|
2022-03-05 06:15:35 +00:00
|
|
|
saveSettings();
|
|
|
|
},
|
|
|
|
};
|
2022-01-28 09:45:56 +00:00
|
|
|
};
|