Compare commits

...
7 Commits
2 changed files with 218 additions and 53 deletions

No files matched your search

+22 -1
View File
@@ -1,3 +1,24 @@
# m5papers3-stockticker # m5papers3-stockticker
daily portfolio tracker for the m5stack paper s3 Daily portfolio tracker for the M5Stack Paper S3.
<img src="https://i.imgur.com/8RX5UDV.jpeg" alt="Project photo" width="420" />
## Add your own stock and Wi-Fi info
1. Copy config.h.example to config.h.
2. Open config.h and set WIFI_SSID and WIFI_PASSWORD.
3. Add your API keys in config.h:
- FINNHUB_API_KEY
- ALPHAVANTAGE_KEY
4. Open m5paper-stockticker.ino and edit the myPortfolio[] list with your own symbols, share count, and total cost basis.
Portfolio format:
{ "SYMBOL", shares, costBasis, useAV }
- useAV = false for Finnhub (stocks and ETFs)
- useAV = true for Alpha Vantage (useful for some mutual funds)
## Device-specific note
This project is tuned for the M5Stack Paper S3 and its display size, rotation, and power behavior. Running it on another e-ink display usually requires extra configuration (display driver/init, resolution/layout coordinates, update behavior, and sleep/power handling) before it will render correctly.
+196 -52
View File
@@ -1,4 +1,21 @@
#include <M5Unified.h> // =======================================================
// M5Paper Stock Ticker - M5Paper v1.1 (classic ESP32) edition
// =======================================================
// Target: M5Paper ESP32 Development Kit V1.1 (SKU K049-B)
// - ESP32-D0WDQ6-V3, 540x960 @4.7" e-ink (IT8951E), BM8563 RTC
//
// Difference vs. the main sketch (../m5paper-stockticker.ino):
// The classic M5Paper's low-power management differs from newer boards,
// so sleep/wake is handled through M5Unified's board-aware power API
// (M5.Power.deepSleep) instead of a raw esp_deep_sleep_start() with a
// manual radio teardown. deepSleep() puts the e-ink panel to sleep and
// drops the board to its rated ~10uA deep-sleep state, waking on the
// ESP32 RTC timer (any duration, so overnight/weekend sleeps are fine).
//
// Everything else - portfolio, market-hours logic, layout - is identical.
// Copy your config.h (and images.h) into this folder before flashing.
// -------------------------------------------------------
#include <M5Unified.h>
#include <WiFi.h> #include <WiFi.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <HTTPClient.h> #include <HTTPClient.h>
@@ -18,6 +35,10 @@ const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = GMT_OFFSET_SEC; const long gmtOffset_sec = GMT_OFFSET_SEC;
const int daylightOffset_sec = DAYLIGHT_OFFSET_SEC; const int daylightOffset_sec = DAYLIGHT_OFFSET_SEC;
// If WiFi/NTP fails, retry after this many seconds instead of powering off
// (the classic M5Paper's powerOff needs a manual button press to restart).
#define WIFI_RETRY_SECONDS 900 // 15 minutes
// ------------------------------------------------------- // -------------------------------------------------------
// Portfolio - symbol / shares / total cost basis ($) // Portfolio - symbol / shares / total cost basis ($)
// ------------------------------------------------------- // -------------------------------------------------------
@@ -30,7 +51,6 @@ Stock myPortfolio[] = {
{ "SPYI", 6.0000f, 313.15f, false }, { "SPYI", 6.0000f, 313.15f, false },
{ "JEPQ", 3.0000f, 178.52f, false }, { "JEPQ", 3.0000f, 178.52f, false },
{ "SCHD", 3.0000f, 95.97f, false }, { "SCHD", 3.0000f, 95.97f, false },
{"VOO", 1.0000f, 684.60f, true },
}; };
// To add an entry: { "SYMBOL", shares, costBasis, useAV } // To add an entry: { "SYMBOL", shares, costBasis, useAV }
// useAV = false -> Finnhub (stocks & ETFs) // useAV = false -> Finnhub (stocks & ETFs)
@@ -47,71 +67,120 @@ struct StockData { float price, change, pct; bool valid; };
bool fetchStock(const char* sym, StockData& out) { bool fetchStock(const char* sym, StockData& out) {
out = { 0.0f, 0.0f, 0.0f, false }; out = { 0.0f, 0.0f, 0.0f, false };
if (WiFi.status() != WL_CONNECTED) return false; if (WiFi.status() != WL_CONNECTED) { Serial.println("[FH] no WiFi"); return false; }
char url[220]; char url[220];
snprintf(url, sizeof(url), snprintf(url, sizeof(url),
"https://finnhub.io/api/v1/quote?symbol=%s&token=%s", sym, apiKey); "https://finnhub.io/api/v1/quote?symbol=%s&token=%s", sym, apiKey);
WiFiClientSecure client; for (int attempt = 1; attempt <= 3; attempt++) {
client.setInsecure(); WiFiClientSecure client;
HTTPClient http; client.setInsecure();
http.begin(client, url); HTTPClient http;
int code = http.GET(); http.setConnectTimeout(8000);
if (code != HTTP_CODE_OK) { http.end(); return false; } 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(); String body = http.getString();
http.end(); http.end();
StaticJsonDocument<512> doc; StaticJsonDocument<512> doc;
if (deserializeJson(doc, body)) return false; 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.price = doc["c"] | 0.0f;
out.change = doc["d"] | 0.0f; out.change = doc["d"] | 0.0f;
out.pct = doc["dp"] | 0.0f; out.pct = doc["dp"] | 0.0f;
out.valid = (out.price > 0.0f); out.valid = (out.price > 0.0f);
return out.valid; 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) // Alpha Vantage fetch (for mutual funds not covered by Finnhub)
bool fetchStockAV(const char* sym, StockData& out) { bool fetchStockAV(const char* sym, StockData& out) {
out = { 0.0f, 0.0f, 0.0f, false }; out = { 0.0f, 0.0f, 0.0f, false };
if (WiFi.status() != WL_CONNECTED) return false; if (WiFi.status() != WL_CONNECTED) { Serial.println("[AV] no WiFi"); return false; }
char url[256]; char url[256];
snprintf(url, sizeof(url), snprintf(url, sizeof(url),
"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=%s&apikey=%s", "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=%s&apikey=%s",
sym, avApiKey); sym, avApiKey);
WiFiClientSecure client; for (int attempt = 1; attempt <= 3; attempt++) {
client.setInsecure(); WiFiClientSecure client;
HTTPClient http; client.setInsecure();
http.begin(client, url); HTTPClient http;
int code = http.GET(); http.setConnectTimeout(8000);
if (code != HTTP_CODE_OK) { http.end(); return false; } 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(); String body = http.getString();
http.end(); http.end();
StaticJsonDocument<1024> doc; StaticJsonDocument<1024> doc;
if (deserializeJson(doc, body)) return false; 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"]; JsonObject q = doc["Global Quote"];
if (q.isNull()) return false; 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.price = atof(q["05. price"] | "0");
out.change = atof(q["09. change"] | "0"); out.change = atof(q["09. change"] | "0");
// "10. change percent" is like "0.0500%" - strip the trailing '%' // "10. change percent" is like "0.0500%" - strip the trailing '%'
char pctBuf[16]; char pctBuf[16];
strncpy(pctBuf, q["10. change percent"] | "0%", sizeof(pctBuf) - 1); strncpy(pctBuf, q["10. change percent"] | "0%", sizeof(pctBuf) - 1);
pctBuf[sizeof(pctBuf) - 1] = '\0'; pctBuf[sizeof(pctBuf) - 1] = '\0';
char* pctMark = strchr(pctBuf, '%'); char* pctMark = strchr(pctBuf, '%');
if (pctMark) *pctMark = '\0'; if (pctMark) *pctMark = '\0';
out.pct = atof(pctBuf); out.pct = atof(pctBuf);
out.valid = (out.price > 0.0f); out.valid = (out.price > 0.0f);
return out.valid; if (out.valid) return true;
Serial.printf("[AV] %s price 0\n", sym);
delay(300);
}
return false;
} }
// ------------------------------------------------------- // -------------------------------------------------------
@@ -147,6 +216,68 @@ void fmtMoney(char* buf, size_t len, float val) {
snprintf(buf, len, "%d.%02d", dollars, cents); 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";
}
// Seconds 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 secsUntilNextEvent(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;
}
return 24ULL * 3600ULL; // fallback: 24h
}
// ------------------------------------------------------- // -------------------------------------------------------
// Setup // Setup
// ------------------------------------------------------- // -------------------------------------------------------
@@ -154,22 +285,30 @@ void setup() {
auto cfg = M5.config(); auto cfg = M5.config();
M5.begin(cfg); M5.begin(cfg);
M5.Display.setRotation(1); M5.Display.setRotation(1);
Serial.begin(115200);
// Connect to WiFi // Connect to WiFi
WiFi.begin(ssid, password); WiFi.begin(ssid, password);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500); for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500);
if (WiFi.status() != WL_CONNECTED) { if (WiFi.status() != WL_CONNECTED) {
int batPct = M5.Power.getBatteryLevel();
M5.Display.fillScreen(TFT_WHITE); M5.Display.fillScreen(TFT_WHITE);
M5.Display.setFont(&fonts::AsciiFont8x16); M5.Display.setFont(&fonts::AsciiFont8x16);
M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); M5.Display.setTextColor(TFT_BLACK, TFT_WHITE);
M5.Display.setTextSize(2); M5.Display.setTextSize(2);
M5.Display.setCursor(20, 20); M5.Display.setCursor(20, 20);
M5.Display.print("WiFi connection failed."); M5.Display.print("WiFi connection failed - retrying later.");
M5.Display.setCursor(20, 55); M5.Display.setCursor(20, 55);
M5.Display.printf("SSID: %s", ssid); M5.Display.printf("SSID: %s", ssid);
M5.Display.setCursor(20, 90);
M5.Display.print("Battery:");
drawBattery(150, 93, batPct);
M5.Display.setCursor(205, 90);
M5.Display.printf("%d%%", batPct);
delay(2000); delay(2000);
M5.Power.powerOff(); // Don't powerOff (would need a manual restart) - nap and try again.
M5.Power.deepSleep((uint64_t)WIFI_RETRY_SECONDS * 1000000ULL, false);
return; return;
} }
@@ -178,6 +317,11 @@ void setup() {
struct tm now = {}; struct tm now = {};
for (int i = 0; i < 10 && !getLocalTime(&now); i++) delay(500); for (int i = 0; i < 10 && !getLocalTime(&now); i++) delay(500);
// Current US Eastern time -> tells us which event (open/mid/close) this is
// and, later, when the next event is.
struct tm etTm;
getEasternTime(etTm);
// Fetch market data // Fetch market data
StockData sd[NUM_STOCKS]; StockData sd[NUM_STOCKS];
float dayGain = 0.0f, openValue = 0.0f; float dayGain = 0.0f, openValue = 0.0f;
@@ -203,10 +347,11 @@ void setup() {
// -- Row 1: timestamp (left) | "zZz [battery] xx%" (right) -- // -- Row 1: timestamp (left) | "zZz [battery] xx%" (right) --
char timeStr[32]; char timeStr[32];
strftime(timeStr, sizeof(timeStr), "%d/%m %I:%M %p", &now); strftime(timeStr, sizeof(timeStr), "%m/%d %I:%M %p", &now);
M5.Display.setCursor(20, 22); M5.Display.setCursor(20, 22);
M5.Display.print("U: "); M5.Display.print("U: ");
M5.Display.print(timeStr); M5.Display.print(timeStr);
M5.Display.printf(" (%s)", marketPhase(etTm));
M5.Display.setCursor(740, 15); M5.Display.setCursor(740, 15);
M5.Display.print("zZz"); M5.Display.print("zZz");
@@ -262,15 +407,14 @@ void setup() {
} }
} }
// Page indicator
// M5.Display.setTextSize(1);
// M5.Display.setCursor(890, 525);
//M5.Display.print("Pa. 1/1");
M5.Display.endWrite(); M5.Display.endWrite();
delay(1000); delay(1000);
M5.Power.deepSleep(43200000000ULL); // 12 hours // We wake at open, midday, and close, so update now and sleep until
// whichever event comes next (Mon-Fri). M5.Power.deepSleep handles the
// classic M5Paper's e-ink + power-down and wakes on the ESP32 RTC timer.
WiFi.disconnect(true, true);
M5.Power.deepSleep(secsUntilNextEvent(etTm) * 1000000ULL, false);
} }
void loop() {} void loop() {}