#include #include #include #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 }, { "TSLA", 1.0000f, 370.50f, false }, { "SPYI", 6.0000f, 313.15f, false }, { "JEPQ", 3.0000f, 178.52f, false }, { "SCHD", 3.0000f, 95.97f, false }, {"VOO", 1.0000f, 684.60f, true }, }; // To add an entry: { "SYMBOL", shares, costBasis, useAV } // useAV = false -> Finnhub (stocks & ETFs) // useAV = true -> Alpha Vantage (mutual funds e.g. SWPPX) // costBasis -> total amount paid (Value $ - Total Gain $ from your broker CSV) // The display auto-scales font size to fit all entries on one page. const int NUM_STOCKS = sizeof(myPortfolio) / sizeof(myPortfolio[0]); // ------------------------------------------------------- // Runtime stock data // ------------------------------------------------------- struct StockData { float price, change, pct; bool valid; }; void prepareForSleepAndEnter(uint64_t sleepUs) { // Disable all wake sources first, then allow only timer wake. esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); esp_sleep_enable_timer_wakeup(sleepUs); // Shut down radios and protocol stacks. WiFi.disconnect(true, true); WiFi.mode(WIFI_OFF); esp_wifi_stop(); esp_wifi_deinit(); btStop(); esp_bt_controller_disable(); esp_bt_controller_deinit(); // Power down optional domains where this core/target exposes them. #ifdef ESP_PD_DOMAIN_RTC_PERIPH esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF); #endif #ifdef ESP_PD_DOMAIN_RTC_FAST_MEM esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF); #endif #ifdef ESP_PD_DOMAIN_RTC_SLOW_MEM esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_OFF); #endif #ifdef ESP_PD_DOMAIN_XTAL esp_sleep_pd_config(ESP_PD_DOMAIN_XTAL, ESP_PD_OPTION_OFF); #endif esp_deep_sleep_start(); } bool fetchStock(const char* sym, StockData& out) { out = { 0.0f, 0.0f, 0.0f, false }; if (WiFi.status() != WL_CONNECTED) { Serial.println("[FH] no WiFi"); return false; } char url[220]; snprintf(url, sizeof(url), "https://finnhub.io/api/v1/quote?symbol=%s&token=%s", sym, apiKey); for (int attempt = 1; attempt <= 3; attempt++) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.setConnectTimeout(8000); http.setTimeout(8000); if (!http.begin(client, url)) { Serial.printf("[FH] %s begin() failed (try %d)\n", sym, attempt); http.end(); delay(300); continue; } int code = http.GET(); if (code != HTTP_CODE_OK) { Serial.printf("[FH] %s HTTP %d (try %d)\n", sym, code, attempt); http.end(); delay(400); continue; } String body = http.getString(); http.end(); StaticJsonDocument<512> doc; DeserializationError err = deserializeJson(doc, body); if (err) { Serial.printf("[FH] %s JSON err: %s\n", sym, err.c_str()); delay(300); continue; } out.price = doc["c"] | 0.0f; out.change = doc["d"] | 0.0f; out.pct = doc["dp"] | 0.0f; out.valid = (out.price > 0.0f); if (out.valid) return true; Serial.printf("[FH] %s price 0 (body: %s)\n", sym, body.c_str()); delay(300); } return false; } // 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) { Serial.println("[AV] no WiFi"); return false; } char url[256]; snprintf(url, sizeof(url), "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=%s&apikey=%s", sym, avApiKey); for (int attempt = 1; attempt <= 3; attempt++) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.setConnectTimeout(8000); http.setTimeout(8000); if (!http.begin(client, url)) { Serial.printf("[AV] %s begin() failed (try %d)\n", sym, attempt); http.end(); delay(300); continue; } int code = http.GET(); if (code != HTTP_CODE_OK) { Serial.printf("[AV] %s HTTP %d (try %d)\n", sym, code, attempt); http.end(); delay(400); continue; } String body = http.getString(); http.end(); StaticJsonDocument<1024> doc; DeserializationError err = deserializeJson(doc, body); if (err) { Serial.printf("[AV] %s JSON err: %s\n", sym, err.c_str()); delay(300); continue; } JsonObject q = doc["Global Quote"]; if (q.isNull()) { // Free tier limit / rate note comes back without "Global Quote". Serial.printf("[AV] %s no quote (body: %s)\n", sym, body.c_str()); delay(300); continue; } 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); if (out.valid) return true; Serial.printf("[AV] %s price 0\n", sym); delay(300); } return false; } // ------------------------------------------------------- // Drawing helpers // ------------------------------------------------------- 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); } // ------------------------------------------------------- // US market hours (Eastern time) helpers // ------------------------------------------------------- // Approximate US Eastern DST window: 2nd Sunday of March .. 1st Sunday of Nov. // (The exact 2 AM changeover happens on a Sunday when markets are closed, so // day-level precision is sufficient here.) bool usEasternDST(const struct tm& u) { int m = u.tm_mon + 1, d = u.tm_mday, wday = u.tm_wday; if (m < 3 || m > 11) return false; if (m > 3 && m < 11) return true; int prevSunday = d - wday; if (m == 3) return prevSunday >= 8; // on/after 2nd Sunday -> DST return prevSunday <= 0; // Nov: before 1st Sunday -> DST } // Break current UTC time down into US Eastern local time. void getEasternTime(struct tm& etTm) { time_t utc = time(nullptr); struct tm u; gmtime_r(&utc, &u); int off = usEasternDST(u) ? -4 * 3600 : -5 * 3600; time_t et = utc + off; gmtime_r(&et, &etTm); } // Daily market events, as seconds-of-day in US Eastern time. const long EVT_OPEN = 9 * 3600 + 30 * 60; // 09:30 -> 34200 s const long EVT_MID = 12 * 3600 + 45 * 60; // 12:45 -> 45900 s (session midpoint) const long EVT_CLOSE = 16 * 3600; // 16:00 -> 57600 s // Which event this update corresponds to: nearest of open / mid / close. const char* marketPhase(const struct tm& et) { long s = et.tm_hour * 3600 + et.tm_min * 60 + et.tm_sec; long dOpen = labs(s - EVT_OPEN); long dMid = labs(s - EVT_MID); long dClose = labs(s - EVT_CLOSE); if (dOpen <= dMid && dOpen <= dClose) return "open"; if (dClose <= dMid && dClose <= dOpen) return "close"; return "mid"; } // Microseconds until the next market event: the opening bell (9:30 AM ET), // midday (12:45 PM ET), or the closing bell (4:00 PM ET), whichever comes // first, Mon-Fri only. We wake for these three events, so the ticker refreshes // three times per session. uint64_t usUntilNextEvent(const struct tm& et) { const long events[3] = { EVT_OPEN, EVT_MID, EVT_CLOSE }; long secsToday = et.tm_hour * 3600 + et.tm_min * 60 + et.tm_sec; for (int d = 0; d <= 7; d++) { int wd = (et.tm_wday + d) % 7; if (wd < 1 || wd > 5) continue; // skip Sat/Sun long base = (long)d * 86400 - secsToday; long best = -1; for (int e = 0; e < 3; e++) { long cand = base + events[e]; if (cand > 0 && (best < 0 || cand < best)) best = cand; } if (best > 0) return (uint64_t)best * 1000000ULL; } return 24ULL * 3600ULL * 1000000ULL; // fallback: 24h } // ------------------------------------------------------- // Setup // ------------------------------------------------------- void setup() { auto cfg = M5.config(); M5.begin(cfg); M5.Display.setRotation(1); Serial.begin(115200); // 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) { int batPct = M5.Power.getBatteryLevel(); 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: timestamp (left) | "zZz [battery] xx%" (right) -- char timeStr[32]; strftime(timeStr, sizeof(timeStr), "%d/%m %I:%M %p", &now); M5.Display.setCursor(20, 22); M5.Display.print("U: "); 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, charW, colPrice, colArrow, arrowSz, arrowOff; if (ROWY >= 48) { textSz=3; charW=24; colPrice=240; colArrow=460; arrowSz=20; arrowOff=14; } else if (ROWY >= 32) { textSz=2; charW=16; colPrice=168; colArrow=310; arrowSz=14; arrowOff= 9; } else { textSz=1; charW= 8; colPrice= 84; colArrow=152; arrowSz= 8; arrowOff= 4; } 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); // Right-align change text so all closing ')' line up at x=940 char chgBuf[32]; snprintf(chgBuf, sizeof(chgBuf), "%+.2f (%+.2f%%)", sd[i].change, sd[i].pct); M5.Display.setCursor(940 - (int)strlen(chgBuf) * charW, y); M5.Display.print(chgBuf); } 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() {}