From dda628beed55e8f9dbce278782f369920bfc9b7b Mon Sep 17 00:00:00 2001 From: singintime Date: Wed, 26 Aug 2020 00:25:11 +0200 Subject: [PATCH 1/2] banglerun v0.05 --- apps.json | 2 +- apps/banglerun/.gitignore | 1 + apps/banglerun/ChangeLog | 1 + apps/banglerun/app.js | 320 +----------------------------- apps/banglerun/jasmine.json | 6 + apps/banglerun/package.json | 24 +++ apps/banglerun/rollup.config.js | 15 ++ apps/banglerun/src/activity.ts | 41 ++++ apps/banglerun/src/app.ts | 20 ++ apps/banglerun/src/display.ts | 114 +++++++++++ apps/banglerun/src/gps.ts | 120 +++++++++++ apps/banglerun/src/hrm.ts | 29 +++ apps/banglerun/src/log.ts | 37 ++++ apps/banglerun/src/state.ts | 87 ++++++++ apps/banglerun/src/step.ts | 15 ++ apps/banglerun/tsconfig.json | 10 + apps/banglerun/tsconfig.spec.json | 10 + 17 files changed, 532 insertions(+), 320 deletions(-) create mode 100644 apps/banglerun/.gitignore create mode 100644 apps/banglerun/jasmine.json create mode 100644 apps/banglerun/package.json create mode 100644 apps/banglerun/rollup.config.js create mode 100644 apps/banglerun/src/activity.ts create mode 100644 apps/banglerun/src/app.ts create mode 100644 apps/banglerun/src/display.ts create mode 100644 apps/banglerun/src/gps.ts create mode 100644 apps/banglerun/src/hrm.ts create mode 100644 apps/banglerun/src/log.ts create mode 100644 apps/banglerun/src/state.ts create mode 100644 apps/banglerun/src/step.ts create mode 100644 apps/banglerun/tsconfig.json create mode 100644 apps/banglerun/tsconfig.spec.json diff --git a/apps.json b/apps.json index e768c0dc6..3edf7b5c5 100644 --- a/apps.json +++ b/apps.json @@ -1465,7 +1465,7 @@ "name": "BangleRun", "shortName": "BangleRun", "icon": "banglerun.png", - "version": "0.04", + "version": "0.05", "description": "An app for running sessions.", "tags": "run,running,fitness,outdoors", "allow_emulator": false, diff --git a/apps/banglerun/.gitignore b/apps/banglerun/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/apps/banglerun/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/apps/banglerun/ChangeLog b/apps/banglerun/ChangeLog index e5d3665d7..b96e5af7a 100755 --- a/apps/banglerun/ChangeLog +++ b/apps/banglerun/ChangeLog @@ -2,3 +2,4 @@ 0.02: Bugfix time: Reset minutes to 0 when hitting 60 0.03: Fix distance >=10 km (fix #529) 0.04: Use offscreen buffer for flickerless updates +0.05: Complete rewrite. New UI, GPS & HRM Kalman filters, activity logging diff --git a/apps/banglerun/app.js b/apps/banglerun/app.js index 53b285ab1..07e8aa135 100644 --- a/apps/banglerun/app.js +++ b/apps/banglerun/app.js @@ -1,319 +1 @@ -/** Global constants */ -const DEG_TO_RAD = Math.PI / 180; -const EARTH_RADIUS = 6371008.8; - -/** Utilities for handling vectors */ -class Vector { - static magnitude(a) { - let sum = 0; - for (const key of Object.keys(a)) { - sum += a[key] * a[key]; - } - return Math.sqrt(sum); - } - - static add(a, b) { - const result = {}; - for (const key of Object.keys(a)) { - result[key] = a[key] + b[key]; - } - return result; - } - - static sub(a, b) { - const result = {}; - for (const key of Object.keys(a)) { - result[key] = a[key] - b[key]; - } - return result; - } - - static multiplyScalar(a, x) { - const result = {}; - for (const key of Object.keys(a)) { - result[key] = a[key] * x; - } - return result; - } - - static divideScalar(a, x) { - const result = {}; - for (const key of Object.keys(a)) { - result[key] = a[key] / x; - } - return result; - } -} - -/** Interquartile range filter, to detect outliers */ -class IqrFilter { - constructor(size, threshold) { - const q = Math.floor(size / 4); - this._buffer = []; - this._size = 4 * q + 2; - this._i1 = q; - this._i3 = 3 * q + 1; - this._threshold = threshold; - } - - isReady() { - return this._buffer.length === this._size; - } - - isOutlier(point) { - let result = true; - if (this._buffer.length === this._size) { - result = false; - for (const key of Object.keys(point)) { - const data = this._buffer.map(item => item[key]); - data.sort((a, b) => (a - b) / Math.abs(a - b)); - const q1 = data[this._i1]; - const q3 = data[this._i3]; - const iqr = q3 - q1; - const lower = q1 - this._threshold * iqr; - const upper = q3 + this._threshold * iqr; - if (point[key] < lower || point[key] > upper) { - result = true; - break; - } - } - } - this._buffer.push(point); - this._buffer = this._buffer.slice(-this._size); - return result; - } -} - -/** Process GPS data */ -class Gps { - constructor() { - this._lastCall = Date.now(); - this._lastValid = 0; - this._coords = null; - this._filter = new IqrFilter(10, 1.5); - this._shift = { x: 0, y: 0, z: 0 }; - } - - isReady() { - return this._filter.isReady(); - } - - getDistance(gps) { - const time = Date.now(); - const interval = (time - this._lastCall) / 1000; - this._lastCall = time; - - if (!gps.fix) { - return { t: interval, d: 0 }; - } - - const p = gps.lat * DEG_TO_RAD; - const q = gps.lon * DEG_TO_RAD; - const coords = { - x: EARTH_RADIUS * Math.sin(p) * Math.cos(q), - y: EARTH_RADIUS * Math.sin(p) * Math.sin(q), - z: EARTH_RADIUS * Math.cos(p), - }; - - if (!this._coords) { - this._coords = coords; - this._lastValid = time; - return { t: interval, d: 0 }; - } - - const ds = Vector.sub(coords, this._coords); - const dt = (time - this._lastValid) / 1000; - const v = Vector.divideScalar(ds, dt); - - if (this._filter.isOutlier(v)) { - return { t: interval, d: 0 }; - } - - this._shift = Vector.add(this._shift, ds); - const length = Vector.magnitude(this._shift); - const remainder = length % 10; - const distance = length - remainder; - - this._coords = coords; - this._lastValid = time; - if (distance > 0) { - this._shift = Vector.multiplyScalar(this._shift, remainder / length); - } - - return { t: interval, d: distance }; - } -} - -/** Process step counter data */ -class Step { - constructor(size) { - this._buffer = []; - this._size = size; - } - - getCadence() { - this._buffer.push(Date.now() / 1000); - this._buffer = this._buffer.slice(-this._size); - const interval = this._buffer[this._buffer.length - 1] - this._buffer[0]; - return interval ? Math.round(60 * (this._buffer.length - 1) / interval) : 0; - } -} - -const gps = new Gps(); -const step = new Step(10); - -let totDist = 0; -let totTime = 0; -let totSteps = 0; - -let speed = 0; -let cadence = 0; -let heartRate = 0; - -let gpsReady = false; -let hrmReady = false; -let running = false; - -let b = Graphics.createArrayBuffer(240,210,2,{msb:true}); -let bpal = new Uint16Array([0,0xF800,0x07E0,0xFFFF]); -let COL = { RED:1,GREEN:2,WHITE:3 }; -let bimg = {width:240,height:210,bpp:2,buffer:b.buffer,palette:bpal}; - -function formatClock(date) { - return ('0' + date.getHours()).substr(-2) + ':' + ('0' + date.getMinutes()).substr(-2); -} - -function formatDistance(m) { - return (m / 1000).toFixed(2) + ' km'; -} - -function formatTime(s) { - const hrs = Math.floor(s / 3600); - const min = Math.floor(s / 60) % 60; - const sec = Math.floor(s % 60); - return (hrs ? hrs + ':' : '') + ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2); -} - -function formatSpeed(kmh) { - if (kmh <= 0.6) { - return `__'__"`; - } - const skm = 3600 / kmh; - const min = Math.floor(skm / 60); - const sec = Math.floor(skm % 60); - return ('0' + min).substr(-2) + `'` + ('0' + sec).substr(-2) + `"`; -} - -function drawBackground() { - b.clear(); - - b.setColor(COL.WHITE); - b.setFontAlign(0, -1, 0); - b.setFont('6x8', 2); - - b.drawString('DISTANCE', 120, 20); - b.drawString('TIME', 60, 70); - b.drawString('PACE', 180, 70); - b.drawString('STEPS', 60, 120); - b.drawString('STP/m', 180, 120); - b.drawString('SPEED', 40, 170); - b.drawString('HEART', 120, 170); - b.drawString('CADENCE', 200, 170); -} - -function draw() { - const totSpeed = totTime ? 3.6 * totDist / totTime : 0; - const totCadence = totTime ? Math.round(60 * totSteps / totTime) : 0; - - b.clearRect(0, 00, 240, 20); - b.clearRect(0, 40, 240, 70); - b.clearRect(0, 90, 240, 120); - b.clearRect(0, 140, 240, 170); - b.clearRect(0, 190, 240, 210); - - b.setFont('6x8', 2); - - b.setFontAlign(-1, -1, 0); - b.setColor(gpsReady ? COL.GREEN : COL.RED); - b.drawString(' GPS', 6, 0); - - b.setFontAlign(1, -1, 0); - b.setColor(COL.WHITE); - b.drawString(formatClock(new Date()), 234, 0); - - b.setFontAlign(0, -1, 0); - b.setFontVector(20); - b.drawString(formatDistance(totDist), 120, 40); - b.drawString(formatTime(totTime), 60, 90); - b.drawString(formatSpeed(totSpeed), 180, 90); - b.drawString(totSteps, 60, 140); - b.drawString(totCadence, 180, 140); - - b.setFont('6x8', 2); - b.drawString(formatSpeed(speed), 40,190); - - b.setColor(hrmReady ? COL.GREEN : COL.RED); - b.drawString(heartRate, 120, 190); - - b.setColor(COL.WHITE); - b.drawString(cadence, 200, 190); - - g.drawImage(bimg,0,30); -} - -function handleGps(coords) { - const step = gps.getDistance(coords); - gpsReady = coords.fix > 0 && gps.isReady(); - speed = isFinite(gps.speed) ? gps.speed : 0; - if (running) { - totDist += step.d; - totTime += step.t; - } -} - -function handleHrm(hrm) { - hrmReady = hrm.confidence > 50; - heartRate = hrm.bpm; -} - -function handleStep() { - cadence = step.getCadence(); - if (running) { - totSteps += 1; - } -} - -function start() { - running = true; - drawBackground(); - draw(); -} - -function stop() { - if (!running) { - totDist = 0; - totTime = 0; - totSteps = 0; - } - running = false; - drawBackground(); - draw(); -} - -Bangle.on('GPS', handleGps); -Bangle.on('HRM', handleHrm); -Bangle.on('step', handleStep); - -Bangle.setGPSPower(1); -Bangle.setHRMPower(1); - -g.clear(); -Bangle.loadWidgets(); -Bangle.drawWidgets(); -drawBackground(); -draw(); - -setInterval(draw, 500); - -setWatch(start, BTN1, { repeat: true }); -setWatch(stop, BTN3, { repeat: true }); +!function(){"use strict";const t={STOP:63488,PAUSE:65504,RUN:2016};function n(t,n,r){g.setColor(0),g.fillRect(n-60,r,n+60,r+30),g.setColor(65535),g.drawString(t,n,r)}function r(r){var e;g.setFontVector(30),g.setFontAlign(0,-1,0),n((r.distance/1e3).toFixed(2),60,55),n(function(t){const n=Math.round(t),r=Math.floor(n/3600),e=Math.floor(n/60)%60,a=n%60;return(r?r+":":"")+("0"+e).substr(-2)+":"+("0"+a).substr(-2)}(r.duration),180,55),n(function(t){if(t<.1667)return"__'__\"";const n=Math.round(1e3/t),r=Math.floor(n/60),e=n%60;return("0"+r).substr(-2)+"'"+("0"+e).substr(-2)+'"'}(r.speed),60,115),n(r.hr.toFixed(0),180,115),n(r.steps.toFixed(0),60,175),n(r.cadence.toFixed(0),180,175),g.setFont("6x8",2),g.setColor(r.gpsValid?2016:63488),g.fillRect(0,216,80,240),g.setColor(0),g.drawString("GPS",40,220),g.setColor(65535),g.fillRect(80,216,160,240),g.setColor(0),g.drawString(("0"+(e=new Date).getHours()).substr(-2)+":"+("0"+e.getMinutes()).substr(-2),120,220),g.setColor(t[r.status]),g.fillRect(160,216,240,240),g.setColor(0),g.drawString(r.status,200,220)}function e(t){g.clear(),g.setColor(50712),g.setFont("6x8",2),g.setFontAlign(0,-1,0),g.drawString("DIST (KM)",60,32),g.drawString("TIME",180,32),g.drawString("PACE",60,92),g.drawString("HEART",180,92),g.drawString("STEPS",60,152),g.drawString("CADENCE",180,152),r(t),Bangle.drawWidgets()}var a;function o(t){t.status===a.Stopped&&function(t){const n=(new Date).toISOString().replace(/[-:]/g,""),r=`banglerun_${n.substr(2,6)}_${n.substr(9,6)}`;t.file=require("Storage").open(r,"w"),t.file.write(["timestamp","latitude","longitude","altitude","duration","distance","heartrate","steps"].join(","))}(t),t.status===a.Running?t.status=a.Paused:t.status=a.Running,r(t)}!function(t){t.Stopped="STOP",t.Paused="PAUSE",t.Running="RUN"}(a||(a={}));function s(t){const n=t.indexOf(".")-2;return(parseInt(t.substr(0,n))+parseFloat(t.substr(n))/60)*Math.PI/180}const i={fix:NaN,lat:NaN,lon:NaN,alt:NaN,vel:NaN,dop:NaN,gpsValid:!1,x:NaN,y:NaN,z:NaN,v:NaN,t:NaN,dt:NaN,pError:NaN,vError:NaN,hr:60,hrError:100,file:null,drawing:!1,status:a.Stopped,duration:0,distance:0,speed:0,steps:0,cadence:0};var d;d=i,Bangle.on("GPS-raw",t=>function(t,n){const e=n.split(",");switch(e[0].substr(3,3)){case"GGA":t.lat=s(e[2])*("N"===e[3]?1:-1),t.lon=s(e[4])*("E"===e[5]?1:-1),t.alt=parseFloat(e[9]);break;case"VTG":t.vel=parseFloat(e[7])/3.6;break;case"GSA":t.fix=parseInt(e[2]),t.dop=parseFloat(e[15]);break;case"GLL":t.gpsValid=3===t.fix&&t.dop<=5,function(t){const n=Date.now(),r=(n-t.t)/1e3;if(t.t=n,t.dt+=r,t.status===a.Running&&(t.duration+=r),!t.gpsValid)return;const e=6371008.8+t.alt,o=e*Math.cos(t.lat)*Math.cos(t.lon),s=e*Math.cos(t.lat)*Math.sin(t.lon),i=e*Math.sin(t.lat),d=t.vel;if(!t.x)return t.x=o,t.y=s,t.z=i,t.v=d,t.pError=2.5*t.dop,void(t.vError=.05*t.dop);const u=o-t.x,l=s-t.y,g=i-t.z,c=d-t.v,p=Math.sqrt(u*u+l*l+g*g),f=Math.abs(c);t.pError+=t.v*t.dt,t.dt=0;const N=p+2.5*t.dop,h=f+.05*t.dop,S=t.pError/(t.pError+N),E=t.vError/(t.vError+h);t.x+=u*S,t.y+=l*S,t.z+=g*S,t.v+=c*E,t.pError+=(N-t.pError)*S,t.vError+=(h-t.vError)*E;const w=Math.sqrt(t.x*t.x+t.y*t.y+t.z*t.z);t.lat=180*Math.asin(t.z/w)/Math.PI,t.lon=180*Math.atan2(t.y,t.x)/Math.PI||0,t.alt=w-6371008.8,t.status===a.Running&&(t.distance+=p*S,t.speed=t.distance/t.duration||0,t.cadence=60*t.steps/t.duration||0)}(t),r(t),t.gpsValid&&t.status===a.Running&&function(t){t.file.write("\n"),t.file.write([Date.now().toFixed(0),t.lat.toFixed(6),t.lon.toFixed(6),t.alt.toFixed(2),t.duration.toFixed(0),t.distance.toFixed(2),t.hr.toFixed(0),t.steps.toFixed(0)].join(","))}(t)}}(d,t)),Bangle.setGPSPower(1),function(t){Bangle.on("HRM",n=>function(t,n){if(0===n.confidence)return;const r=n.bpm-t.hr,e=Math.abs(r)+101-n.confidence,a=t.hrError/(t.hrError+e);t.hr+=r*a,t.hrError+=(e-t.hrError)*a}(t,n)),Bangle.setHRMPower(1)}(i),function(t){Bangle.on("step",()=>function(t){t.status===a.Running&&(t.steps+=1)}(t))}(i),function(t){Bangle.loadWidgets(),Bangle.on("lcdPower",n=>{t.drawing=n,n&&e(t)}),e(t)}(i),setWatch(()=>o(i),BTN1,{repeat:!0,edge:"falling"}),setWatch(()=>function(t){t.status===a.Paused&&function(t){t.duration=0,t.distance=0,t.speed=0,t.steps=0,t.cadence=0}(t),t.status===a.Running?t.status=a.Paused:t.status=a.Stopped,r(t)}(i),BTN3,{repeat:!0,edge:"falling"})}(); diff --git a/apps/banglerun/jasmine.json b/apps/banglerun/jasmine.json new file mode 100644 index 000000000..813363b27 --- /dev/null +++ b/apps/banglerun/jasmine.json @@ -0,0 +1,6 @@ +{ + "spec_dir": "test", + "spec_files": [ + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/apps/banglerun/package.json b/apps/banglerun/package.json new file mode 100644 index 000000000..ba75bd8fe --- /dev/null +++ b/apps/banglerun/package.json @@ -0,0 +1,24 @@ +{ + "name": "banglerun", + "version": "0.5.0", + "description": "Bangle.js app for running sessions", + "main": "app.js", + "types": "app.d.ts", + "scripts": { + "build": "rollup -c", + "test": "ts-node -P tsconfig.spec.json node_modules/jasmine/bin/jasmine --config=jasmine.json" + }, + "author": "Stefano Baldan", + "license": "ISC", + "devDependencies": { + "@rollup/plugin-typescript": "^4.1.1", + "@types/jasmine": "^3.5.10", + "jasmine": "^3.5.0", + "rollup": "^2.10.2", + "rollup-plugin-terser": "^5.3.0", + "terser": "^4.7.0", + "ts-node": "^8.10.2", + "tslib": "^2.0.0", + "typescript": "^3.9.2" + } +} diff --git a/apps/banglerun/rollup.config.js b/apps/banglerun/rollup.config.js new file mode 100644 index 000000000..f7027eb2b --- /dev/null +++ b/apps/banglerun/rollup.config.js @@ -0,0 +1,15 @@ +import typescript from '@rollup/plugin-typescript'; +import { terser } from 'rollup-plugin-terser'; + +export default { + input: './src/app.ts', + output: { + dir: '.', + format: 'iife', + name: 'banglerun' + }, + plugins: [ + typescript(), + terser(), + ] +}; diff --git a/apps/banglerun/src/activity.ts b/apps/banglerun/src/activity.ts new file mode 100644 index 000000000..c1a01f30b --- /dev/null +++ b/apps/banglerun/src/activity.ts @@ -0,0 +1,41 @@ +import { draw } from './display'; +import { initLog } from './log'; +import { ActivityStatus, AppState } from './state'; + +function startActivity(state: AppState): void { + if (state.status === ActivityStatus.Stopped) { + initLog(state); + } + + if (state.status === ActivityStatus.Running) { + state.status = ActivityStatus.Paused; + } else { + state.status = ActivityStatus.Running; + } + + draw(state); +} + +function stopActivity(state: AppState): void { + if (state.status === ActivityStatus.Paused) { + clearActivity(state); + } + + if (state.status === ActivityStatus.Running) { + state.status = ActivityStatus.Paused; + } else { + state.status = ActivityStatus.Stopped; + } + + draw(state); +} + +function clearActivity(state: AppState): void { + state.duration = 0; + state.distance = 0; + state.speed = 0; + state.steps = 0; + state.cadence = 0; +} + +export { clearActivity, startActivity, stopActivity }; diff --git a/apps/banglerun/src/app.ts b/apps/banglerun/src/app.ts new file mode 100644 index 000000000..7093e24e0 --- /dev/null +++ b/apps/banglerun/src/app.ts @@ -0,0 +1,20 @@ +import { startActivity, stopActivity } from './activity'; +import { initDisplay } from './display'; +import { initGps } from './gps'; +import { initHrm } from './hrm'; +import { initState } from './state'; +import { initStep } from './step'; + +declare var BTN1: any; +declare var BTN3: any; +declare var setWatch: any; + +const appState = initState(); + +initGps(appState); +initHrm(appState); +initStep(appState); +initDisplay(appState); + +setWatch(() => startActivity(appState), BTN1, { repeat: true, edge: 'falling' }); +setWatch(() => stopActivity(appState), BTN3, { repeat: true, edge: 'falling' }); diff --git a/apps/banglerun/src/display.ts b/apps/banglerun/src/display.ts new file mode 100644 index 000000000..baa370860 --- /dev/null +++ b/apps/banglerun/src/display.ts @@ -0,0 +1,114 @@ +import { AppState } from './state'; + +declare var Bangle: any; +declare var g: any; + +const STATUS_COLORS = { + 'STOP': 0xF800, + 'PAUSE': 0xFFE0, + 'RUN': 0x07E0, +} + +function initDisplay(state: AppState): void { + Bangle.loadWidgets(); + Bangle.on('lcdPower', (on: boolean) => { + state.drawing = on; + if (on) { + drawAll(state); + } + }); + drawAll(state); +} + +function drawBackground(): void { + g.clear(); + g.setColor(0xC618); + g.setFont('6x8', 2); + g.setFontAlign(0, -1, 0); + g.drawString('DIST (KM)', 60, 32); + g.drawString('TIME', 180, 32); + g.drawString('PACE', 60, 92); + g.drawString('HEART', 180, 92); + g.drawString('STEPS', 60, 152); + g.drawString('CADENCE', 180, 152); +} + +function drawValue(value: string, x: number, y: number) { + g.setColor(0x0000); + g.fillRect(x - 60, y, x + 60, y + 30); + g.setColor(0xFFFF); + g.drawString(value, x, y); +} + +function draw(state: AppState): void { + g.setFontVector(30); + g.setFontAlign(0, -1, 0); + + drawValue(formatDistance(state.distance), 60, 55); + drawValue(formatTime(state.duration), 180, 55); + drawValue(formatPace(state.speed), 60, 115); + drawValue(state.hr.toFixed(0), 180, 115); + drawValue(state.steps.toFixed(0), 60, 175); + drawValue(state.cadence.toFixed(0), 180, 175); + + g.setFont('6x8', 2); + + g.setColor(state.gpsValid ? 0x07E0 : 0xF800); + g.fillRect(0, 216, 80, 240); + g.setColor(0x0000); + g.drawString('GPS', 40, 220); + + g.setColor(0xFFFF); + g.fillRect(80, 216, 160, 240); + g.setColor(0x0000); + g.drawString(formatClock(new Date()), 120, 220); + + g.setColor(STATUS_COLORS[state.status]); + g.fillRect(160, 216, 240, 240); + g.setColor(0x0000); + g.drawString(state.status, 200, 220); +} + +function drawAll(state: AppState) { + drawBackground(); + draw(state); + Bangle.drawWidgets(); +} + +function formatClock(date: Date): string { + return ('0' + date.getHours()).substr(-2) + ':' + ('0' + date.getMinutes()).substr(-2); +} + +function formatDistance(meters: number): string { + return (meters / 1000).toFixed(2); +} + +function formatPace(speed: number): string { + if (speed < 0.1667) { + return `__'__"`; + } + const pace = Math.round(1000 / speed); + const min = Math.floor(pace / 60); + const sec = pace % 60; + return ('0' + min).substr(-2) + `'` + ('0' + sec).substr(-2) + `"`; +} + +function formatTime(time: number): string { + const seconds = Math.round(time); + const hrs = Math.floor(seconds / 3600); + const min = Math.floor(seconds / 60) % 60; + const sec = seconds % 60; + return (hrs ? hrs + ':' : '') + ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2); +} + +export { + draw, + drawAll, + drawBackground, + drawValue, + formatClock, + formatDistance, + formatPace, + formatTime, + initDisplay, +}; diff --git a/apps/banglerun/src/gps.ts b/apps/banglerun/src/gps.ts new file mode 100644 index 000000000..3c0ee120d --- /dev/null +++ b/apps/banglerun/src/gps.ts @@ -0,0 +1,120 @@ +import { draw } from './display'; +import { updateLog } from './log'; +import { ActivityStatus, AppState } from './state'; + +declare var Bangle: any; + +const EARTH_RADIUS = 6371008.8; +const POS_ACCURACY = 2.5; +const VEL_ACCURACY = 0.05; + +function initGps(state: AppState): void { + Bangle.on('GPS-raw', (nmea: string) => parseNmea(state, nmea)); + Bangle.setGPSPower(1); +} + +function parseCoordinate(coordinate: string): number { + const pivot = coordinate.indexOf('.') - 2; + const degrees = parseInt(coordinate.substr(0, pivot)); + const minutes = parseFloat(coordinate.substr(pivot)) / 60; + return (degrees + minutes) * Math.PI / 180; +} + +function parseNmea(state: AppState, nmea: string): void { + const tokens = nmea.split(','); + const sentence = tokens[0].substr(3, 3); + + switch (sentence) { + case 'GGA': + state.lat = parseCoordinate(tokens[2]) * (tokens[3] === 'N' ? 1 : -1); + state.lon = parseCoordinate(tokens[4]) * (tokens[5] === 'E' ? 1 : -1); + state.alt = parseFloat(tokens[9]); + break; + case 'VTG': + state.vel = parseFloat(tokens[7]) / 3.6; + break; + case 'GSA': + state.fix = parseInt(tokens[2]); + state.dop = parseFloat(tokens[15]); + break; + case 'GLL': + state.gpsValid = state.fix === 3 && state.dop <= 5; + updateGps(state); + draw(state); + if (state.gpsValid && state.status === ActivityStatus.Running) { + updateLog(state); + } + break; + default: + break; + } +} + +function updateGps(state: AppState): void { + const t = Date.now(); + const dt = (t - state.t) / 1000; + + state.t = t; + state.dt += dt; + + if (state.status === ActivityStatus.Running) { + state.duration += dt; + } + + if (!state.gpsValid) { + return; + } + + const r = EARTH_RADIUS + state.alt; + const x = r * Math.cos(state.lat) * Math.cos(state.lon); + const y = r * Math.cos(state.lat) * Math.sin(state.lon); + const z = r * Math.sin(state.lat); + const v = state.vel; + + if (!state.x) { + state.x = x; + state.y = y; + state.z = z; + state.v = v; + state.pError = state.dop * POS_ACCURACY; + state.vError = state.dop * VEL_ACCURACY; + return; + } + + const dx = x - state.x; + const dy = y - state.y; + const dz = z - state.z; + const dv = v - state.v; + const dpMag = Math.sqrt(dx * dx + dy * dy + dz * dz); + const dvMag = Math.abs(dv); + + state.pError += state.v * state.dt; + state.dt = 0; + + const pError = dpMag + state.dop * POS_ACCURACY; + const vError = dvMag + state.dop * VEL_ACCURACY; + + const pGain = state.pError / (state.pError + pError); + const vGain = state.vError / (state.vError + vError); + + state.x += dx * pGain; + state.y += dy * pGain; + state.z += dz * pGain; + state.v += dv * vGain; + state.pError += (pError - state.pError) * pGain; + state.vError += (vError - state.vError) * vGain; + + const pMag = Math.sqrt(state.x * state.x + state.y * state.y + state.z * state.z); + + state.lat = Math.asin(state.z / pMag) * 180 / Math.PI; + state.lon = (Math.atan2(state.y, state.x) * 180 / Math.PI) || 0; + state.alt = pMag - EARTH_RADIUS; + + if (state.status === ActivityStatus.Running) { + state.distance += dpMag * pGain; + state.speed = (state.distance / state.duration) || 0; + state.cadence = (60 * state.steps / state.duration) || 0; + } +} + +export { initGps, parseCoordinate, parseNmea, updateGps }; diff --git a/apps/banglerun/src/hrm.ts b/apps/banglerun/src/hrm.ts new file mode 100644 index 000000000..ac43f4625 --- /dev/null +++ b/apps/banglerun/src/hrm.ts @@ -0,0 +1,29 @@ +import { AppState } from './state'; + +interface HrmData { + bpm: number; + confidence: number; + raw: string; +} + +declare var Bangle: any; + +function initHrm(state: AppState) { + Bangle.on('HRM', (hrm: HrmData) => updateHrm(state, hrm)); + Bangle.setHRMPower(1); +} + +function updateHrm(state: AppState, hrm: HrmData) { + if (hrm.confidence === 0) { + return; + } + + const dHr = hrm.bpm - state.hr; + const hrError = Math.abs(dHr) + 101 - hrm.confidence; + const hrGain = state.hrError / (state.hrError + hrError); + + state.hr += dHr * hrGain; + state.hrError += (hrError - state.hrError) * hrGain; +} + +export { initHrm, updateHrm }; diff --git a/apps/banglerun/src/log.ts b/apps/banglerun/src/log.ts new file mode 100644 index 000000000..fb0676121 --- /dev/null +++ b/apps/banglerun/src/log.ts @@ -0,0 +1,37 @@ +import { AppState } from './state'; + +declare var require: any; + +function initLog(state: AppState): void { + const datetime = new Date().toISOString().replace(/[-:]/g, ''); + const date = datetime.substr(2, 6); + const time = datetime.substr(9, 6); + const filename = `banglerun_${date}_${time}`; + state.file = require('Storage').open(filename, 'w'); + state.file.write([ + 'timestamp', + 'latitude', + 'longitude', + 'altitude', + 'duration', + 'distance', + 'heartrate', + 'steps', + ].join(',')); +} + +function updateLog(state: AppState): void { + state.file.write('\n'); + state.file.write([ + Date.now().toFixed(0), + state.lat.toFixed(6), + state.lon.toFixed(6), + state.alt.toFixed(2), + state.duration.toFixed(0), + state.distance.toFixed(2), + state.hr.toFixed(0), + state.steps.toFixed(0), + ].join(',')); +} + +export { initLog, updateLog }; diff --git a/apps/banglerun/src/state.ts b/apps/banglerun/src/state.ts new file mode 100644 index 000000000..ddb60e1ad --- /dev/null +++ b/apps/banglerun/src/state.ts @@ -0,0 +1,87 @@ +enum ActivityStatus { + Stopped = 'STOP', + Paused = 'PAUSE', + Running = 'RUN', +} + +interface AppState { + // GPS NMEA data + fix: number; + lat: number; + lon: number; + alt: number; + vel: number; + dop: number; + gpsValid: boolean; + + // GPS Kalman data + x: number; + y: number; + z: number; + v: number; + t: number; + dt: number; + pError: number; + vError: number; + + // HRM data + hr: number, + hrError: number, + + // Logger data + file: File; + + // Drawing data + drawing: boolean; + + // Activity data + status: ActivityStatus; + duration: number; + distance: number; + speed: number; + steps: number; + cadence: number; +} + +interface File { + read: Function; + write: Function; + erase: Function; +} + +function initState(): AppState { + return { + fix: NaN, + lat: NaN, + lon: NaN, + alt: NaN, + vel: NaN, + dop: NaN, + gpsValid: false, + + x: NaN, + y: NaN, + z: NaN, + v: NaN, + t: NaN, + dt: NaN, + pError: NaN, + vError: NaN, + + hr: 60, + hrError: 100, + + file: null, + + drawing: false, + + status: ActivityStatus.Stopped, + duration: 0, + distance: 0, + speed: 0, + steps: 0, + cadence: 0, + } +} + +export { ActivityStatus, AppState, File, initState }; diff --git a/apps/banglerun/src/step.ts b/apps/banglerun/src/step.ts new file mode 100644 index 000000000..c7fcb61ea --- /dev/null +++ b/apps/banglerun/src/step.ts @@ -0,0 +1,15 @@ +import { ActivityStatus, AppState } from './state'; + +declare var Bangle: any; + +function initStep(state: AppState) { + Bangle.on('step', () => updateStep(state)); +} + +function updateStep(state: AppState) { + if (state.status === ActivityStatus.Running) { + state.steps += 1; + } +} + +export { initStep, updateStep }; diff --git a/apps/banglerun/tsconfig.json b/apps/banglerun/tsconfig.json new file mode 100644 index 000000000..a341a5a5e --- /dev/null +++ b/apps/banglerun/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "es2015", + "noImplicitAny": true, + "target": "es2015" + }, + "include": [ + "src" + ] +} diff --git a/apps/banglerun/tsconfig.spec.json b/apps/banglerun/tsconfig.spec.json new file mode 100644 index 000000000..136ae137b --- /dev/null +++ b/apps/banglerun/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": true, + "target": "es2015" + }, + "include": [ + "test" + ] +} From b28b149d1c5b751ce71fc592a9b09fcebacb9cbc Mon Sep 17 00:00:00 2001 From: singintime Date: Mon, 31 Aug 2020 10:50:07 +0200 Subject: [PATCH 2/2] banglerun: Add rollup config to .eslintignore --- .eslintignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintignore b/.eslintignore index 9d2105618..09832ff22 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,4 @@ core/lib/qrcode.min.js core/lib/heatshrink.js core/lib/marked.min.js apps/animclk/V29.LBM.js +apps/banglerun/rollup.config.js