commit 173597e212c8609b6bb64302af12315076aefabb Author: Alina Marquardt Date: Tue Apr 4 23:17:35 2023 +0200 initial commit diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..7ed2746 --- /dev/null +++ b/app/index.js @@ -0,0 +1,574 @@ +import document from "document"; +import clock from "clock"; +import * as messaging from "messaging"; +import * as fs from "fs"; +import { me } from "appbit"; +import { me as device } from "device"; +import { Accelerometer } from "accelerometer"; +import { HeartRateSensor } from "heart-rate"; +import { today } from "user-activity"; +import { goals } from "user-activity"; +import { user } from "user-profile"; +import { vibration } from "haptics"; +import { display } from "display"; +import { preferences } from "user-settings"; + +if (!device.screen) device.screen = { width: 348, height: 250 }; + +import * as util from "../common/utils"; + +const DEBUG = false; +const COLORDEBUG = false; + +let accel = new Accelerometer({ frequency: 4 }); +let hrm = new HeartRateSensor; + +let touchArea = document.getElementById('touchArea'); +let overlay = document.getElementById('overlay'); +let mainHealthText = document.getElementById('mainHealthText'); +let timeElements = document.getElementsByClassName('time'); +let healthTextInstances = document.getElementsByClassName('healthTextInstance'); +let timeElementsNum = timeElements.length; + +let averageAccelZ = 10.0; +let lastAccelZ = 10.0; +let hasOverlay = false; +let overlayTimer; +let sublineTimer; +let colorTimer; +let saveTimer; +let hrTimer; + +let currentColor; + +let hiddenSubline = false; + +const smoothingFactor = 0.2; + +const colorGreen = [0.525,1.0,0.0]; +const colorYellow = [0.3,1.0,0.0]; +const colorOrange = [0.213,1.0,0.0]; +const colorRed = [0.108,1.0,0.5]; +const colorNeutral = [0.632,0.8,0.7]; + +const placeholdertext = '· · · '; +let hrLabel = placeholdertext; + +const sublines = ['-', 'date', 'steps', 'cal', 'dist', 'elev', 'actmin', 'hr']; + +const defaults = {leadingZero: false, secondary: 'steps', tap: 'overlay', colors: 'manual', customcolor:['0.632','0.8','0.7'], dateformat: 'md/', colorgoal: 'steps'}; + +let userSettings; + +try { + //console.log("Reading settings"); + userSettings = fs.readFileSync("echocentric_settings.json", "json"); +} catch (e) { + //console.log("No settings found. Using default settings"); + userSettings = defaults; +} + +function writeSettings() { + //console.log("Writing settings"); + fs.writeFileSync("echocentric_settings.json", userSettings, "json"); +} + +me.onunload = () => { + //console.log("Unloading. Good Bye"); + writeSettings(); +} + +messaging.peerSocket.onmessage = e => { + //console.log("Message received -> "+e.data.key+": "+e.data.newValue); + display.poke(); + vibration.start("bump"); + let doClockUpdate = false; + let doSublineChange = false; + let doSublineUpdate = false; + let doColorUpdate = false; + switch (e.data.key) { + case 'leadingZero': + userSettings.leadingZero = (e.data.newValue == "true"); + doClockUpdate = true; + break; + case 'secondary': + let oldval = userSettings.secondary; + let newval = JSON.parse(e.data.newValue).values[0].value; + if (oldval != newval) { + userSettings.secondary = newval; + doSublineChange = true; + } + break; + case 'tap': + userSettings.tap = JSON.parse(e.data.newValue).values[0].value; + break; + case 'colors': + userSettings.colors = JSON.parse(e.data.newValue).values[0].value; + doColorUpdate = true; + break; + case 'customcolor': + userSettings.customcolor = e.data.newValue.replace(/["']/g, "").split(','); + doColorUpdate = true; + break; + case 'colorgoal': + userSettings.colorgoal = JSON.parse(e.data.newValue).values[0].value; + doColorUpdate = true; + if (userSettings.colorgoal === 'hr') { + doClockUpdate = true; + } + break; + case 'dateformat': + userSettings.dateformat = JSON.parse(e.data.newValue).values[0].value; + doSublineUpdate = true; + break; + }; + + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + writeSettings(); + }, 8000); + + if (doColorUpdate) { updateColors(false); } + if (doClockUpdate) { updateClock(); } + if (doSublineChange) { changeSubline(); } + if (doSublineUpdate) { updateSubline(); } +} + +messaging.peerSocket.onopen = () => { + //console.log("Clockface opened socket"); +}; + +messaging.peerSocket.close = () => { + //console.log("Clockface closed socket"); +}; + +messaging.peerSocket.onerror = (err) => { + //console.log("Clockface socket error: " + err.code + " - " + err.message); +} + +function sendSetting() { + if (messaging.peerSocket.readyState === messaging.peerSocket.OPEN) { + messaging.peerSocket.send(userSettings); + } +} + +display.onchange = () => { + if (display.on) { + accel.start(); + averageAccelZ = lastAccelZ = Math.max(accel.z,5); + callback(); + if (hiddenSubline === false) { + mainHealthText.animate('enable'); + } + } else { + accel.stop(); + if (hiddenSubline === false) { + mainHealthText.animate('disable'); + } + } +} + +function updateClock() { + let currDate = new Date(); + let hours = currDate.getHours(); + let displayHours = hours; + if (preferences.clockDisplay === '12h') { + displayHours = displayHours % 12; + displayHours = displayHours ? displayHours : 12; + } + if (userSettings.leadingZero === true) { + displayHours = util.zeroPad(displayHours); + } + let mins = util.zeroPad(currDate.getMinutes()); + let timeString = `${displayHours}:${mins}`; + for(var i = 0; i < timeElementsNum; i++) { + timeElements[i].text = timeString; + } + if (userSettings.colors === 'random') { + updateColors(false); + } + updateSubline(); + switch (userSettings.secondary) { + case 'steps': + case 'cal': + case 'dist': + case 'elev': + case 'actmin': + case 'hr': + clearInterval(sublineTimer); + sublineTimer = setInterval(() => { + updateSubline(); + },2000); + } + if (userSettings.colorgoal === 'hr') { + clearInterval(colorTimer); + colorTimer = setInterval(() => { + updateColors(false); + },2000); + } + if (userSettings.colors === 'auto') { + updateColors(false); + } +} + +function updateSubline() { + if (userSettings.secondary === 'hr') { + mainHealthText.getElementById('healthTextLabel').text = hrLabel; + hrm.start(); + } else { + let sublineText = getActivityText(userSettings.secondary); + if (mainHealthText.getElementById('healthTextLabel').text !== sublineText) + mainHealthText.getElementById('healthTextLabel').text = sublineText; + } + if (display.on === false) { + clearInterval(sublineTimer); + } +} + +function getActivityNumber(activity) { + let values = {raw: 0, display: 0}; + + switch (activity) { + case 'steps': + values.raw = (today.adjusted.steps || 0); + values.display = util.addCommas(values.raw); + break; + case 'cal': + values.raw = (today.adjusted.calories || 0); + values.display = util.addCommas(values.raw); + break; + case 'dist': + values.raw = (today.adjusted.distance || 0); + values.display = (Math.round(values.raw/10)/100 || 0); + break; + case 'elev': + values.raw = values.display = (today.adjusted.elevationGain || 0); + break; + case 'actmin': + values.raw = (today.adjusted.activeMinutes || 0); + values.display = util.addCommas(values.raw); + break; + }; + return values; +} + +function getActivityText(activity) { + let label = placeholdertext; + switch (activity) { + case 'hr': + label = hrLabel; + break; + case 'steps': + case 'cal': + case 'actmin': + case 'dist': + case 'elev': + label = getActivityNumber(activity).display; + break; + case 'date': + let currDate = new Date(); + let day = currDate.getDate(); + let month = currDate.getMonth()+1; + + let zeroPadded = false; + let ddmm = false; + let delimiter = '/'; + let endsWithDelimiter = false; + + if (userSettings.dateformat[0] === 'd') { + ddmm = true; + } + delimiter = userSettings.dateformat[2]; + endsWithDelimiter = ((delimiter === '.') ? true : false); + + if (zeroPadded) { + day = util.zeroPad(day); + month = util.zeroPad(month); + } + if (ddmm) { + label = day+delimiter+month+(endsWithDelimiter ? delimiter : ''); + } else { + label = month+delimiter+day+(endsWithDelimiter ? delimiter : ''); + } + break; + default: + break; + }; + return label; +} + +function activityGoalColor(activity, heartRate) { + let level = colorNeutral; + if (heartRate !== false) { + let hrZone = (user.heartRateZone(heartRate) || 'none'); + switch (hrZone) { + case 'out-of-range': + case 'below-custom': + level = colorGreen; + break; + case 'fat-burn': + case 'custom': + level = colorYellow; + break; + case 'cardio': + level = colorOrange; + break; + case 'above-custom': + case 'peak': + level = colorRed; + break; + } + return level; + } else { + if (activity === 'hr') { + hrm.start(); + } else { + let goal = 0; + switch (activity) { + case 'steps': + goal = (goals.steps || 0); + break; + case 'cal': + goal = (goals.calories || 0); + break; + case 'dist': + goal = (goals.distance || 0); + break; + case 'elev': + goal = (Math.floor(goals.elevationGain/10) || 0); + break; + case 'actmin': + goal = (goals.activeMinutes || 0); + break; + }; + let current = getActivityNumber(activity).raw; + //console.log('Goal for '+activity+' is '+current+' of '+goal+' = '+(current/goal)); + if (goal > 0) { + let goalPercentage = current/goal; + if (goalPercentage > 1) { + level = colorGreen; + } else if (goalPercentage > 0.666) { + level = util.interpolateColors(goalPercentage, 0.666, 1, colorYellow, colorGreen); + } else if (goalPercentage > 0.333) { + level = util.interpolateColors(goalPercentage, 0.333, 0.666, colorOrange, colorYellow); + } else { + level = util.interpolateColors(goalPercentage, 0, 0.333, colorRed, colorOrange); + } + } + } + } + return level; +} + +function changeSubline() { + if (hiddenSubline === false) { + mainHealthText.animate('disable'); + } + let href = ''; + switch (userSettings.secondary) { + case 'date': + case 'steps': + case 'cal': + case 'dist': + case 'elev': + case 'actmin': + href = 'img/icon-'+userSettings.secondary+'.png'; + break; + default: + break; + }; + setTimeout(() => { + updateSubline(); + if (userSettings.secondary === 'hr') { hrm.start(); } + mainHealthText.getElementById('healthTextIcon').href = href; + if (userSettings.secondary === 'hr') { + mainHealthText.getElementById("healthTextHrIcon").style.display="inline"; + } else { + mainHealthText.getElementById("healthTextHrIcon").style.display="none"; + } + if (userSettings.secondary !== '-') { + mainHealthText.animate('enable'); + hiddenSubline = false; + } else { + hiddenSubline = true; + } + }, 200); +} + +function updateColors(color) { + let newColor = false; + if (color !== false) { + newColor = color; + } else { + switch (userSettings.colors) { + case 'auto': + let activityColor = activityGoalColor(userSettings.colorgoal, false); + if (userSettings.colorgoal !== 'hr') { + newColor = activityColor; + } + break; + case 'manual': + newColor = userSettings.customcolor; + break; + case 'random': + default: + newColor = [Math.random(),1,0.5]; + break; + } + } + if (COLORDEBUG) { + newColor = [Math.random(),1,0]; + } + //console.log('Color comparison '+newColor+' == '+currentColor+': '+(newColor == currentColor)); + if (newColor !== false && newColor != currentColor) { + //console.log('Color was changed'); + currentColor = newColor; + for(var i = 0; i < timeElementsNum-1; i++) { + let fraction = i/timeElementsNum; + timeElements[i].style.fill = util.hslToHex(newColor[0]-fraction*0.3, newColor[1]-fraction*newColor[2], fraction*0.74+0.15); + } + } else { + //console.log('Color is unchanged'); + } + if (display.on === false) { + clearInterval(colorTimer); + } +} + +function setPositions(offset) { + for(var i = 0; i < timeElementsNum; i++) { + let ypos = ((1-(offset-9))*5*(timeElementsNum-0.5-i))+20; + timeElements[i].y = ypos; + if (i == timeElementsNum-1) { + mainHealthText.y = ypos+160+((device.screen.height-250)/2); + } + } +} + +hrm.onreading = () => { + //console.log("Reading heart rate"); + if (display.on) { + let heartRate = (hrm.heartRate || 0); + hrLabel = heartRate; + document.getElementById('hrText').getElementById('healthTextLabel').text = heartRate; + if (userSettings.secondary == 'hr') { + mainHealthText.getElementById('healthTextLabel').text = heartRate; + } + if (userSettings.colorgoal === 'hr') { + let color = activityGoalColor('hr', heartRate); + //console.log('got HR color from hr '+heartRate+': '+color); + updateColors(color); + } + } + clearTimeout(hrTimer); + hrTimer = setTimeout(() => { + hrLabel = placeholdertext; + }, 1500); + hrm.stop(); +} + +function updateOverlay() { + document.getElementById('hrText').getElementById('healthTextLabel').text = hrLabel; + hrm.start(); + document.getElementById('stepsText').getElementById('healthTextLabel').text = getActivityText('steps'); + document.getElementById('calText').getElementById('healthTextLabel').text = getActivityText('cal'); + document.getElementById('distText').getElementById('healthTextLabel').text = getActivityText('dist'); + document.getElementById('elevText').getElementById('healthTextLabel').text = getActivityText('elev'); + document.getElementById('actminText').getElementById('healthTextLabel').text = getActivityText('actmin'); +} + +touchArea.onclick = (e) => { + if (COLORDEBUG) { + updateColors(false); + } else { + switch (userSettings.tap) { + case 'overlay': + if (hasOverlay == false) { + vibration.start("bump"); + addOverlay(); + } else { + removeOverlay(); + } + break; + case 'cycle': + vibration.start("bump"); + let next = sublines[0]; + let index = sublines.indexOf(userSettings.secondary); + if(index >= 0 && index < sublines.length - 1) { + next = sublines[index + 1]; + } + userSettings.secondary = next; + changeSubline(); + sendSetting(); + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + writeSettings(); + }, 8000); + break; + } + } +} + +function addOverlay() { + updateOverlay(); + overlay.style.display = 'inline'; + hasOverlay = true; + overlayTimer = setTimeout(() => { + removeOverlay(); + },6000); + document.getElementById('overlayShadeInstance').animate('enable'); + for(var i = 0; i < healthTextInstances.length; i++) { + let index = i; + setTimeout(() => { + healthTextInstances[index].animate('enable'); + }, 200+index*100); + } +} + +function removeOverlay() { + clearTimeout(overlayTimer); + document.getElementById('overlayShadeInstance').animate('disable'); + for(var i = 0; i < healthTextInstances.length; i++) { + healthTextInstances[i].animate('disable'); + } + setTimeout(() => { + overlay.style.display = 'none'; + }, 100); + hasOverlay = false; +} + +accel.onreading = () => { + //console.log(accel.z); + if (hasOverlay == false) { + lastAccelZ = Math.max(accel.z,5); + } + //accel.stop(); +} + +function callback(timestamp) { + if (hasOverlay == false) { + averageAccelZ = (lastAccelZ*smoothingFactor)+(averageAccelZ*(1.0-smoothingFactor)); + setPositions(averageAccelZ); + requestAnimationFrame(callback); + } +} + +updateColors(false); + +changeSubline(); + +clock.granularity = "minutes"; +clock.ontick = () => updateClock(); + +document.getElementById("hrText").getElementById("healthTextHrIcon").style.display="inline"; + +updateClock(); +updateOverlay(); + +requestAnimationFrame(callback); +accel.start(); + +if (DEBUG) { + display.autoOff = false; + display.on = true; +} \ No newline at end of file diff --git a/common/utils.js b/common/utils.js new file mode 100644 index 0000000..95d26bf --- /dev/null +++ b/common/utils.js @@ -0,0 +1,79 @@ +function map(value, sourceBottom, sourceTop, targetBottom, targetTop) { + let valueRatio = (value-sourceBottom)/(sourceTop-sourceBottom); + return ((targetTop-targetBottom)*valueRatio)+targetBottom; +} + +export function interpolateColors(value, sourceBottom, sourceTop, color1, color2) { + let newColor = [ + map(value, sourceBottom, sourceTop, color1[0], color2[0]), + map(value, sourceBottom, sourceTop, color1[1], color2[1]), + map(value, sourceBottom, sourceTop, color1[2], color2[2]) + ]; + return newColor; +} + +export function addCommas(nStr) { + if (nStr < 1000) { return nStr; } + nStr += ''; + let x = nStr.split('.'); + let x1 = x[0]; + let x2 = x.length > 1 ? '.' + x[1] : ''; + let rgx = /(\d+)(\d{3})/; + while (rgx.test(x1)) { + x1 = x1.replace(rgx, '$1' + ',' + '$2'); + } + return x1 + x2; +} + +// Add zero in front of numbers < 10 +export function zeroPad(i) { + if (i < 10) { + i = "0" + i; + } + return i; +} + +/** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * @param {number} h The hue + * @param {number} s The saturation + * @param {number} l The lightness + * @return {Array} The RGB representation + */ +function hslToRgb(h, s, l) { + var r, g, b; + + if (s == 0) { + r = g = b = l; // achromatic + } else { + var hue2rgb = function hue2rgb (p, q, t) { + if(t < 0) t += 1; + if(t > 1) t -= 1; + if(t < 1/6) return p + (q - p) * 6 * t; + if(t < 1/2) return q; + if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + } + + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function rgbToHex(r, g, b) { + return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); +} + +export function hslToHex(h, s, l) { + let rgb = hslToRgb(h, s, l); + return rgbToHex(rgb[0],rgb[1],rgb[2]); +} \ No newline at end of file diff --git a/companion/index.js b/companion/index.js new file mode 100644 index 0000000..66fc3ad --- /dev/null +++ b/companion/index.js @@ -0,0 +1,68 @@ +import * as messaging from "messaging"; +import { settingsStorage } from "settings"; + +//console.log("Companion Started"); + +//settingsStorage.setItem('colors', Json.stringify({})); +//settingsStorage.setItem('secondary', 'cal'); + +// Message socket opens +messaging.peerSocket.onopen = () => { + //console.log("Companion opened socket"); + //restoreSettings(); +}; + +// Message socket closes +messaging.peerSocket.close = () => { + //console.log("Companion closed Socket"); +}; + +/* +messaging.peerSocket.onmessage = (evt) => { + // Output the message to the console + console.log(JSON.stringify(evt.data)); + Object.keys(evt.data).forEach((k) => { + console.log(k + ' - ' + evt.data[k]); + //settingsStorage.setItem(k, evt.data[k]); + }); + //settingsStorage.setItem('secondary', 'cal'); +} +*/ + + +// A user changes settings +settingsStorage.onchange = evt => { + let data = { + key: evt.key, + newValue: evt.newValue + }; + switch (evt.key) { + case 'button': + // perform magic here + break; + default: + sendVal(data); + break; + } +}; + +// Restore any previously saved settings and send to the device +function restoreSettings() { + for (let index = 0; index < settingsStorage.length; index++) { + let key = settingsStorage.key(index); + if (key) { + let data = { + key: key, + newValue: settingsStorage.getItem(key) + }; + sendVal(data); + } + } +} + +// Send data to device using Messaging API +function sendVal(data) { + if (messaging.peerSocket.readyState === messaging.peerSocket.OPEN) { + messaging.peerSocket.send(data); + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..662fd11 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "fitbit": { + "appUUID": "753385ec-70bd-4560-baf0-5538105d67e9", + "appType": "clockface", + "appDisplayName": "Echocentric", + "iconFile": "resources/icon.png", + "wipeColor": "#009688", + "requestedPermissions": [ + "access_activity", + "access_heart_rate", + "access_user_profile" + ], + "buildTargets": [ + "higgs", + "meson" + ], + "i18n": { + "en": { + "name": "Echocentric" + } + } + }, + "devDependencies": { + "@fitbit/sdk": "~3.0.0" + } +} \ No newline at end of file diff --git a/resources/img/icon-actmin.png b/resources/img/icon-actmin.png new file mode 100644 index 0000000..19dc3a5 Binary files /dev/null and b/resources/img/icon-actmin.png differ diff --git a/resources/img/icon-cal.png b/resources/img/icon-cal.png new file mode 100644 index 0000000..082d912 Binary files /dev/null and b/resources/img/icon-cal.png differ diff --git a/resources/img/icon-date.png b/resources/img/icon-date.png new file mode 100644 index 0000000..5cb0137 Binary files /dev/null and b/resources/img/icon-date.png differ diff --git a/resources/img/icon-dist.png b/resources/img/icon-dist.png new file mode 100644 index 0000000..26dafd6 Binary files /dev/null and b/resources/img/icon-dist.png differ diff --git a/resources/img/icon-elev.png b/resources/img/icon-elev.png new file mode 100644 index 0000000..ad3bd7e Binary files /dev/null and b/resources/img/icon-elev.png differ diff --git a/resources/img/icon-steps.png b/resources/img/icon-steps.png new file mode 100644 index 0000000..92fe7f3 Binary files /dev/null and b/resources/img/icon-steps.png differ diff --git a/resources/index.gui b/resources/index.gui new file mode 100644 index 0000000..0f3306c --- /dev/null +++ b/resources/index.gui @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/styles-common.css b/resources/styles-common.css new file mode 100644 index 0000000..5c10205 --- /dev/null +++ b/resources/styles-common.css @@ -0,0 +1,33 @@ +.time { + font-size: 140; + font-family: Fabrikat-Bold; + text-length: 5; + text-anchor: end; + x: 0; +} + +#mainTime +{ + fill: white; +} + +#overlay { + display: none; +} + +#overlayShadeColor { + fill: #009688; + fill: black; +} + +.healthTextLabel { + font-size: 35; + font-family: Seville-Bold-Condensed; + text-length: 8; + text-anchor: end; + fill: white; +} + +#healthTextHrIcon { + display: none; +} \ No newline at end of file diff --git a/resources/styles.css b/resources/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/styles~300x300.css b/resources/styles~300x300.css new file mode 100644 index 0000000..f04a97c --- /dev/null +++ b/resources/styles~300x300.css @@ -0,0 +1,3 @@ +.time { + font-size: 120; +} \ No newline at end of file diff --git a/resources/widgets.gui b/resources/widgets.gui new file mode 100644 index 0000000..ce1ae63 --- /dev/null +++ b/resources/widgets.gui @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/settings/index.jsx b/settings/index.jsx new file mode 100644 index 0000000..d24651f --- /dev/null +++ b/settings/index.jsx @@ -0,0 +1,110 @@ +function Echocentric(props) { + return ( + +
Time Settings}> + + + } + { (props.settings.colors && JSON.parse(props.settings.colors).values[0].value === 'auto' || false) && + You will see different colors depending on your activity. Red means you still have a long way to your goal. On your way there you will see orange and yellow, until finally when you've reached your goal you will see green. With heart rate the colors will work the other way around. Green means you are below your Fat Burn zone, your Fat Burn zone is yellow, Cardio is orange and Peak is red. + } +
+
Secondary Information}> + +