Compare commits
4
Commits
6d64aabc51
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73d4821b8 | ||
|
|
52162da3e8 | ||
|
|
652a622954 | ||
|
|
cef7814700 |
+203
-43
@@ -4,6 +4,9 @@
|
|||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
#include <esp_bt.h>
|
||||||
|
#include <esp_wifi.h>
|
||||||
#include "images.h"
|
#include "images.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
@@ -30,7 +33,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)
|
||||||
@@ -45,73 +47,154 @@ const int NUM_STOCKS = sizeof(myPortfolio) / sizeof(myPortfolio[0]);
|
|||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
struct StockData { float price, change, pct; bool valid; };
|
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) {
|
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 +230,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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Setup
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
@@ -154,12 +299,14 @@ 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);
|
||||||
@@ -168,6 +315,11 @@ void setup() {
|
|||||||
M5.Display.print("WiFi connection failed.");
|
M5.Display.print("WiFi connection failed.");
|
||||||
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();
|
M5.Power.powerOff();
|
||||||
return;
|
return;
|
||||||
@@ -178,6 +330,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 +360,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");
|
||||||
@@ -270,7 +428,9 @@ void setup() {
|
|||||||
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).
|
||||||
|
prepareForSleepAndEnter(usUntilNextEvent(etTm));
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {}
|
void loop() {}
|
||||||
Reference in new issue
Block a user