forked from FOSS/BangleApps
550 lines
15 KiB
JavaScript
550 lines
15 KiB
JavaScript
// Bike Speedometer by https://github.com/HilmarSt
|
|
// Big parts of this software are based on https://github.com/espruino/BangleApps/tree/master/apps/speedalt
|
|
// Compass and Compass Calibration based on https://github.com/espruino/BangleApps/tree/master/apps/magnav
|
|
|
|
const BANGLEJS2 = 1;
|
|
const screenH = g.getHeight();
|
|
const screenYstart = 24; // 0..23 for widgets
|
|
const screenY_Half = screenH / 2 + screenYstart;
|
|
const screenW = g.getWidth();
|
|
const screenW_Half = screenW / 2;
|
|
const fontFactorB2 = 2/3;
|
|
const colfg=g.theme.fg, colbg=g.theme.bg;
|
|
const col1=colfg, colUncertain="#88f"; // if (lf.fix) g.setColor(col1); else g.setColor(colUncertain);
|
|
|
|
var altiBaro=0;
|
|
var hdngGPS=0, hdngCompass=0, calibrateCompass=false;
|
|
|
|
/*kalmanjs, Wouter Bulten, MIT, https://github.com/wouterbulten/kalmanjs */
|
|
var KalmanFilter = (function () {
|
|
'use strict';
|
|
|
|
function _classCallCheck(instance, Constructor) {
|
|
if (!(instance instanceof Constructor)) {
|
|
throw new TypeError("Cannot call a class as a function");
|
|
}
|
|
}
|
|
|
|
function _defineProperties(target, props) {
|
|
for (var i = 0; i < props.length; i++) {
|
|
var descriptor = props[i];
|
|
descriptor.enumerable = descriptor.enumerable || false;
|
|
descriptor.configurable = true;
|
|
if ("value" in descriptor) descriptor.writable = true;
|
|
Object.defineProperty(target, descriptor.key, descriptor);
|
|
}
|
|
}
|
|
|
|
function _createClass(Constructor, protoProps, staticProps) {
|
|
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
|
|
if (staticProps) _defineProperties(Constructor, staticProps);
|
|
return Constructor;
|
|
}
|
|
|
|
/**
|
|
* KalmanFilter
|
|
* @class
|
|
* @author Wouter Bulten
|
|
* @see {@link http://github.com/wouterbulten/kalmanjs}
|
|
* @version Version: 1.0.0-beta
|
|
* @copyright Copyright 2015-2018 Wouter Bulten
|
|
* @license MIT License
|
|
* @preserve
|
|
*/
|
|
var KalmanFilter =
|
|
/*#__PURE__*/
|
|
function () {
|
|
/**
|
|
* Create 1-dimensional kalman filter
|
|
* @param {Number} options.R Process noise
|
|
* @param {Number} options.Q Measurement noise
|
|
* @param {Number} options.A State vector
|
|
* @param {Number} options.B Control vector
|
|
* @param {Number} options.C Measurement vector
|
|
* @return {KalmanFilter}
|
|
*/
|
|
function KalmanFilter() {
|
|
var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
|
|
_ref$R = _ref.R,
|
|
R = _ref$R === void 0 ? 1 : _ref$R,
|
|
_ref$Q = _ref.Q,
|
|
Q = _ref$Q === void 0 ? 1 : _ref$Q,
|
|
_ref$A = _ref.A,
|
|
A = _ref$A === void 0 ? 1 : _ref$A,
|
|
_ref$B = _ref.B,
|
|
B = _ref$B === void 0 ? 0 : _ref$B,
|
|
_ref$C = _ref.C,
|
|
C = _ref$C === void 0 ? 1 : _ref$C;
|
|
|
|
_classCallCheck(this, KalmanFilter);
|
|
|
|
this.R = R; // noise power desirable
|
|
|
|
this.Q = Q; // noise power estimated
|
|
|
|
this.A = A;
|
|
this.C = C;
|
|
this.B = B;
|
|
this.cov = NaN;
|
|
this.x = NaN; // estimated signal without noise
|
|
}
|
|
/**
|
|
* Filter a new value
|
|
* @param {Number} z Measurement
|
|
* @param {Number} u Control
|
|
* @return {Number}
|
|
*/
|
|
|
|
|
|
_createClass(KalmanFilter, [{
|
|
key: "filter",
|
|
value: function filter(z) {
|
|
var u = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
|
|
|
|
if (isNaN(this.x)) {
|
|
this.x = 1 / this.C * z;
|
|
this.cov = 1 / this.C * this.Q * (1 / this.C);
|
|
} else {
|
|
// Compute prediction
|
|
var predX = this.predict(u);
|
|
var predCov = this.uncertainty(); // Kalman gain
|
|
|
|
var K = predCov * this.C * (1 / (this.C * predCov * this.C + this.Q)); // Correction
|
|
|
|
this.x = predX + K * (z - this.C * predX);
|
|
this.cov = predCov - K * this.C * predCov;
|
|
}
|
|
|
|
return this.x;
|
|
}
|
|
/**
|
|
* Predict next value
|
|
* @param {Number} [u] Control
|
|
* @return {Number}
|
|
*/
|
|
|
|
}, {
|
|
key: "predict",
|
|
value: function predict() {
|
|
var u = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
|
|
return this.A * this.x + this.B * u;
|
|
}
|
|
/**
|
|
* Return uncertainty of filter
|
|
* @return {Number}
|
|
*/
|
|
|
|
}, {
|
|
key: "uncertainty",
|
|
value: function uncertainty() {
|
|
return this.A * this.cov * this.A + this.R;
|
|
}
|
|
/**
|
|
* Return the last filtered measurement
|
|
* @return {Number}
|
|
*/
|
|
|
|
}, {
|
|
key: "lastMeasurement",
|
|
value: function lastMeasurement() {
|
|
return this.x;
|
|
}
|
|
/**
|
|
* Set measurement noise Q
|
|
* @param {Number} noise
|
|
*/
|
|
|
|
}, {
|
|
key: "setMeasurementNoise",
|
|
value: function setMeasurementNoise(noise) {
|
|
this.Q = noise;
|
|
}
|
|
/**
|
|
* Set the process noise R
|
|
* @param {Number} noise
|
|
*/
|
|
|
|
}, {
|
|
key: "setProcessNoise",
|
|
value: function setProcessNoise(noise) {
|
|
this.R = noise;
|
|
}
|
|
}]);
|
|
|
|
return KalmanFilter;
|
|
}();
|
|
|
|
return KalmanFilter;
|
|
|
|
}());
|
|
|
|
|
|
//==================================== MAIN ====================================
|
|
|
|
var lf = {fix:0,satellites:0};
|
|
var showMax = 0; // 1 = display the max values. 0 = display the cur fix
|
|
var time = ''; // Last time string displayed. Re displayed in background colour to remove before drawing new time.
|
|
var sec; // actual seconds for testing purposes
|
|
|
|
var max = {};
|
|
max.spd = 0;
|
|
max.alt = 0;
|
|
max.n = 0; // counter. Only start comparing for max after a certain number of fixes to allow kalman filter to have smoohed the data.
|
|
|
|
var emulator = (process.env.BOARD=="EMSCRIPTEN" || process.env.BOARD=="EMSCRIPTEN2")?1:0; // 1 = running in emulator. Supplies test values;
|
|
|
|
var SATinView = 0;
|
|
|
|
function drawFix(dat) {
|
|
g.clearRect(0,screenYstart,screenW,screenH);
|
|
|
|
var v = '';
|
|
var u='';
|
|
|
|
// Primary Display
|
|
v = (cfg.primSpd)?dat.speed.toString():dat.alt.toString();
|
|
|
|
// Primary Units
|
|
u = (showMax ? 'max ' : '') + (cfg.primSpd?cfg.spd_unit:dat.alt_units);
|
|
|
|
drawPrimary(v,u);
|
|
|
|
// Secondary Display
|
|
v = (cfg.primSpd)?dat.alt.toString():dat.speed.toString();
|
|
|
|
// Secondary Units
|
|
u = (cfg.primSpd)?dat.alt_units:cfg.spd_unit;
|
|
|
|
drawSecondary(v,u);
|
|
|
|
// Time
|
|
drawTime();
|
|
|
|
//Sats
|
|
if ( dat.age > 10 ) {
|
|
if ( dat.age > 90 ) dat.age = '>90';
|
|
drawSats('Age:'+dat.age);
|
|
}
|
|
else if (!BANGLEJS2) {
|
|
drawSats('Sats:'+dat.sats);
|
|
} else {
|
|
if (lf.fix) {
|
|
drawSats('Sats:'+dat.sats);
|
|
} else {
|
|
drawSats('View:' + SATinView);
|
|
}
|
|
}
|
|
g.reset();
|
|
}
|
|
|
|
|
|
function drawPrimary(n,u) {
|
|
//if(emulator)console.log("\n1: " + n +" "+ u);
|
|
var s=40; // Font size
|
|
var l=n.length;
|
|
|
|
if ( l <= 7 ) s=48;
|
|
if ( l <= 6 ) s=55;
|
|
if ( l <= 5 ) s=66;
|
|
if ( l <= 4 ) s=85;
|
|
if ( l <= 3 ) s=110;
|
|
|
|
// X -1=left (default), 0=center, 1=right
|
|
// Y -1=top (default), 0=center, 1=bottom
|
|
g.setFontAlign(0,-1); // center, top
|
|
if (lf.fix) g.setColor(col1); else g.setColor(colUncertain);
|
|
if (BANGLEJS2) s *= fontFactorB2;
|
|
g.setFontVector(s);
|
|
g.drawString(n, screenW_Half - 10, screenYstart);
|
|
|
|
// Primary Units
|
|
s = 35; // Font size
|
|
g.setFontAlign(1,-1,3); // right, top, rotate
|
|
g.setColor(col1);
|
|
if (BANGLEJS2) s = 20;
|
|
g.setFontVector(s);
|
|
g.drawString(u, screenW - 20, screenYstart + 2);
|
|
}
|
|
|
|
|
|
function drawSecondary(n,u) {
|
|
//if(emulator)console.log("2: " + n +" "+ u);
|
|
|
|
if (calibrateCompass) hdngCompass = "CALIB!";
|
|
else hdngCompass +="°";
|
|
|
|
g.setFontAlign(0,1);
|
|
g.setColor(col1);
|
|
|
|
g.setFontVector(12).drawString("Altitude GPS / Barometer", screenW_Half - 5, screenY_Half - 10);
|
|
g.setFontVector(20);
|
|
g.drawString(n+" "+u+" / "+altiBaro+" "+u, screenW_Half, screenY_Half + 11);
|
|
|
|
g.setFontVector(12).drawString("Heading GPS / Compass", screenW_Half - 10, screenY_Half + 26);
|
|
g.setFontVector(20);
|
|
g.drawString(hdngGPS+"° / "+hdngCompass, screenW_Half, screenY_Half + 47);
|
|
}
|
|
|
|
|
|
function drawTime() {
|
|
var x = 0, y = screenH;
|
|
g.setFontAlign(-1,1); // left, bottom
|
|
g.setFont("6x8", 2);
|
|
|
|
g.setColor(colbg);
|
|
g.drawString(time,x+1,y); // clear old time
|
|
|
|
time = require("locale").time(new Date(),1);
|
|
|
|
g.setColor(colfg); // draw new time
|
|
g.drawString(time,x+2,y);
|
|
}
|
|
|
|
|
|
function drawSats(sats) {
|
|
|
|
g.setColor(col1);
|
|
g.setFont("6x8", 2);
|
|
g.setFontAlign(1,1); //right, bottom
|
|
g.drawString(sats,screenW,screenH);
|
|
}
|
|
|
|
function onGPS(fix) {
|
|
|
|
if ( emulator ) {
|
|
fix.fix = 1;
|
|
fix.speed = Math.random()*30; // calmed by Kalman filter if cfg.spdFilt
|
|
fix.alt = Math.random()*200 -20; // calmed by Kalman filter if cfg.altFilt
|
|
fix.lat = 50.59; // google.de/maps/@50.59,8.53,17z
|
|
fix.lon = 8.53;
|
|
fix.course = 365;
|
|
fix.satellites = sec;
|
|
fix.time = new Date();
|
|
fix.smoothed = 0;
|
|
}
|
|
|
|
var m;
|
|
|
|
var sp = '---';
|
|
var al = '---';
|
|
var age = '---';
|
|
|
|
if (fix.fix) lf = fix;
|
|
|
|
hdngGPS = lf.course;
|
|
if (isNaN(hdngGPS)) hdngGPS = "---";
|
|
else if (0 == hdngGPS) hdngGPS = "0?";
|
|
else hdngGPS = hdngGPS.toFixed(0);
|
|
|
|
if (emulator) hdngCompass = hdngGPS;
|
|
if (emulator) altiBaro = lf.alt.toFixed(0);
|
|
|
|
if (lf.fix) {
|
|
|
|
if (BANGLEJS2 && !emulator) Bangle.removeListener('GPS-raw', onGPSraw);
|
|
|
|
// Smooth data
|
|
if ( lf.smoothed !== 1 ) {
|
|
if ( cfg.spdFilt ) lf.speed = spdFilter.filter(lf.speed);
|
|
if ( cfg.altFilt ) lf.alt = altFilter.filter(lf.alt);
|
|
lf.smoothed = 1;
|
|
if ( max.n <= 15 ) max.n++;
|
|
}
|
|
|
|
|
|
// Speed
|
|
if ( cfg.spd == 0 ) {
|
|
m = require("locale").speed(lf.speed).match(/([0-9,\.]+)(.*)/); // regex splits numbers from units
|
|
sp = parseFloat(m[1]);
|
|
cfg.spd_unit = m[2];
|
|
}
|
|
else sp = parseFloat(lf.speed)/parseFloat(cfg.spd); // Calculate for selected units
|
|
|
|
if ( sp < 10 ) sp = sp.toFixed(1);
|
|
else sp = Math.round(sp);
|
|
if (isNaN(sp)) sp = '---';
|
|
|
|
if (parseFloat(sp) > parseFloat(max.spd) && max.n > 15 ) max.spd = parseFloat(sp);
|
|
|
|
// Altitude
|
|
al = lf.alt;
|
|
al = Math.round(parseFloat(al)/parseFloat(cfg.alt));
|
|
if (parseFloat(al) > parseFloat(max.alt) && max.n > 15 ) max.alt = parseFloat(al);
|
|
|
|
// Age of last fix (secs)
|
|
age = Math.max(0,Math.round(getTime())-(lf.time.getTime()/1000));
|
|
} else {
|
|
// populate spd_unit
|
|
if (cfg.spd == 0) {
|
|
m = require("locale").speed(0).match(/[0-9,\.]+(.*)/);
|
|
cfg.spd_unit = m[1];
|
|
}
|
|
}
|
|
|
|
if ( cfg.modeA == 1 ) {
|
|
if ( showMax )
|
|
drawFix({
|
|
speed:max.spd,
|
|
sats:lf.satellites,
|
|
alt:max.alt,
|
|
alt_units:cfg.alt_unit,
|
|
age:age,
|
|
fix:lf.fix
|
|
}); // Speed and alt maximums
|
|
else
|
|
drawFix({
|
|
speed:sp,
|
|
sats:lf.satellites,
|
|
alt:al,
|
|
alt_units:cfg.alt_unit,
|
|
age:age,
|
|
fix:lf.fix
|
|
}); // Show speed/altitude
|
|
}
|
|
}
|
|
|
|
function updateClock() {
|
|
drawTime();
|
|
g.reset();
|
|
|
|
if ( emulator ) {
|
|
max.spd++; max.alt++;
|
|
const d=new Date();
|
|
sec=d.getSeconds();
|
|
onGPS(lf);
|
|
}
|
|
}
|
|
|
|
|
|
// =Main Prog
|
|
|
|
// Read settings.
|
|
let cfg = require('Storage').readJSON('bikespeedo.json',1)||{};
|
|
|
|
cfg.spd = cfg.localeUnits ? 0 : 1; // Multiplier for speed unit conversions. 0 = use the locale values for speed
|
|
cfg.spd_unit = 'km/h'; // Displayed speed unit
|
|
cfg.alt = 1; // Multiplier for altitude unit conversions. (feet:'0.3048')
|
|
cfg.alt_unit = 'm'; // Displayed altitude units ('feet')
|
|
cfg.dist = 1000; // Multiplier for distnce unit conversions.
|
|
cfg.dist_unit = 'km'; // Displayed distnce units
|
|
cfg.modeA = 1;
|
|
cfg.primSpd = 1; // 1 = Spd in primary, 0 = Spd in secondary
|
|
|
|
cfg.altDiff = cfg.altDiff==undefined?100:cfg.altDiff;
|
|
cfg.spdFilt = cfg.spdFilt==undefined?true:cfg.spdFilt;
|
|
cfg.altFilt = cfg.altFilt==undefined?false:cfg.altFilt;
|
|
// console.log("cfg.altDiff: " + cfg.altDiff);
|
|
// console.log("cfg.spdFilt: " + cfg.spdFilt);
|
|
// console.log("cfg.altFilt: " + cfg.altFilt);
|
|
|
|
if ( cfg.spdFilt ) var spdFilter = new KalmanFilter({R: 0.1 , Q: 1 });
|
|
if ( cfg.altFilt ) var altFilter = new KalmanFilter({R: 0.01, Q: 2 });
|
|
|
|
function onGPSraw(nmea) {
|
|
var nofGP = 0, nofBD = 0, nofGL = 0;
|
|
if (nmea.slice(3,6) == "GSV") {
|
|
// console.log(nmea.slice(1,3) + " " + nmea.slice(11,13));
|
|
if (nmea.slice(0,7) == "$GPGSV,") nofGP = Number(nmea.slice(11,13));
|
|
if (nmea.slice(0,7) == "$BDGSV,") nofBD = Number(nmea.slice(11,13));
|
|
if (nmea.slice(0,7) == "$GLGSV,") nofGL = Number(nmea.slice(11,13));
|
|
SATinView = nofGP + nofBD + nofGL;
|
|
} }
|
|
if(BANGLEJS2) Bangle.on('GPS-raw', onGPSraw);
|
|
|
|
function onPressure(dat) {
|
|
altiBaro = Number(dat.altitude.toFixed(0)) + Number(cfg.altDiff);
|
|
}
|
|
|
|
var CALIBDATA = require("Storage").readJSON("magnav.json",1)||null;
|
|
if (!CALIBDATA) calibrateCompass = true;
|
|
function Compass_tiltfixread(O,S){
|
|
"ram";
|
|
//console.log(O.x+" "+O.y+" "+O.z);
|
|
var m = Bangle.getCompass();
|
|
var g = Bangle.getAccel();
|
|
m.dx =(m.x-O.x)*S.x; m.dy=(m.y-O.y)*S.y; m.dz=(m.z-O.z)*S.z;
|
|
var d = Math.atan2(-m.dx,m.dy)*180/Math.PI;
|
|
if (d<0) d+=360;
|
|
var phi = Math.atan(-g.x/-g.z);
|
|
var cosphi = Math.cos(phi), sinphi = Math.sin(phi);
|
|
var theta = Math.atan(-g.y/(-g.x*sinphi-g.z*cosphi));
|
|
var costheta = Math.cos(theta), sintheta = Math.sin(theta);
|
|
var xh = m.dy*costheta + m.dx*sinphi*sintheta + m.dz*cosphi*sintheta;
|
|
var yh = m.dz*sinphi - m.dx*cosphi;
|
|
var psi = Math.atan2(yh,xh)*180/Math.PI;
|
|
if (psi<0) psi+=360;
|
|
return psi;
|
|
}
|
|
var Compass_heading = 0;
|
|
function Compass_newHeading(m,h){
|
|
var s = Math.abs(m - h);
|
|
var delta = (m>h)?1:-1;
|
|
if (s>=180){s=360-s; delta = -delta;}
|
|
if (s<2) return h;
|
|
var hd = h + delta*(1 + Math.round(s/5));
|
|
if (hd<0) hd+=360;
|
|
if (hd>360)hd-= 360;
|
|
return hd;
|
|
}
|
|
function Compass_reading() {
|
|
"ram";
|
|
var d = Compass_tiltfixread(CALIBDATA.offset,CALIBDATA.scale);
|
|
Compass_heading = Compass_newHeading(d,Compass_heading);
|
|
hdngCompass = Compass_heading.toFixed(0);
|
|
}
|
|
|
|
function nextMode() {
|
|
showMax = 1 - showMax;
|
|
}
|
|
|
|
function start() {
|
|
Bangle.setBarometerPower(1); // needs some time...
|
|
g.clearRect(0,screenYstart,screenW,screenH);
|
|
onGPS(lf);
|
|
Bangle.setGPSPower(1);
|
|
Bangle.on('GPS', onGPS);
|
|
Bangle.on('pressure', onPressure);
|
|
|
|
Bangle.setCompassPower(1);
|
|
if (!calibrateCompass) setInterval(Compass_reading,200);
|
|
|
|
if (emulator) setInterval(updateClock, 2000);
|
|
else setInterval(updateClock, 10000);
|
|
|
|
let createdRecording = false;
|
|
Bangle.setUI({
|
|
mode: "custom",
|
|
touch: nextMode,
|
|
btn: () => {
|
|
const rec = WIDGETS["recorder"];
|
|
if(rec){
|
|
const active = rec.isRecording();
|
|
if(active){
|
|
createdRecording = true;
|
|
rec.setRecording(false);
|
|
}else{
|
|
rec.setRecording(true, { force: createdRecording ? "append" : "new" });
|
|
}
|
|
}else{
|
|
nextMode();
|
|
}
|
|
},
|
|
});
|
|
|
|
// can't delay loadWidgets til here - need to have already done so for recorder
|
|
Bangle.drawWidgets();
|
|
}
|
|
|
|
Bangle.loadWidgets();
|
|
if (cfg.record && WIDGETS["recorder"]) {
|
|
WIDGETS["recorder"]
|
|
.setRecording(true)
|
|
.then(start);
|
|
|
|
if (cfg.recordStopOnExit)
|
|
E.on('kill', () => WIDGETS["recorder"].setRecording(false));
|
|
|
|
} else {
|
|
start();
|
|
}
|