/* * 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 #include #include #include #include #include #include #include #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(); 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(); 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(); } }