diff --git a/apps/pushups/ChangeLog b/apps/pushups/ChangeLog new file mode 100644 index 000000000..6d4fa6441 --- /dev/null +++ b/apps/pushups/ChangeLog @@ -0,0 +1 @@ +0.1: Initial code diff --git a/apps/pushups/README.md b/apps/pushups/README.md new file mode 100644 index 000000000..df2fe5358 --- /dev/null +++ b/apps/pushups/README.md @@ -0,0 +1,34 @@ +# Pushups + +Pushups is an exercising app with a twist : the accelerometer. + +![Screenshot](shot_pushups.png) + +I initially just wanted a pushups counter but i kind of got out of hand. + +The accelerometer will work on the following exercises : + +- pushups +- situps +- squats +- jumping jacks + +For each of them it will try to detect two positions (for example up and down for pushups) +and buzz on each change. You can set up a target counter for each exercise. + +Precision is not 100% but it's good for me and kind of increases my motivation. + +Other activities are time based like + +- plank +- rest + + +Define your training routine, set a duration and you're ready to go. + + +## Creator + +Feel free to give me feedback : is it useful for you ? what other features would you like ? + +frederic.wagner@imag.fr diff --git a/apps/pushups/app-icon.js b/apps/pushups/app-icon.js new file mode 100644 index 000000000..3f98295ad --- /dev/null +++ b/apps/pushups/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwhC/AH4A/AH4A/ACcEogXVolEoAuVAAIXYhvdC6vdAAPQL6QuBAAaPRhtNDAgyQDQIXE7qYPC48iMaFM5gWC6kikAXPpvMDAXSC6BIBhoYB5oXBJB4XBOQPc5lCC4IYPC4StBCwUikgvQAAMCC4TlCeZgHFC4YYKFwoADkgXCDBIuHEQgACOpZTKC6oNGiIASC7YA/AH4A/AH4AdA")) diff --git a/apps/pushups/app.js b/apps/pushups/app.js new file mode 100644 index 000000000..102a0a141 --- /dev/null +++ b/apps/pushups/app.js @@ -0,0 +1,490 @@ +let h = require("heatshrink"); + +//TODO: should we force not moving for planks and rests + +const ACTIVITIES = ["pushups", "situps", "squats", "plank", "jacks", "rest"]; +const IMAGES = [ + h.decompress( + atob( + "mEwwhC/AH4A/AH4A/ACcEogXVolEoAuVAAIXYhvdC6vdAAPQL6QuBAAaPRhtNDAgyQDQIXE7qYPC48iMaFM5gWC6kikAXPpvMDAXSC6BIBhoYB5oXBJB4XBOQPc5lCC4IYPC4StBCwUikgvQAAMCC4TlCeZgHFC4YYKFwoADkgXCDBIuHEQgACOpZTKC6oNGiIASC7YA/AH4A/AH4AdA" + ) + ), + h.decompress( + atob( + "mEwwhC/AH4A/ADlEogWUggXBoAuVAAQXXDKQXGJaAXXO4RhVCYSSWGQTZWFy8wC6c0olDmYXTmdEmYXVAARITC4YwTgYXHkUgC6JICgUiDBxIFCwQwTIoIADC6MyC4hIRCwgwRFggwPC4JFESKRbGJB4uFYSMDLI5IPIA7aQkUwA4hIBE5AASC7IA/AH4A/ACwA=" + ) + ), + h.decompress( + atob( + "mEwwhC/AFUN7vQC6ndAAIuVAAIwTC/53fU7AAugUjmczmRjCPp4VBAAM9C4h+MgYXDloXRCwczCwpHMCoU4u51RIwc3uAXVw4XSLoZGSL35e/AB0IxAADxvdAAIXX6AX8gBfWGAcikgWCC54ADhpGRAAguDC65GWL35eyF4ZGTC7BJCCygA+" + ) + ), + h.decompress( + atob( + "mEwwhC/AH4A/AH4A/AHkEolAC6lEAAIuVAAIwTC6nd7oXTCoIAB6AXPgVNCwYwCO5siCopIEFxUCkUtC44AMkQX/CowACUogXLQAIXHbAgAHV4QXFTwa7IbwdEoQWCEg4sIC4kgHhAtJC4RULAAJQBC4qZKE4oXENwYXPP5KGLFJgA/AH4A/AH4AUA==" + ) + ), + h.decompress( + atob( + "mEwwhC/AFdEogWUggXBoAuVC7BIUC7RHPE4h3GAwKJPIwiVLBYQMHMgZNJBoY+EBBAYKoUikUkSaAXJSRxjBCwIXCeKMEC4UiCyIABC4bvTC/4AGhvdAAQXX6AXRCwYwSFwgwSCwoXQFwfQAgYXSGggwOFIowQCAQoEGB4nHD44XJBwxINEwRQOBo4MIJBouDmYACBQwAKgYWDAAMwa5wXCAoYXRADBIEIyQAGDJoWCCAgHHCxANIDBkDBawA/ADQA==" + ) + ), + h.decompress( + atob( + "mEwwhC/AH4AU1QAGC/4XXAAkKC6wWB0Au/F3KvPFw4XOLpAXOLpaBKRhgMBEZAKKHYYKKK5IjLC5alNC5BSOC5AuLe5guKABQuWd54XYgUiAAUgC7IAPC64ACC4IWUGIIu/F2a/va7QA/AH4AGA=" + ) + ) +]; + +// number of movements or duration required for each activity +const DEFAULTS = [7, 10, 10, 30, 15, 30]; + +// detector sensitivity for each activity +// (less is more reactive but more sensitive to noise) +const COUNTS = [6, 10, 6, 6, 6, 5]; + +function default_config() { + return {duration: 10*60, routine: default_routine()}; +} + +function default_routine() { + let routine = []; + DEFAULTS.forEach((d, i) => { + routine.push([i, d]); + }); + return routine; +} + +const DETECTORS = [ + (xyz) => { + "ram" + if (xyz.y > 0.15) { + return 1; + } else if (xyz.y < 0.1) { + return 0; + } else { + return null; + } + }, + (xyz) => { + "ram" + if (xyz.x > 0.075) { + return 1; + } else if (xyz.x < -0.075) { + return 0; + } else { + return null; + } + // return xyz.x > 0 ? 1 : 0; + }, + (xyz) => { + "ram" + if (xyz.z > -0.4) { + return 0; + } else if (xyz.z < -0.6) { + return 1; + } else { + return null; + } + }, + null, + (xyz) => { + "ram" + if (xyz.x < -0.8) { + return 0; + } + if (xyz.x > 0.3) { + return 1; + } + return null; + }, + null +]; + +class FitnessStatus { + constructor(config) { + this.routine = config.routine; + this.routine_step = 0; + this.current_status = 0; + this.buzzing = false; + + // to get rid of noise we'll need to count how many measures confirm where we think we are + this.counts_in_opposite_status = 0; + this.remaining = this.routine[this.routine_step][1]; + this.activity_start = getTime(); + this.starting_time = this.activity_start; + this.duration = config.duration; + this.completed = false; + } + + display() { + g.setBgColor(g.theme.bg); + g.clear(); + g.setColor(g.theme.fg); + if (this.completed) { + g.setFont("Vector:32") + .setFontAlign(0, 0) + .drawString("Good Job!", g.getWidth()/2, g.getHeight()/2); + return; + } + let activity = this.routine[this.routine_step][0]; + let countdown = this.remaining; + if (DETECTORS[activity] === null) { + countdown = this.remaining - Math.floor(getTime() - this.activity_start); + } + g.setFont("Vector:70") + .setFontAlign(0, 0) + .drawString("" + countdown, (g.getWidth() * 3) / 10, g.getHeight() / 2); + let activity_name = ACTIVITIES[activity]; + g.drawImage(IMAGES[activity], g.getWidth() / 2, (g.getHeight() * 1) / 5, { + scale: 2, + }); + let global_countdown = ""; + if (this.duration !== null) { + let elapsed = getTime() - this.starting_time; + let remaining = Math.max(0, this.duration - elapsed); + let seconds = Math.floor(remaining % 60); + let minutes = Math.floor(remaining / 60) % 60; + let hours = Math.floor(remaining / 3600); + if (hours > 0) { + global_countdown = " / " + hours + "h" + minutes +"m" + seconds + "s"; + } else if (minutes > 0) { + global_countdown = " / " + minutes +"m" + seconds + "s"; + } else { + global_countdown = " / " + seconds + "s"; + } + } + g.setFont("6x8:2") + .setFontAlign(0, 1) + .drawString(activity_name+global_countdown, g.getWidth() / 2, g.getHeight()); + Bangle.drawWidgets(); + g.flip(); + } + + first_activity() { + return this.routine_step == 0; + } + + last_activity() { + return this.routine_step == this.routine.length - 1; + } + + next_activity() { + this.routine_step += 1; + + this.completed = (this.duration===null)?(this.routine_step >= this.routine_length):(getTime() - this.starting_time > this.duration); + + if (this.completed) { + Bangle.buzz(1000).then(() => { + Bangle.setPollInterval(80); // default poll interval + Bangle.accelWr(0x18, 0b01101100); // off, +-4g + Bangle.accelWr(0x1B, 0x0); // default 12.5hz output + Bangle.accelWr(0x18, 0b11101100); // +-4g + load(); + }); + return; + } + this.routine_step = this.routine_step % this.routine.length; + this.remaining = this.routine[this.routine_step][1]; + // this.display(); + this.activity_start = getTime(); + this.current_status = 0; + this.counts_in_opposite_status = 0; + this.buzzing = false; + } + + previous_activity() { + this.routine_step -= 1; + this.remaining = this.routine[this.routine_step][1]; + // this.display(); + this.activity_start = getTime(); + this.current_status = 0; + this.counts_in_opposite_status = 0; + } + + detect(xyz) { + if (this.buzzing) { + return; + } + let activity = this.routine[this.routine_step][0]; + let detector = DETECTORS[activity]; + let status = this; + if (detector === null) { + // it's time based + let activity_duration = getTime() - this.activity_start; + if (activity_duration > this.remaining) { + this.buzzing = true; + Bangle.buzz(500).then(() => { + status.next_activity(); + }); + } + return; + } + // it's movement based + let new_status = DETECTORS[activity](xyz); + if (new_status === null) { + return; + } + if (new_status != this.current_status) { + this.counts_in_opposite_status += 1; + + if (this.counts_in_opposite_status == COUNTS[activity]) { + this.current_status = 1 - this.current_status; + this.counts_in_opposite_status = 0; + if (this.current_status == 0) { + this.remaining -= 1; + // this.display(); + if (this.remaining == 0) { + this.buzzing = true; + Bangle.buzz(500).then(() => { + status.next_activity(); + }); + } + } + Bangle.buzz(100); + } + } else { + this.counts_in_opposite_status = 0; + } + } +} + + +function start_routine(config) { + let status = new FitnessStatus(config); + + Bangle.accelWr(0x18,0b01110100); // off, +-8g // NOTE: this code is taken from 'accelrec' app + Bangle.accelWr(0x1B,0x03 | 0x40); // 100hz output, ODR/2 filter + Bangle.accelWr(0x18,0b11110100); // +-8g + Bangle.setPollInterval(10); + + status.remaining = status.routine[status.routine_step][1]; + Bangle.loadWidgets(); + + Bangle.on("swipe", function (directionLR, directionUD) { + if (directionUD == -1) { + status.remaining += 1; + } else if (directionUD == 1) { + status.remaining = Math.max(status.remaining - 1, 1); + } else if (directionLR == -1) { + if (!status.last_activity()) { + status.next_activity(); + } + } else if (directionLR == 1) { + if (!status.first_activity()) { + status.previous_activity(); + } + } + // status.display(); + }); + + Bangle.on("accel", function (xyz) { + status.detect(xyz); + }); + + setInterval(() => { + status.display(); + }, 350); + +} + + +function edit_menu(config) { + let routine = config.routine; + + E.showScroller({ + h : 60, + c : routine.length+2, + draw : function(idx, r) { + g.setColor(g.theme.fg).drawRect(r.x, r.y, r.w, r.h); + if (idx == routine.length + 1) { + g.setFont("Vector:28").setFontAlign(0, 0).drawString("Ok", r.x+r.w/2, r.y+r.h/2); + } else if (idx == routine.length) { + g.setFont("Vector:28").setFontAlign(0, 0).drawString("Add", r.x+r.w/2, r.y+r.h/2); + } else { + let activity = routine[idx][0]; + let count = routine[idx][1]; + let img = IMAGES[activity]; + g.drawImage(img, r.x + r.w / 5, r.y + 10); + g.setFont("6x8:2").setFontAlign(0, 0).drawString(""+count, r.x+r.w*4/5, r.y+r.h/2); + } + }, + select : function(idx) { + if (idx == routine.length + 1) { + E.showScroller(); + set_duration(config); + } else if (idx == routine.length) { + E.showScroller(); + add_activity(config); + } else { + E.showScroller(); + set_counter(config, idx); + } + } + }); +} + + +function add_activity(config) { + E.showScroller({ + h : 60, + c : IMAGES.length, + draw : function(idx, r) { + g.setColor(g.theme.fg).drawRect(r.x, r.y, r.w, r.h); + let img = IMAGES[idx]; + g.drawImage(img, r.x + r.w / 3, r.y + 10); + }, + select : function(idx) { + let new_index = config.routine.length; + config.routine.push([idx, 10]); + E.showScroller(); + set_counter(config, new_index); + } + }); +} + + +function set_counter(config, index) { + let w = g.getWidth(); + let h = g.getHeight(); + let counter = config.routine[index][1]; + function display() { + g.clear(); + g.setFont("6x8:2") + .setFontAlign(1, 0) + .drawString("+1", w, h/2); + g.setFontAlign(-1, 0) + .drawString("-1", 0, h/2); + g.setFontAlign(0, -1) + .drawString("+5", w/2, 0); + g.setFontAlign(0, 1) + .drawString("-5", w/2, h); + g.setFont("Vector:64") + .setFontAlign(0, 0) + .drawString(""+counter, w/2, h/2); + } + display(); + Bangle.on("swipe", function (directionLR, directionUD) { + if (directionUD == -1) { + counter += 5; + } else if (directionUD == 1) { + counter -= 5; + } else if (directionLR == -1) { + counter -= 1; + } else if (directionLR == 1) { + counter += 1; + } + if (counter < 0) { + counter = 0; + } + display(); + }); + Bangle.on("touch", function(button, xy) { + if (counter == 0) { + config.routine.splice(index, 1); + } else { + config.routine[index][1] = counter; + } + Bangle.removeAllListeners("touch"); + Bangle.removeAllListeners("swipe"); + edit_menu(config); + }); + +} + + +//TODO: factorize code with set_counter +function set_duration(config) { + let w = g.getWidth(); + let h = g.getHeight(); + let duration = config.duration; + let minutes = Math.floor(duration / 60); + function display() { + g.setBgColor(g.theme.bg); + g.clear(); + g.setColor(g.theme.fg); + g.setFont("6x8:2") + .setFontAlign(1, 0) + .drawString("+1", w, h/2); + g.setFontAlign(-1, 0) + .drawString("-1", 0, h/2); + g.setFontAlign(0, -1) + .drawString("+5", w/2, 0); + g.setFontAlign(0, 1) + .drawString("-5", w/2, h); + g.drawString("minutes", w/2, h-40); + g.setFont("Vector:64") + .setFontAlign(0, 0) + .drawString(""+minutes, w/2, h/2); + } + display(); + Bangle.on("swipe", function (directionLR, directionUD) { + if (directionUD == -1) { + minutes += 5; + } else if (directionUD == 1) { + minutes -= 5; + } else if (directionLR == -1) { + minutes -= 1; + } else if (directionLR == 1) { + minutes += 1; + } + if (minutes < 1) { + minutes = 1; + } + display(); + }); + Bangle.on("touch", function(button, xy) { + Bangle.removeAllListeners("touch"); + Bangle.removeAllListeners("swipe"); + config.duration = minutes * 60; + //TODO: don't write if no change + require("Storage").writeJSON("pushups.cfg", config); + start_routine(config); + }); + +} + +function main_menu(config) { + let w = g.getWidth(); + let h = g.getHeight(); + g.setBgColor(g.theme.bg); + g.clear(); + g.setColor(g.theme.fg); + g.drawRect(10, 10, w-10, h/2-10); + g.drawRect(10, h/2+10, w-10, h-10); + g.setFont("Vector:32") + .setFontAlign(0, 0) + .drawString("Start", w/2, h/4); + g.drawString("Edit", w/2, 3*h/4); + Bangle.setLocked(false); + Bangle.on("touch", function(button, xy) { + if (xy.y > h/2+10) { + g.setColor(g.theme.fg); + g.fillRect(10, h/2+10, w-10, h-10); + g.setColor(g.theme.bg) + .setFont("Vector:32") + .setFontAlign(0, 0) + .drawString("Edit", w/2, 3*h/4); + Bangle.removeAllListeners("touch"); + edit_menu(config); + } else if (xy.y < h/2-10) { + g.setColor(g.theme.fg); + g.fillRect(10, 10, w-10, h/2-10); + g.setColor(g.theme.bg) + .setFont("Vector:32") + .setFontAlign(0, 0) + .drawString("Start", w/2, h/4); + Bangle.removeAllListeners("touch"); + set_duration(config); + } + }) +} + + +let config = require("Storage").readJSON("pushups.cfg", true); + +if (config === undefined) { + config = default_config(); +} + +main_menu(config); diff --git a/apps/pushups/metadata.json b/apps/pushups/metadata.json new file mode 100644 index 000000000..dc8dd6989 --- /dev/null +++ b/apps/pushups/metadata.json @@ -0,0 +1,18 @@ +{ + "id": "pushups", + "name": "Pushups", + "shortName": "Pushups", + "version": "0.1", + "description": "Pushups countdown using the accelerometer.", + "allow_emulator":false, + "icon": "pushups.png", + "type": "app", + "tags": "health", + "supports": ["BANGLEJS2"], + "screenshots": [{"url":"shot_pushups.png"}, {"url":"shot_squats.png"}, {"url":"shot_menu.png"}], + "readme": "README.md", + "storage": [ + {"name":"pushups.app.js","url":"app.js"}, + {"name":"pushups.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/pushups/pushups.png b/apps/pushups/pushups.png new file mode 100644 index 000000000..66c5902b6 Binary files /dev/null and b/apps/pushups/pushups.png differ diff --git a/apps/pushups/shot_menu.png b/apps/pushups/shot_menu.png new file mode 100644 index 000000000..b7f44b6e2 Binary files /dev/null and b/apps/pushups/shot_menu.png differ diff --git a/apps/pushups/shot_pushups.png b/apps/pushups/shot_pushups.png new file mode 100644 index 000000000..3f22ee8d1 Binary files /dev/null and b/apps/pushups/shot_pushups.png differ diff --git a/apps/pushups/shot_squats.png b/apps/pushups/shot_squats.png new file mode 100644 index 000000000..de3ac7bdd Binary files /dev/null and b/apps/pushups/shot_squats.png differ