From e0c434840b847e1338197bb8dad219c6d8ed1bd4 Mon Sep 17 00:00:00 2001 From: C <0xicl33n@gmail.com> Date: Thu, 2 Jul 2026 18:43:01 -0700 Subject: [PATCH] feat: Implement M5Paper Stock Ticker application --- m5paper-stockticker.ino | 300 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 m5paper-stockticker.ino diff --git a/m5paper-stockticker.ino b/m5paper-stockticker.ino new file mode 100644 index 0000000..027c245 --- /dev/null +++ b/m5paper-stockticker.ino @@ -0,0 +1,300 @@ +#include +#include +#include +#include +#include +#include +#include "images.h" +#include "config.h" + +// ------------------------------------------------------- +// Configuration - edit config.h before flashing +// ------------------------------------------------------- +const char* ssid = WIFI_SSID; +const char* password = WIFI_PASSWORD; +const char* apiKey = FINNHUB_API_KEY; +const char* avApiKey = ALPHAVANTAGE_KEY; +const char* ntpServer = "pool.ntp.org"; +const long gmtOffset_sec = GMT_OFFSET_SEC; +const int daylightOffset_sec = DAYLIGHT_OFFSET_SEC; + +// ------------------------------------------------------- +// Portfolio - symbol / shares / total cost basis ($) +// ------------------------------------------------------- +struct Stock { const char* symbol; float shares; float costBasis; bool useAV; }; + +Stock myPortfolio[] = { + { "AMD", 2.0000f, 53.02f, false }, + { "SWPPX", 41.2180f, 743.39f, true }, // mutual fund -> Alpha Vantage + { "TSLA", 1.0000f, 370.50f, false }, + { "SPYI", 6.0000f, 313.15f, false }, + { "JEPQ", 3.0000f, 178.52f, false }, + { "SCHD", 3.0000f, 95.97f, false }, +}; +const int NUM_STOCKS = sizeof(myPortfolio) / sizeof(myPortfolio[0]); + +// ------------------------------------------------------- +// Runtime stock data +// ------------------------------------------------------- +struct StockData { float price, change, pct; bool valid; }; + +bool fetchStock(const char* sym, StockData& out) { + out = { 0.0f, 0.0f, 0.0f, false }; + if (WiFi.status() != WL_CONNECTED) return false; + + char url[220]; + snprintf(url, sizeof(url), + "https://finnhub.io/api/v1/quote?symbol=%s&token=%s", sym, apiKey); + + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + http.begin(client, url); + int code = http.GET(); + if (code != HTTP_CODE_OK) { http.end(); return false; } + + String body = http.getString(); + http.end(); + + StaticJsonDocument<512> doc; + if (deserializeJson(doc, body)) return false; + + out.price = doc["c"] | 0.0f; + out.change = doc["d"] | 0.0f; + out.pct = doc["dp"] | 0.0f; + out.valid = (out.price > 0.0f); + return out.valid; +} + +// Alpha Vantage fetch (for mutual funds not covered by Finnhub) +bool fetchStockAV(const char* sym, StockData& out) { + out = { 0.0f, 0.0f, 0.0f, false }; + if (WiFi.status() != WL_CONNECTED) return false; + + char url[256]; + snprintf(url, sizeof(url), + "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=%s&apikey=%s", + sym, avApiKey); + + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + http.begin(client, url); + int code = http.GET(); + if (code != HTTP_CODE_OK) { http.end(); return false; } + + String body = http.getString(); + http.end(); + + StaticJsonDocument<1024> doc; + if (deserializeJson(doc, body)) return false; + + JsonObject q = doc["Global Quote"]; + if (q.isNull()) return false; + + out.price = atof(q["05. price"] | "0"); + out.change = atof(q["09. change"] | "0"); + + // "10. change percent" is like "0.0500%" - strip the trailing '%' + char pctBuf[16]; + strncpy(pctBuf, q["10. change percent"] | "0%", sizeof(pctBuf) - 1); + pctBuf[sizeof(pctBuf) - 1] = '\0'; + char* pctMark = strchr(pctBuf, '%'); + if (pctMark) *pctMark = '\0'; + out.pct = atof(pctBuf); + + out.valid = (out.price > 0.0f); + return out.valid; +} + +// ------------------------------------------------------- +// Drawing helpers +// ------------------------------------------------------- + +// Circular refresh icon drawn with primitives (two arcs + two arrowheads) +void drawRefreshIcon(int x, int y, int sz) { + const float DEG2RAD = 3.14159265f / 180.0f; + int cx = x + sz / 2; + int cy = y + sz / 2; + int ro = sz * 9 / 20; // outer radius + int ri = sz * 5 / 20; // inner radius + float rm = (ro + ri) / 2.0f; // midline + float as = sz / 5.0f; // arrowhead size + + // Two ~160° arcs with a small gap at top-right and bottom-left for arrowheads + M5.Display.fillArc(cx, cy, ro, ri, 200, 358, TFT_BLACK); // upper/left arc + M5.Display.fillArc(cx, cy, ro, ri, 22, 180, TFT_BLACK); // lower/right arc + + // Arrowheads at 0° (top, pointing right) and 180° (bottom, pointing left) + for (int pass = 0; pass < 2; pass++) { + float a = (pass == 0 ? 0.0f : 180.0f) * DEG2RAD; + float sa = sinf(a), ca = cosf(a); + float px = cx + rm * sa, py = cy - rm * ca; + float tx = ca, ty = sa; // clockwise tangent + float rx = sa, ry = -ca; // outward radial + M5.Display.fillTriangle( + (int)(px + tx*as*0.7f), + (int)(py + ty*as*0.7f), + (int)(px - tx*as*0.5f + rx*as*0.6f), + (int)(py - ty*as*0.5f + ry*as*0.6f), + (int)(px - tx*as*0.5f - rx*as*0.6f), + (int)(py - ty*as*0.5f - ry*as*0.6f), + TFT_BLACK + ); + } +} + +void drawUpArrow(int x, int y, int sz) { + M5.Display.fillTriangle(x + sz / 2, y, x, y + sz, x + sz, y + sz, TFT_BLACK); +} + +void drawDownArrow(int x, int y, int sz) { + M5.Display.fillTriangle(x, y, x + sz, y, x + sz / 2, y + sz, TFT_BLACK); +} + +void drawBattery(int x, int y, int pct) { + M5.Display.drawRect(x, y, 42, 20, TFT_BLACK); + M5.Display.fillRect(x + 42, y + 6, 4, 8, TFT_BLACK); + int fw = (int)(38.0f * constrain(pct, 0, 100) / 100.0f); + if (fw > 0) M5.Display.fillRect(x + 2, y + 2, fw, 16, TFT_BLACK); +} + +// Format a float as dollars with comma separators e.g. 1234.56 -> "1,234.56" +void fmtMoney(char* buf, size_t len, float val) { + float absVal = val < 0.0f ? -val : val; + int dollars = (int)absVal; + int cents = (int)((absVal - dollars) * 100.0f + 0.5f); + if (cents >= 100) { dollars++; cents -= 100; } + if (dollars >= 1000000) + snprintf(buf, len, "%d,%03d,%03d.%02d", dollars/1000000, (dollars%1000000)/1000, dollars%1000, cents); + else if (dollars >= 1000) + snprintf(buf, len, "%d,%03d.%02d", dollars/1000, dollars%1000, cents); + else + snprintf(buf, len, "%d.%02d", dollars, cents); +} + +// ------------------------------------------------------- +// Setup +// ------------------------------------------------------- +void setup() { + auto cfg = M5.config(); + M5.begin(cfg); + M5.Display.setRotation(1); + + // Connect to WiFi + WiFi.begin(ssid, password); + for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500); + + if (WiFi.status() != WL_CONNECTED) { + M5.Display.fillScreen(TFT_WHITE); + M5.Display.setFont(&fonts::AsciiFont8x16); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextSize(2); + M5.Display.setCursor(20, 20); + M5.Display.print("WiFi connection failed."); + M5.Display.setCursor(20, 55); + M5.Display.printf("SSID: %s", ssid); + delay(2000); + M5.Power.powerOff(); + return; + } + + // Sync time via NTP + configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); + struct tm now = {}; + for (int i = 0; i < 10 && !getLocalTime(&now); i++) delay(500); + + // Fetch market data + StockData sd[NUM_STOCKS]; + float dayGain = 0.0f, openValue = 0.0f; + for (int i = 0; i < NUM_STOCKS; i++) { + if (myPortfolio[i].useAV) + fetchStockAV(myPortfolio[i].symbol, sd[i]); + else + fetchStock(myPortfolio[i].symbol, sd[i]); + if (sd[i].valid) { + dayGain += sd[i].change * myPortfolio[i].shares; + openValue += (sd[i].price - sd[i].change) * myPortfolio[i].shares; + } + } + float dayPct = (openValue > 0.0f) ? (dayGain / openValue) * 100.0f : 0.0f; + int batPct = M5.Power.getBatteryLevel(); + + // ---- Render ---- + M5.Display.startWrite(); + M5.Display.fillScreen(TFT_WHITE); + M5.Display.setFont(&fonts::AsciiFont8x16); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setTextSize(2); + + // -- Row 1: refresh icon + timestamp (left) | "zZz [battery] xx%" (right) -- + drawRefreshIcon(20, 8, 46); + char timeStr[32]; + strftime(timeStr, sizeof(timeStr), "%d/%m %I:%M %p", &now); + M5.Display.setCursor(74, 22); + M5.Display.print(timeStr); + M5.Display.print(timeStr); + + M5.Display.setCursor(740, 15); + M5.Display.print("zZz"); + drawBattery(806, 18, batPct); + M5.Display.setCursor(856, 15); + M5.Display.printf("%d%%", batPct); + + // -- Row 2: Day's gain / loss -- + char moneyBuf[24]; + fmtMoney(moneyBuf, sizeof(moneyBuf), dayGain); + M5.Display.setCursor(20, 55); + if (dayGain >= 0.0f) + M5.Display.printf("DAY'S GAIN / LOSS: +$%s (+%.2f%%)", moneyBuf, dayPct); + else + M5.Display.printf("DAY'S GAIN / LOSS: -$%s (%.2f%%)", moneyBuf, dayPct); + + // Divider + M5.Display.drawLine(20, 100, 940, 100, TFT_BLACK); + + // -- Stock rows: auto-scale to fit all entries on one page -- + const int ROW0 = 110, SCREEN_BOTTOM = 530; + int ROWY = (SCREEN_BOTTOM - ROW0) / NUM_STOCKS; + + // Pick the largest font that fits the row height, with matching column positions + int textSz, colPrice, colArrow, colChg, arrowSz, arrowOff; + if (ROWY >= 48) { textSz=3; colPrice=240; colArrow=455; colChg=500; arrowSz=32; arrowOff=6; } + else if (ROWY >= 32) { textSz=2; colPrice=168; colArrow=300; colChg=340; arrowSz=22; arrowOff=4; } + else { textSz=1; colPrice= 84; colArrow=152; colChg=168; arrowSz=12; arrowOff=2; } + + M5.Display.setTextSize(textSz); + + for (int i = 0; i < NUM_STOCKS; i++) { + int y = ROW0 + i * ROWY; + + M5.Display.setCursor(20, y); + M5.Display.printf("%-6s", myPortfolio[i].symbol); + + if (sd[i].valid) { + M5.Display.setCursor(colPrice, y); + M5.Display.printf("$%7.2f", sd[i].price); + + if (sd[i].change >= 0.0f) drawUpArrow (colArrow, y + arrowOff, arrowSz); + else drawDownArrow(colArrow, y + arrowOff, arrowSz); + + M5.Display.setCursor(colChg, y); + M5.Display.printf("%+.2f (%+.2f%%)", sd[i].change, sd[i].pct); + } else { + M5.Display.setCursor(240, y); + M5.Display.print(" N/A"); + } + } + + // Page indicator + // M5.Display.setTextSize(1); + // M5.Display.setCursor(890, 525); + //M5.Display.print("Pa. 1/1"); + + M5.Display.endWrite(); + + delay(1000); + M5.Power.deepSleep(43200000000ULL); // 12 hours +} + +void loop() {} \ No newline at end of file