275 lines
8.8 KiB
Arduino
275 lines
8.8 KiB
Arduino
/*
|
||
* VFX Review — ESP32 dot-matrix dashboard
|
||
*
|
||
* Hardware : ESP32 dev board + 4× MAX7219 8×8 modules chained (8×32 display)
|
||
* Libraries (install via Arduino Library Manager):
|
||
* - MD_Parola by majicDesigns (≥3.7)
|
||
* - MD_MAX72XX by majicDesigns (≥3.5)
|
||
* - ArduinoJson by Benoit Blanchon (≥7)
|
||
*
|
||
* Behaviour:
|
||
* Normal → scrolls "APR:142 CHG:12 TODO:38" on a loop
|
||
* Event → interrupts with e.g. "OK SH0010" / "REVISE SH0010" / "NOTE SH0010"
|
||
* held for NOTIFY_HOLD_MS then resumes stats scroll
|
||
*/
|
||
|
||
#include <Arduino.h>
|
||
#include <WiFi.h>
|
||
#include <HTTPClient.h>
|
||
#include <ArduinoJson.h>
|
||
#include <Preferences.h>
|
||
#include <MD_Parola.h>
|
||
#include <MD_MAX72xx.h>
|
||
#include <SPI.h>
|
||
#include "config.h"
|
||
|
||
// ── Display setup ─────────────────────────────────────────────────────────────
|
||
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
|
||
MD_Parola display(HARDWARE_TYPE, CS_PIN, NUM_DEVICES);
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
Preferences prefs;
|
||
|
||
int approvedCount = 0;
|
||
int changesCount = 0;
|
||
int todoCount = 0;
|
||
int lastEventId = 0;
|
||
|
||
bool notifyActive = false;
|
||
uint32_t notifyUntil = 0;
|
||
|
||
char statsMsg[64]; // "APR:142 CHG:12 TODO:38"
|
||
char notifyMsg[64]; // interrupt message
|
||
|
||
uint32_t lastStatsFetch = 0;
|
||
uint32_t lastEventsFetch = 0;
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
void buildStatsMsg() {
|
||
snprintf(statsMsg, sizeof(statsMsg),
|
||
"APR:%d CHG:%d TODO:%d",
|
||
approvedCount, changesCount, todoCount);
|
||
}
|
||
|
||
// Truncate a string to maxLen chars, safe for display
|
||
void truncate(const char* src, char* dst, int maxLen) {
|
||
strncpy(dst, src, maxLen);
|
||
dst[maxLen] = '\0';
|
||
}
|
||
|
||
void connectWiFi() {
|
||
Serial.printf("[WiFi] Connecting to %s", WIFI_SSID);
|
||
WiFi.mode(WIFI_STA);
|
||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||
while (WiFi.status() != WL_CONNECTED) {
|
||
delay(500);
|
||
Serial.print('.');
|
||
}
|
||
Serial.printf("\n[WiFi] Connected — IP: %s\n", WiFi.localIP().toString().c_str());
|
||
}
|
||
|
||
// Make an authenticated GET request; fills `out` with the response body.
|
||
// Returns HTTP status code, or -1 on connection failure.
|
||
int apiGet(const char* path, String& out) {
|
||
if (WiFi.status() != WL_CONNECTED) connectWiFi();
|
||
|
||
char url[256];
|
||
snprintf(url, sizeof(url), "http://%s:%d%s", SERVER_HOST, SERVER_PORT, path);
|
||
|
||
HTTPClient http;
|
||
http.begin(url);
|
||
http.addHeader("x-display-key", DISPLAY_API_KEY);
|
||
http.setTimeout(8000);
|
||
|
||
int code = http.GET();
|
||
if (code > 0) out = http.getString();
|
||
http.end();
|
||
return code;
|
||
}
|
||
|
||
// ── Stats fetch ───────────────────────────────────────────────────────────────
|
||
|
||
void fetchStats() {
|
||
String body;
|
||
int code = apiGet("/api/dashboard/stats", body);
|
||
if (code != 200) {
|
||
Serial.printf("[Stats] HTTP %d\n", code);
|
||
return;
|
||
}
|
||
|
||
JsonDocument doc;
|
||
if (deserializeJson(doc, body)) { Serial.println("[Stats] JSON parse error"); return; }
|
||
|
||
approvedCount = doc["approved"] | approvedCount;
|
||
changesCount = doc["changes"] | changesCount;
|
||
todoCount = doc["todo"] | todoCount;
|
||
|
||
buildStatsMsg();
|
||
Serial.printf("[Stats] APR:%d CHG:%d TODO:%d\n", approvedCount, changesCount, todoCount);
|
||
}
|
||
|
||
// ── Events fetch ──────────────────────────────────────────────────────────────
|
||
|
||
void fetchEvents() {
|
||
char path[64];
|
||
snprintf(path, sizeof(path), "/api/display/events?after=%d", lastEventId);
|
||
|
||
String body;
|
||
int code = apiGet(path, body);
|
||
if (code != 200) {
|
||
Serial.printf("[Events] HTTP %d\n", code);
|
||
return;
|
||
}
|
||
|
||
JsonDocument doc;
|
||
if (deserializeJson(doc, body)) { Serial.println("[Events] JSON parse error"); return; }
|
||
|
||
JsonArray arr = doc.as<JsonArray>();
|
||
if (arr.isNull() || arr.size() == 0) return;
|
||
|
||
// Pick the most recent event to display, update lastEventId across all
|
||
const char* bestType = nullptr;
|
||
const char* bestShotCode = nullptr;
|
||
const char* bestBy = nullptr;
|
||
int newestId = lastEventId;
|
||
|
||
for (JsonObject ev : arr) {
|
||
int id = ev["id"] | 0;
|
||
const char* t = ev["type"] | "";
|
||
const char* s = ev["shotCode"] | "";
|
||
const char* b = ev["by"] | "";
|
||
|
||
if (id > newestId) {
|
||
newestId = id;
|
||
bestType = t;
|
||
bestShotCode = s;
|
||
bestBy = b;
|
||
}
|
||
}
|
||
|
||
if (newestId > lastEventId) {
|
||
lastEventId = newestId;
|
||
prefs.putInt("lastEvtId", lastEventId);
|
||
}
|
||
|
||
if (!bestType || !bestShotCode) return;
|
||
|
||
// Build notification message
|
||
char shotBuf[16];
|
||
truncate(bestShotCode, shotBuf, 12);
|
||
|
||
if (strcmp(bestType, "APPROVED") == 0) {
|
||
snprintf(notifyMsg, sizeof(notifyMsg), "OK: %s", shotBuf);
|
||
} else if (strcmp(bestType, "CHANGES") == 0) {
|
||
snprintf(notifyMsg, sizeof(notifyMsg), "REVISE: %s", shotBuf);
|
||
} else if (strcmp(bestType, "REJECTED") == 0) {
|
||
snprintf(notifyMsg, sizeof(notifyMsg), "REJECT: %s", shotBuf);
|
||
} else if (strcmp(bestType, "COMMENT") == 0) {
|
||
char byBuf[10];
|
||
truncate(bestBy, byBuf, 8);
|
||
snprintf(notifyMsg, sizeof(notifyMsg), "NOTE: %s", shotBuf);
|
||
} else {
|
||
snprintf(notifyMsg, sizeof(notifyMsg), "%s", shotBuf);
|
||
}
|
||
|
||
Serial.printf("[Events] id=%d type=%s shot=%s\n", newestId, bestType, bestShotCode);
|
||
|
||
notifyActive = true;
|
||
notifyUntil = millis() + NOTIFY_HOLD_MS;
|
||
|
||
display.displayClear();
|
||
display.displayText(notifyMsg, PA_CENTER, SCROLL_SPEED, 500, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
|
||
}
|
||
|
||
// ── Arduino setup ─────────────────────────────────────────────────────────────
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
delay(500);
|
||
|
||
// Restore last seen event ID from NVS so we don't replay old events on reboot
|
||
prefs.begin("vfxdisp", false);
|
||
lastEventId = prefs.getInt("lastEvtId", 0);
|
||
Serial.printf("[Init] lastEventId restored = %d\n", lastEventId);
|
||
|
||
// Display init
|
||
display.begin();
|
||
display.setIntensity(BRIGHTNESS);
|
||
display.displayClear();
|
||
display.setTextAlignment(PA_LEFT);
|
||
display.setScrollSpacing(4);
|
||
|
||
// Show connecting message
|
||
display.displayText("Connecting...", PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
|
||
while (!display.displayAnimate()) {}
|
||
|
||
connectWiFi();
|
||
|
||
// Initial data fetch
|
||
fetchStats();
|
||
|
||
// Prime the events endpoint — catch up to current without showing stale events
|
||
// We do a silent fetch just to advance lastEventId to now
|
||
{
|
||
char path[64];
|
||
snprintf(path, sizeof(path), "/api/display/events?after=%d", lastEventId);
|
||
String body;
|
||
int code = apiGet(path, body);
|
||
if (code == 200) {
|
||
JsonDocument doc;
|
||
if (!deserializeJson(doc, body)) {
|
||
JsonArray arr = doc.as<JsonArray>();
|
||
for (JsonObject ev : arr) {
|
||
int id = ev["id"] | 0;
|
||
if (id > lastEventId) lastEventId = id;
|
||
}
|
||
prefs.putInt("lastEvtId", lastEventId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Kick off the scrolling stats message
|
||
buildStatsMsg();
|
||
display.displayClear();
|
||
display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
|
||
|
||
lastStatsFetch = millis();
|
||
lastEventsFetch = millis();
|
||
}
|
||
|
||
// ── Arduino loop ──────────────────────────────────────────────────────────────
|
||
|
||
void loop() {
|
||
uint32_t now = millis();
|
||
|
||
// ── Timed fetches ──
|
||
if (now - lastStatsFetch >= STATS_INTERVAL_MS) {
|
||
lastStatsFetch = now;
|
||
fetchStats();
|
||
// If no notification is active, refresh the scrolling text immediately
|
||
if (!notifyActive) {
|
||
display.displayClear();
|
||
display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
|
||
}
|
||
}
|
||
|
||
if (now - lastEventsFetch >= EVENTS_INTERVAL_MS) {
|
||
lastEventsFetch = now;
|
||
if (!notifyActive) fetchEvents(); // don't overlap notifications
|
||
}
|
||
|
||
// ── Notification expiry ──
|
||
if (notifyActive && now >= notifyUntil) {
|
||
notifyActive = false;
|
||
display.displayClear();
|
||
display.displayText(statsMsg, PA_LEFT, SCROLL_SPEED, 0, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
|
||
}
|
||
|
||
// ── Animate display ──
|
||
// When a scroll finishes, restart it (loops the message)
|
||
if (display.displayAnimate()) {
|
||
display.displayReset();
|
||
}
|
||
}
|