diff --git a/apps/f9lander/ChangeLog b/apps/f9lander/ChangeLog index 5560f00bc..a13f2a313 100644 --- a/apps/f9lander/ChangeLog +++ b/apps/f9lander/ChangeLog @@ -1 +1,2 @@ 0.01: New App! +0.02: Add lightning diff --git a/apps/f9lander/app.js b/apps/f9lander/app.js index 7e52104c0..2f17a5bd5 100644 --- a/apps/f9lander/app.js +++ b/apps/f9lander/app.js @@ -46,6 +46,9 @@ var booster = { x : g.getWidth()/4 + Math.random()*g.getWidth()/2, var exploded = false; var nExplosions = 0; var landed = false; +var lightning = 0; + +var settings = require("Storage").readJSON('f9settings.json', 1) || {}; const gravity = 4; const dt = 0.1; @@ -61,18 +64,40 @@ function flameImageGen (throttle) { function drawFalcon(x, y, throttle, angle) { g.setColor(1, 1, 1).drawImage(falcon9, x, y, {rotate:angle}); - if (throttle>0) { + if (throttle>0 || lightning>0) { var flameImg = flameImageGen(throttle); var r = falcon9.height/2 + flameImg.height/2-1; var xoffs = -Math.sin(angle)*r; var yoffs = Math.cos(angle)*r; if (Math.random()>0.7) g.setColor(1, 0.5, 0); else g.setColor(1, 1, 0); - g.drawImage(flameImg, x+xoffs, y+yoffs, {rotate:angle}); + if (throttle>0) g.drawImage(flameImg, x+xoffs, y+yoffs, {rotate:angle}); + if (lightning>1 && lightning<30) { + for (var i=0; i<6; ++i) { + var r = Math.random()*6; + var x = Math.random()*5 - xoffs; + var y = Math.random()*5 - yoffs; + g.setColor(1, Math.random()*0.5+0.5, 0).fillCircle(booster.x+x, booster.y+y, r); + } + } } } +function drawLightning() { + var c = {x:cloudOffs+50, y:30}; + var dx = c.x-booster.x; + var dy = c.y-booster.y; + var m1 = {x:booster.x+0.6*dx+Math.random()*20, y:booster.y+0.6*dy+Math.random()*10}; + var m2 = {x:booster.x+0.4*dx+Math.random()*20, y:booster.y+0.4*dy+Math.random()*10}; + g.setColor(1, 1, 1).drawLine(c.x, c.y, m1.x, m1.y).drawLine(m1.x, m1.y, m2.x, m2.y).drawLine(m2.x, m2.y, booster.x, booster.y); +} + function drawBG() { + if (lightning==1) { + g.setBgColor(1, 1, 1).clear(); + Bangle.buzz(200); + return; + } g.setBgColor(0.2, 0.2, 1).clear(); g.setColor(0, 0, 1).fillRect(0, g.getHeight()-oceanHeight, g.getWidth()-1, g.getHeight()-1); g.setColor(0.5, 0.5, 1).fillCircle(cloudOffs+34, 30, 15).fillCircle(cloudOffs+60, 35, 20).fillCircle(cloudOffs+75, 20, 10); @@ -88,6 +113,7 @@ function renderScreen(input) { drawBG(); showFuel(); drawFalcon(booster.x, booster.y, Math.floor(input.throttle*12), input.angle); + if (lightning>1 && lightning<6) drawLightning(); } function getInputs() { @@ -97,6 +123,7 @@ function getInputs() { if (t > 1) t = 1; if (t < 0) t = 0; if (booster.fuel<=0) t = 0; + if (lightning>0 && lightning<20) t = 0; return {throttle: t, angle: a}; } @@ -121,7 +148,6 @@ function gameStep() { else { var input = getInputs(); if (booster.y >= targetY) { -// console.log(booster.x + " " + booster.y + " " + booster.vy + " " + droneX + " " + input.angle); if (Math.abs(booster.x-droneX-droneShip.width/2)40) && Math.random()>0.98) lightning = 1; booster.x += booster.vx*dt; booster.y += booster.vy*dt; booster.vy += gravity*dt; diff --git a/apps/f9lander/metadata.json b/apps/f9lander/metadata.json index 75c6a0164..1db777099 100644 --- a/apps/f9lander/metadata.json +++ b/apps/f9lander/metadata.json @@ -1,7 +1,7 @@ { "id": "f9lander", "name": "Falcon9 Lander", "shortName":"F9lander", - "version":"0.01", + "version":"0.02", "description": "Land a rocket booster", "icon": "f9lander.png", "screenshots" : [ { "url":"f9lander_screenshot1.png" }, { "url":"f9lander_screenshot2.png" }, { "url":"f9lander_screenshot3.png" }], @@ -10,6 +10,7 @@ "supports" : ["BANGLEJS", "BANGLEJS2"], "storage": [ {"name":"f9lander.app.js","url":"app.js"}, - {"name":"f9lander.img","url":"app-icon.js","evaluate":true} + {"name":"f9lander.img","url":"app-icon.js","evaluate":true}, + {"name":"f9lander.settings.js", "url":"settings.js"} ] } diff --git a/apps/f9lander/settings.js b/apps/f9lander/settings.js new file mode 100644 index 000000000..0f9fba302 --- /dev/null +++ b/apps/f9lander/settings.js @@ -0,0 +1,36 @@ +// This file should contain exactly one function, which shows the app's settings +/** + * @param {function} back Use back() to return to settings menu + */ +const boolFormat = v => v ? /*LANG*/"On" : /*LANG*/"Off"; +(function(back) { + const SETTINGS_FILE = 'f9settings.json' + // initialize with default settings... + let settings = { + 'lightning': false, + } + // ...and overwrite them with any saved values + // This way saved values are preserved if a new version adds more settings + const storage = require('Storage') + const saved = storage.readJSON(SETTINGS_FILE, 1) || {} + for (const key in saved) { + settings[key] = saved[key]; + } + // creates a function to safe a specific setting, e.g. save('color')(1) + function save(key) { + return function (value) { + settings[key] = value; + storage.write(SETTINGS_FILE, settings); + } + } + const menu = { + '': { 'title': 'OpenWind' }, + '< Back': back, + 'Lightning': { + value: settings.lightning, + format: boolFormat, + onchange: save('lightning'), + } + } + E.showMenu(menu); +}) diff --git a/apps/hrmmar/README.md b/apps/hrmmar/README.md new file mode 100644 index 000000000..ff90d9156 --- /dev/null +++ b/apps/hrmmar/README.md @@ -0,0 +1,11 @@ +# HRM Motion Artifacts removal + +Measurements from the build in PPG-Sensor (Photoplethysmograph) is sensitive to motion and can be corrupted with Motion Artifacts (MA). This module allows to remove these. + +## Settings + +* **MA removal** + +Select the algorithm to Remove Motion artifacts: + - None: (default) No Motion Artifact removal. + - fft elim: (*experimental*) Remove Motion Artifacts by cutting out the frequencies from the HRM frequency spectrum that are noisy in acceleration spectrum. Under motion this can report a heart rate that is closer to the real one but will fail if motion frequency and heart rate overlap. diff --git a/apps/hrmmar/app.png b/apps/hrmmar/app.png new file mode 100644 index 000000000..9db19be37 Binary files /dev/null and b/apps/hrmmar/app.png differ diff --git a/apps/hrmmar/boot.js b/apps/hrmmar/boot.js new file mode 100644 index 000000000..52d88c313 --- /dev/null +++ b/apps/hrmmar/boot.js @@ -0,0 +1,40 @@ +{ + let bpm_corrected; // result of algorithm + + const updateHrm = (bpm) => { + bpm_corrected = bpm; + }; + + Bangle.on('HRM', (hrm) => { + if (bpm_corrected > 0) { + // replace bpm data in event + hrm.bpm_orig = hrm.bpm; + hrm.confidence_orig = hrm.confidence; + hrm.bpm = bpm_corrected; + hrm.confidence = 0; + } + }); + + let run = () => { + const settings = Object.assign({ + mAremoval: 0 + }, require("Storage").readJSON("hrmmar.json", true) || {}); + + // select motion artifact removal algorithm + switch(settings.mAremoval) { + case 1: + require("hrmfftelim").run(settings, updateHrm); + break; + } + } + + // override setHRMPower so we can run our code on HRM enable + const oldSetHRMPower = Bangle.setHRMPower; + Bangle.setHRMPower = function(on, id) { + if (on && run !== undefined) { + run(); + run = undefined; // Make sure we run only once + } + return oldSetHRMPower(on, id); + }; +} diff --git a/apps/hrmmar/fftelim.js b/apps/hrmmar/fftelim.js new file mode 100644 index 000000000..98b7f33ad --- /dev/null +++ b/apps/hrmmar/fftelim.js @@ -0,0 +1,190 @@ +exports.run = (settings, updateHrm) => { + const SAMPLE_RATE = 12.5; + const NUM_POINTS = 256; // fft size + const ACC_PEAKS = 2; // remove this number of ACC peaks + + // ringbuffers + const hrmvalues = new Int16Array(8*SAMPLE_RATE); + const accvalues = new Int16Array(8*SAMPLE_RATE); + // fft buffers + const hrmfftbuf = new Int16Array(NUM_POINTS); + const accfftbuf = new Int16Array(NUM_POINTS); + let BPM_est_1 = 0; + let BPM_est_2 = 0; + + let hrmdata; + let idx=0, wraps=0; + + // init settings + Bangle.setOptions({hrmPollInterval: 40, powerSave: false}); // hrm=25Hz + Bangle.setPollInterval(80); // 12.5Hz + + calcfft = (values, idx, normalize, fftbuf) => { + fftbuf.fill(0); + let i_out=0; + let avg = 0; + if (normalize) { + const sum = values.reduce((a, b) => a + b, 0); + avg = sum/values.length; + } + // sort ringbuffer to fft buffer + for(let i_in=idx; i_in { + let maxVal = -Number.MAX_VALUE; + let maxIdx = 0; + + values.forEach((value,i) => { + if (value > maxVal) { + maxVal = value; + maxIdx = i; + } + }); + return {idx: maxIdx, val: maxVal}; + }; + + getSign = (value) => { + return value < 0 ? -1 : 1; + }; + + // idx in fft buffer to frequency + getFftFreq = (idx, rate, size) => { + return idx*rate/(size-1); + }; + + // frequency to idx in fft buffer + getFftIdx = (freq, rate, size) => { + return Math.round(freq*(size-1)/rate); + }; + + calc2ndDeriative = (values) => { + const result = new Int16Array(values.length-2); + for(let i=1; i { + // fft + const ppg_fft = calcfft(hrmvalues, idx, true, hrmfftbuf).subarray(minFreqIdx, maxFreqIdx+1); + const acc_fft = calcfft(accvalues, idx, false, accfftbuf).subarray(minFreqIdx, maxFreqIdx+1); + + // remove spectrum that have peaks in acc fft from ppg fft + const accGlobalMax = getMax(acc_fft); + const acc2nddiff = calc2ndDeriative(acc_fft); // calculate second derivative + for(let iClean=0; iClean < ACC_PEAKS; iClean++) { + // get max peak in ACC + const accMax = getMax(acc_fft); + + if (accMax.val >= 10 && accMax.val/accGlobalMax.val > 0.75) { + // set all values in PPG FFT to zero until second derivative of ACC has zero crossing + for (let k = accMax.idx-1; k>=0; k--) { + ppg_fft[k] = 0; + acc_fft[k] = -Math.abs(acc_fft[k]); // max(acc_fft) should no longer find this + if (k-2 > 0 && getSign(acc2nddiff[k-1-2]) != getSign(acc2nddiff[k-2]) && Math.abs(acc_fft[k]) < accMax.val*0.75) { + break; + } + } + // set all values in PPG FFT to zero until second derivative of ACC has zero crossing + for (let k = accMax.idx; k < acc_fft.length-1; k++) { + ppg_fft[k] = 0; + acc_fft[k] = -Math.abs(acc_fft[k]); // max(acc_fft) should no longer find this + if (k-2 >= 0 && getSign(acc2nddiff[k+1-2]) != getSign(acc2nddiff[k-2]) && Math.abs(acc_fft[k]) < accMax.val*0.75) { + break; + } + } + } + } + + // bpm result is maximum peak in PPG fft + const hrRangeMax = getMax(ppg_fft.subarray(rangeIdx[0], rangeIdx[1])); + const hrTotalMax = getMax(ppg_fft); + const maxDiff = hrTotalMax.val/hrRangeMax.val; + let idxMaxPPG = hrRangeMax.idx+rangeIdx[0]; // offset range limit + + if ((maxDiff > 3 && idxMaxPPG != hrTotalMax.idx) || hrRangeMax.val === 0) { // prevent tracking from loosing the real heart rate by checking the full spectrum + if (hrTotalMax.idx > idxMaxPPG) { + idxMaxPPG = idxMaxPPG+Math.ceil(6/freqStep); // step 6 BPM up into the direction of max peak + } else { + idxMaxPPG = idxMaxPPG-Math.ceil(2/freqStep); // step 2 BPM down into the direction of max peak + } + } + + idxMaxPPG = idxMaxPPG + minFreqIdx; + const BPM_est_0 = getFftFreq(idxMaxPPG, SAMPLE_RATE, NUM_POINTS)*60; + + // smooth with moving average + let BPM_est_res; + if (BPM_est_2 > 0) { + BPM_est_res = 0.9*BPM_est_0 + 0.05*BPM_est_1 + 0.05*BPM_est_2; + } else { + BPM_est_res = BPM_est_0; + } + + return BPM_est_res.toFixed(1); + }; + + Bangle.on('HRM-raw', (hrm) => { + hrmdata = hrm; + }); + + Bangle.on('accel', (acc) => { + if (hrmdata !== undefined) { + hrmvalues[idx] = hrmdata.filt; + accvalues[idx] = acc.x*1000 + acc.y*1000 + acc.z*1000; + idx++; + if (idx >= 8*SAMPLE_RATE) { + idx = 0; + wraps++; + } + + if (idx % (SAMPLE_RATE*2) == 0) { // every two seconds + if (wraps === 0) { // use rate of firmware until hrmvalues buffer is filled + updateHrm(undefined); + BPM_est_2 = BPM_est_1; + BPM_est_1 = hrmdata.bpm; + } else { + let bpm_result; + if (hrmdata.confidence >= 90) { // display firmware value if good + bpm_result = hrmdata.bpm; + updateHrm(undefined); + } else { + bpm_result = calculate(idx); + bpm_corrected = bpm_result; + updateHrm(bpm_result); + } + BPM_est_2 = BPM_est_1; + BPM_est_1 = bpm_result; + + // set search range of next BPM + const est_res_idx = getFftIdx(bpm_result/60, SAMPLE_RATE, NUM_POINTS)-minFreqIdx; + rangeIdx = [est_res_idx-maxBpmDiffIdxDown, est_res_idx+maxBpmDiffIdxUp]; + if (rangeIdx[0] < 0) { + rangeIdx[0] = 0; + } + if (rangeIdx[1] > maxFreqIdx-minFreqIdx) { + rangeIdx[1] = maxFreqIdx-minFreqIdx; + } + } + } + } + }); +}; diff --git a/apps/hrmmar/metadata.json b/apps/hrmmar/metadata.json new file mode 100644 index 000000000..232ff64a7 --- /dev/null +++ b/apps/hrmmar/metadata.json @@ -0,0 +1,18 @@ +{ + "id": "hrmmar", + "name": "HRM Motion Artifacts removal", + "shortName":"HRM MA removal", + "icon": "app.png", + "version":"0.01", + "description": "Removes Motion Artifacts in Bangle.js's heart rate sensor data.", + "type": "bootloader", + "tags": "health", + "supports": ["BANGLEJS","BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"hrmmar.boot.js","url":"boot.js"}, + {"name":"hrmfftelim","url":"fftelim.js"}, + {"name":"hrmmar.settings.js","url":"settings.js"} + ], + "data": [{"name":"hrmmar.json"}] +} diff --git a/apps/hrmmar/settings.js b/apps/hrmmar/settings.js new file mode 100644 index 000000000..3c6e62c91 --- /dev/null +++ b/apps/hrmmar/settings.js @@ -0,0 +1,26 @@ +(function(back) { + var FILE = "hrmmar.json"; + // Load settings + var settings = Object.assign({ + mAremoval: 0, + }, require('Storage').readJSON(FILE, true) || {}); + + function writeSettings() { + require('Storage').writeJSON(FILE, settings); + } + + // Show the menu + E.showMenu({ + "" : { "title" : "HRM MA removal" }, + "< Back" : () => back(), + 'MA removal': { + value: settings.mAremoval, + min: 0, max: 1, + format: v => ["None", "fft elim."][v], + onchange: v => { + settings.mAremoval = v; + writeSettings(); + } + }, + }); +}) diff --git a/apps/qcenter/ChangeLog b/apps/qcenter/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/qcenter/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/qcenter/README.md b/apps/qcenter/README.md new file mode 100644 index 000000000..4931b9c7f --- /dev/null +++ b/apps/qcenter/README.md @@ -0,0 +1,20 @@ +# Quick Center + +An app with a status bar showing various information and up to six shortcuts for your favorite apps! +Designed for use with any kind of quick launcher, such as Quick Launch or Pattern Launcher. + +![](screenshot.png) + +## Usage + +Pin your apps with settings, then launch them with your favorite quick launcher to access them quickly. +If you don't have any apps pinned, the settings and about apps will be shown as an example. + +## Features + +Battery and GPS status display (for now) +Up to six shortcuts to your favorite apps + +## Upcoming features +- Quick switches for toggleable features such as Bluetooth or HID mode +- Customizable status information \ No newline at end of file diff --git a/apps/qcenter/app-icon.js b/apps/qcenter/app-icon.js new file mode 100644 index 000000000..bfc94d10a --- /dev/null +++ b/apps/qcenter/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UB6cA/4ACBYNVAElQHAsFBYZFHCxIYEoALHgILNOxILChWqAAmgBYNUBZMVBYIAIBc0C1WAlWoAgQL/O96D/Qf4LZqoLJqoLMoAKHgILNqALHgoLBGBAKCDA4WDAEQA=")) \ No newline at end of file diff --git a/apps/qcenter/app.js b/apps/qcenter/app.js new file mode 100644 index 000000000..1530cc5fb --- /dev/null +++ b/apps/qcenter/app.js @@ -0,0 +1,120 @@ +require("Font8x12").add(Graphics); + +// load pinned apps from config +var settings = require("Storage").readJSON("qcenter.json", 1) || {}; +var pinnedApps = settings.pinnedApps || []; +var exitGesture = settings.exitGesture || "swipeup"; + +// if empty load a default set of apps as an example +if (pinnedApps.length == 0) { + pinnedApps = [ + { src: "setting.app.js", icon: "setting.img" }, + { src: "about.app.js", icon: "about.img" }, + ]; +} + +// button drawing from Layout.js, edited to have completely custom button size with icon +function drawButton(l) { + var x = l.x + (0 | l.pad), + y = l.y + (0 | l.pad), + w = l.w - (l.pad << 1), + h = l.h - (l.pad << 1); + var poly = [ + x, + y + 4, + x + 4, + y, + x + w - 5, + y, + x + w - 1, + y + 4, + x + w - 1, + y + h - 5, + x + w - 5, + y + h - 1, + x + 4, + y + h - 1, + x, + y + h - 5, + x, + y + 4, + ], + bg = l.selected ? g.theme.bgH : g.theme.bg2; + g.setColor(bg) + .fillPoly(poly) + .setColor(l.selected ? g.theme.fgH : g.theme.fg2) + .drawPoly(poly); + if (l.src) + g.setBgColor(bg).drawImage( + "function" == typeof l.src ? l.src() : l.src, + l.x + l.w / 2, + l.y + l.h / 2, + { scale: l.scale || undefined, rotate: Math.PI * 0.5 * (l.r || 0) } + ); +} + +// function to split array into group of 3, for button placement +function groupBy3(data) { + var result = []; + for (var i = 0; i < data.length; i += 3) result.push(data.slice(i, i + 3)); + return result; +} + +// generate object with buttons for apps by group of 3 +var appButtons = groupBy3(pinnedApps).map((appGroup, i) => { + return appGroup.map((app, j) => { + return { + type: "custom", + render: drawButton, + width: 50, + height: 50, + pad: 5, + src: require("Storage").read(app.icon), + scale: 0.75, + cb: (l) => Bangle.load(app.src), + }; + }); +}); + +// create basic layout content with status info and sensor status on top +var layoutContent = [ + { + type: "h", + pad: 5, + fillx: 1, + c: [ + { type: "txt", font: "8x12", pad: 3, scale: 2, label: E.getBattery() + "%" }, + { type: "txt", font: "8x12", pad: 3, scale: 2, label: "GPS: " + (Bangle.isGPSOn() ? "ON" : "OFF") }, + ], + }, +]; + +// create rows for buttons and add them to layoutContent +appButtons.forEach((appGroup) => { + layoutContent.push({ + type: "h", + pad: 2, + c: appGroup, + }); +}); + +// create layout with content + +Bangle.loadWidgets(); + +var Layout = require("Layout"); +var layout = new Layout({ + type: "v", + c: layoutContent, +}); +g.clear(); +layout.render(); +Bangle.drawWidgets(); + +// swipe event listener for exit gesture +Bangle.on("swipe", function (lr, ud) { + if(exitGesture == "swipeup" && ud == -1) Bangle.showClock(); + if(exitGesture == "swipedown" && ud == 1) Bangle.showClock(); + if(exitGesture == "swipeleft" && lr == -1) Bangle.showClock(); + if(exitGesture == "swiperight" && lr == 1) Bangle.showClock(); +}); \ No newline at end of file diff --git a/apps/qcenter/app.png b/apps/qcenter/app.png new file mode 100644 index 000000000..27ec75f1c Binary files /dev/null and b/apps/qcenter/app.png differ diff --git a/apps/qcenter/metadata.json b/apps/qcenter/metadata.json new file mode 100644 index 000000000..96d8fa9f7 --- /dev/null +++ b/apps/qcenter/metadata.json @@ -0,0 +1,17 @@ +{ + "id": "qcenter", + "name": "Quick Center", + "shortName": "QCenter", + "version": "0.01", + "description": "An app for quickly launching your favourite apps, inspired by the control centres of other watches.", + "icon": "app.png", + "tags": "", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "screenshots": [{ "url": "screenshot.png" }], + "storage": [ + { "name": "qcenter.app.js", "url": "app.js" }, + { "name": "qcenter.settings.js", "url": "settings.js" }, + { "name": "qcenter.img", "url": "app-icon.js", "evaluate": true } + ] +} diff --git a/apps/qcenter/screenshot.png b/apps/qcenter/screenshot.png new file mode 100644 index 000000000..8c0a335aa Binary files /dev/null and b/apps/qcenter/screenshot.png differ diff --git a/apps/qcenter/settings.js b/apps/qcenter/settings.js new file mode 100644 index 000000000..544d85301 --- /dev/null +++ b/apps/qcenter/settings.js @@ -0,0 +1,141 @@ +// make sure to enclose the function in parentheses +(function (back) { + let settings = require("Storage").readJSON("qcenter.json", 1) || {}; + var apps = require("Storage") + .list(/\.info$/) + .map((app) => { + var a = require("Storage").readJSON(app, 1); + return ( + a && { + name: a.name, + type: a.type, + sortorder: a.sortorder, + src: a.src, + icon: a.icon, + } + ); + }) + .filter( + (app) => + app && + (app.type == "app" || + app.type == "launch" || + app.type == "clock" || + !app.type) + ); + apps.sort((a, b) => { + var n = (0 | a.sortorder) - (0 | b.sortorder); + if (n) return n; // do sortorder first + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }); + + function save(key, value) { + settings[key] = value; + require("Storage").write("qcenter.json", settings); + } + + var pinnedApps = settings.pinnedApps || []; + var exitGesture = settings.exitGesture || "swipeup"; + + function showMainMenu() { + var mainmenu = { + "": { title: "Quick Center" }, + "< Back": () => { + load(); + }, + }; + + // Set exit gesture + mainmenu["Exit Gesture: " + exitGesture] = function () { + E.showMenu(exitGestureMenu); + }; + + //List all pinned apps, redirecting to menu with options to unpin and reorder + pinnedApps.forEach((app, i) => { + mainmenu[app.name] = function () { + E.showMenu({ + "": { title: app.name }, + "< Back": () => { + showMainMenu(); + }, + "Unpin": () => { + pinnedApps.splice(i, 1); + save("pinnedApps", pinnedApps); + showMainMenu(); + }, + "Move Up": () => { + if (i > 0) { + pinnedApps.splice(i - 1, 0, pinnedApps.splice(i, 1)[0]); + save("pinnedApps", pinnedApps); + showMainMenu(); + } + }, + "Move Down": () => { + if (i < pinnedApps.length - 1) { + pinnedApps.splice(i + 1, 0, pinnedApps.splice(i, 1)[0]); + save("pinnedApps", pinnedApps); + showMainMenu(); + } + }, + }); + }; + }); + + // Show pin app menu, or show alert if max amount of apps are pinned + mainmenu["Pin App"] = function () { + if (pinnedApps.length < 6) { + E.showMenu(pinAppMenu); + } else { + E.showAlert("Max apps pinned").then(showMainMenu); + } + }; + + return E.showMenu(mainmenu); + } + + // menu for adding apps to the quick launch menu, listing all apps + var pinAppMenu = { + "": { title: "Add App" }, + "< Back": showMainMenu, + }; + apps.forEach((a) => { + pinAppMenu[a.name] = function () { + // strip unncecessary properties + delete a.type; + delete a.sortorder; + pinnedApps.push(a); + save("pinnedApps", pinnedApps); + showMainMenu(); + }; + }); + + // menu for setting exit gesture + var exitGestureMenu = { + "": { title: "Exit Gesture" }, + "< Back": showMainMenu, + }; + exitGestureMenu["Swipe Up"] = function () { + exitGesture = "swipeup"; + save("exitGesture", "swipeup"); + showMainMenu(); + }; + exitGestureMenu["Swipe Down"] = function () { + exitGesture = "swipedown"; + save("exitGesture", "swipedown"); + showMainMenu(); + }; + exitGestureMenu["Swipe Left"] = function () { + exitGesture = "swipeleft"; + save("exitGesture", "swipeleft"); + showMainMenu(); + }; + exitGestureMenu["Swipe Right"] = function () { + exitGesture = "swiperight"; + save("exitGesture", "swiperight"); + showMainMenu(); + }; + + showMainMenu(); +}); diff --git a/apps/sched/ChangeLog b/apps/sched/ChangeLog index f23cf93cb..f2dd54338 100644 --- a/apps/sched/ChangeLog +++ b/apps/sched/ChangeLog @@ -14,3 +14,4 @@ Improve timer message using formatDuration Fix wrong fallback for buzz pattern 0.13: Ask to delete a timer after stopping it +0.14: Added clkinfo for alarms and timers diff --git a/apps/sched/clkinfo.js b/apps/sched/clkinfo.js new file mode 100644 index 000000000..71992dbb8 --- /dev/null +++ b/apps/sched/clkinfo.js @@ -0,0 +1,73 @@ +(function() { + const alarm = require('sched'); + const iconAlarmOn = atob("GBiBAAAAAAAAAAYAYA4AcBx+ODn/nAP/wAf/4A/n8A/n8B/n+B/n+B/n+B/n+B/h+B/4+A/+8A//8Af/4AP/wAH/gAB+AAAAAAAAAA=="); + const iconAlarmOff = atob("GBiBAAAAAAAAAAYAYA4AcBx+ODn/nAP/wAf/4A/n8A/n8B/n+B/n+B/nAB/mAB/geB/5/g/5tg/zAwfzhwPzhwHzAwB5tgAB/gAAeA=="); + const iconTimerOn = atob("GBiBAAAAAAAAAAAAAAf/4Af/4AGBgAGBgAGBgAD/AAD/AAB+AAA8AAA8AAB+AADnAADDAAGBgAGBgAGBgAf/4Af/4AAAAAAAAAAAAA=="); + const iconTimerOff = atob("GBiBAAAAAAAAAAAAAAf/4Af/4AGBgAGBgAGBgAD/AAD/AAB+AAA8AAA8AAB+AADkeADB/gGBtgGDAwGDhwfzhwfzAwABtgAB/gAAeA=="); + + //from 0 to max, the higher the closer to fire (as in a progress bar) + function getAlarmValue(a){ + let min = Math.round(alarm.getTimeToAlarm(a)/(60*1000)); + if(!min) return 0; //not active or more than a day + return getAlarmMax(a)-min; + } + + function getAlarmMax(a) { + if(a.timer) + return Math.round(a.timer/(60*1000)); + //minutes cannot be more than a full day + return 1440; + } + + function getAlarmIcon(a) { + if(a.on) { + if(a.timer) return iconTimerOn; + return iconAlarmOn; + } else { + if(a.timer) return iconTimerOff; + return iconAlarmOff; + } + } + + function getAlarmText(a){ + if(a.timer) { + if(!a.on) return "off"; + let time = Math.round(alarm.getTimeToAlarm(a)/(60*1000)); + if(time > 60) + time = Math.round(time / 60) + "h"; + else + time += "m"; + return time; + } + return require("time_utils").formatTime(a.t); + } + + //workaround for sorting undefined values + function getAlarmOrder(a) { + let val = alarm.getTimeToAlarm(a); + if(typeof val == "undefined") return 86400*1000; + return val; + } + + var img = iconAlarmOn; + //get only alarms not created by other apps + var alarmItems = { + name: "Alarms", + img: img, + dynamic: true, + items: alarm.getAlarms().filter(a=>!a.appid) + //.sort((a,b)=>alarm.getTimeToAlarm(a)-alarm.getTimeToAlarm(b)) + .sort((a,b)=>getAlarmOrder(a)-getAlarmOrder(b)) + .map((a, i)=>({ + name: null, + hasRange: true, + get: () => ({ text: getAlarmText(a), img: getAlarmIcon(a), + v: getAlarmValue(a), min:0, max:getAlarmMax(a)}), + show: function() { alarmItems.items[i].emit("redraw"); }, + hide: function () {}, + run: function() { } + })), + }; + + return alarmItems; +}) diff --git a/apps/sched/metadata.json b/apps/sched/metadata.json index 163d2f552..4b38ee653 100644 --- a/apps/sched/metadata.json +++ b/apps/sched/metadata.json @@ -1,7 +1,7 @@ { "id": "sched", "name": "Scheduler", - "version": "0.13", + "version": "0.14", "description": "Scheduling library for alarms and timers", "icon": "app.png", "type": "scheduler", @@ -13,7 +13,8 @@ {"name":"sched.js","url":"sched.js"}, {"name":"sched.img","url":"app-icon.js","evaluate":true}, {"name":"sched","url":"lib.js"}, - {"name":"sched.settings.js","url":"settings.js"} + {"name":"sched.settings.js","url":"settings.js"}, + {"name":"sched.clkinfo.js","url":"clkinfo.js"} ], "data": [{"name":"sched.json"}, {"name":"sched.settings.json"}] } diff --git a/apps/tetris/README.md b/apps/tetris/README.md new file mode 100644 index 000000000..2c41657f4 --- /dev/null +++ b/apps/tetris/README.md @@ -0,0 +1,8 @@ +# Tetris + +Bangle version of the classic game of Tetris. + +## Controls + +Tapping the screen rotates the pieces once, swiping left, right or down moves the +piece in that direction, if possible. \ No newline at end of file diff --git a/apps/tetris/app-icon.js b/apps/tetris/app-icon.js new file mode 100644 index 000000000..b87ef84f4 --- /dev/null +++ b/apps/tetris/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+If4A/AH4A/AH4A/ABe5AA0jABwvYAIovBgABEFAQHFL7IuEL4QuFA45fcF4YuNL7i/FFwoHHL7QvFFxpfaF4wAOF/4nHF5+0AAy3SXYoHGW4QBDF4MAAIgvRFwwHHdAbqDFIQuDL6ouJL4ovDFwpfUAAoHFL4a/FFwhfTFxZfDF4ouFL6QANFopfDF/4vNjwAGF8ABFF4MAAIgvBX4IBDX4YBDL6TyFFIIuEL4QuEL4QuEL6ovDFwpfFF4YuFL6i/FFwhfEX4ouEL6YvFFwpfDF4ouFL6QvGAAwtFL4Yv/AAonHAB4vHG563CAIbuDA5i/CAIb2DA4hfJEwoHPFApZEGwpfLFyJfFFxJfMAAoHNFAa5GX54uTL4YuLL5QAVFowAIF+4A/AH4A/AH4A/AHY")) diff --git a/apps/tetris/metadata.json b/apps/tetris/metadata.json new file mode 100644 index 000000000..5669d8953 --- /dev/null +++ b/apps/tetris/metadata.json @@ -0,0 +1,14 @@ +{ "id": "tetris", + "name": "Tetris", + "shortName":"Tetris", + "version":"0.01", + "description": "Tetris", + "icon": "tetris.png", + "readme": "README.md", + "tags": "games", + "supports" : ["BANGLEJS2"], + "storage": [ + {"name":"tetris.app.js","url":"tetris.app.js"}, + {"name":"tetris.img","url":"app-icon.js","evaluate":true} + ] +} diff --git a/apps/tetris/tetris.app.js b/apps/tetris/tetris.app.js new file mode 100644 index 000000000..e24a731a9 --- /dev/null +++ b/apps/tetris/tetris.app.js @@ -0,0 +1,170 @@ +const block = Graphics.createImage(` +######## +# # # ## +## # ### +# # #### +## ##### +# ###### +######## +######## +`); +const tcols = [ {r:0, g:0, b:1}, {r:0, g:1, b:0}, {r:0, g:1, b:1}, {r:1, g:0, b:0}, {r:1, g:0, b:1}, {r:1, g:1, b:0}, {r:1, g:0.5, b:0.5} ]; +const tiles = [ + [[0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0]], + [[0, 0, 0], + [0, 1, 0], + [1, 1, 1]], + [[0, 0, 0], + [1, 0, 0], + [1, 1, 1]], + [[0, 0, 0], + [0, 0, 1], + [1, 1, 1]], + [[0, 0, 0], + [1, 1, 0], + [0, 1, 1]], + [[0, 0, 0], + [0, 1, 1], + [1, 1, 0]], + [[1, 1], + [1, 1]] +]; + +const ox = 176/2 - 5*8; +const oy = 8; + +var pf = Array(23).fill().map(()=>Array(12).fill(0)); // field is really 10x20, but adding a border for collision checks +pf[20].fill(1); +pf[21].fill(1); +pf[22].fill(1); +pf.forEach((x,i) => { pf[i][0] = 1; pf[i][11] = 1; }); + +function rotateTile(t, r) { + var nt = JSON.parse(JSON.stringify(t)); + if (t.length==2) return nt; + var s = t.length; + for (m=0; m0) + if (qClear) g.fillRect(x+8*i, y+8*j, x+8*(i+1)-1, y+8*(j+1)-1); + else g.drawImage(block, x+8*i, y+8*j); +} + +function showNext(n, r) { + var nt = rotateTile(tiles[n], r); + g.setColor(0).fillRect(176-33, 40, 176-33+33, 82); + drawTile(nt, ntn, 176-33, 40); +} + +var time = Date.now(); +var px=4, py=0; +var ctn = Math.floor(Math.random()*7); // current tile number +var ntn = Math.floor(Math.random()*7); // next tile number +var ntr = Math.floor(Math.random()*4); // next tile rotation +var ct = rotateTile(tiles[ctn], Math.floor(Math.random()*4)); // current tile (rotated) +var dropInterval = 450; +var nlines = 0; + +function redrawPF(ly) { + for (y=0; y<=ly; ++y) + for (x=1; x<11; ++x) { + c = pf[y][x]; + if (c>0) g.setColor(tcols[c-1].r, tcols[c-1].g, tcols[c-1].b).drawImage(block, ox+(x-1)*8, oy+y*8); + else g.setColor(0, 0, 0).fillRect(ox+(x-1)*8, oy+y*8, ox+x*8-1, oy+(y+1)*8-1); + } +} + +function insertAndCheck() { + for (y=0; y0) pf[py+y][px+x+1] = ctn+1; + // check for full lines + for (y=19; y>0; y--) { + var qFull = true; + for (x=1; x<11; ++x) qFull &= pf[y][x]>0; + if (qFull) { + nlines++; + dropInterval -= 5; + Bangle.buzz(30); + for (ny=y; ny>0; ny--) pf[ny] = JSON.parse(JSON.stringify(pf[ny-1])); + redrawPF(y); + g.setColor(0).fillRect(5, 30, 41, 80).setColor(1, 1, 1).drawString(nlines.toString(), 22, 50); + } + } + // spawn new tile + px = 4; py = 0; + ctn = ntn; + ntn = Math.floor(Math.random()*7); + ct = rotateTile(tiles[ctn], ntr); + ntr = Math.floor(Math.random()*4); + showNext(ntn, ntr); +} + +function moveOk(t, dx, dy) { + var ok = true; + for (y=0; y 0) ok = false; + return ok; +} + +function gameStep() { + if (Date.now()-time > dropInterval) { // drop one step + time = Date.now(); + if (moveOk(ct, 0, 1)) { + drawTile(ct, ctn, ox+px*8, oy+py*8, true); + py++; + } + else { // reached the bottom + insertAndCheck(ct, ctn, px, py); + } + drawTile(ct, ctn, ox+px*8, oy+py*8, false); + } +} + +Bangle.setUI(); +Bangle.on("touch", (e) => { + t = rotateTile(ct, 3); + if (moveOk(t, 0, 0)) { + drawTile(ct, ctn, ox+px*8, oy+py*8, true); + ct = t; + drawTile(ct, ctn, ox+px*8, oy+py*8, false); + } +}); + +Bangle.on("swipe", (x,y) => { + if (y<0) y = 0; + if (moveOk(ct, x, y)) { + drawTile(ct, ctn, ox+px*8, oy+py*8, true); + px += x; + py += y; + drawTile(ct, ctn, ox+px*8, oy+py*8, false); + } +}); + +drawBoundingBox(); +g.setColor(1, 1, 1).setFontAlign(0, 1, 0).setFont("6x15", 1).drawString("Lines", 22, 30).drawString("Next", 176-22, 30); +showNext(ntn, ntr); +g.setColor(0).fillRect(5, 30, 41, 80).setColor(1, 1, 1).drawString(nlines.toString(), 22, 50); +var gi = setInterval(gameStep, 20); diff --git a/apps/tetris/tetris.png b/apps/tetris/tetris.png new file mode 100644 index 000000000..8e884eaf3 Binary files /dev/null and b/apps/tetris/tetris.png differ diff --git a/apps/weather/ChangeLog b/apps/weather/ChangeLog index da28d8d5a..b11ed24ff 100644 --- a/apps/weather/ChangeLog +++ b/apps/weather/ChangeLog @@ -13,4 +13,5 @@ 0.14: Use weather condition code for icon selection 0.15: Fix widget icon 0.16: Don't mark app as clock -0.17: Added clkinfo for clocks. \ No newline at end of file +0.17: Added clkinfo for clocks. +0.18: Added hasRange to clkinfo. diff --git a/apps/weather/clkinfo.js b/apps/weather/clkinfo.js index caaebf273..6191c6dbe 100644 --- a/apps/weather/clkinfo.js +++ b/apps/weather/clkinfo.js @@ -5,34 +5,41 @@ wind: "?", }; - var weatherJson = storage.readJSON('weather.json'); + var weatherJson = require("Storage").readJSON('weather.json'); if(weatherJson !== undefined && weatherJson.weather !== undefined){ weather = weatherJson.weather; - weather.temp = locale.temp(weather.temp-273.15); + weather.temp = require("locale").temp(weather.temp-273.15); weather.hum = weather.hum + "%"; - weather.wind = locale.speed(weather.wind).match(/^(\D*\d*)(.*)$/); + weather.wind = require("locale").speed(weather.wind).match(/^(\D*\d*)(.*)$/); weather.wind = Math.round(weather.wind[1]) + "kph"; } + //FIXME ranges are somehow arbitrary var weatherItems = { name: "Weather", img: atob("GBiBAf+///u5//n7//8f/9wHP8gDf/gB//AB/7AH/5AcP/AQH/DwD/uAD84AD/4AA/wAAfAAAfAAAfAAAfgAA/////+bP/+zf/+zfw=="), items: [ { name: "temperature", - get: () => ({ text: weather.temp, img: atob("GBiBAAA8AAB+AADnAADDAADDAADDAADDAADDAADbAADbAADbAADbAADbAADbAAHbgAGZgAM8wAN+wAN+wAM8wAGZgAHDgAD/AAA8AA==")}), + hasRange : true, + get: () => ({ text: weather.temp, img: atob("GBiBAAA8AAB+AADnAADDAADDAADDAADDAADDAADbAADbAADbAADbAADbAADbAAHbgAGZgAM8wAN+wAN+wAM8wAGZgAHDgAD/AAA8AA=="), + v: parseInt(weather.temp), min: -30, max: 55}), show: function() { weatherItems.items[0].emit("redraw"); }, hide: function () {} }, { name: "humidity", - get: () => ({ text: weather.hum, img: atob("GBiBAAAEAAAMAAAOAAAfAAAfAAA/gAA/gAI/gAY/AAcfAA+AQA+A4B/A4D/B8D/h+D/j+H/n/D/n/D/n/B/H/A+H/AAH/AAD+AAA8A==")}), + hasRange : true, + get: () => ({ text: weather.hum, img: atob("GBiBAAAEAAAMAAAOAAAfAAAfAAA/gAA/gAI/gAY/AAcfAA+AQA+A4B/A4D/B8D/h+D/j+H/n/D/n/D/n/B/H/A+H/AAH/AAD+AAA8A=="), + v: parseInt(weather.hum), min: 0, max: 100}), show: function() { weatherItems.items[1].emit("redraw"); }, hide: function () {} }, { name: "wind", - get: () => ({ text: weather.wind, img: atob("GBiBAAHgAAPwAAYYAAwYAAwMfAAY/gAZh3/xg//hgwAAAwAABg///g//+AAAAAAAAP//wH//4AAAMAAAMAAYMAAYMAAMcAAP4AADwA==")}), + hasRange : true, + get: () => ({ text: weather.wind, img: atob("GBiBAAHgAAPwAAYYAAwYAAwMfAAY/gAZh3/xg//hgwAAAwAABg///g//+AAAAAAAAP//wH//4AAAMAAAMAAYMAAYMAAMcAAP4AADwA=="), + v: parseInt(weather.wind), min: 0, max: 118}), show: function() { weatherItems.items[2].emit("redraw"); }, hide: function () {} }, diff --git a/apps/weather/metadata.json b/apps/weather/metadata.json index e28a282d6..4a8751302 100644 --- a/apps/weather/metadata.json +++ b/apps/weather/metadata.json @@ -1,7 +1,7 @@ { "id": "weather", "name": "Weather", - "version": "0.17", + "version": "0.18", "description": "Show Gadgetbridge weather report", "icon": "icon.png", "screenshots": [{"url":"screenshot.png"}], diff --git a/modules/clock_info.js b/modules/clock_info.js index cdd3c7520..238888b1c 100644 --- a/modules/clock_info.js +++ b/modules/clock_info.js @@ -2,9 +2,9 @@ that can be scrolled through on the clock face. `load()` returns an array of menu objects, where each object contains a list of menu items: - * `name` : text to display and identify menu object (e.g. weather) * `img` : a 24x24px image +* `dynamic` : if `true`, items are not constant but are sorted (e.g. calendar events sorted by date) * `items` : menu items such as temperature, humidity, wind etc. Note that each item is an object with: @@ -15,6 +15,7 @@ Note that each item is an object with: { 'text' // the text to display for this item + 'short' : (optional) a shorter text to display for this item (at most 6 characters) 'img' // optional: a 24x24px image to display for this item 'v' // (if hasRange==true) a numerical value 'min','max' // (if hasRange==true) a minimum and maximum numerical value (if this were to be displayed as a guage) @@ -48,6 +49,15 @@ example.clkinfo.js : */ +let storage = require("Storage"); +let stepGoal = undefined; +// Load step goal from health app and pedometer widget +let d = storage.readJSON("health.json", true) || {}; +stepGoal = d != undefined && d.settings != undefined ? d.settings.stepGoal : undefined; +if (stepGoal == undefined) { + d = storage.readJSON("wpedom.json", true) || {}; + stepGoal = d != undefined && d.settings != undefined ? d.settings.goal : 10000; +} exports.load = function() { // info used for drawing... @@ -81,7 +91,7 @@ exports.load = function() { { name : "Steps", hasRange : true, get : () => { let v = Bangle.getHealthStatus("day").steps; return { - text : v, v : v, min : 0, max : 10000, // TODO: do we have a target step amount anywhere? + text : v, v : v, min : 0, max : stepGoal, img : atob("GBiBAAcAAA+AAA/AAA/AAB/AAB/gAA/g4A/h8A/j8A/D8A/D+AfH+AAH8AHn8APj8APj8AHj4AHg4AADAAAHwAAHwAAHgAAHgAADAA==") }}, show : function() { Bangle.on("step", stepUpdateHandler); stepUpdateHandler(); },