feat: Implement M5Paper Stock Ticker application
This commit is contained in:
1 parent
69d97828ca
commit
e0c434840b
1 file changed
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#include <M5Unified.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <time.h>
|
||||||
|
#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() {}
|
||||||
Reference in new issue
Block a user