diff --git a/apps/2047pp/2047pp.app.js b/apps/2047pp/2047pp.app.js new file mode 100644 index 000000000..58738d04a --- /dev/null +++ b/apps/2047pp/2047pp.app.js @@ -0,0 +1,140 @@ +class TwoK { + constructor() { + this.b = Array(4).fill().map(() => Array(4).fill(0)); + this.score = 0; + this.cmap = {0: "#caa", 2:"#ccc", 4: "#bcc", 8: "#ba6", 16: "#e61", 32: "#d20", 64: "#d00", 128: "#da0", 256: "#ec0", 512: "#dd0"}; + } + drawBRect(x1, y1, x2, y2, th, c, cf, fill) { + g.setColor(c); + for (i=0; i 4) g.setColor(1, 1, 1); + else g.setColor(0, 0, 0); + g.setFont("Vector", bh*(b>8 ? (b>64 ? (b>512 ? 0.32 : 0.4) : 0.6) : 0.7)); + if (b>0) g.drawString(b.toString(), xo+(x+0.5)*bw+1, yo+(y+0.5)*bh); + } + } + shift(d) { // +/-1: shift x, +/- 2: shift y + var crc = E.CRC32(this.b.toString()); + if (d==-1) { // shift x left + for (y=0; y<4; ++y) { + for (x=2; x>=0; x--) + if (this.b[y][x]==0) { + for (i=x; i<3; i++) this.b[y][i] = this.b[y][i+1]; + this.b[y][3] = 0; + } + for (x=0; x<3; ++x) + if (this.b[y][x]==this.b[y][x+1]) { + this.score += 2*this.b[y][x]; + this.b[y][x] += this.b[y][x+1]; + for (j=x+1; j<3; ++j) this.b[y][j] = this.b[y][j+1]; + this.b[y][3] = 0; + } + } + } + else if (d==1) { // shift x right + for (y=0; y<4; ++y) { + for (x=1; x<4; x++) + if (this.b[y][x]==0) { + for (i=x; i>0; i--) this.b[y][i] = this.b[y][i-1]; + this.b[y][0] = 0; + } + for (x=3; x>0; --x) + if (this.b[y][x]==this.b[y][x-1]) { + this.score += 2*this.b[y][x]; + this.b[y][x] += this.b[y][x-1] ; + for (j=x-1; j>0; j--) this.b[y][j] = this.b[y][j-1]; + this.b[y][0] = 0; + } + } + } + else if (d==-2) { // shift y down + for (x=0; x<4; ++x) { + for (y=1; y<4; y++) + if (this.b[y][x]==0) { + for (i=y; i>0; i--) this.b[i][x] = this.b[i-1][x]; + this.b[0][x] = 0; + } + for (y=3; y>0; y--) + if (this.b[y][x]==this.b[y-1][x] || this.b[y][x]==0) { + this.score += 2*this.b[y][x]; + this.b[y][x] += this.b[y-1][x]; + for (j=y-1; j>0; j--) this.b[j][x] = this.b[j-1][x]; + this.b[0][x] = 0; + } + } + } + else if (d==2) { // shift y up + for (x=0; x<4; ++x) { + for (y=2; y>=0; y--) + if (this.b[y][x]==0) { + for (i=y; i<3; i++) this.b[i][x] = this.b[i+1][x]; + this.b[3][x] = 0; + } + for (y=0; y<3; ++y) + if (this.b[y][x]==this.b[y+1][x] || this.b[y][x]==0) { + this.score += 2*this.b[y][x]; + this.b[y][x] += this.b[y+1][x]; + for (j=y+1; j<3; ++j) this.b[j][x] = this.b[j+1][x]; + this.b[3][x] = 0; + } + } + } + return (E.CRC32(this.b.toString())!=crc); + } + addDigit() { + var d = Math.random()>0.9 ? 4 : 2; + var id = Math.floor(Math.random()*16); + while (this.b[Math.floor(id/4)][id%4] > 0) id = Math.floor(Math.random()*16); + this.b[Math.floor(id/4)][id%4] = d; + } +} + +function dragHandler(e) { + if (e.b && (Math.abs(e.dx)>7 || Math.abs(e.dy)>7)) { + var res = false; + if (Math.abs(e.dx)>Math.abs(e.dy)) { + if (e.dx>0) res = twok.shift(1); + if (e.dx<0) res = twok.shift(-1); + } + else { + if (e.dy>0) res = twok.shift(-2); + if (e.dy<0) res = twok.shift(2); + } + if (res) twok.addDigit(); + twok.render(); + } +} + +function swipeHandler() { + +} + +function buttonHandler() { + +} + +var twok = new TwoK(); +twok.addDigit(); twok.addDigit(); +twok.render(); +if (process.env.HWVERSION==2) Bangle.on("drag", dragHandler); +if (process.env.HWVERSION==1) { + Bangle.on("swipe", (e) => { res = twok.shift(e); if (res) twok.addDigit(); twok.render(); }); + setWatch(() => { res = twok.shift(2); if (res) twok.addDigit(); twok.render(); }, BTN1, {repeat: true}); + setWatch(() => { res = twok.shift(-2); if (res) twok.addDigit(); twok.render(); }, BTN3, {repeat: true}); +} diff --git a/apps/2047pp/2047pp_screenshot.png b/apps/2047pp/2047pp_screenshot.png new file mode 100644 index 000000000..8c407fb6f Binary files /dev/null and b/apps/2047pp/2047pp_screenshot.png differ diff --git a/apps/2047pp/README.md b/apps/2047pp/README.md new file mode 100644 index 000000000..cac3323a6 --- /dev/null +++ b/apps/2047pp/README.md @@ -0,0 +1,9 @@ + +# Game of 2047pp (2047++) + +Tile shifting game inspired by the well known 2048 game. Also very similar to another Bangle game, Game1024. + +Attempt to combine equal numbers by swiping left, right, up or down (on Bangle 2) or swiping left/right and using +the top/bottom button (Bangle 1). + +![Screenshot](./2047pp_screenshot.png) diff --git a/apps/2047pp/app-icon.js b/apps/2047pp/app-icon.js new file mode 100644 index 000000000..4086d1879 --- /dev/null +++ b/apps/2047pp/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4A31gAeFtoxPF9wujGBYQG1YAWF6ur5gAYGIovOFzIABF6ReaMAwv/F/4v/F7ejv9/0Yvq1Eylksv4vqvIuBF9ZeDF9ZeBqovr1AsB0YvrLwXMF9ReDF9ZeBq1/v4vBqowKF7lWFYIAFF/7vXAAa/qF+jxB0YvsABov/F/4v/F6WsF7YgEF5xgaLwgvPGIQAWDwwvQADwvJGEguKF+AxhFpoA/AH4A/AFI=")) \ No newline at end of file diff --git a/apps/2047pp/app.png b/apps/2047pp/app.png new file mode 100644 index 000000000..d1fb4a5e5 Binary files /dev/null and b/apps/2047pp/app.png differ diff --git a/apps/2047pp/metadata.json b/apps/2047pp/metadata.json new file mode 100644 index 000000000..f0fd6c1e3 --- /dev/null +++ b/apps/2047pp/metadata.json @@ -0,0 +1,15 @@ +{ "id": "2047pp", + "name": "2047pp", + "shortName":"2047pp", + "icon": "app.png", + "version":"0.01", + "description": "Bangle version of a tile shifting game", + "supports" : ["BANGLEJS","BANGLEJS2"], + "allow_emulator": true, + "readme": "README.md", + "tags": "game", + "storage": [ + {"name":"2047pp.app.js","url":"2047pp.app.js"}, + {"name":"2047pp.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/authentiwatch/ChangeLog b/apps/authentiwatch/ChangeLog index bb2945db4..655916170 100644 --- a/apps/authentiwatch/ChangeLog +++ b/apps/authentiwatch/ChangeLog @@ -4,3 +4,4 @@ 0.04: Fix tapping at very bottom of list, exit on inactivity 0.05: Add support for bulk importing and exporting tokens 0.06: Add spaces to codes for improved readability (thanks @BartS23) +0.07: Bangle 2: Improve drag responsiveness and exit on button press diff --git a/apps/authentiwatch/app.js b/apps/authentiwatch/app.js index 73b8bdeea..05d94fc46 100644 --- a/apps/authentiwatch/app.js +++ b/apps/authentiwatch/app.js @@ -1,6 +1,10 @@ -const tokenextraheight = 16; -var tokendigitsheight = 30; -var tokenheight = tokendigitsheight + tokenextraheight; +const COUNTER_TRIANGLE_SIZE = 10; +const TOKEN_EXTRA_HEIGHT = 16; +var TOKEN_DIGITS_HEIGHT = 30; +var TOKEN_HEIGHT = TOKEN_DIGITS_HEIGHT + TOKEN_EXTRA_HEIGHT; +const PROGRESSBAR_HEIGHT = 3; +const IDLE_REPEATS = 1; // when idle, the number of extra timed periods to show before hiding +const SETTINGS = "authentiwatch.json"; // Hash functions const crypto = require("crypto"); const algos = { @@ -8,33 +12,24 @@ const algos = { "SHA256":{sha:crypto.SHA256,retsz:32,blksz:64 }, "SHA1" :{sha:crypto.SHA1 ,retsz:20,blksz:64 }, }; -const calculating = "Calculating"; -const notokens = "No tokens"; -const notsupported = "Not supported"; +const CALCULATING = /*LANG*/"Calculating"; +const NO_TOKENS = /*LANG*/"No tokens"; +const NOT_SUPPORTED = /*LANG*/"Not supported"; // sample settings: // {tokens:[{"algorithm":"SHA1","digits":6,"period":30,"issuer":"","account":"","secret":"Bbb","label":"Aaa"}],misc:{}} -var settings = require("Storage").readJSON("authentiwatch.json", true) || {tokens:[],misc:{}}; +var settings = require("Storage").readJSON(SETTINGS, true) || {tokens:[], misc:{}}; if (settings.data ) tokens = settings.data ; /* v0.02 settings */ if (settings.tokens) tokens = settings.tokens; /* v0.03+ settings */ -// QR Code Text -// -// Example: -// -// otpauth://totp/${url}:AA_${algorithm}_${digits}dig_${period}s@${url}?algorithm=${algorithm}&digits=${digits}&issuer=${url}&period=${period}&secret=${secret} -// -// ${algorithm} : one of SHA1 / SHA256 / SHA512 -// ${digits} : one of 6 / 8 -// ${period} : one of 30 / 60 -// ${url} : a domain name "example.com" -// ${secret} : the seed code - function b32decode(seedstr) { - // RFC4648 - var i, buf = 0, bitcount = 0, retstr = ""; - for (i in seedstr) { - var c = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(seedstr.charAt(i).toUpperCase(), 0); + // RFC4648 Base16/32/64 Data Encodings + let buf = 0, bitcount = 0, retstr = ""; + for (let c of seedstr.toUpperCase()) { + if (c == '0') c = 'O'; + if (c == '1') c = 'I'; + if (c == '8') c = 'B'; + c = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(c); if (c != -1) { buf <<= 5; buf |= c; @@ -46,195 +41,127 @@ function b32decode(seedstr) { } } } - var retbuf = new Uint8Array(retstr.length); - for (i in retstr) { + let retbuf = new Uint8Array(retstr.length); + for (let i in retstr) { retbuf[i] = retstr.charCodeAt(i); } return retbuf; } -function do_hmac(key, message, algo) { - var a = algos[algo]; - // RFC2104 + +function hmac(key, message, algo) { + let a = algos[algo.toUpperCase()]; + // RFC2104 HMAC if (key.length > a.blksz) { key = a.sha(key); } - var istr = new Uint8Array(a.blksz + message.length); - var ostr = new Uint8Array(a.blksz + a.retsz); - for (var i = 0; i < a.blksz; ++i) { - var c = (i < key.length) ? key[i] : 0; + let istr = new Uint8Array(a.blksz + message.length); + let ostr = new Uint8Array(a.blksz + a.retsz); + for (let i = 0; i < a.blksz; ++i) { + let c = (i < key.length) ? key[i] : 0; istr[i] = c ^ 0x36; ostr[i] = c ^ 0x5C; } istr.set(message, a.blksz); ostr.set(a.sha(istr), a.blksz); - var ret = a.sha(ostr); - // RFC4226 dynamic truncation - var v = new DataView(ret, ret[ret.length - 1] & 0x0F, 4); + let ret = a.sha(ostr); + // RFC4226 HOTP (dynamic truncation) + let v = new DataView(ret, ret[ret.length - 1] & 0x0F, 4); return v.getUint32(0) & 0x7FFFFFFF; } -function hotp(d, token, dohmac) { - var tick; + +function formatOtp(otp, digits) { + // add 0 padding + let ret = "" + otp % Math.pow(10, digits); + while (ret.length < digits) { + ret = "0" + ret; + } + // add a space after every 3rd or 4th digit + let re = (digits % 3 == 0 || (digits % 3 >= digits % 4 && digits % 4 != 0)) ? "" : "."; + return ret.replace(new RegExp("(..." + re + ")", "g"), "$1 ").trim(); +} + +function hotp(token) { + let d = Date.now(); + let tick, next; if (token.period > 0) { // RFC6238 - timed - var seconds = Math.floor(d.getTime() / 1000); - tick = Math.floor(seconds / token.period); + tick = Math.floor(Math.floor(d / 1000) / token.period); + next = (tick + 1) * token.period * 1000; } else { // RFC4226 - counter tick = -token.period; + next = d + 30000; } - var msg = new Uint8Array(8); - var v = new DataView(msg.buffer); + let msg = new Uint8Array(8); + let v = new DataView(msg.buffer); v.setUint32(0, tick >> 16 >> 16); v.setUint32(4, tick & 0xFFFFFFFF); - var ret = calculating; - if (dohmac) { - try { - var hash = do_hmac(b32decode(token.secret), msg, token.algorithm.toUpperCase()); - ret = "" + hash % Math.pow(10, token.digits); - while (ret.length < token.digits) { - ret = "0" + ret; - } - // add a space after every 3rd or 4th digit - var re = (token.digits % 3 == 0 || (token.digits % 3 >= token.digits % 4 && token.digits % 4 != 0)) ? "" : "."; - ret = ret.replace(new RegExp("(..." + re + ")", "g"), "$1 ").trim(); - } catch(err) { - ret = notsupported; - } + let ret; + try { + ret = hmac(b32decode(token.secret), msg, token.algorithm); + ret = formatOtp(ret, token.digits); + } catch(err) { + ret = NOT_SUPPORTED; } - return {hotp:ret, next:((token.period > 0) ? ((tick + 1) * token.period * 1000) : d.getTime() + 30000)}; + return {hotp:ret, next:next}; } +// Tokens are displayed in three states: +// 1. Unselected (state.id<0) +// 2. Selected, inactive (no code) (state.id>=0,state.hotp.hotp=="") +// 3. Selected, active (code showing) (state.id>=0,state.hotp.hotp!="") +var fontszCache = {}; var state = { - listy: 0, - prevcur:0, - curtoken:-1, - nextTime:0, - otp:"", - rem:0, - hide:0 + listy:0, // list scroll position + id:-1, // current token ID + hotp:{hotp:"",next:0} }; -function drawToken(id, r) { - var x1 = r.x; - var y1 = r.y; - var x2 = r.x + r.w - 1; - var y2 = r.y + r.h - 1; - var adj, lbl, sz; - g.setClipRect(Math.max(x1, Bangle.appRect.x ), Math.max(y1, Bangle.appRect.y ), - Math.min(x2, Bangle.appRect.x2), Math.min(y2, Bangle.appRect.y2)); - lbl = tokens[id].label.substr(0, 10); - if (id == state.curtoken) { - // current token - g.setColor(g.theme.fgH) - .setBgColor(g.theme.bgH) - .setFont("Vector", tokenextraheight) - // center just below top line - .setFontAlign(0, -1, 0); - adj = y1; - } else { - g.setColor(g.theme.fg) - .setBgColor(g.theme.bg); - sz = tokendigitsheight; +function sizeFont(id, txt, w) { + let sz = fontszCache[id]; + if (!sz) { + sz = TOKEN_DIGITS_HEIGHT; do { g.setFont("Vector", sz--); - } while (g.stringWidth(lbl) > r.w); - // center in box - g.setFontAlign(0, 0, 0); - adj = (y1 + y2) / 2; + } while (g.stringWidth(txt) > w); + fontszCache[id] = ++sz; } - g.clearRect(x1, y1, x2, y2) - .drawString(lbl, (x1 + x2) / 2, adj, false); - if (id == state.curtoken) { - if (tokens[id].period > 0) { - // timed - draw progress bar - let xr = Math.floor(Bangle.appRect.w * state.rem / tokens[id].period); - g.fillRect(x1, y2 - 4, xr, y2 - 1); - adj = 0; - } else { - // counter - draw triangle as swipe hint - let yc = (y1 + y2) / 2; - g.fillPoly([0, yc, 10, yc - 10, 10, yc + 10, 0, yc]); - adj = 12; - } - // digits just below label - sz = tokendigitsheight; - do { - g.setFont("Vector", sz--); - } while (g.stringWidth(state.otp) > (r.w - adj)); - g.drawString(state.otp, (x1 + adj + x2) / 2, y1 + tokenextraheight, false); - } - // shaded lines top and bottom - g.setColor(0.5, 0.5, 0.5) - .drawLine(x1, y1, x2, y1) - .drawLine(x1, y2, x2, y2) - .setClipRect(0, 0, g.getWidth(), g.getHeight()); + g.setFont("Vector", sz); } -function draw() { - var timerfn = exitApp; - var timerdly = 10000; - var d = new Date(); - if (state.curtoken != -1) { - var t = tokens[state.curtoken]; - if (state.otp == calculating) { - state.otp = hotp(d, t, true).hotp; - } - if (d.getTime() > state.nextTime) { - if (state.hide == 0) { - // auto-hide the current token - if (state.curtoken != -1) { - state.prevcur = state.curtoken; - state.curtoken = -1; +tokenY = id => id * TOKEN_HEIGHT + AR.y - state.listy; +half = n => Math.floor(n / 2); + +function timerCalc() { + let timerfn = exitApp; + let timerdly = 10000; + if (state.id >= 0 && state.hotp.hotp != "") { + if (tokens[state.id].period > 0) { + // timed HOTP + if (state.hotp.next < Date.now()) { + if (state.cnt > 0) { + state.cnt--; + state.hotp = hotp(tokens[state.id]); + } else { + state.hotp.hotp = ""; } - state.nextTime = 0; + timerdly = 1; + timerfn = updateCurrentToken; } else { - // time to generate a new token - var r = hotp(d, t, state.otp != ""); - state.nextTime = r.next; - state.otp = r.hotp; - if (t.period <= 0) { - state.hide = 1; - } - state.hide--; - } - } - state.rem = Math.max(0, Math.floor((state.nextTime - d.getTime()) / 1000)); - } - if (tokens.length > 0) { - var drewcur = false; - var id = Math.floor(state.listy / tokenheight); - var y = id * tokenheight + Bangle.appRect.y - state.listy; - while (id < tokens.length && y < Bangle.appRect.y2) { - drawToken(id, {x:Bangle.appRect.x, y:y, w:Bangle.appRect.w, h:tokenheight}); - if (id == state.curtoken && (tokens[id].period <= 0 || state.nextTime != 0)) { - drewcur = true; - } - id += 1; - y += tokenheight; - } - if (drewcur) { - // the current token has been drawn - schedule a redraw - if (tokens[state.curtoken].period > 0) { - timerdly = (state.otp == calculating) ? 1 : 1000; // timed - } else { - timerdly = state.nexttime - d.getTime(); // counter - } - timerfn = draw; - if (tokens[state.curtoken].period <= 0) { - state.hide = 0; + timerdly = 1000; + timerfn = updateProgressBar; } } else { - // de-select the current token if it is scrolled out of view - if (state.curtoken != -1) { - state.prevcur = state.curtoken; - state.curtoken = -1; + // counter HOTP + if (state.cnt > 0) { + state.cnt--; + timerdly = 30000; + } else { + state.hotp.hotp = ""; + timerdly = 1; } - state.nexttime = 0; + timerfn = updateCurrentToken; } - } else { - g.setFont("Vector", tokendigitsheight) - .setFontAlign(0, 0, 0) - .drawString(notokens, Bangle.appRect.x + Bangle.appRect.w / 2, Bangle.appRect.y + Bangle.appRect.h / 2, false); } if (state.drawtimer) { clearTimeout(state.drawtimer); @@ -242,97 +169,236 @@ function draw() { state.drawtimer = setTimeout(timerfn, timerdly); } -function onTouch(zone, e) { - if (e) { - var id = Math.floor((state.listy + (e.y - Bangle.appRect.y)) / tokenheight); - if (id == state.curtoken || tokens.length == 0 || id >= tokens.length) { - id = -1; - } - if (state.curtoken != id) { - if (id != -1) { - var y = id * tokenheight - state.listy; - if (y < 0) { - state.listy += y; - y = 0; +function updateCurrentToken() { + drawToken(state.id); + timerCalc(); +} + +function updateProgressBar() { + drawProgressBar(); + timerCalc(); +} + +function drawProgressBar() { + let id = state.id; + if (id >= 0 && tokens[id].period > 0) { + let rem = Math.min(tokens[id].period, Math.floor((state.hotp.next - Date.now()) / 1000)); + if (rem >= 0) { + let y1 = tokenY(id); + let y2 = y1 + TOKEN_HEIGHT - 1; + if (y2 >= AR.y && y1 <= AR.y2) { + // token visible + y1 = y2 - PROGRESSBAR_HEIGHT; + if (y1 <= AR.y2) + { + // progress bar visible + y2 = Math.min(y2, AR.y2); + let xr = Math.floor(AR.w * rem / tokens[id].period) + AR.x; + g.setColor(g.theme.fgH) + .setBgColor(g.theme.bgH) + .fillRect(AR.x, y1, xr, y2) + .clearRect(xr + 1, y1, AR.x2, y2); } - y += tokenheight; - if (y > Bangle.appRect.h) { - state.listy += (y - Bangle.appRect.h); - } - state.otp = ""; + } else { + // token not visible + state.id = -1; } - state.nextTime = 0; - state.curtoken = id; - state.hide = 2; } } - draw(); +} + +// id = token ID number (0...) +function drawToken(id) { + let x1 = AR.x; + let y1 = tokenY(id); + let x2 = AR.x2; + let y2 = y1 + TOKEN_HEIGHT - 1; + let lbl = (id >= 0 && id < tokens.length) ? tokens[id].label.substr(0, 10) : ""; + let adj; + g.setClipRect(x1, Math.max(y1, AR.y), x2, Math.min(y2, AR.y2)); + if (id === state.id) { + g.setColor(g.theme.fgH) + .setBgColor(g.theme.bgH); + } else { + g.setColor(g.theme.fg) + .setBgColor(g.theme.bg); + } + if (id == state.id && state.hotp.hotp != "") { + // small label centered just below top line + g.setFont("Vector", TOKEN_EXTRA_HEIGHT) + .setFontAlign(0, -1, 0); + adj = y1; + } else { + // large label centered in box + sizeFont("l" + id, lbl, AR.w); + g.setFontAlign(0, 0, 0); + adj = half(y1 + y2); + } + g.clearRect(x1, y1, x2, y2) + .drawString(lbl, half(x1 + x2), adj, false); + if (id == state.id && state.hotp.hotp != "") { + adj = 0; + if (tokens[id].period <= 0) { + // counter - draw triangle as swipe hint + let yc = half(y1 + y2); + adj = COUNTER_TRIANGLE_SIZE; + g.fillPoly([AR.x, yc, AR.x + adj, yc - adj, AR.x + adj, yc + adj]); + adj += 2; + } + // digits just below label + x1 = half(x1 + adj + x2); + y1 += TOKEN_EXTRA_HEIGHT; + if (state.hotp.hotp == CALCULATING) { + sizeFont("c", CALCULATING, AR.w - adj); + g.drawString(CALCULATING, x1, y1, false) + .flip(); + state.hotp = hotp(tokens[id]); + g.clearRect(AR.x + adj, y1, AR.x2, y2); + } + sizeFont("d" + id, state.hotp.hotp, AR.w - adj); + g.drawString(state.hotp.hotp, x1, y1, false); + if (tokens[id].period > 0) { + drawProgressBar(); + } + } + g.setClipRect(0, 0, g.getWidth() - 1, g.getHeight() - 1); +} + +function changeId(id) { + if (id != state.id) { + state.hotp.hotp = CALCULATING; + let pid = state.id; + state.id = id; + if (pid >= 0) { + drawToken(pid); + } + if (id >= 0) { + drawToken( id); + } + } } function onDrag(e) { - if (e.x > g.getWidth() || e.y > g.getHeight()) return; - if (e.dx == 0 && e.dy == 0) return; - var newy = Math.min(state.listy - e.dy, tokens.length * tokenheight - Bangle.appRect.h); - state.listy = Math.max(0, newy); - draw(); + state.cnt = IDLE_REPEATS; + if (e.b != 0 && e.dy != 0) { + let y = E.clip(state.listy - E.clip(e.dy, -AR.h, AR.h), 0, Math.max(0, tokens.length * TOKEN_HEIGHT - AR.h)); + if (state.listy != y) { + let id, dy = state.listy - y; + state.listy = y; + g.setClipRect(AR.x, AR.y, AR.x2, AR.y2) + .scroll(0, dy); + if (dy > 0) { + id = Math.floor((state.listy + dy) / TOKEN_HEIGHT); + y = tokenY(id + 1); + do { + drawToken(id); + id--; + y -= TOKEN_HEIGHT; + } while (y > AR.y); + } + if (dy < 0) { + id = Math.floor((state.listy + dy + AR.h) / TOKEN_HEIGHT); + y = tokenY(id); + while (y < AR.y2) { + drawToken(id); + id++; + y += TOKEN_HEIGHT; + } + } + } + } + if (e.b == 0) { + timerCalc(); + } +} + +function onTouch(zone, e) { + state.cnt = IDLE_REPEATS; + if (e) { + let id = Math.floor((state.listy + e.y - AR.y) / TOKEN_HEIGHT); + if (id == state.id || tokens.length == 0 || id >= tokens.length) { + id = -1; + } + if (state.id != id) { + if (id >= 0) { + // scroll token into view if necessary + let dy = 0; + let y = id * TOKEN_HEIGHT - state.listy; + if (y < 0) { + dy -= y; + y = 0; + } + y += TOKEN_HEIGHT; + if (y > AR.h) { + dy -= (y - AR.h); + } + onDrag({b:1, dy:dy}); + } + changeId(id); + } + } + timerCalc(); } function onSwipe(e) { - if (e == 1) { + state.cnt = IDLE_REPEATS; + switch (e) { + case 1: exitApp(); + break; + case -1: + if (state.id >= 0 && tokens[state.id].period <= 0) { + tokens[state.id].period--; + require("Storage").writeJSON(SETTINGS, {tokens:tokens, misc:settings.misc}); + state.hotp.hotp = CALCULATING; + drawToken(state.id); + } + break; } - if (e == -1 && state.curtoken != -1 && tokens[state.curtoken].period <= 0) { - tokens[state.curtoken].period--; - let newsettings={tokens:tokens,misc:settings.misc}; - require("Storage").writeJSON("authentiwatch.json", newsettings); - state.nextTime = 0; - state.otp = ""; - state.hide = 2; - } - draw(); + timerCalc(); } -function bangle1Btn(e) { +function bangleBtn(e) { + state.cnt = IDLE_REPEATS; if (tokens.length > 0) { - if (state.curtoken == -1) { - state.curtoken = state.prevcur; - } else { - switch (e) { - case -1: state.curtoken--; break; - case 1: state.curtoken++; break; - } - } - state.curtoken = Math.max(state.curtoken, 0); - state.curtoken = Math.min(state.curtoken, tokens.length - 1); - state.listy = state.curtoken * tokenheight; - state.listy -= (Bangle.appRect.h - tokenheight) / 2; - state.listy = Math.min(state.listy, tokens.length * tokenheight - Bangle.appRect.h); - state.listy = Math.max(state.listy, 0); - var fakee = {}; - fakee.y = state.curtoken * tokenheight - state.listy + Bangle.appRect.y; - state.curtoken = -1; - state.nextTime = 0; - onTouch(0, fakee); - } else { - draw(); // resets idle timer + let id = E.clip(state.id + e, 0, tokens.length - 1); + onDrag({b:1, dy:state.listy - E.clip(id * TOKEN_HEIGHT - half(AR.h - TOKEN_HEIGHT), 0, Math.max(0, tokens.length * TOKEN_HEIGHT - AR.h))}); + changeId(id); + drawProgressBar(); } + timerCalc(); } function exitApp() { + if (state.drawtimer) { + clearTimeout(state.drawtimer); + } Bangle.showLauncher(); } Bangle.on('touch', onTouch); Bangle.on('drag' , onDrag ); Bangle.on('swipe', onSwipe); -if (typeof BTN2 == 'number') { - setWatch(function(){bangle1Btn(-1);}, BTN1, {edge:"rising" , debounce:50, repeat:true}); - setWatch(function(){exitApp(); }, BTN2, {edge:"falling", debounce:50}); - setWatch(function(){bangle1Btn( 1);}, BTN3, {edge:"rising" , debounce:50, repeat:true}); +if (typeof BTN1 == 'number') { + if (typeof BTN2 == 'number' && typeof BTN3 == 'number') { + setWatch(()=>bangleBtn(-1), BTN1, {edge:"rising" , debounce:50, repeat:true}); + setWatch(()=>exitApp() , BTN2, {edge:"falling", debounce:50}); + setWatch(()=>bangleBtn( 1), BTN3, {edge:"rising" , debounce:50, repeat:true}); + } else { + setWatch(()=>exitApp() , BTN1, {edge:"falling", debounce:50}); + } } Bangle.loadWidgets(); - -// Clear the screen once, at startup +const AR = Bangle.appRect; +// draw the initial display g.clear(); -draw(); +if (tokens.length > 0) { + state.listy = AR.h; + onDrag({b:1, dy:AR.h}); +} else { + g.setFont("Vector", TOKEN_DIGITS_HEIGHT) + .setFontAlign(0, 0, 0) + .drawString(NO_TOKENS, AR.x + half(AR.w), AR.y + half(AR.h), false); +} +timerCalc(); Bangle.drawWidgets(); diff --git a/apps/authentiwatch/interface.html b/apps/authentiwatch/interface.html index 5ee32fd8e..7d567d34f 100644 --- a/apps/authentiwatch/interface.html +++ b/apps/authentiwatch/interface.html @@ -54,9 +54,9 @@ var tokens = settings.tokens; */ function base32clean(val, nows) { var ret = val.replaceAll(/\s+/g, ' '); - ret = ret.replaceAll(/0/g, 'O'); - ret = ret.replaceAll(/1/g, 'I'); - ret = ret.replaceAll(/8/g, 'B'); + ret = ret.replaceAll('0', 'O'); + ret = ret.replaceAll('1', 'I'); + ret = ret.replaceAll('8', 'B'); ret = ret.replaceAll(/[^A-Za-z2-7 ]/g, ''); if (nows) { ret = ret.replaceAll(/\s+/g, ''); @@ -81,9 +81,9 @@ function b32encode(str) { function b32decode(seedstr) { // RFC4648 - var i, buf = 0, bitcount = 0, ret = ''; - for (i in seedstr) { - var c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(seedstr.charAt(i).toUpperCase(), 0); + var buf = 0, bitcount = 0, ret = ''; + for (var c of seedstr.toUpperCase()) { + c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(c); if (c != -1) { buf <<= 5; buf |= c; @@ -405,7 +405,7 @@ class proto3decoder { constructor(str) { this.buf = []; for (let i in str) { - this.buf = this.buf.concat(str.charCodeAt(i)); + this.buf.push(str.charCodeAt(i)); } } getVarint() { @@ -487,7 +487,7 @@ function startScan(handler,cancel) { document.body.className = 'scanning'; navigator.mediaDevices .getUserMedia({video:{facingMode:'environment'}}) - .then(function(stream){ + .then(stream => { scanning=true; video.setAttribute('playsinline',true); video.srcObject = stream; diff --git a/apps/authentiwatch/metadata.json b/apps/authentiwatch/metadata.json index b4ed34a12..36e1ea34d 100644 --- a/apps/authentiwatch/metadata.json +++ b/apps/authentiwatch/metadata.json @@ -4,7 +4,7 @@ "shortName": "AuthWatch", "icon": "app.png", "screenshots": [{"url":"screenshot1.png"},{"url":"screenshot2.png"},{"url":"screenshot3.png"},{"url":"screenshot4.png"}], - "version": "0.06", + "version": "0.07", "description": "Google Authenticator compatible tool.", "tags": "tool", "interface": "interface.html", diff --git a/apps/authentiwatch/screenshot1.png b/apps/authentiwatch/screenshot1.png index c7ca744b4..c3ac0e3b7 100644 Binary files a/apps/authentiwatch/screenshot1.png and b/apps/authentiwatch/screenshot1.png differ diff --git a/apps/authentiwatch/screenshot2.png b/apps/authentiwatch/screenshot2.png index 8156dd3e8..26bafdbb2 100644 Binary files a/apps/authentiwatch/screenshot2.png and b/apps/authentiwatch/screenshot2.png differ diff --git a/apps/authentiwatch/screenshot3.png b/apps/authentiwatch/screenshot3.png index 6d14e0b96..80f2fb172 100644 Binary files a/apps/authentiwatch/screenshot3.png and b/apps/authentiwatch/screenshot3.png differ diff --git a/apps/authentiwatch/screenshot4.png b/apps/authentiwatch/screenshot4.png index 7576e1aff..00756228d 100644 Binary files a/apps/authentiwatch/screenshot4.png and b/apps/authentiwatch/screenshot4.png differ diff --git a/apps/boot/ChangeLog b/apps/boot/ChangeLog index ef437fe3b..87b5f7def 100644 --- a/apps/boot/ChangeLog +++ b/apps/boot/ChangeLog @@ -49,3 +49,4 @@ 0.43: Fix Gadgetbridge handling with Programmable:off 0.44: Write .boot0 without ever having it all in RAM (fix Bangle.js 1 issues with BTHRM) 0.45: Fix 0.44 regression (auto-add semi-colon between each boot code chunk) +0.46: Fix no clock found error on Bangle.js 2 diff --git a/apps/boot/bootloader.js b/apps/boot/bootloader.js index 3cf885ac9..45e271f30 100644 --- a/apps/boot/bootloader.js +++ b/apps/boot/bootloader.js @@ -14,6 +14,6 @@ if (!clockApp) { if (clockApp) clockApp = require("Storage").read(clockApp.src); } -if (!clockApp) clockApp=`E.showMessage("No Clock Found");setWatch(()=>{Bangle.showLauncher();}, BTN2, {repeat:false,edge:"falling"});`; +if (!clockApp) clockApp=`E.showMessage("No Clock Found");setWatch(()=>{Bangle.showLauncher();}, global.BTN2||BTN, {repeat:false,edge:"falling"});`; eval(clockApp); delete clockApp; diff --git a/apps/boot/metadata.json b/apps/boot/metadata.json index c21ab6833..11884576f 100644 --- a/apps/boot/metadata.json +++ b/apps/boot/metadata.json @@ -1,7 +1,7 @@ { "id": "boot", "name": "Bootloader", - "version": "0.45", + "version": "0.46", "description": "This is needed by Bangle.js to automatically load the clock, menu, widgets and settings", "icon": "bootloader.png", "type": "bootloader", diff --git a/apps/clockcal/ChangeLog b/apps/clockcal/ChangeLog index 299b1ec69..f73c6ed97 100644 --- a/apps/clockcal/ChangeLog +++ b/apps/clockcal/ChangeLog @@ -1,2 +1,3 @@ 0.01: Initial upload 0.2: Added scrollable calendar and swipe gestures +0.3: Configurable drag gestures diff --git a/apps/clockcal/README.md b/apps/clockcal/README.md index da6887177..d30205be0 100644 --- a/apps/clockcal/README.md +++ b/apps/clockcal/README.md @@ -9,25 +9,24 @@ I know that it seems redundant because there already **is** a *time&cal*-app, bu |![unlocked screen](screenshot2.png)|unlocked: smaller clock, but with seconds| |![big calendar](screenshot3.png)|swipe up for big calendar, (up down to scroll, left/right to exit)| - - - ## Configurable Features - Number of calendar rows (weeks) - Buzz on connect/disconnect (I know, this should be an extra widget, but for now, it is included) -- Clock Mode (24h/12h). Doesn't have an am/pm indicator. It's only there because it was easy. +- Clock Mode (24h/12h). (No am/pm indicator) - First day of the week -- Red Saturday -- Red Sunday -- Swipes (to disable all gestures) -- Swipes: music (swipe down) -- Spipes: messages (swipe right) +- Red Saturday/Sunday +- Swipe/Drag gestures to launch features or apps. ## Auto detects your message/music apps: -- swiping down will search your files for an app with the string "music" in its filename and launch it -- swiping right will search your files for an app with the string "message" in its filename and launch it. -- Configurable apps coming soon. +- swiping down will search your files for an app with the string "message" in its filename and launch it. (configurable) +- swiping right will search your files for an app with the string "music" in its filename and launch it. (configurable) ## Feedback The clock works for me in a 24h/MondayFirst/WeekendFree environment but is not well-tested with other settings. So if something isn't working, please tell me: https://github.com/foostuff/BangleApps/issues + +## Planned features: + - Internal lightweight music control, because switching apps has a loading time. + - Clean up settings + - Maybe am/pm indicator for 12h-users + - Step count (optional) diff --git a/apps/clockcal/app.js b/apps/clockcal/app.js index 86fa0815a..5e8c7f796 100644 --- a/apps/clockcal/app.js +++ b/apps/clockcal/app.js @@ -4,12 +4,13 @@ var s = Object.assign({ CAL_ROWS: 4, //number of calendar rows.(weeks) Shouldn't exceed 5 when using widgets. BUZZ_ON_BT: true, //2x slow buzz on disconnect, 2x fast buzz on connect. Will be extra widget eventually MODE24: true, //24h mode vs 12h mode - FIRSTDAYOFFSET: 6, //First day of the week: 0-6: Sun, Sat, Fri, Thu, Wed, Tue, Mon + FIRSTDAY: 6, //First day of the week: mo, tu, we, th, fr, sa, su REDSUN: true, // Use red color for sunday? REDSAT: true, // Use red color for saturday? - DRAGENABLED: true, - DRAGMUSIC: true, - DRAGMESSAGES: true + DRAGDOWN: "[AI:messg]", + DRAGRIGHT: "[AI:music]", + DRAGLEFT: "[ignore]", + DRAGUP: "[calend.]" }, require('Storage').readJSON("clockcal.json", true) || {}); const h = g.getHeight(); @@ -27,13 +28,13 @@ var monthOffset = 0; */ function drawFullCalendar(monthOffset) { addMonths = function (_d, _am) { - var ay = 0, m = _d.getMonth(), y = _d.getFullYear(); - while ((m + _am) > 11) { ay++; _am -= 12; } - while ((m + _am) < 0) { ay--; _am += 12; } - n = new Date(_d.getTime()); - n.setMonth(m + _am); - n.setFullYear(y + ay); - return n; + var ay = 0, m = _d.getMonth(), y = _d.getFullYear(); + while ((m + _am) > 11) { ay++; _am -= 12; } + while ((m + _am) < 0) { ay--; _am += 12; } + n = new Date(_d.getTime()); + n.setMonth(m + _am); + n.setFullYear(y + ay); + return n; }; monthOffset = (typeof monthOffset == "undefined") ? 0 : monthOffset; state = "calendar"; @@ -45,22 +46,22 @@ function drawFullCalendar(monthOffset) { if (typeof minuteInterval !== "undefined") clearTimeout(minuteInterval); d = addMonths(Date(), monthOffset); tdy = Date().getDate() + "." + Date().getMonth(); - newmonth=false; + newmonth = false; c_y = 0; g.reset(); g.setBgColor(0); g.clear(); - var prevmonth = addMonths(d, -1) + var prevmonth = addMonths(d, -1); const today = prevmonth.getDate(); var rD = new Date(prevmonth.getTime()); rD.setDate(rD.getDate() - (today - 1)); - const dow = (s.FIRSTDAYOFFSET + rD.getDay()) % 7; + const dow = (s.FIRSTDAY + rD.getDay()) % 7; rD.setDate(rD.getDate() - dow); var rDate = rD.getDate(); bottomrightY = c_y - 3; - clrsun=s.REDSUN?'#f00':'#fff'; - clrsat=s.REDSUN?'#f00':'#fff'; - var fg=[clrsun,'#fff','#fff','#fff','#fff','#fff',clrsat]; + clrsun = s.REDSUN ? '#f00' : '#fff'; + clrsat = s.REDSUN ? '#f00' : '#fff'; + var fg = [clrsun, '#fff', '#fff', '#fff', '#fff', '#fff', clrsat]; for (var y = 1; y <= 11; y++) { bottomrightY += CELL_H; bottomrightX = -2; @@ -69,14 +70,14 @@ function drawFullCalendar(monthOffset) { rMonth = rD.getMonth(); rDate = rD.getDate(); if (tdy == rDate + "." + rMonth) { - caldrawToday(rDate); + caldrawToday(rDate); } else if (rDate == 1) { - caldrawFirst(rDate); + caldrawFirst(rDate); } else { - caldrawNormal(rDate,fg[rD.getDay()]); + caldrawNormal(rDate, fg[rD.getDay()]); } if (newmonth && x == 7) { - caldrawMonth(rDate,monthclr[rMonth % 6],months[rMonth],rD); + caldrawMonth(rDate, monthclr[rMonth % 6], months[rMonth], rD); } rD.setDate(rDate + 1); } @@ -84,7 +85,7 @@ function drawFullCalendar(monthOffset) { delete addMonths; if (DEBUG) console.log("Calendar performance (ms):" + (Date().getTime() - start)); } -function caldrawMonth(rDate,c,m,rD) { +function caldrawMonth(rDate, c, m, rD) { g.setColor(c); g.setFont("Vector", 18); g.setFontAlign(-1, 1, 1); @@ -93,29 +94,29 @@ function caldrawMonth(rDate,c,m,rD) { newmonth = false; } function caldrawToday(rDate) { - g.setFont("Vector", 16); - g.setFontAlign(1, 1); - g.setColor('#0f0'); - g.fillRect(bottomrightX - CELL2_W + 1, bottomrightY - CELL_H - 1, bottomrightX, bottomrightY - 2); - g.setColor('#000'); - g.drawString(rDate, bottomrightX, bottomrightY); + g.setFont("Vector", 16); + g.setFontAlign(1, 1); + g.setColor('#0f0'); + g.fillRect(bottomrightX - CELL2_W + 1, bottomrightY - CELL_H - 1, bottomrightX, bottomrightY - 2); + g.setColor('#000'); + g.drawString(rDate, bottomrightX, bottomrightY); } function caldrawFirst(rDate) { - g.flip(); - g.setFont("Vector", 16); - g.setFontAlign(1, 1); - bottomrightY += 3; - newmonth = true; - g.setColor('#0ff'); - g.fillRect(bottomrightX - CELL2_W + 1, bottomrightY - CELL_H - 1, bottomrightX, bottomrightY - 2); - g.setColor('#000'); - g.drawString(rDate, bottomrightX, bottomrightY); + g.flip(); + g.setFont("Vector", 16); + g.setFontAlign(1, 1); + bottomrightY += 3; + newmonth = true; + g.setColor('#0ff'); + g.fillRect(bottomrightX - CELL2_W + 1, bottomrightY - CELL_H - 1, bottomrightX, bottomrightY - 2); + g.setColor('#000'); + g.drawString(rDate, bottomrightX, bottomrightY); } -function caldrawNormal(rDate,c) { - g.setFont("Vector", 16); - g.setFontAlign(1, 1); - g.setColor(c); - g.drawString(rDate, bottomrightX, bottomrightY);//100 +function caldrawNormal(rDate, c) { + g.setFont("Vector", 16); + g.setFontAlign(1, 1); + g.setColor(c); + g.drawString(rDate, bottomrightX, bottomrightY);//100 } function drawMinutes() { if (DEBUG) console.log("|-->minutes"); @@ -163,7 +164,7 @@ function drawWatch() { g.clear(); drawMinutes(); if (!dimSeconds) drawSeconds(); - const dow = (s.FIRSTDAYOFFSET + d.getDay()) % 7; //MO=0, SU=6 + const dow = (s.FIRSTDAY + d.getDay()) % 7; //MO=0, SU=6 const today = d.getDate(); var rD = new Date(d.getTime()); rD.setDate(rD.getDate() - dow); @@ -205,27 +206,52 @@ function BTevent() { setTimeout(function () { Bangle.buzz(interval); }, interval * 3); } } - +function action(a) { + g.reset(); + if (typeof secondInterval !== "undefined") clearTimeout(secondInterval); + if (DEBUG) console.log("action:" + a); + switch (a) { + case "[ignore]": + break; + case "[calend.]": + drawFullCalendar(); + break; + case "[AI:music]": + l = require("Storage").list(RegExp("music.*app.js")); + if (l.length > 0) { + load(l[0]); + } else E.showAlert("Music app not found", "Not found").then(drawWatch); + break; + case "[AI:messg]": + l = require("Storage").list(RegExp("message.*app.js")); + if (l.length > 0) { + load(l[0]); + } else E.showAlert("Message app not found", "Not found").then(drawWatch); + break; + default: + l = require("Storage").list(RegExp(a + ".app.js")); + if (l.length > 0) { + load(l[0]); + } else E.showAlert(a + ": App not found", "Not found").then(drawWatch); + break; + } +} function input(dir) { - if (s.DRAGENABLED) { - Bangle.buzz(100,1); - console.log("swipe:"+dir); + Bangle.buzz(100, 1); + if (DEBUG) console.log("swipe:" + dir); switch (dir) { case "r": if (state == "calendar") { drawWatch(); } else { - if (s.DRAGMUSIC) { - l=require("Storage").list(RegExp("music.*app")); - if (l.length > 0) { - load(l[0]); - } else Bangle.buzz(3000,1);//not found - } + action(s.DRAGRIGHT); } break; case "l": if (state == "calendar") { drawWatch(); + } else { + action(s.DRAGLEFT); } break; case "d": @@ -233,21 +259,15 @@ function input(dir) { monthOffset--; drawFullCalendar(monthOffset); } else { - if (s.DRAGMESSAGES) { - l=require("Storage").list(RegExp("message.*app")); - if (l.length > 0) { - load(l[0]); - } else Bangle.buzz(3000,1);//not found - } + action(s.DRAGDOWN); } break; case "u": - if (state == "watch") { - state = "calendar"; - drawFullCalendar(0); - } else if (state == "calendar") { + if (state == "calendar") { monthOffset++; drawFullCalendar(monthOffset); + } else { + action(s.DRAGUP); } break; default: @@ -255,26 +275,24 @@ function input(dir) { drawWatch(); } break; + } - } } let drag; Bangle.on("drag", e => { - if (s.DRAGENABLED) { - if (!drag) { - drag = { x: e.x, y: e.y }; - } else if (!e.b) { - const dx = e.x - drag.x, dy = e.y - drag.y; - var dir = "t"; - if (Math.abs(dx) > Math.abs(dy) + 10) { - dir = (dx > 0) ? "r" : "l"; - } else if (Math.abs(dy) > Math.abs(dx) + 10) { - dir = (dy > 0) ? "d" : "u"; - } - drag = null; - input(dir); + if (!drag) { + drag = { x: e.x, y: e.y }; + } else if (!e.b) { + const dx = e.x - drag.x, dy = e.y - drag.y; + var dir = "t"; + if (Math.abs(dx) > Math.abs(dy) + 20) { + dir = (dx > 0) ? "r" : "l"; + } else if (Math.abs(dy) > Math.abs(dx) + 20) { + dir = (dy > 0) ? "d" : "u"; } + drag = null; + input(dir); } }); diff --git a/apps/clockcal/metadata.json b/apps/clockcal/metadata.json index a42e1ad2e..dde32f746 100644 --- a/apps/clockcal/metadata.json +++ b/apps/clockcal/metadata.json @@ -1,7 +1,7 @@ { "id": "clockcal", "name": "Clock & Calendar", - "version": "0.2", + "version": "0.3", "description": "Clock with Calendar", "readme":"README.md", "icon": "app.png", diff --git a/apps/clockcal/settings.js b/apps/clockcal/settings.js index c4ec764c9..abedad99b 100644 --- a/apps/clockcal/settings.js +++ b/apps/clockcal/settings.js @@ -1,19 +1,22 @@ (function (back) { var FILE = "clockcal.json"; - - settings = Object.assign({ + defaults={ CAL_ROWS: 4, //number of calendar rows.(weeks) Shouldn't exceed 5 when using widgets. BUZZ_ON_BT: true, //2x slow buzz on disconnect, 2x fast buzz on connect. Will be extra widget eventually MODE24: true, //24h mode vs 12h mode FIRSTDAY: 6, //First day of the week: mo, tu, we, th, fr, sa, su REDSUN: true, // Use red color for sunday? REDSAT: true, // Use red color for saturday? - DRAGENABLED: true, //Enable drag gestures (bigger calendar etc) - DRAGMUSIC: true, //Enable drag down for music (looks for "music*app") - DRAGMESSAGES: true //Enable drag right for messages (looks for "message*app") - }, require('Storage').readJSON(FILE, true) || {}); - + DRAGDOWN: "[AI:messg]", + DRAGRIGHT: "[AI:music]", + DRAGLEFT: "[ignore]", + DRAGUP: "[calend.]" + }; + settings = Object.assign(defaults, require('Storage').readJSON(FILE, true) || {}); + actions = ["[ignore]","[calend.]","[AI:music]","[AI:messg]"]; + require("Storage").list(RegExp(".app.js")).forEach(element => actions.push(element.replace(".app.js",""))); + function writeSettings() { require('Storage').writeJSON(FILE, settings); } @@ -70,27 +73,39 @@ writeSettings(); } }, - 'Swipes (big cal.)?': { - value: settings.DRAGENABLED, - format: v => v ? "On" : "Off", + 'Drag Up ': { + min:0, max:actions.length-1, + value: actions.indexOf(settings.DRAGUP), + format: v => actions[v], onchange: v => { - settings.DRAGENABLED = v; + settings.DRAGUP = actions[v]; writeSettings(); } }, - 'Swipes (music)?': { - value: settings.DRAGMUSIC, - format: v => v ? "On" : "Off", + 'Drag Right': { + min:0, max:actions.length-1, + value: actions.indexOf(settings.DRAGRIGHT), + format: v => actions[v], onchange: v => { - settings.DRAGMUSIC = v; + settings.DRAGRIGHT = actions[v]; writeSettings(); } }, - 'Swipes (messg)?': { - value: settings.DRAGMESSAGES, - format: v => v ? "On" : "Off", + 'Drag Down': { + min:0, max:actions.length-1, + value: actions.indexOf(settings.DRAGDOWN), + format: v => actions[v], onchange: v => { - settings.DRAGMESSAGES = v; + settings.DRGDOWN = actions[v]; + writeSettings(); + } + }, + 'Drag Left': { + min:0, max:actions.length-1, + value: actions.indexOf(settings.DRAGLEFT), + format: v => actions[v], + onchange: v => { + settings.DRAGLEFT = actions[v]; writeSettings(); } }, @@ -100,17 +115,7 @@ format: v => ["No", "Yes"][v], onchange: v => { if (v == 1) { - settings = { - CAL_ROWS: 4, //number of calendar rows.(weeks) Shouldn't exceed 5 when using widgets. - BUZZ_ON_BT: true, //2x slow buzz on disconnect, 2x fast buzz on connect. - MODE24: true, //24h mode vs 12h mode - FIRSTDAY: 6, //First day of the week: mo, tu, we, th, fr, sa, su - REDSUN: true, // Use red color for sunday? - REDSAT: true, // Use red color for saturday? - DRAGENABLED: true, - DRAGMUSIC: true, - DRAGMESSAGES: true - }; + settings = defaults; writeSettings(); load(); } diff --git a/apps/cycling/ChangeLog b/apps/cycling/ChangeLog new file mode 100644 index 000000000..ec66c5568 --- /dev/null +++ b/apps/cycling/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/cycling/README.md b/apps/cycling/README.md new file mode 100644 index 000000000..7ba8ee224 --- /dev/null +++ b/apps/cycling/README.md @@ -0,0 +1,34 @@ +# Cycling +> Displays data from a BLE Cycling Speed and Cadence sensor. + +*This is a fork of the CSCSensor app using the layout library and separate module for CSC functionality. It also drops persistence of total distance on the Bangle, as this information is also persisted on the sensor itself. Further, it allows configuration of display units (metric/imperial) independent of chosen locale. Finally, multiple sensors can be used and wheel circumference can be configured for each sensor individually.* + +The following data are displayed: +- curent speed +- moving time +- average speed +- maximum speed +- trip distance +- total distance + +Other than in the original version of the app, total distance is not stored on the Bangle, but instead is calculated from the CWR (cumulative wheel revolutions) reported by the sensor. This metric is, according to the BLE spec, an absolute value that persists throughout the lifetime of the sensor and never rolls over. + +**Cadence / Crank features are currently not implemented** + +## Usage +Open the app and connect to a CSC sensor. + +Upon first connection, close the app afain and enter the settings app to configure the wheel circumference. The total circumference is (cm + mm) - it is split up into two values for ease of configuration. Check the status screen inside the Cycling app while connected to see the address of the currently connected sensor (if you need to differentiate between multiple sensors). + +Inside the Cycling app, use button / tap screen to: +- cycle through screens (if connected) +- reconnect (if connection aborted) + +## TODO +* Sensor battery status +* Implement crank events / show cadence +* Bangle.js 1 compatibility +* Allow setting CWR on the sensor (this is a feature intended by the BLE CSC spec, in case the sensor is replaced or transferred to a different bike) + +## Development +There is a "mock" version of the `blecsc` module, which can be used to test features in the emulator. Check `blecsc-emu.js` for usage. diff --git a/apps/cycling/blecsc-emu.js b/apps/cycling/blecsc-emu.js new file mode 100644 index 000000000..ca5058545 --- /dev/null +++ b/apps/cycling/blecsc-emu.js @@ -0,0 +1,111 @@ +// UUID of the Bluetooth CSC Service +const SERVICE_UUID = "1816"; +// UUID of the CSC measurement characteristic +const MEASUREMENT_UUID = "2a5b"; + +// Wheel revolution present bit mask +const FLAGS_WREV_BM = 0x01; +// Crank revolution present bit mask +const FLAGS_CREV_BM = 0x02; + +/** + * Fake BLECSC implementation for the emulator, where it's hard to test + * with actual hardware. Generates "random" wheel events (no crank). + * + * To upload as a module, paste the entire file in the console using this + * command: require("Storage").write("blecsc-emu",``); + */ +class BLECSCEmulator { + constructor() { + this.timeout = undefined; + this.interval = 500; + this.ccr = 0; + this.lwt = 0; + this.handlers = { + // value + // disconnect + // wheelEvent + // crankEvent + }; + } + + getDeviceAddress() { + return 'fa:ke:00:de:vi:ce'; + } + + /** + * Callback for the GATT characteristicvaluechanged event. + * Consumers must not call this method! + */ + onValue(event) { + // Not interested in non-CSC characteristics + if (event.target.uuid != "0x" + MEASUREMENT_UUID) return; + + // Notify the generic 'value' handler + if (this.handlers.value) this.handlers.value(event); + + const flags = event.target.value.getUint8(0, true); + // Notify the 'wheelEvent' handler + if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({ + cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions + lwet: event.target.value.getUint16(5, true), // last wheel event time + }); + + // Notify the 'crankEvent' handler + if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({ + ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions + lcet: event.target.value.getUint16(9, true), // last crank event time + }); + } + + /** + * Register an event handler. + * + * @param {string} event value|disconnect + * @param {function} handler handler function that receives the event as its first argument + */ + on(event, handler) { + this.handlers[event] = handler; + } + + fakeEvent() { + this.interval = Math.max(50, Math.min(1000, this.interval + Math.random()*40-20)); + this.lwt = (this.lwt + this.interval) % 0x10000; + this.ccr++; + + var buffer = new ArrayBuffer(8); + var view = new DataView(buffer); + view.setUint8(0, 0x01); // Wheel revolution data present bit + view.setUint32(1, this.ccr, true); // Cumulative crank revolutions + view.setUint16(5, this.lwt, true); // Last wheel event time + + this.onValue({ + target: { + uuid: "0x2a5b", + value: view, + }, + }); + + this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval); + } + + /** + * Find and connect to a device which exposes the CSC service. + * + * @return {Promise} + */ + connect() { + this.timeout = setTimeout(this.fakeEvent.bind(this), this.interval); + return Promise.resolve(true); + } + + /** + * Disconnect the device. + */ + disconnect() { + if (!this.timeout) return; + clearTimeout(this.timeout); + } +} + +exports = BLECSCEmulator; diff --git a/apps/cycling/blecsc.js b/apps/cycling/blecsc.js new file mode 100644 index 000000000..7a47108e5 --- /dev/null +++ b/apps/cycling/blecsc.js @@ -0,0 +1,150 @@ +const SERVICE_UUID = "1816"; +// UUID of the CSC measurement characteristic +const MEASUREMENT_UUID = "2a5b"; + +// Wheel revolution present bit mask +const FLAGS_WREV_BM = 0x01; +// Crank revolution present bit mask +const FLAGS_CREV_BM = 0x02; + +/** + * This class communicates with a Bluetooth CSC peripherial using the Espruino NRF library. + * + * ## Usage: + * 1. Register event handlers using the \`on(eventName, handlerFunction)\` method + * You can subscribe to the \`wheelEvent\` and \`crankEvent\` events or you can + * have raw characteristic values passed through using the \`value\` event. + * 2. Search and connect to a BLE CSC peripherial by calling the \`connect()\` method + * 3. To tear down the connection, call the \`disconnect()\` method + * + * ## Events + * - \`wheelEvent\` - the peripharial sends a notification containing wheel event data + * - \`crankEvent\` - the peripharial sends a notification containing crank event data + * - \`value\` - the peripharial sends any CSC characteristic notification (including wheel & crank event) + * - \`disconnect\` - the peripherial ends the connection or the connection is lost + * + * Each event can only have one handler. Any call to \`on()\` will + * replace a previously registered handler for the same event. + */ +class BLECSC { + constructor() { + this.device = undefined; + this.ccInterval = undefined; + this.gatt = undefined; + this.handlers = { + // wheelEvent + // crankEvent + // value + // disconnect + }; + } + + getDeviceAddress() { + if (!this.device || !this.device.id) + return '00:00:00:00:00:00'; + return this.device.id.split(" ")[0]; + } + + checkConnection() { + if (!this.device) + console.log("no device"); + // else + // console.log("rssi: " + this.device.rssi); + } + + /** + * Callback for the GATT characteristicvaluechanged event. + * Consumers must not call this method! + */ + onValue(event) { + // Not interested in non-CSC characteristics + if (event.target.uuid != "0x" + MEASUREMENT_UUID) return; + + // Notify the generic 'value' handler + if (this.handlers.value) this.handlers.value(event); + + const flags = event.target.value.getUint8(0, true); + // Notify the 'wheelEvent' handler + if ((flags & FLAGS_WREV_BM) && this.handlers.wheelEvent) this.handlers.wheelEvent({ + cwr: event.target.value.getUint32(1, true), // cumulative wheel revolutions + lwet: event.target.value.getUint16(5, true), // last wheel event time + }); + + // Notify the 'crankEvent' handler + if ((flags & FLAGS_CREV_BM) && this.handlers.crankEvent) this.handlers.crankEvent({ + ccr: event.target.value.getUint16(7, true), // cumulative crank revolutions + lcet: event.target.value.getUint16(9, true), // last crank event time + }); + } + + /** + * Callback for the NRF disconnect event. + * Consumers must not call this method! + */ + onDisconnect(event) { + console.log("disconnected"); + if (this.ccInterval) + clearInterval(this.ccInterval); + + if (!this.handlers.disconnect) return; + this.handlers.disconnect(event); + } + + /** + * Register an event handler. + * + * @param {string} event wheelEvent|crankEvent|value|disconnect + * @param {function} handler function that will receive the event as its first argument + */ + on(event, handler) { + this.handlers[event] = handler; + } + + /** + * Find and connect to a device which exposes the CSC service. + * + * @return {Promise} + */ + connect() { + // Register handler for the disconnect event to be passed throug + NRF.on('disconnect', this.onDisconnect.bind(this)); + + // Find a device, then get the CSC Service and subscribe to + // notifications on the CSC Measurement characteristic. + // NRF.setLowPowerConnection(true); + return NRF.requestDevice({ + timeout: 5000, + filters: [{ services: [SERVICE_UUID] }], + }).then(device => { + this.device = device; + this.device.on('gattserverdisconnected', this.onDisconnect.bind(this)); + this.ccInterval = setInterval(this.checkConnection.bind(this), 2000); + return device.gatt.connect(); + }).then(gatt => { + this.gatt = gatt; + return gatt.getPrimaryService(SERVICE_UUID); + }).then(service => { + return service.getCharacteristic(MEASUREMENT_UUID); + }).then(characteristic => { + characteristic.on('characteristicvaluechanged', this.onValue.bind(this)); + return characteristic.startNotifications(); + }); + } + + /** + * Disconnect the device. + */ + disconnect() { + if (this.ccInterval) + clearInterval(this.ccInterval); + + if (!this.gatt) return; + try { + this.gatt.disconnect(); + } catch { + // + } + } +} + +exports = BLECSC; diff --git a/apps/cycling/cycling.app.js b/apps/cycling/cycling.app.js new file mode 100644 index 000000000..268284a29 --- /dev/null +++ b/apps/cycling/cycling.app.js @@ -0,0 +1,453 @@ +const Layout = require('Layout'); +const storage = require('Storage'); + +const SETTINGS_FILE = 'cycling.json'; +const SETTINGS_DEFAULT = { + sensors: {}, + metric: true, +}; + +const RECONNECT_TIMEOUT = 4000; +const MAX_CONN_ATTEMPTS = 2; + +class CSCSensor { + constructor(blecsc, display) { + // Dependency injection + this.blecsc = blecsc; + this.display = display; + + // Load settings + this.settings = storage.readJSON(SETTINGS_FILE, true) || SETTINGS_DEFAULT; + this.wheelCirc = undefined; + + // CSC runtime variables + this.movingTime = 0; // unit: s + this.lastBangleTime = Date.now(); // unit: ms + this.lwet = 0; // last wheel event time (unit: s/1024) + this.cwr = -1; // cumulative wheel revolutions + this.cwrTrip = 0; // wheel revolutions since trip start + this.speed = 0; // unit: m/s + this.maxSpeed = 0; // unit: m/s + this.speedFailed = 0; + + // Other runtime variables + this.connected = false; + this.failedAttempts = 0; + this.failed = false; + + // Layout configuration + this.layout = 0; + this.display.useMetricUnits(true); + this.deviceAddress = undefined; + this.display.useMetricUnits((this.settings.metric)); + } + + onDisconnect(event) { + console.log("disconnected ", event); + + this.connected = false; + this.wheelCirc = undefined; + + this.setLayout(0); + this.display.setDeviceAddress("unknown"); + + if (this.failedAttempts >= MAX_CONN_ATTEMPTS) { + this.failed = true; + this.display.setStatus("Connection failed after " + MAX_CONN_ATTEMPTS + " attempts."); + } else { + this.display.setStatus("Disconnected"); + setTimeout(this.connect.bind(this), RECONNECT_TIMEOUT); + } + } + + loadCircumference() { + if (!this.deviceAddress) return; + + // Add sensor to settings if not present + if (!this.settings.sensors[this.deviceAddress]) { + this.settings.sensors[this.deviceAddress] = { + cm: 223, + mm: 0, + }; + storage.writeJSON(SETTINGS_FILE, this.settings); + } + + const high = this.settings.sensors[this.deviceAddress].cm || 223; + const low = this.settings.sensors[this.deviceAddress].mm || 0; + this.wheelCirc = (10*high + low) / 1000; + } + + connect() { + this.connected = false; + this.setLayout(0); + this.display.setStatus("Connecting"); + console.log("Trying to connect to BLE CSC"); + + // Hook up events + this.blecsc.on('wheelEvent', this.onWheelEvent.bind(this)); + this.blecsc.on('disconnect', this.onDisconnect.bind(this)); + + // Scan for BLE device and connect + this.blecsc.connect() + .then(function() { + this.failedAttempts = 0; + this.failed = false; + this.connected = true; + this.deviceAddress = this.blecsc.getDeviceAddress(); + console.log("Connected to " + this.deviceAddress); + + this.display.setDeviceAddress(this.deviceAddress); + this.display.setStatus("Connected"); + + this.loadCircumference(); + + // Switch to speed screen in 2s + setTimeout(function() { + this.setLayout(1); + this.updateScreen(); + }.bind(this), 2000); + }.bind(this)) + .catch(function(e) { + this.failedAttempts++; + this.onDisconnect(e); + }.bind(this)); + } + + disconnect() { + this.blecsc.disconnect(); + this.reset(); + this.setLayout(0); + this.display.setStatus("Disconnected"); + } + + setLayout(num) { + this.layout = num; + if (this.layout == 0) { + this.display.updateLayout("status"); + } else if (this.layout == 1) { + this.display.updateLayout("speed"); + } else if (this.layout == 2) { + this.display.updateLayout("distance"); + } + } + + reset() { + this.connected = false; + this.failed = false; + this.failedAttempts = 0; + this.wheelCirc = undefined; + } + + interact(d) { + // Only interested in tap / center button + if (d) return; + + // Reconnect in failed state + if (this.failed) { + this.reset(); + this.connect(); + } else if (this.connected) { + this.setLayout((this.layout + 1) % 3); + } + } + + updateScreen() { + var tripDist = this.cwrTrip * this.wheelCirc; + var avgSpeed = this.movingTime > 3 ? tripDist / this.movingTime : 0; + + this.display.setTotalDistance(this.cwr * this.wheelCirc); + this.display.setTripDistance(tripDist); + this.display.setSpeed(this.speed); + this.display.setAvg(avgSpeed); + this.display.setMax(this.maxSpeed); + this.display.setTime(Math.floor(this.movingTime)); + } + + onWheelEvent(event) { + // Calculate number of revolutions since last wheel event + var dRevs = (this.cwr > 0 ? event.cwr - this.cwr : 0); + this.cwr = event.cwr; + + // Increment the trip revolutions counter + this.cwrTrip += dRevs; + + // Calculate time delta since last wheel event + var dT = (event.lwet - this.lwet)/1024; + var now = Date.now(); + var dBT = (now-this.lastBangleTime)/1000; + this.lastBangleTime = now; + if (dT<0) dT+=64; // wheel event time wraps every 64s + if (Math.abs(dT-dBT)>3) dT = dBT; // not sure about the reason for this + this.lwet = event.lwet; + + // Recalculate current speed + if (dRevs>0 && dT>0) { + this.speed = dRevs * this.wheelCirc / dT; + this.speedFailed = 0; + this.movingTime += dT; + } else { + this.speedFailed++; + if (this.speedFailed>3) { + this.speed = 0; + } + } + + // Update max speed + if (this.speed>this.maxSpeed + && (this.movingTime>3 || this.speed<20) + && this.speed<50 + ) this.maxSpeed = this.speed; + + this.updateScreen(); + } +} + +class CSCDisplay { + constructor() { + this.metric = true; + this.fontLabel = "6x8"; + this.fontSmall = "15%"; + this.fontMed = "18%"; + this.fontLarge = "32%"; + this.currentLayout = "status"; + this.layouts = {}; + this.layouts.speed = new Layout({ + type: "v", + c: [ + { + type: "h", + id: "speed_g", + fillx: 1, + filly: 1, + pad: 4, + bgCol: "#fff", + c: [ + {type: undefined, width: 32, halign: -1}, + {type: "txt", id: "speed", label: "00.0", font: this.fontLarge, bgCol: "#fff", col: "#000", width: 122}, + {type: "txt", id: "speed_u", label: " km/h", font: this.fontLabel, col: "#000", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "time_g", + fillx: 1, + pad: 4, + bgCol: "#000", + height: 36, + c: [ + {type: undefined, width: 32, halign: -1}, + {type: "txt", id: "time", label: "00:00", font: this.fontMed, bgCol: "#000", col: "#fff", width: 122}, + {type: "txt", id: "time_u", label: "mins", font: this.fontLabel, bgCol: "#000", col: "#fff", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "stats_g", + fillx: 1, + bgCol: "#fff", + height: 36, + c: [ + { + type: "v", + pad: 4, + bgCol: "#fff", + c: [ + {type: "txt", id: "max_l", label: "MAX", font: this.fontLabel, col: "#000"}, + {type: "txt", id: "max", label: "00.0", font: this.fontSmall, bgCol: "#fff", col: "#000", width: 69}, + ], + }, + { + type: "v", + pad: 4, + bgCol: "#fff", + c: [ + {type: "txt", id: "avg_l", label: "AVG", font: this.fontLabel, col: "#000"}, + {type: "txt", id: "avg", label: "00.0", font: this.fontSmall, bgCol: "#fff", col: "#000", width: 69}, + ], + }, + {type: "txt", id: "stats_u", label: " km/h", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, + ] + }, + ], + }); + this.layouts.distance = new Layout({ + type: "v", + bgCol: "#fff", + c: [ + { + type: "h", + id: "tripd_g", + fillx: 1, + pad: 4, + bgCol: "#fff", + height: 32, + c: [ + {type: "txt", id: "tripd_l", label: "TRP", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36}, + {type: "txt", id: "tripd", label: "0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 118}, + {type: "txt", id: "tripd_u", label: "km", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, + ] + }, + { + type: "h", + id: "totald_g", + fillx: 1, + pad: 4, + bgCol: "#fff", + height: 32, + c: [ + {type: "txt", id: "totald_l", label: "TTL", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36}, + {type: "txt", id: "totald", label: "0", font: this.fontMed, bgCol: "#fff", col: "#000", width: 118}, + {type: "txt", id: "totald_u", label: "km", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 22, r: 90}, + ] + }, + ], + }); + this.layouts.status = new Layout({ + type: "v", + c: [ + { + type: "h", + id: "status_g", + fillx: 1, + bgCol: "#fff", + height: 100, + c: [ + {type: "txt", id: "status", label: "Bangle Cycling", font: this.fontSmall, bgCol: "#fff", col: "#000", width: 176, wrap: 1}, + ] + }, + { + type: "h", + id: "addr_g", + fillx: 1, + pad: 4, + bgCol: "#fff", + height: 32, + c: [ + { type: "txt", id: "addr_l", label: "ADDR", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 36 }, + { type: "txt", id: "addr", label: "unknown", font: this.fontLabel, bgCol: "#fff", col: "#000", width: 140 }, + ] + }, + ], + }); + } + + updateLayout(layout) { + this.currentLayout = layout; + + g.clear(); + this.layouts[layout].update(); + this.layouts[layout].render(); + Bangle.drawWidgets(); + } + + renderIfLayoutActive(layout, node) { + if (layout != this.currentLayout) return; + this.layouts[layout].render(node); + } + + useMetricUnits(metric) { + this.metric = metric; + + // console.log("using " + (metric ? "metric" : "imperial") + " units"); + + var speedUnit = metric ? "km/h" : "mph"; + this.layouts.speed.speed_u.label = speedUnit; + this.layouts.speed.stats_u.label = speedUnit; + + var distanceUnit = metric ? "km" : "mi"; + this.layouts.distance.tripd_u.label = distanceUnit; + this.layouts.distance.totald_u.label = distanceUnit; + + this.updateLayout(this.currentLayout); + } + + convertDistance(meters) { + if (this.metric) return meters / 1000; + return meters / 1609.344; + } + + convertSpeed(mps) { + if (this.metric) return mps * 3.6; + return mps * 2.23694; + } + + setSpeed(speed) { + this.layouts.speed.speed.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.speed_g); + } + + setAvg(speed) { + this.layouts.speed.avg.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.stats_g); + } + + setMax(speed) { + this.layouts.speed.max.label = this.convertSpeed(speed).toFixed(1); + this.renderIfLayoutActive("speed", this.layouts.speed.stats_g); + } + + setTime(seconds) { + var time = ''; + var hours = Math.floor(seconds/3600); + if (hours) { + time += hours + ":"; + this.layouts.speed.time_u.label = " hrs"; + } else { + this.layouts.speed.time_u.label = "mins"; + } + + time += String(Math.floor((seconds%3600)/60)).padStart(2, '0') + ":"; + time += String(seconds % 60).padStart(2, '0'); + + this.layouts.speed.time.label = time; + this.renderIfLayoutActive("speed", this.layouts.speed.time_g); + } + + setTripDistance(distance) { + this.layouts.distance.tripd.label = this.convertDistance(distance).toFixed(1); + this.renderIfLayoutActive("distance", this.layouts.distance.tripd_g); + } + + setTotalDistance(distance) { + distance = this.convertDistance(distance); + if (distance >= 1000) { + this.layouts.distance.totald.label = String(Math.round(distance)); + } else { + this.layouts.distance.totald.label = distance.toFixed(1); + } + this.renderIfLayoutActive("distance", this.layouts.distance.totald_g); + } + + setDeviceAddress(address) { + this.layouts.status.addr.label = address; + this.renderIfLayoutActive("status", this.layouts.status.addr_g); + } + + setStatus(status) { + this.layouts.status.status.label = status; + this.renderIfLayoutActive("status", this.layouts.status.status_g); + } +} + +var BLECSC; +if (process.env.BOARD === "EMSCRIPTEN" || process.env.BOARD === "EMSCRIPTEN2") { + // Emulator + BLECSC = require("blecsc-emu"); +} else { + // Actual hardware + BLECSC = require("blecsc"); +} +var blecsc = new BLECSC(); +var display = new CSCDisplay(); +var sensor = new CSCSensor(blecsc, display); + +E.on('kill',()=>{ + sensor.disconnect(); +}); + +Bangle.setUI("updown", d => { + sensor.interact(d); +}); + +Bangle.loadWidgets(); +sensor.connect(); diff --git a/apps/cycling/cycling.icon.js b/apps/cycling/cycling.icon.js new file mode 100644 index 000000000..12c597956 --- /dev/null +++ b/apps/cycling/cycling.icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4A/AH/OAAIuuGFYuEGFQv/ADOlwV8wK/qwN8AAelGAguiFogACWsulFw6SERcwAFSISLnSMuAFZWCGENWllWLRSZC0vOAAovWmUslkyvbqJwIuHGC4uBAARiDdAwueL4YACMQLmfX5IAFqwwoMIowpMQ4wpGIcywDiYAA2IAAgwGq2kFwIvGC5YtPDJIuCF4gXPFxQHLF44XQFxAKOF4oXRBg4LOFwYvEEag7OBgReQNZzLNF5IXPBJlXq4vVC5Qv8R9TXQFwbvYJBgLlNbYXRBoYOEA44XfCAgAFCxgXYDI4VPC7IA/AH4A/AH4AWA")) diff --git a/apps/cycling/icons8-cycling-48.png b/apps/cycling/icons8-cycling-48.png new file mode 100644 index 000000000..0bc83859f Binary files /dev/null and b/apps/cycling/icons8-cycling-48.png differ diff --git a/apps/cycling/metadata.json b/apps/cycling/metadata.json new file mode 100644 index 000000000..cb4260bb2 --- /dev/null +++ b/apps/cycling/metadata.json @@ -0,0 +1,17 @@ +{ + "id": "cycling", + "name": "Bangle Cycling", + "shortName": "Cycling", + "version": "0.01", + "description": "Display live values from a BLE CSC sensor", + "icon": "icons8-cycling-48.png", + "tags": "outdoors,exercise,ble,bluetooth", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"cycling.app.js","url":"cycling.app.js"}, + {"name":"cycling.settings.js","url":"settings.js"}, + {"name":"blecsc","url":"blecsc.js"}, + {"name":"cycling.img","url":"cycling.icon.js","evaluate": true} + ] +} diff --git a/apps/cycling/settings.js b/apps/cycling/settings.js new file mode 100644 index 000000000..76303379d --- /dev/null +++ b/apps/cycling/settings.js @@ -0,0 +1,57 @@ +// This file should contain exactly one function, which shows the app's settings +/** + * @param {function} back Use back() to return to settings menu + */ +(function(back) { + const storage = require('Storage') + const SETTINGS_FILE = 'cycling.json' + + // Set default values and merge with stored values + let settings = Object.assign({ + metric: true, + sensors: {}, + }, (storage.readJSON(SETTINGS_FILE, true) || {})); + + const menu = { + '': { 'title': 'Cycling' }, + '< Back': back, + 'Units': { + value: settings.metric, + format: v => v ? 'metric' : 'imperial', + onchange: (metric) => { + settings.metric = metric; + storage.writeJSON(SETTINGS_FILE, settings); + }, + }, + } + + const sensorMenus = {}; + for (var addr of Object.keys(settings.sensors)) { + // Define sub menu + sensorMenus[addr] = { + '': { title: addr }, + '< Back': () => E.showMenu(menu), + 'cm': { + value: settings.sensors[addr].cm, + min: 80, max: 240, step: 1, + onchange: (v) => { + settings.sensors[addr].cm = v; + storage.writeJSON(SETTINGS_FILE, settings); + }, + }, + '+ mm': { + value: settings.sensors[addr].mm, + min: 0, max: 9, step: 1, + onchange: (v) => { + settings.sensors[addr].mm = v; + storage.writeJSON(SETTINGS_FILE, settings); + }, + }, + }; + + // Add entry to main menu + menu[addr] = () => E.showMenu(sensorMenus[addr]); + } + + E.showMenu(menu); +}) diff --git a/apps/game1024/ChangeLog b/apps/game1024/ChangeLog index ffb1f94bc..8759fb428 100644 --- a/apps/game1024/ChangeLog +++ b/apps/game1024/ChangeLog @@ -2,4 +2,5 @@ 0.02: Temporary intermediate version 0.03: Basic colors 0.04: Bug fix score reset after Game Over, new icon -0.05: Chevron marker on the randomly added square \ No newline at end of file +0.05: Chevron marker on the randomly added square +0.06: Fixed issue 1609 added a message popup state handler to control unwanted screen redraw \ No newline at end of file diff --git a/apps/game1024/app.js b/apps/game1024/app.js index 9f6081376..133630634 100644 --- a/apps/game1024/app.js +++ b/apps/game1024/app.js @@ -144,6 +144,13 @@ const buttons = { }, add: function(btn) { this.all.push(btn); + }, + isPopUpActive: false, + activatePopUp: function() { + this.isPopUpActive = true; + }, + deActivatePopUp: function() { + this.isPopUpActive = false; } }; /** @@ -253,7 +260,6 @@ const dragThreshold = 10; const clickThreshold = 3; let allSquares = []; -// let buttons = []; class Button { constructor(name, x0, y0, width, height, text, bg, fg, cb, enabled) { @@ -483,6 +489,7 @@ function initGame() { Bangle.drawWidgets(); } function drawPopUp(message,cb) { + buttons.activatePopUp(); g.setColor('#FFFFFF'); let rDims = Bangle.appRect; g.fillPoly([rDims.x+10, rDims.y+20, @@ -505,6 +512,7 @@ function drawPopUp(message,cb) { g.drawString(message, rDims.x+20, rDims.y+20); buttons.add(btnYes); buttons.add(btnNo); + } function handlePopUpClicks(btn) { const name = btn.name; @@ -512,6 +520,7 @@ function handlePopUpClicks(btn) { buttons.all.pop(); // remove the yes button buttons.all.forEach(b => {b.enable();}); // enable the remaining buttons again debug(() => console.log("Button name =", name)); + buttons.deActivatePopUp(); switch (name) { case 'yes': resetGame(); @@ -568,14 +577,13 @@ function handleclick(e) { // Handle a drag event (moving the stones around) function handledrag(e) { - /*debug(Math.abs(e.dx) > Math.abs(e.dy) ? - (e.dx > 0 ? e => console.log('To the right') : e => console.log('To the left') ) : - (e.dy > 0 ? e => console.log('Move down') : e => console.log('Move up') )); - */ - // [move.right, move.left, move.up, move.down] - runGame((Math.abs(e.dx) > Math.abs(e.dy) ? - (e.dx > 0 ? mover.direction.right : mover.direction.left ) : - (e.dy > 0 ? mover.direction.down : mover.direction.up ))); + // Stop moving things around when the popup message is active + // Bangleapps issue #1609 + if (!(buttons.isPopUpActive)) { + runGame((Math.abs(e.dx) > Math.abs(e.dy) ? + (e.dx > 0 ? mover.direction.right : mover.direction.left ) : + (e.dy > 0 ? mover.direction.down : mover.direction.up ))); + } } // Evaluate "drag" events from the UI and call handlers for drags or clicks // The UI sends a drag as a series of events indicating partial movements diff --git a/apps/game1024/metadata.json b/apps/game1024/metadata.json index 557d77b89..73d7607f3 100644 --- a/apps/game1024/metadata.json +++ b/apps/game1024/metadata.json @@ -1,7 +1,7 @@ { "id": "game1024", "name": "1024 Game", "shortName" : "1024 Game", - "version": "0.05", + "version": "0.06", "icon": "game1024.png", "screenshots": [ {"url":"screenshot.png" } ], "readme":"README.md", diff --git a/apps/gsat/ChangeLog b/apps/gsat/ChangeLog new file mode 100644 index 000000000..48156d0d4 --- /dev/null +++ b/apps/gsat/ChangeLog @@ -0,0 +1 @@ +0.01: Added Source Code diff --git a/apps/gsat/README.md b/apps/gsat/README.md new file mode 100644 index 000000000..faf986947 --- /dev/null +++ b/apps/gsat/README.md @@ -0,0 +1,3 @@ +# Geek Squad Appointment Timer + +An app dedicated to setting a 20 minute timer for Geek Squad Appointments. diff --git a/apps/gsat/app-icon.js b/apps/gsat/app-icon.js new file mode 100644 index 000000000..06f93e2ef --- /dev/null +++ b/apps/gsat/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwIdah/wAof//4ECgYFB4AFBg4FB8AFBj/wh/4AoM/wEB/gFBvwCEBAU/AQP4gfAj8AgPwAoMPwED8AFBg/AAYIBDA4ngg4TB4EBApkPKgJSBJQIFTMgIFCJIIFDKoIFEvgFBGoMAnw7DP4IFEh+BAoItBg+DNIQwBMIaeCKoKxCPoIzCEgKVHUIqtFXIrFFaIrdFdIwAV")) diff --git a/apps/gsat/app.js b/apps/gsat/app.js new file mode 100644 index 000000000..3a7d443fe --- /dev/null +++ b/apps/gsat/app.js @@ -0,0 +1,38 @@ +// Clear screen +g.clear(); + +const secsinmin = 60; +const quickfixperiod = 900; +var seconds = 1200; + +function countSecs() { + if (seconds != 0) {seconds -=1;} + console.log(seconds); +} +function drawTime() { + g.clear(); + g.setFontAlign(0,0); + g.setFont('Vector', 12); + g.drawString('Geek Squad Appointment Timer', 125, 20); + if (seconds == 0) { + g.setFont('Vector', 35); + g.drawString('Appointment', 125, 100); + g.drawString('finished!', 125, 150); + Bangle.buzz(); + return; + } + min = seconds / secsinmin; + if (seconds < quickfixperiod) { + g.setFont('Vector', 20); + g.drawString('Quick Fix', 125, 50); + g.drawString('Period Passed!', 125, 75); + } + g.setFont('Vector', 50); + g.drawString(Math.ceil(min), 125, 125); + g.setFont('Vector', 25); + g.drawString('minutes', 125, 165); + g.drawString('remaining', 125, 195); +} +drawTime(); +setInterval(countSecs, 1000); +setInterval(drawTime, 60000); diff --git a/apps/gsat/app.png b/apps/gsat/app.png new file mode 100644 index 000000000..cf057046b Binary files /dev/null and b/apps/gsat/app.png differ diff --git a/apps/gsat/metadata.json b/apps/gsat/metadata.json new file mode 100644 index 000000000..878d213e4 --- /dev/null +++ b/apps/gsat/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "gsat", + "name": "Geek Squad Appointment Timer", + "shortName": "gsat", + "version": "0.01", + "description": "Starts a 20 minute timer for appointments at Geek Squad.", + "icon": "app.png", + "tags": "tool", + "readme": "README.md", + "supports": ["BANGLEJS"], + "screenshots": [{"url":"screenshot.png"}], + "storage": [ + {"name":"gsat.app.js","url":"app.js"}, + {"name":"gsat.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/gsat/screenshot.png b/apps/gsat/screenshot.png new file mode 100644 index 000000000..032319bf6 Binary files /dev/null and b/apps/gsat/screenshot.png differ diff --git a/apps/hrm/ChangeLog b/apps/hrm/ChangeLog index e559adfb6..f05a9dc13 100644 --- a/apps/hrm/ChangeLog +++ b/apps/hrm/ChangeLog @@ -6,3 +6,4 @@ 0.06: Add widgets 0.07: Update scaling for new firmware 0.08: Don't force backlight on/watch unlocked on Bangle 2 +0.09: Grey out BPM until confidence is over 50% diff --git a/apps/hrm/heartrate.js b/apps/hrm/heartrate.js index 305f0e1bc..703b60c01 100644 --- a/apps/hrm/heartrate.js +++ b/apps/hrm/heartrate.js @@ -35,9 +35,9 @@ function onHRM(h) { g.clearRect(0,24,g.getWidth(),80); g.setFont("6x8").drawString("Confidence "+hrmInfo.confidence+"%", px, 75); var str = hrmInfo.bpm; - g.setFontVector(40).drawString(str,px,45); + g.setFontVector(40).setColor(hrmInfo.confidence > 50 ? g.theme.fg : "#888").drawString(str,px,45); px += g.stringWidth(str)/2; - g.setFont("6x8"); + g.setFont("6x8").setColor(g.theme.fg); g.drawString("BPM",px+15,45); } Bangle.on('HRM', onHRM); @@ -101,4 +101,3 @@ function readHRM() { lastHrmPt = [hrmOffset, y]; } } - diff --git a/apps/hrm/metadata.json b/apps/hrm/metadata.json index 3e94c163c..10821d094 100644 --- a/apps/hrm/metadata.json +++ b/apps/hrm/metadata.json @@ -1,7 +1,7 @@ { "id": "hrm", "name": "Heart Rate Monitor", - "version": "0.08", + "version": "0.09", "description": "Measure your heart rate and see live sensor data", "icon": "heartrate.png", "tags": "health", diff --git a/apps/lightswitch/ChangeLog b/apps/lightswitch/ChangeLog index 7a7ecd027..4210ccf03 100644 --- a/apps/lightswitch/ChangeLog +++ b/apps/lightswitch/ChangeLog @@ -1,2 +1,3 @@ 0.01: New App! 0.02: Add the option to enable touching the widget only on clock and settings. +0.03: Settings page now uses built-in min/max/wrap (fix #1607) diff --git a/apps/lightswitch/metadata.json b/apps/lightswitch/metadata.json index 902b1536b..9ac388eda 100644 --- a/apps/lightswitch/metadata.json +++ b/apps/lightswitch/metadata.json @@ -2,7 +2,7 @@ "id": "lightswitch", "name": "Light Switch Widget", "shortName": "Light Switch", - "version": "0.02", + "version": "0.03", "description": "A fast way to switch LCD backlight on/off, change the brightness and show the lock status. All in one widget.", "icon": "images/app.png", "screenshots": [ diff --git a/apps/lightswitch/settings.js b/apps/lightswitch/settings.js index aac159148..bebb16d15 100644 --- a/apps/lightswitch/settings.js +++ b/apps/lightswitch/settings.js @@ -44,9 +44,11 @@ // return entry for string value return { value: entry.value.indexOf(settings[key]), + min : 0, + max : entry.value.length-1, + wrap : true, format: v => entry.title ? entry.title[v] : entry.value[v], onchange: function(v) { - this.value = v = v >= entry.value.length ? 0 : v < 0 ? entry.value.length - 1 : v; writeSetting(key, entry.value[v], entry.drawWidgets); if (entry.exec) entry.exec(entry.value[v]); } @@ -57,8 +59,10 @@ value: settings[key] * entry.factor, step: entry.step, format: v => v > 0 ? v + entry.unit : "off", + min : entry.min, + max : entry.max, + wrap : true, onchange: function(v) { - this.value = v = v > entry.max ? entry.min : v < entry.min ? entry.max : v; writeSetting(key, v / entry.factor, entry.drawWidgets); }, }; @@ -133,16 +137,16 @@ title: "Light Switch" }, "< Back": () => back(), - "-- Widget --------": 0, + "-- Widget": 0, "Bulb col": getEntry("colors"), "Image": getEntry("image"), - "-- Control -------": 0, + "-- Control": 0, "Touch": getEntry("touchOn"), "Drag Delay": getEntry("dragDelay"), "Min Value": getEntry("minValue"), - "-- Unlock --------": 0, + "-- Unlock": 0, "TapSide": getEntry("unlockSide"), - "-- Flash ---------": 0, + "-- Flash": 0, "TapSide ": getEntry("tapSide"), "Tap": getEntry("tapOn"), "Timeout": getEntry("tOut"), diff --git a/apps/recorder/ChangeLog b/apps/recorder/ChangeLog index 90937e160..e9877808c 100644 --- a/apps/recorder/ChangeLog +++ b/apps/recorder/ChangeLog @@ -17,3 +17,4 @@ 0.11: Fix KML and GPX export when there is no GPS data 0.12: Fix 'Back' label positioning on track/graph display, make translateable 0.13: Fix for when widget is used before app +0.14: Remove unneeded variable assignment \ No newline at end of file diff --git a/apps/recorder/metadata.json b/apps/recorder/metadata.json index e2400603d..d715af38d 100644 --- a/apps/recorder/metadata.json +++ b/apps/recorder/metadata.json @@ -2,7 +2,7 @@ "id": "recorder", "name": "Recorder", "shortName": "Recorder", - "version": "0.13", + "version": "0.14", "description": "Record GPS position, heart rate and more in the background, then download to your PC.", "icon": "app.png", "tags": "tool,outdoors,gps,widget", diff --git a/apps/recorder/widget.js b/apps/recorder/widget.js index 221bc6c1a..4a105754b 100644 --- a/apps/recorder/widget.js +++ b/apps/recorder/widget.js @@ -248,7 +248,7 @@ } var buttons={Yes:"yes",No:"no"}; if (newFileName) buttons["New"] = "new"; - var prompt = E.showPrompt("Overwrite\nLog " + settings.file.match(/\d+/)[0] + "?",{title:"Recorder",buttons:buttons}).then(selection=>{ + return E.showPrompt("Overwrite\nLog " + settings.file.match(/\d+/)[0] + "?",{title:"Recorder",buttons:buttons}).then(selection=>{ if (selection==="no") return false; // just cancel if (selection==="yes") { require("Storage").open(settings.file,"r").erase(); @@ -259,7 +259,6 @@ } return WIDGETS["recorder"].setRecording(1); }); - return prompt; } settings.recording = isOn; updateSettings(settings); diff --git a/apps/run/ChangeLog b/apps/run/ChangeLog index 401a68de9..46fdb7e7e 100644 --- a/apps/run/ChangeLog +++ b/apps/run/ChangeLog @@ -9,3 +9,4 @@ 0.08: Added support for notifications from exstats. Support all stats from exstats 0.09: Fix broken start/stop if recording not enabled (fix #1561) 0.10: Don't allow the same setting to be chosen for 2 boxes (fix #1578) +0.11: Notifications fixes \ No newline at end of file diff --git a/apps/run/app.js b/apps/run/app.js index d066c8b1f..fb8158e58 100644 --- a/apps/run/app.js +++ b/apps/run/app.js @@ -59,7 +59,7 @@ function onStartStop() { layout.render(); }) ); - } else { + } else if (!settings.record && WIDGETS["recorder"]) { prepPromises.push( WIDGETS["recorder"].setRecording(false) ); @@ -124,7 +124,7 @@ function configureNotification(stat) { } Object.keys(settings.notify).forEach((statType) => { - if (settings.notify[statType].increment > 0) { + if (settings.notify[statType].increment > 0 && exs.stats[statType]) { configureNotification(exs.stats[statType]); } }); diff --git a/apps/run/metadata.json b/apps/run/metadata.json index 51239d297..09e5a3bed 100644 --- a/apps/run/metadata.json +++ b/apps/run/metadata.json @@ -1,6 +1,6 @@ { "id": "run", "name": "Run", - "version":"0.10", + "version":"0.11", "description": "Displays distance, time, steps, cadence, pace and more for runners.", "icon": "app.png", "tags": "run,running,fitness,outdoors,gps", diff --git a/apps/run/settings.js b/apps/run/settings.js index 949f7a235..6a7d169c4 100644 --- a/apps/run/settings.js +++ b/apps/run/settings.js @@ -90,8 +90,8 @@ [[300, 1],[300, 0],[300, 1],[300, 0],[300, 1]], ]; notificationsMenu[/*LANG*/"Dist Pattern"] = { - value: Math.max(0,vibPatterns.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.dist.notifications))), - min: 0, max: vibPatterns.length, + value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.dist.notifications))), + min: 0, max: vibTimes.length, format: v => vibPatterns[v]||/*LANG*/"Off", onchange: v => { settings.notify.dist.notifications = vibTimes[v]; @@ -100,8 +100,8 @@ } } notificationsMenu[/*LANG*/"Step Pattern"] = { - value: Math.max(0,vibPatterns.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.step.notifications))), - min: 0, max: vibPatterns.length, + value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.step.notifications))), + min: 0, max: vibTimes.length, format: v => vibPatterns[v]||/*LANG*/"Off", onchange: v => { settings.notify.step.notifications = vibTimes[v]; @@ -110,8 +110,8 @@ } } notificationsMenu[/*LANG*/"Time Pattern"] = { - value: Math.max(0,vibPatterns.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.time.notifications))), - min: 0, max: vibPatterns.length, + value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.time.notifications))), + min: 0, max: vibTimes.length, format: v => vibPatterns[v]||/*LANG*/"Off", onchange: v => { settings.notify.time.notifications = vibTimes[v]; diff --git a/apps/touchtimer/ChangeLog b/apps/touchtimer/ChangeLog index 01904c6ea..0969a3da4 100644 --- a/apps/touchtimer/ChangeLog +++ b/apps/touchtimer/ChangeLog @@ -1,2 +1,4 @@ 0.01: Initial creation of the touch timer app -0.02: Add settings menu \ No newline at end of file +0.02: Add settings menu +0.03: Add ability to repeat last timer +0.04: Add 5 second count down buzzer diff --git a/apps/touchtimer/app.js b/apps/touchtimer/app.js index ffa1af80a..c2f2fb5e9 100644 --- a/apps/touchtimer/app.js +++ b/apps/touchtimer/app.js @@ -126,6 +126,14 @@ var main = () => { timerIntervalId = setInterval(() => { timerCountDown.draw(); + // Buzz lightly when there are less then 5 seconds left + if (settings.countDownBuzz) { + var remainingSeconds = timerCountDown.getAdjustedTime().seconds; + if (remainingSeconds <= 5 && remainingSeconds > 0) { + Bangle.buzz(); + } + } + if (timerCountDown.isFinished()) { buttonStartPause.value = "FINISHED!"; buttonStartPause.draw(); @@ -141,6 +149,13 @@ var main = () => { if (buzzCount >= settings.buzzCount) { clearInterval(buzzIntervalId); buzzIntervalId = undefined; + + buttonStartPause.value = "REPEAT"; + buttonStartPause.draw(); + buttonStartPause.value = "START"; + timerCountDown = undefined; + timerEdit.draw(); + return; } else { Bangle.buzz(settings.buzzDuration * 1000, 1); diff --git a/apps/touchtimer/metadata.json b/apps/touchtimer/metadata.json index 645a0ce18..0f2b9f491 100644 --- a/apps/touchtimer/metadata.json +++ b/apps/touchtimer/metadata.json @@ -2,7 +2,7 @@ "id": "touchtimer", "name": "Touch Timer", "shortName": "Touch Timer", - "version": "0.02", + "version": "0.04", "description": "Quickly and easily create a timer with touch-only input. The time can be easily set with a number pad.", "icon": "app.png", "tags": "tools", diff --git a/apps/touchtimer/settings.js b/apps/touchtimer/settings.js index 885670f57..79424f250 100644 --- a/apps/touchtimer/settings.js +++ b/apps/touchtimer/settings.js @@ -31,6 +31,14 @@ writeSettings(settings); }, }, + "CountDown Buzz": { + value: !!settings.countDownBuzz, + format: value => value?"On":"Off", + onchange: (value) => { + settings.countDownBuzz = value; + writeSettings(settings); + }, + }, "Pause Between": { value: settings.pauseBetween, min: 1, diff --git a/apps/waveclk/ChangeLog b/apps/waveclk/ChangeLog index 8c2a33143..f1fb77c59 100644 --- a/apps/waveclk/ChangeLog +++ b/apps/waveclk/ChangeLog @@ -1,2 +1,3 @@ 0.01: New App! 0.02: Load widgets after setUI so widclk knows when to hide +0.03: Show the day of the week diff --git a/apps/waveclk/app.js b/apps/waveclk/app.js index f1c67ce2f..18b28500b 100644 --- a/apps/waveclk/app.js +++ b/apps/waveclk/app.js @@ -41,6 +41,7 @@ function draw() { var date = new Date(); var timeStr = require("locale").time(date,1); var dateStr = require("locale").date(date).toUpperCase(); + var dowStr = require("locale").dow(date).toUpperCase(); // draw time g.setFontAlign(0,0).setFont("ZCOOL"); g.drawString(timeStr,x,y); @@ -48,6 +49,9 @@ function draw() { y += 35; g.setFontAlign(0,0,1).setFont("6x8"); g.drawString(dateStr,g.getWidth()-8,g.getHeight()/2); + // draw the day of the week + g.setFontAlign(0,0,3).setFont("6x8"); + g.drawString(dowStr,8,g.getHeight()/2); // queue draw in one minute queueDraw(); } diff --git a/apps/waveclk/metadata.json b/apps/waveclk/metadata.json index a8d270da2..9ba2798ff 100644 --- a/apps/waveclk/metadata.json +++ b/apps/waveclk/metadata.json @@ -1,7 +1,7 @@ { "id": "waveclk", "name": "Wave Clock", - "version": "0.02", + "version": "0.03", "description": "A clock using a wave image by [Lillith May](https://www.instagram.com/_lilustrations_/)", "icon": "app.png", "screenshots": [{"url":"screenshot.png"}], diff --git a/apps/waveclk/screenshot.png b/apps/waveclk/screenshot.png index 7f05ce688..161ef96ef 100644 Binary files a/apps/waveclk/screenshot.png and b/apps/waveclk/screenshot.png differ diff --git a/apps/waypointer/ChangeLog b/apps/waypointer/ChangeLog new file mode 100644 index 000000000..1b584f7dd --- /dev/null +++ b/apps/waypointer/ChangeLog @@ -0,0 +1,2 @@ +0.01: New app! +0.02: Make Bangle.js 2 compatible diff --git a/apps/waypointer/README.md b/apps/waypointer/README.md index e98fdbb7e..c0b4c5125 100644 --- a/apps/waypointer/README.md +++ b/apps/waypointer/README.md @@ -24,7 +24,7 @@ need to travel in to reach the selected waypoint. The blue text is the name of the current waypoint. NONE means that there is no waypoint set and so bearing and distance will remain at 0. To select a waypoint, press BTN2 (middle) and wait for the blue text to turn -white. Then use BTN1 and BTN3 to select a waypoint. The waypoint +white. Then use BTN1 and BTN3 (swipe up/down on Bangle.js 2) to select a waypoint. The waypoint choice is fixed by pressing BTN2 again. In the screen shot below a waypoint giving the location of Stone Henge has been selected. diff --git a/apps/waypointer/app.js b/apps/waypointer/app.js index d3aab7c50..615fbbc36 100644 --- a/apps/waypointer/app.js +++ b/apps/waypointer/app.js @@ -1,24 +1,25 @@ -var pal_by = new Uint16Array([0x0000,0xFFC0],0,1); // black, yellow -var pal_bw = new Uint16Array([0x0000,0xffff],0,1); // black, white -var pal_bb = new Uint16Array([0x0000,0x07ff],0,1); // black, blue +const scale = g.getWidth()/240; +var pal_by = new Uint16Array([g.getBgColor(),0xFFC0],0,1); // black, yellow +var pal_bw = new Uint16Array([g.getBgColor(),g.getColor()],0,1); // black, white +var pal_bb = new Uint16Array([g.getBgColor(),0x07ff],0,1); // black, blue // having 3 2 color pallette keeps the memory requirement lower -var buf1 = Graphics.createArrayBuffer(160,160,1, {msb:true}); -var buf2 = Graphics.createArrayBuffer(80,40,1, {msb:true}); +var buf1 = Graphics.createArrayBuffer(160*scale,160*scale,1, {msb:true}); +var buf2 = Graphics.createArrayBuffer(g.getWidth()/3,40*scale,1, {msb:true}); var arrow_img = require("heatshrink").decompress(atob("lEowIPMjAEDngEDvwED/4DCgP/wAEBgf/4AEBg//8AEBh//+AEBj///AEBn///gEBv///wmCAAImCAAIoBFggE/AkaaEABo=")); function flip1(x,y) { - g.drawImage({width:160,height:160,bpp:1,buffer:buf1.buffer, palette:pal_by},x,y); + g.drawImage({width:160*scale,height:160*scale,bpp:1,buffer:buf1.buffer, palette:pal_by},x,y); buf1.clear(); } function flip2_bw(x,y) { - g.drawImage({width:80,height:40,bpp:1,buffer:buf2.buffer, palette:pal_bw},x,y); + g.drawImage({width:g.getWidth()/3,height:40*scale,bpp:1,buffer:buf2.buffer, palette:pal_bw},x,y); buf2.clear(); } function flip2_bb(x,y) { - g.drawImage({width:80,height:40,bpp:1,buffer:buf2.buffer, palette:pal_bb},x,y); + g.drawImage({width:g.getWidth()/3,height:40*scale,bpp:1,buffer:buf2.buffer, palette:pal_bb},x,y); buf2.clear(); } @@ -51,12 +52,12 @@ function drawCompass(course) { previous.course = course; buf1.setColor(1); - buf1.fillCircle(80,80,79,79); + buf1.fillCircle(buf1.getWidth()/2,buf1.getHeight()/2,79*scale); buf1.setColor(0); - buf1.fillCircle(80,80,69,69); + buf1.fillCircle(buf1.getWidth()/2,buf1.getHeight()/2,69*scale); buf1.setColor(1); - buf1.drawImage(arrow_img, 80, 80, {scale:3, rotate:radians(course)} ); - flip1(40, 30); + buf1.drawImage(arrow_img, buf1.getWidth()/2, buf1.getHeight()/2, {scale:3*scale, rotate:radians(course)} ); + flip1(40*scale, Bangle.appRect.y+6*scale); } /***** COMPASS CODE ***********/ @@ -138,7 +139,7 @@ function distance(a,b){ function drawN(){ - buf2.setFont("Vector",24); + buf2.setFont("Vector",24*scale); var bs = wp_bearing.toString(); bs = wp_bearing<10?"00"+bs : wp_bearing<100 ?"0"+bs : bs; var dst = loc.distance(dist); @@ -147,12 +148,12 @@ function drawN(){ // show distance on the left if (previous.dst !== dst) { - previous.dst = dst + previous.dst = dst; buf2.setColor(1); buf2.setFontAlign(-1,-1); - buf2.setFont("Vector", 20); + buf2.setFont("Vector", 20*scale); buf2.drawString(dst,0,0); - flip2_bw(0, 200); + flip2_bw(0, g.getHeight()-40*scale); } // bearing, place in middle at bottom of compass @@ -160,9 +161,9 @@ function drawN(){ previous.bs = bs; buf2.setColor(1); buf2.setFontAlign(0, -1); - buf2.setFont("Vector",38); - buf2.drawString(bs,40,0); - flip2_bw(80, 200); + buf2.setFont("Vector",38*scale); + buf2.drawString(bs,40*scale,0); + flip2_bw(g.getWidth()/3, g.getHeight()-40*scale); } // waypoint name on right @@ -170,13 +171,13 @@ function drawN(){ previous.selected = selected; buf2.setColor(1); buf2.setFontAlign(1,-1); // right, bottom - buf2.setFont("Vector", 20); - buf2.drawString(wp.name, 80, 0); + buf2.setFont("Vector", 20*scale); + buf2.drawString(wp.name, 80*scale, 0); if (selected) - flip2_bw(160, 200); + flip2_bw(g.getWidth()/3*2, g.getHeight()-40*scale); else - flip2_bb(160, 200); + flip2_bb(g.getWidth()/3*2, g.getHeight()-40*scale); } } @@ -229,9 +230,11 @@ function startdraw(){ } function setButtons(){ - setWatch(nextwp.bind(null,-1), BTN1, {repeat:true,edge:"falling"}); - setWatch(doselect, BTN2, {repeat:true,edge:"falling"}); - setWatch(nextwp.bind(null,1), BTN3, {repeat:true,edge:"falling"}); + Bangle.setUI("updown", d=>{ + if (d<0) { nextwp(-1); } + else if (d>0) { nextwp(1); } + else { doselect(); } + }); } Bangle.on('lcdPower',function(on) { diff --git a/apps/waypointer/metadata.json b/apps/waypointer/metadata.json index cb477107b..111259bbc 100644 --- a/apps/waypointer/metadata.json +++ b/apps/waypointer/metadata.json @@ -1,11 +1,11 @@ { "id": "waypointer", "name": "Way Pointer", - "version": "0.01", + "version": "0.02", "description": "Navigate to a waypoint using the GPS for bearing and compass to point way, uses the same waypoint interface as GPS Navigation", "icon": "waypointer.png", "tags": "tool,outdoors,gps", - "supports": ["BANGLEJS"], + "supports": ["BANGLEJS", "BANGLEJS2"], "readme": "README.md", "interface": "waypoints.html", "storage": [ diff --git a/gadgetbridge.js b/gadgetbridge.js new file mode 100644 index 000000000..679fffc60 --- /dev/null +++ b/gadgetbridge.js @@ -0,0 +1,162 @@ +/* Detects if we're running under Gadgetbridge in a WebView, and if +so it overwrites the 'Puck' library with a special one that calls back +into Gadgetbridge to handle watch communications */ + +/*// test code +Android = { + bangleTx : function(data) { + console.log("TX : "+JSON.stringify(data)); + } +};*/ + +if (typeof Android!=="undefined") { + console.log("Running under Gadgetbridge, overwrite Puck library"); + + var isBusy = false; + var queue = []; + var connection = { + cb : function(data) {}, + write : function(data, writecb) { + Android.bangleTx(data); + Puck.writeProgress(data.length, data.length); + if (writecb) setTimeout(writecb,10); + }, + close : function() {}, + received : "", + hadData : false + } + + function bangleRx(data) { +// document.getElementById("status").innerText = "RX:"+data; + connection.received += data; + connection.hadData = true; + if (connection.cb) connection.cb(data); + } + + function log(level, s) { + if (Puck.log) Puck.log(level, s); + } + + function handleQueue() { + if (!queue.length) return; + var q = queue.shift(); + log(3,"Executing "+JSON.stringify(q)+" from queue"); + if (q.type == "write") Puck.write(q.data, q.callback, q.callbackNewline); + else log(1,"Unknown queue item "+JSON.stringify(q)); + } + + /* convenience function... Write data, call the callback with data: + callbackNewline = false => if no new data received for ~0.2 sec + callbackNewline = true => after a newline */ + function write(data, callback, callbackNewline) { + let result; + /// If there wasn't a callback function, then promisify + if (typeof callback !== 'function') { + callbackNewline = callback; + + result = new Promise((resolve, reject) => callback = (value, err) => { + if (err) reject(err); + else resolve(value); + }); + } + + if (isBusy) { + log(3, "Busy - adding Puck.write to queue"); + queue.push({type:"write", data:data, callback:callback, callbackNewline:callbackNewline}); + return result; + } + + var cbTimeout; + function onWritten() { + if (callbackNewline) { + connection.cb = function(d) { + var newLineIdx = connection.received.indexOf("\n"); + if (newLineIdx>=0) { + var l = connection.received.substr(0,newLineIdx); + connection.received = connection.received.substr(newLineIdx+1); + connection.cb = undefined; + if (cbTimeout) clearTimeout(cbTimeout); + cbTimeout = undefined; + if (callback) + callback(l); + isBusy = false; + handleQueue(); + } + }; + } + // wait for any received data if we have a callback... + var maxTime = 300; // 30 sec - Max time we wait in total, even if getting data + var dataWaitTime = callbackNewline ? 100/*10 sec if waiting for newline*/ : 3/*300ms*/; + var maxDataTime = dataWaitTime; // max time we wait after having received data + cbTimeout = setTimeout(function timeout() { + cbTimeout = undefined; + if (maxTime) maxTime--; + if (maxDataTime) maxDataTime--; + if (connection.hadData) maxDataTime=dataWaitTime; + if (maxDataTime && maxTime) { + cbTimeout = setTimeout(timeout, 100); + } else { + connection.cb = undefined; + if (callback) + callback(connection.received); + isBusy = false; + handleQueue(); + connection.received = ""; + } + connection.hadData = false; + }, 100); + } + + if (!connection.txInProgress) connection.received = ""; + isBusy = true; + connection.write(data, onWritten); + return result + } + + // ---------------------------------------------------------- + + Puck = { + /// Are we writing debug information? 0 is no, 1 is some, 2 is more, 3 is all. + debug : Puck.debug, + /// Should we use flow control? Default is true + flowControl : true, + /// Used internally to write log information - you can replace this with your own function + log : function(level, s) { if (level <= this.debug) console.log(" "+s)}, + /// Called with the current send progress or undefined when done - you can replace this with your own function + writeProgress : Puck.writeProgress, + connect : function(callback) { + setTimeout(callback, 10); + }, + write : write, + eval : function(expr, cb) { + const response = write('\x10Bluetooth.println(JSON.stringify(' + expr + '))\n', true) + .then(function (d) { + try { + return JSON.parse(d); + } catch (e) { + log(1, "Unable to decode " + JSON.stringify(d) + ", got " + e.toString()); + return Promise.reject(d); + } + }); + if (cb) { + return void response.then(cb, (err) => cb(null, err)); + } else { + return response; + } + }, + isConnected : function() { return true; }, + getConnection : function() { return connection; }, + close : function() { + if (connection) + connection.close(); + }, + }; + // no need for header + document.getElementsByTagName("header")[0].style="display:none"; + // force connection attempt automatically + setTimeout(function() { + getInstalledApps(true).catch(err => { + showToast("Device connection failed, "+err,"error"); + }); + }, 100); +} diff --git a/index.html b/index.html index a418b48eb..bd8ddea5a 100644 --- a/index.html +++ b/index.html @@ -179,5 +179,6 @@ + diff --git a/modules/exstats.js b/modules/exstats.js index b106622d0..ec0a838a7 100644 --- a/modules/exstats.js +++ b/modules/exstats.js @@ -139,9 +139,9 @@ Bangle.on("GPS", function(fix) { if (stats["pacea"]) stats["pacea"].emit("changed",stats["pacea"]); if (stats["pacec"]) stats["pacec"].emit("changed",stats["pacec"]); if (stats["speed"]) stats["speed"].emit("changed",stats["speed"]); - if (state.notify.dist.increment > 0 && state.notify.dist.next <= stats["dist"]) { + if (state.notify.dist.increment > 0 && state.notify.dist.next <= state.distance) { stats["dist"].emit("notify",stats["dist"]); - state.notify.dist.next = stats["dist"] + state.notify.dist.increment; + state.notify.dist.next = state.notify.dist.next + state.notify.dist.increment; } }); @@ -152,7 +152,7 @@ Bangle.on("step", function(steps) { state.lastStepCount = steps; if (state.notify.step.increment > 0 && state.notify.step.next <= steps) { stats["step"].emit("notify",stats["step"]); - state.notify.step.next = steps + state.notify.step.increment; + state.notify.step.next = state.notify.step.next + state.notify.step.increment; } }); Bangle.on("HRM", function(h) { @@ -285,7 +285,7 @@ exports.getStats = function(statIDs, options) { } if (state.notify.time.increment > 0 && state.notify.time.next <= now) { stats["time"].emit("notify",stats["time"]); - state.notify.time.next = now + state.notify.time.increment; + state.notify.time.next = state.notify.time.next + state.notify.time.increment; } }, 1000); function reset() { @@ -299,6 +299,8 @@ exports.getStats = function(statIDs, options) { state.curSpeed = 0; state.BPM = 0; state.BPMage = 0; + state.thisGPS = {}; + state.lastGPS = {}; state.notify = options.notify; if (options.notify.dist.increment > 0) { state.notify.dist.next = state.distance + options.notify.dist.increment; @@ -338,7 +340,7 @@ exports.appendMenuItems = function(menu, settings, saveSettings) { } exports.appendNotifyMenuItems = function(menu, settings, saveSettings) { var distNames = ['Off', "1000m","1 mile","1/2 Mthn", "Marathon",]; - var distAmts = [0, 1000,1609,21098,42195]; + 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),