-
Notifications
You must be signed in to change notification settings - Fork 1.2k
/
exstats.js
431 lines (409 loc) · 14.9 KB
/
exstats.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
/* Copyright (c) 2022 Bangle.js contributors. 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: "Max BPM", id:"maxbpm"},
{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);
'options' can also include:
options = {
paceLength : meters to measure pace over
notify: {
dist: {
increment: 0 to not notify on distance milestones, otherwise the number of meters to notify after, repeating
},
step: {
increment: 0 to not notify on step milestones, otherwise the number of steps to notify after, repeating
},
time: {
increment: 0 to not notify on time milestones, otherwise the number of milliseconds to notify after, repeating
}
}
}
// Additionally, if your app makes use of the stat notifications, you can display additional menu
// settings for configuring when to notify (note the added line in the example below)
var menu = { ... };
ExStats.appendMenuItems(menu, settings, saveSettingsFunction);
ExStats.appendNotifyMenuItems(menu, settings, saveSettingsFunction);
E.showMenu(menu);
*/
var state = {
active : false, // are we working or not?
// startTime, // time exercise started (in ms from 1970)
// lastTime, // time we had our last reading (in ms from 1970)
duration : 0, // the length of this exercise (in ms)
lastGPS:{}, thisGPS:{}, // This & previous GPS readings
// distance : 0, ///< distance in meters
// avrSpeed : 0, ///< speed over whole run in m/sec
// curSpeed : 0, ///< current (but averaged speed) 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?
// maxBPM // The highest BPM reached while active
// notify: { }, // Notifies: 0 for disabled, otherwise how often to notify in meters, seconds, or steps
};
// list of active stats (indexed by ID)
var stats = {};
const DATA_FILE = "exstats.json";
// Load the state from a saved file if there was one
state = Object.assign(state, require("Storage").readJSON(DATA_FILE,1)||{});
state.startSteps = Bangle.getStepCount() - (state.lastSteps - state.startSteps);
// force step history to a uint8array
state.stepHistory = new Uint8Array(state.stepHistory);
// when we exit, write the current state
E.on('kill', function() {
require("Storage").writeJSON(DATA_FILE, state);
});
// distance between 2 lat and lons, in meters, Mean Earth Radius = 6371km
// https://www.movable-type.co.uk/scripts/latlong.html
// (Equirectangular approximation)
function calcDistance(a,b) {
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);
return 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
state.lastGPS = state.thisGPS;
state.thisGPS = fix;
if (stats["altg"]) stats["altg"].emit("changed",stats["altg"]);
if (stats["speed"]) stats["speed"].emit("changed",stats["speed"]);
if (!state.active) return;
if (state.lastGPS.fix)
state.distance += calcDistance(state.lastGPS, fix);
if (stats["dist"]) stats["dist"].emit("changed",stats["dist"]);
state.avrSpeed = state.distance * 1000 / state.duration; // meters/sec
if (!isNaN(fix.speed)) state.curSpeed = state.curSpeed*0.8 + fix.speed*0.2/3.6; // meters/sec
if (stats["pacea"]) stats["pacea"].emit("changed",stats["pacea"]);
if (stats["pacec"]) stats["pacec"].emit("changed",stats["pacec"]);
if (state.notify.dist.increment > 0 && state.notify.dist.next <= state.distance) {
state.notify.dist.next = state.notify.dist.next + state.notify.dist.increment;
stats["dist"].emit("notify",stats["dist"]);
}
});
Bangle.on("step", function(steps) {
if (!state.active) return;
if (stats["step"]) stats["step"].emit("changed",stats["step"]);
state.stepHistory[0] += steps-state.lastSteps;
state.lastSteps = steps;
if (state.notify.step.increment > 0 && state.notify.step.next <= steps) {
state.notify.step.next = state.notify.step.next + state.notify.step.increment;
stats["step"].emit("notify",stats["step"]);
}
});
Bangle.on("HRM", function(h) {
if (h.confidence>=60) {
state.BPM = h.bpm;
state.BPMage = 0;
if (state.maxBPM < h.bpm) {
state.maxBPM = h.bpm;
if (stats["maxbpm"]) stats["maxbpm"].emit("changed",stats["maxbpm"]);
}
if (stats["bpm"]) stats["bpm"].emit("changed",stats["bpm"]);
}
});
if (Bangle.setBarometerPower) Bangle.on("pressure", function(e) {
if (state.alt === undefined)
state.alt = e.altitude;
else
state.alt = state.alt*0.9 + e.altitude*0.1;
var i = Math.round(state.alt);
if (i!==state.alti) {
state.alti = i;
if (stats["altb"]) stats["altb"].emit("changed",stats["altb"]);
}
});
/** Get list of available statistic types */
exports.getList = function() {
var l = [
{name: "Time", id:"time"},
{name: "Distance", id:"dist"},
{name: "Steps", id:"step"},
{name: "Heart (BPM)", id:"bpm"},
{name: "Max BPM", id:"maxbpm"},
{name: "Pace (avg)", id:"pacea"},
{name: "Pace (curr)", id:"pacec"},
{name: "Speed", id:"speed"},
{name: "Cadence", id:"caden"},
{name: "Altitude (GPS)", id:"altg"}
];
if (Bangle.setBarometerPower) l.push({name: "Altitude (baro)", id:"altb"});
return l;
};
/** Instantiate the given list of statistic IDs (see comments at top)
*/
exports.getStats = function(statIDs, options) {
options = options||{};
options.paceLength = options.paceLength||1000;
if (!options.notify) options.notify = {};
["dist","step","time"].forEach(stat => {
if (!options.notify[stat]) options.notify[stat] = {};
options.notify[stat].increment = options.notify[stat].increment||0;
});
state.notify = options.notify;
var needGPS,needHRM,needBaro;
// ======================
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,2); },
};
}
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("maxbpm")) {
needHRM = true;
stats["maxbpm"]={
title : "Max BPM",
getValue : function() { return state.maxBPM; },
getString : function() { return state.maxBPM||"--" },
};
}
if (statIDs.includes("pacea")) {
needGPS = true;
stats["pacea"]={
title : "A Pace",
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 : "C Pace",
getValue : function() { return state.curSpeed; }, // in m/sec
getString : function() { return formatPace(state.curSpeed, options.paceLength); },
};
}
if (statIDs.includes("speed")) {
needGPS = true;
stats["speed"]={
title : "Speed",
getValue : function() { return state.curSpeed*3.6; }, // in kph
getString : function() { return require("locale").speed(state.curSpeed*3.6,2); },
};
}
if (statIDs.includes("caden")) {
stats["caden"]={
title : "Cadence",
getValue : function() { return state.stepsPerMin; },
getString : function() { return state.stepsPerMin; },
};
}
if (statIDs.includes("altg")) {
needGPS = true;
stats["altg"]={
title : "Altitude",
getValue : function() { return state.thisGPS.alt; },
getString : function() { return (state.thisGPS.alt===undefined)?"-":Math.round(state.thisGPS.alt)+"m"; },
};
}
if (statIDs.includes("altb")) {
needBaro = true;
stats["altb"]={
title : "Altitude",
getValue : function() { return state.alt; },
getString : function() { return (state.alt===undefined)?"-":state.alti+"m"; },
};
}
// ======================
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");
if (needBaro) Bangle.setBarometerPower(true,"exs");
setInterval(function() { // run once a second....
if (!state.active) return;
// called once a second
var now = Date.now();
state.duration += now - state.lastTime; // in ms
state.lastTime = now;
// set cadence -> steps over last minute
state.stepsPerMin = Math.round(60000 * E.sum(state.stepHistory) / Math.min(state.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"]);
}
if (state.notify.time.increment > 0 && state.notify.time.next <= now) {
state.notify.time.next = state.notify.time.next + state.notify.time.increment;
stats["time"].emit("notify",stats["time"]);
}
}, 1000);
function reset() {
state.startTime = state.lastTime = Date.now();
state.duration = 0;
state.startSteps = state.lastSteps = Bangle.getStepCount();
state.stepHistory.fill(0);
state.stepsPerMin = 0;
state.distance = 0;
state.avrSpeed = 0;
state.curSpeed = 0;
state.BPM = 0;
state.BPMage = 0;
state.maxBPM = 0;
state.alt = undefined; // barometer altitude (meters)
state.alti = 0; // integer ver of state.alt (to avoid repeated 'changed' notifications)
state.notify = options.notify;
if (state.notify.dist.increment > 0)
state.notify.dist.next = state.distance + state.notify.dist.increment;
if (state.notify.step.increment > 0)
state.notify.step.next = state.startSteps + state.notify.step.increment;
if (state.notify.time.increment > 0)
state.notify.time.next = state.startTime + state.notify.time.increment;
}
if (!state.active) reset(); // we might already be active
return {
stats : stats,
state : state,
start : function() {
reset();
state.active = true;
},
stop : function() {
state.active = false;
},
resume : function() {
state.lastTime = Date.now();
state.lastSteps = Bangle.getStepCount()
state.active = true;
},
};
};
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();
},
};
}
exports.appendNotifyMenuItems = function(menu, settings, saveSettings) {
var distNames = ['Off', "1000m","1 mile","1/2 Mthn", "Marathon",];
var distAmts = [0, 1000, 1609, 21098, 42195];
menu['Ntfy Dist'] = {
min: 0, max: distNames.length-1,
value: Math.max(distAmts.indexOf(settings.notify.dist.increment),0),
format: v => distNames[v],
onchange: v => {
settings.notify.dist.increment = distAmts[v];
saveSettings();
},
};
var stepNames = ['Off', '100', '500', '1000', '5000', '10000'];
var stepAmts = [0, 100, 500, 1000, 5000, 10000];
menu['Ntfy Steps'] = {
min: 0, max: stepNames.length-1,
value: Math.max(stepAmts.indexOf(settings.notify.step.increment),0),
format: v => stepNames[v],
onchange: v => {
settings.notify.step.increment = stepAmts[v];
saveSettings();
},
};
var timeNames = ['Off', '30s', '1min', '2min', '5min', '10min', '30min', '1hr'];
var timeAmts = [0, 30000, 60000, 120000, 300000, 600000, 1800000, 3600000];
menu['Ntfy Time'] = {
min: 0, max: timeNames.length-1,
value: Math.max(timeAmts.indexOf(settings.notify.time.increment),0),
format: v => timeNames[v],
onchange: v => {
settings.notify.time.increment = timeAmts[v];
saveSettings();
},
};
};