diff --git a/apps/coin_info/.gitignore b/apps/coin_info/.gitignore new file mode 100644 index 000000000..2461f4834 --- /dev/null +++ b/apps/coin_info/.gitignore @@ -0,0 +1,2 @@ +/dummy.js +/pebbleppApp.js diff --git a/apps/coin_info/ChangeLog b/apps/coin_info/ChangeLog new file mode 100644 index 000000000..5c40ee93d --- /dev/null +++ b/apps/coin_info/ChangeLog @@ -0,0 +1,7 @@ +0.03: Initial creation +0.04: Using GB http for Binance API requests +0.05: Lot of cleanup +0.06: Creating app v1 +0.07: Finishing touches for v1 +0.09: Clean up +0.10: Finalize documentation for v1 \ No newline at end of file diff --git a/apps/coin_info/README.md b/apps/coin_info/README.md new file mode 100644 index 000000000..47bb8fc06 --- /dev/null +++ b/apps/coin_info/README.md @@ -0,0 +1,66 @@ +# Crypto-Coin Info + +Crypto-Coins Infos with the help of the Binance and CoinStats API + +## Description + +- Is a clock_info module and an app +- I use Pebble++ watch to show a bigger size of clock_info +- I use a wider, more readable font for Pebble++ +- Upload data via App-Loader interface first!!! + +![Screenshot Click_Info 01](screenshots/20250316_01.jpg) +![Screenshot Click_Info 02](screenshots/20250316_02.jpg) +![Screenshot App BTC Graph](screenshots/20250322_01.jpg) +![Screenshot APP BTC Details](screenshots/20250322_02.jpg) +![Screenshot APP STORJ Low/High](screenshots/20250323_01.jpg) + +## Creator + +Martin Zwigl + +## Parts Infos + +### App-Loader web-interface + +- Binance + - Find docs here [Binance API](https://www.binance.com/en/binance-api) + - For Binance use symbols like BTC,ETH,STORJ + - For the calc counterpart use USDT (I don't know why USD is measured on the stablecoin) or EUR or other fiat currency +- Coinstats + - Find docs here [Coinstats API](https://openapi.coinstats.app/) + - Get an API key at the website. Free is worth 1Mio token, which in turn is worth around 250k - 300k requests per month + - Supply crypto token in the form of its IDs like bitcoin,ethereum,storj +- It is not necessary to re-upload the app when uploading data. Data is read with app start + +### Clock-Info + +- Updates prices with the free Binance API +- clkInfo updates after around 15 sec and then every x minutes (via settings) thereafter. +- The token you want to have tracked and compared to what currency have to be uploaded via app loader web-interface +- After that you can decide which token to display in settings + +### App + +- Using CoinStats for chart-data +- token-names on CoinStats are different to Binance; they also have to be uploaded via Interface +- You also need a CoinStats API access key which is good for a fair amount of calls +- I tried with gridy for the axis, but for this data - it is just not readable... +- Let me know when you have good suggestions for improvement. +- ".." button shows current details for current token +- "LH" button shows low and high on graph as well as the first and last point in series +- Swipe L-R changes token you supplied via interface +- Not much guard-rails in the app -> you should have at least one token (each) present +- Also the API token and fiat currency you want to match against (eg. USD, EUR) +- New data is requested every minute, except on button touch + +### Settings + +- Choose which of the uploaded tokens to display in clock_info +- Choose update-time for clock_info HTTP requests to Binance + +## Possible Improvements / TODOs + +- Better choosing of fonts for more space +- set UI properly to have back button next to widgets +- clean-up code structure \ No newline at end of file diff --git a/apps/coin_info/app-icon.js b/apps/coin_info/app-icon.js new file mode 100644 index 000000000..6cd06c707 --- /dev/null +++ b/apps/coin_info/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4AT5gAGFdYzmFx4xfEAfN53U6oAC53O5owg6wjB5wrDAA3UGQxaW5vWAAgwLGIIwXRAYvFGBxiEXCnOF4wwBKoKWHGAPUGCguLAAINCMJIwEFzgvLGC3NFw7gFAAfNehovN65aKABDEGGAouNAAIvSSg6RRF4QwGDAQIF6qSLMBwuDGAwvIGAj0IF6YwEF5IKDF4/VSAQtIO4QwJL6vVSBaoDGBC/UF5gJCGBQNEAA/VGoIAEBIIvEGQYaHGA4vML4IwEFgYAFF5PM54vSGAKXCL4gvOIIXVF5DvG6xWD5wwMYBAUCR5IvHGAZgCGBLwJFo4vEegIAHHgwwGFwwwD5wvMGBHNL4gwFFpBgEF5owI53VA4ovQ6wvNSRIAGFxYvD5ovKAAyKFAA4vLMBYvJagIuXMBfOL6fN5ovNGAfOeRD0PFwIPCGCPPF54wHOQYuOF4YwKEIJtFFxAvQGAiSLAAqMGFyIwFcQIwP6ouXGAoxOFoouVGI6VB6ozDRAPVXAgtZGBAANFzQxSFrozNFcYA/AH4AvA")) \ No newline at end of file diff --git a/apps/coin_info/app.js b/apps/coin_info/app.js new file mode 100644 index 000000000..128bddade --- /dev/null +++ b/apps/coin_info/app.js @@ -0,0 +1,219 @@ +// const logFile = require("Storage").open("coin_info_log.txt", "a"); +const db = require("Storage").readJSON("coin_info.cmc_key.json", 1) || {}; +const csTokens = db.csTokens.split(','); +// +const ciLib = require("coin_info"); +// +var ticker = 0; +var currLoadMsg = "..."; +var timePeriod = "24h"; +var tknChrtData = [5,6,5,6,5,6,5,6,5,6,5,6,5,6,]; +var optSpacing = {}; +var isPaused = false; + + +// +Bangle.loadWidgets(); // loading widgets after drawing the layout in `drawMain()` to display the app UI ASAP. +require("widget_utils").swipeOn(); // hide widgets, make them visible with a swipe +// Bangle.setUI({ +// mode: 'custom', +// back: Bangle.showClock +// // btn: function() { // Handle button press +// // console.log("Button pressed"); +// // } +// }); +// +function swipeHandler(lr, ud) { + if (lr == 1) { + ticker = ticker - 1; + if (ticker < 0) ticker = 0; + } + if (lr == -1) { + ticker = ticker + 1; + if (ticker > csTokens.length - 1) ticker = csTokens.length - 1; + } +} +Bangle.on("swipe", swipeHandler); + + +// +function renderGraph(l) { + const bounds = ciLib.findMinMax(tknChrtData); + // logFile.write("?. graphy: " + JSON.stringify(bounds) + "\n"); + require("graph").drawLine(g, tknChrtData, { + axes: true, + x: l.x, y: l.y, width: l.w, height: l.h, + miny: bounds.min, + maxy: bounds.max, + // gridy: 5 + }); +} + +var Layout = require("Layout"); +var layout = new Layout({ + type:"v", c: [ + {type:"h", valign:-1, + c: [ + {type:"txt", id:"tknName", font:"6x8:2", label:"", halign:-1, fillx:1}, + {type:"btn", label:"..", halign:1, cb: d=>showDetails()}, + {type:"btn", label:"LH", halign:1, cb: d=>showLowHigh()} + ] + }, + {type:"txt", id:"loadMsg", font:"6x8", label:"", fillx:1 }, + {type:"custom", render:renderGraph, id:"tknGraph", bgCol:g.theme.bg, fillx:1, filly:1 }, + {type:"h", valign:1, + c: [ + {type:"btn", label:"24h", cb: d=>getChart("24h")}, + {type:"btn", label:"1w", cb: d=>getChart("1w")}, + {type:"btn", label:"1m", cb: d=>getChart("1m")}, + {type:"btn", label:"3m", cb: d=>getChart("3m")} + ] + } + ] + }, + { lazy:true }); +layout.update(); + + +// +var updateTimeout; +function getChart(period) { + if (isPaused) { + if (updateTimeout) clearTimeout(updateTimeout); + return; + } + + // + timePeriod = period; + currLoadMsg = `Load... ${period}`; + // + // const date = new Date(); + // logFile.write("Called:" + date.toISOString() + " -- " + timePeriod + " -- " + csTokens[ticker] + "\n"); + + const url = `https://openapiv1.coinstats.app/coins/${csTokens[ticker]}/charts?period=${timePeriod}`; + Bangle + .http(url, { + method: 'GET', + headers: { + 'X-API-KEY': db.csApiKey + } + }) + .then(data => { + // logFile.write("HTTP resp:" + JSON.stringify(data)); + const apiData = JSON.parse(data.resp); + tknChrtData = apiData.map(innerArray => innerArray[1]); + // logFile.write("Chart data:" + JSON.stringify(tknChrtData)); + + // just not readable + optSpacing = ciLib.calculateOptimalYAxisSpacing(tknChrtData); + // + g.clearRect(layout.tknGraph.x, layout.tknGraph.y, layout.tknGraph.w, layout.tknGraph.h); + layout.forgetLazyState(); // Force a full re-render + layout.render(layout.tknGraph); // Render just the graph area + + // + currLoadMsg = ""; + layout.render(layout.loadMsg); + }) + .catch(err => { + // logFile.write("API Error: " + JSON.stringify(err)); + tknChrtData = [1,2,3,4,5,6,7,8,9,8,7,6,5,4,]; + }); + + if (updateTimeout) clearTimeout(updateTimeout); + updateTimeout = setTimeout(function() { + updateTimeout = undefined; + getChart(period); + }, 60000 - (Date.now() % 60000)); +} + +// +function showLowHigh() { + const title = `L/H ${csTokens[ticker]}`; + // + // logFile.write("OptSpacing:" + JSON.stringify(optSpacing) + "\n"); + const first = ciLib.formatPriceString(optSpacing.first); + const last = ciLib.formatPriceString(optSpacing.last); + const low = ciLib.formatPriceString(optSpacing.rawMin); + const high = ciLib.formatPriceString(optSpacing.rawMax); + const msg = ` + First: ${first} + Last: ${last} + Low: ${low} + High: ${high} + `; + isPaused = true; + E.showAlert(msg, title).then(function() { + isPaused = false; + g.clear(); + layout.forgetLazyState(); + layout.render(); + layout.setUI(); + }); +} +function showDetails() { + const token = csTokens[ticker]; + const url = `https://openapiv1.coinstats.app/coins/${token}`; + Bangle.http(url, { + method: 'GET', + headers: { + 'X-API-KEY': db.csApiKey + } + }) + .then(data => { + const tokenInfo = JSON.parse(data.resp); + const priceFmt = ciLib.formatPriceString(tokenInfo.price); + const mCapFmt = ciLib.formatPriceString(tokenInfo.marketCap); + const title = `Details ${tokenInfo.symbol}`; + const msg = ` + Price: ${priceFmt} + M-Cap: ${mCapFmt} + 1h:${tokenInfo.priceChange1h} + 1d:${tokenInfo.priceChange1d} 1w:${tokenInfo.priceChange1w} + `; + isPaused = true; + E.showAlert(msg, title).then(function() { + isPaused = false; + g.clear(); + layout.forgetLazyState(); + layout.render(); + layout.setUI(); + }); + }) + .catch(err => { + const msg = `Failed to fetch details for ${token.toUpperCase()}`; + E.showAlert(msg, "Error").then(function() { + // print("Ok pressed"); + g.clear(); + layout.forgetLazyState(); + layout.render(); + layout.setUI(); + }); + }); +} + + +// timeout used to update every minute +var drawTimeout; +// update the screen +function draw() { + // + layout.tknName.label = (csTokens[ticker]).toUpperCase(); + layout.loadMsg.label = currLoadMsg; + // + layout.render(layout.graph); + // + layout.render(); + + // schedule a draw for the next minute + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + draw(); + }, 1000 - (Date.now() % 1000)); +} + +// update time and draw +g.clear(); +draw(); +getChart("24h"); diff --git a/apps/coin_info/app.png b/apps/coin_info/app.png new file mode 100644 index 000000000..97fe8df7d Binary files /dev/null and b/apps/coin_info/app.png differ diff --git a/apps/coin_info/app_mono.png b/apps/coin_info/app_mono.png new file mode 100644 index 000000000..794f2b563 Binary files /dev/null and b/apps/coin_info/app_mono.png differ diff --git a/apps/coin_info/clkinfo.js b/apps/coin_info/clkinfo.js new file mode 100644 index 000000000..f16829522 --- /dev/null +++ b/apps/coin_info/clkinfo.js @@ -0,0 +1,89 @@ +(function() { + const LOAD_ICON_24 = atob("GBiBAAAAAAAeAAGfwAGB4AAAcBgAOBgYHAAYDAAYDGAYBmAYBgAYBgAYBmDbBmB+BgA8DAAYDBgAHBgAOAAAcAGB4AGfwAAeAAAAAA=="); + const DECR_ICON_24 = atob("GBiBAAAAAAAAAAAAAAAAAAAAABgAADwAAH4cAD8+AB//AA//gAf/wAPz7AHh/ADA/AAAfAAA/gAA/gAAHgAAAAAAAAAAAAAAAAAAAA=="); + const INCR_ICON_24 = atob("GBiBAAAAAAAAAAAAAAAAAAAAAAAAHgAA/gAA/gAAfADA/AHh/APz7Af/wA//gB//AD8+AH4cADwAABgAAAAAAAAAAAAAAAAAAAAAAA=="); + + const settings = require("Storage").readJSON("coin_info.settings.json", 1) || {}; + const db = require("Storage").readJSON("coin_info.cmc_key.json", 1) || {}; + // const logFile = require("Storage").open("coin_info_log.txt", "a"); + const ciLib = require("coin_info"); + + if (!(settings.tokenSelected instanceof Array)) settings.tokenSelected = []; + + let cache = {}; + return { + name: "CoinInfo", + items: settings.tokenSelected.map(token => { + return { + name: token, + get: function() { + // Return cached data if available + if (cache[token]) { + return cache[token]; + } + + // Return placeholder while waiting for data + return { + text: "Load", + img: LOAD_ICON_24 + }; + }, + show: function() { + var self = this; + + // Function to fetch data from API + const fetchData = (callback) => { + const url = `https://api.binance.com/api/v3/ticker/24hr?symbol=${token}${db.calcPair}`; + + Bangle.http(url, { method: 'GET' }) + .then(cmcResult => { + // logFile.write("HTTP resp:" + JSON.stringify(cmcResult)); + const apiData = JSON.parse(cmcResult.resp); + // logFile.write("data:" + JSON.stringify(apiData)); + let priceString = ciLib.formatPriceString(apiData.lastPrice); + + let changeIcon = INCR_ICON_24; + if (apiData.priceChange.startsWith("-")) + changeIcon = DECR_ICON_24; + // Update cache with fetched data + cache[token] = { + text: `${token}\n${priceString}`, + img: changeIcon + }; + + callback(); + }) + .catch(err => { + // logFile.write("API Error: " + JSON.stringify(err)); + cache[token] = { + text: "Error", + img: LOAD_ICON_24 + }; + callback(); + }); + }; + + // Set timeout to align to the next hour and then continue updating every hour + const updateTime = settings.getRateMin * 60 * 1000; + self.interval = setTimeout(function timerTimeout() { + fetchData(() => { + self.emit("redraw"); + }); + // Continue updating every hour + self.interval = setInterval(function intervalCallback() { + fetchData(() => { + self.emit("redraw"); + }); + }, updateTime); + }, 30000 - (Date.now() % 30000 )); + }, + hide: function() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + }; + }) + }; +}) \ No newline at end of file diff --git a/apps/coin_info/icons/icons8-decrease-24.png b/apps/coin_info/icons/icons8-decrease-24.png new file mode 100644 index 000000000..323dfea2a Binary files /dev/null and b/apps/coin_info/icons/icons8-decrease-24.png differ diff --git a/apps/coin_info/icons/icons8-increase-24.png b/apps/coin_info/icons/icons8-increase-24.png new file mode 100644 index 000000000..dc1132f56 Binary files /dev/null and b/apps/coin_info/icons/icons8-increase-24.png differ diff --git a/apps/coin_info/icons/icons8-submit-progress-24.png b/apps/coin_info/icons/icons8-submit-progress-24.png new file mode 100644 index 000000000..71b950334 Binary files /dev/null and b/apps/coin_info/icons/icons8-submit-progress-24.png differ diff --git a/apps/coin_info/interface.html b/apps/coin_info/interface.html new file mode 100644 index 000000000..7d91dbea5 --- /dev/null +++ b/apps/coin_info/interface.html @@ -0,0 +1,80 @@ + + + + + +

Settings for Crypto-Coin Info

+

Input for coins to request and calculate against pair.

+

+
+ + +
+ + +
+ + +
+ + + +

+

General Info:

+

Requesting from Binance with free API. Get info from their website.

+ + + + + diff --git a/apps/coin_info/lib.js b/apps/coin_info/lib.js new file mode 100644 index 000000000..9f4bf9b62 --- /dev/null +++ b/apps/coin_info/lib.js @@ -0,0 +1,115 @@ +/** + * Formats a number string with common prefixes like K, M, B, T. + * For negative numbers, it returns "Negative". + * For numbers between 0 and 1, it rounds up to two decimal places. + * + * @param {string} input - The input string containing numerals. + * @returns {string} The formatted string. + */ +exports.formatPriceString = function(input) { + // Ensure input is a number + let number = typeof input === 'string' ? parseFloat(input) : input; + + // Check if input is not a number + if (isNaN(number)) { + return 'Invalid input'; + } + + // Handle negative numbers + if (number < 0) { + return 'Negative'; + } + + // Handle zero + if (number === 0) { + return 'Zero'; + } + + // Handle numbers between 0 and 1 + if (number < 1) { + return Math.ceil(number * 100) / 100; + } + + // Define suffixes + const suffixes = ['', 'K', 'M', 'B', 'T']; + + // Determine the suffix index + let suffixIndex = 0; + while (number >= 1000 && suffixIndex < suffixes.length - 1) { + number /= 1000; + suffixIndex++; + } + + // Format the number with three decimal places after the comma + const formattedNumber = number.toFixed(3) + suffixes[suffixIndex]; + + return formattedNumber; +} + +exports.findMinMax = function(values) { + var min = values[0]; + var max = values[0]; + + for (var i = 1; i < values.length; i++) { + if (values[i] < min) min = values[i]; + if (values[i] > max) max = values[i]; + } + + return { min: min, max: max }; +} + +exports.myLog10 = function(value) { + return Math.log(value) / Math.LN10; +} + +exports.calculateOptimalYAxisSpacing = function(data) { + // Check if data is empty + if (data.length === 0) { + return { min: 0, max: 1, interval: 1 }; + } + + // Find the minimum and maximum values in the data + const bounds = exports.findMinMax(data); + let minY = bounds.min; + let maxY = bounds.max; + + // Calculate the range of the data + let range = maxY - minY; + + // If all values are the same, set a small range to avoid division by zero + if (range === 0) { + range = 1; + } + + // Determine the number of ticks (e.g., 5 to 10 ticks) + let numTicks = 7; // You can adjust this value based on your preference + + // Calculate the interval + let interval = range / (numTicks - 1); + + // Round the interval to a nice number (e.g., 1, 2, 5, 10) + let roundedInterval = Math.pow(10, Math.floor(exports.myLog10(interval))); + if (interval / roundedInterval > 5) { + roundedInterval *= 5; + } else if (interval / roundedInterval > 2) { + roundedInterval *= 2; + } + + // Adjust min and max to ensure they are on the rounded interval + let adjustedMin = Math.floor(minY / roundedInterval) * roundedInterval; + let adjustedMax = Math.ceil(maxY / roundedInterval) * roundedInterval; + + let first = data[0]; + let last = data[data.length - 1]; + return { + min: adjustedMin, + max: adjustedMax, + interval: roundedInterval, + first: first, + last: last, + rawMin: minY, + rawMax: maxY + }; +} + + diff --git a/apps/coin_info/metadata.json b/apps/coin_info/metadata.json new file mode 100644 index 000000000..976be7707 --- /dev/null +++ b/apps/coin_info/metadata.json @@ -0,0 +1,23 @@ +{ "id": "coin_info", + "name": "Crypto-Coins Info", + "shortName":"Coins Info", + "version": "0.10", + "description": "Crypto-Coins Infos with the help of the Binance API", + "icon": "app.png", + "tags": "clkinfo", + "supports" : ["BANGLEJS2"], + "interface": "interface.html", + "readme": "README.md", + "data": [ + {"name":"coin_info.settings.json"}, + {"name":"coin_info.cmc_key.json"}, + {"name":"coin_info.coin_info_log.txt"} + ], + "storage": [ + {"name":"coin_info.app.js","url":"app.js"}, + {"name":"coin_info.clkinfo.js","url":"clkinfo.js"}, + {"name":"coin_info.settings.js","url":"settings.js"}, + {"name":"coin_info.img","url":"app-icon.js","evaluate":true}, + {"name":"coin_info","url":"lib.js"} + ] +} \ No newline at end of file diff --git a/apps/coin_info/screenshots/20250316_01.jpg b/apps/coin_info/screenshots/20250316_01.jpg new file mode 100644 index 000000000..80ed025a0 Binary files /dev/null and b/apps/coin_info/screenshots/20250316_01.jpg differ diff --git a/apps/coin_info/screenshots/20250316_02.jpg b/apps/coin_info/screenshots/20250316_02.jpg new file mode 100644 index 000000000..8b431c980 Binary files /dev/null and b/apps/coin_info/screenshots/20250316_02.jpg differ diff --git a/apps/coin_info/screenshots/20250322_01.jpg b/apps/coin_info/screenshots/20250322_01.jpg new file mode 100644 index 000000000..04cc22c94 Binary files /dev/null and b/apps/coin_info/screenshots/20250322_01.jpg differ diff --git a/apps/coin_info/screenshots/20250322_02.jpg b/apps/coin_info/screenshots/20250322_02.jpg new file mode 100644 index 000000000..38ba6052d Binary files /dev/null and b/apps/coin_info/screenshots/20250322_02.jpg differ diff --git a/apps/coin_info/screenshots/20250323_01.jpg b/apps/coin_info/screenshots/20250323_01.jpg new file mode 100644 index 000000000..462d4fac6 Binary files /dev/null and b/apps/coin_info/screenshots/20250323_01.jpg differ diff --git a/apps/coin_info/settings.js b/apps/coin_info/settings.js new file mode 100644 index 000000000..944f737c3 --- /dev/null +++ b/apps/coin_info/settings.js @@ -0,0 +1,53 @@ +(function(back) { + const SETTINGS_FILE = "coin_info.settings.json"; + const storage = require('Storage'); + + // Default settings with sorted tokens and load settings + let settings = Object.assign({ + // TODO: MZw - retrieve from upload-storage + tokens: ['BTC', 'ETH', 'STORJ'], + tokenSelected: ['BTC'], + getRateMin: 60 + }, storage.readJSON(SETTINGS_FILE, 1) || {}); + + function save() { + storage.write(SETTINGS_FILE, settings); + } + + function createMenu() { + const menu = { + '': { 'title': 'Crypto-Coin Info' }, + '< Back': () => Bangle.showClock() + }; + + // Dynamic checkbox creation + settings.tokens.sort().forEach(token => { + menu[token] = { + value: settings.tokenSelected.includes(token), + onchange: v => { + if (v) { + settings.tokenSelected.push(token); + } else { + settings.tokenSelected = settings.tokenSelected.filter(f => f !== token); + } + save(); + } + }; + }); + + // update time + menu['Refresh Rate (min)'] = { + value: settings.getRateMin, + min: 1, + max: 1440, + onchange: v => { + settings.getRateMin = v; + save(); + } + }; + + return menu; + } + + E.showMenu(createMenu()); +}) \ No newline at end of file