PVE-FANCTRL CODEFULL SOURCE
ESP32 + 1.9" ST7789 TFT // LVGL UI // Captive Portal // Live Theme Switching
NORMAL MODE
SETUP MODE
MAIN SKETCH
fan_controller_lvgl.ino
// ===================================================================== // DRIVE FAN CTRL — LVGL EDITION // ESP32 + Ideaspark 1.9" 170x320 ST7789 / landscape (320x170) // // REQUIRES (Library Manager): // - lvgl v9.x (tested with 9.2.x) // - TFT_eSPI (already configured for the Ideaspark 1.9" board) // // CONFIG: Mike's existing lv_conf.h at libraries\lv_conf.h is fine — // it has 16-bit color and Montserrat 12/18/36 enabled (which this // sketch uses). No changes needed there. // ===================================================================== #include <Arduino.h> #include <WiFi.h> #include <WebServer.h> #include <ESPmDNS.h> #include <DNSServer.h> #include <Preferences.h> #include <TFT_eSPI.h> #include <lvgl.h> #include "ui.h" // ---------------------------- USER CONFIG ---------------------------- // No WiFi credentials hard-coded. On first boot (or if connect fails) the // device starts an AP at "<HOSTNAME>-Setup" with a captive portal at // http://192.168.4.1/ for the user to enter their WiFi creds. Saved to NVS. // Hold BOOT (GPIO 0) for 5 seconds at power-on to wipe saved credentials. const char* HOSTNAME = "PVE-FANCTRL"; // mDNS + DHCP + AP name prefix const char* AP_SUFFIX = "-Setup"; // AP becomes "<HOSTNAME>-Setup" // Bottom-line FQDN shown on the TFT; defaults to "<hostname>.local". If you // want a custom domain (e.g., your.lan), change here. char displayFqdn[40]; // populated in setup() from HOSTNAME // Hardware pins #define PWM_PIN 26 #define TACH_PIN 27 #define BTN_UP_PIN 14 #define BTN_DN_PIN 0 #define PWM_FREQ 25000 #define PWM_RES 8 #define STEP_SIZE 5 #define MIN_PCT 0 #define MAX_PCT 100 #define DEFAULT_PCT 50 // Display dimensions in landscape #define SCR_W 320 #define SCR_H 170 // Theme struct — every visible color slot. Themes lifted from CyberNetView's // themes.css plus Mike's custom default. Selected theme is stored in NVS // "ui" namespace, applied at boot, and changeable via the captive portal. struct Theme { const char* id; // NVS-stored identifier const char* label; // user-friendly name in the picker uint32_t bg; // screen background uint32_t track; // arc track (the dim ring) uint32_t indicator; // arc fill (the colored portion) uint32_t knob_bg; // knob ball uint32_t knob_ring; // knob outline uint32_t title; // title text uint32_t subtitle; // subtitle text uint32_t num; // big numbers uint32_t unit; // unit labels uint32_t host; // hostname text uint32_t ip; // IP text }; const Theme themes[] = { // Mike's preferred — black bg, gray arcs, blue knob, white ring, teal host/IP {"default", "Default (Purple/Blue)", 0x000000, 0xAAAAAA, 0x4A0080, 0x4488FF, 0xFFFFFF, 0xE8E8E8, 0x888888, 0xFFFFFF, 0x888888, 0x00CCCC, 0x00CCCC}, // CyberNetView default — bright cyan + gold IP {"cybernet", "CyberNet Default", 0x0A0A0F, 0x2A2A4A, 0x00D4FF, 0xFFFFFF, 0x00D4FF, 0x00D4FF, 0x666666, 0xFFFFFF, 0x666666, 0x00FF88, 0xFFD700}, // Cyberpunk neon — magenta on dark purple {"cyberpunk", "Cyberpunk Neon", 0x0A0014, 0x3D0070, 0xFF00FF, 0xF0E0FF, 0xFF00FF, 0xFF00FF, 0x663399, 0xF0E0FF, 0x663399, 0x00FF88, 0xFF00FF}, // Neon Grid — cyan on dark blue {"neon-grid", "Neon Grid", 0x000A14, 0x004488, 0x00D4FF, 0xE0F4FF, 0x00D4FF, 0x00D4FF, 0x336688, 0xE0F4FF, 0x336688, 0x00FF44, 0x00D4FF}, // Retro Command — amber/orange terminal {"retro-command", "Retro Command", 0x0C0800, 0x554400, 0xFF9900, 0xFFE8CC, 0xFF9900, 0xFF9900, 0x886633, 0xFFE8CC, 0x886633, 0x88FF00, 0xFF9900}, // Military Ops — amber on dark green {"military", "Military Ops", 0x0A0D08, 0x2A4A2A, 0xFFB000, 0xCCDDBB, 0xFFB000, 0xFFB000, 0x557744, 0xCCDDBB, 0x557744, 0x44CC44, 0xFFB000}, // Noir Neon — pink/cyan {"noir-neon", "Noir Neon", 0x08050A, 0x3D2844, 0xFF2A6D, 0xF0E0F0, 0xFF2A6D, 0xFF2A6D, 0x664455, 0xF0E0F0, 0x664455, 0x05D9E8, 0x05D9E8}, // Clinical Light — LIGHT THEME, cream bg + slate accent {"clinical", "Clinical Light", 0xF5F0E6, 0xB8B0A0, 0x4A6670, 0xFFFFFF, 0x4A6670, 0x4A6670, 0x888888, 0x1A1A1A, 0x888888, 0x2A8040, 0x4A6670}, // Holographic — light cyan/blue {"holographic", "Holographic", 0x040810, 0x103060, 0x4FC3F7, 0xE0F0FF, 0x4FC3F7, 0x4FC3F7, 0x335577, 0xE0F0FF, 0x335577, 0x66BB6A, 0x4FC3F7}, // Classic Grey — flat dark with blue accent {"grey", "Classic Grey", 0x1A1A1A, 0x444444, 0x0088CC, 0xDDDDDD, 0x0088CC, 0x0088CC, 0x666666, 0xDDDDDD, 0x666666, 0x44AA44, 0x0088CC}, // Minimal Dark — pure grayscale + muted green success {"dark", "Minimal Dark", 0x141414, 0x3A3A3A, 0x888888, 0xCCCCCC, 0x888888, 0xCCCCCC, 0x555555, 0xCCCCCC, 0x555555, 0x669966, 0xAAAAAA}, // Blood and Fire — dark crimson bg, blood-red arcs, fire-orange knob with flame-yellow ring {"blood-fire", "Blood and Fire", 0x1A0000, 0x550000, 0xE60000, 0xFF6600, 0xFFCC00, 0xFF3300, 0x884444, 0xFFEEDD, 0x884444, 0xFF6633, 0xFFEEDD}, // Matrix — phosphor green cascades on the void. All green, all the time. {"matrix", "Matrix", 0x000000, 0x003311, 0x00FF41, 0x88FF88, 0x00CC33, 0x00FF41, 0x006622, 0x00FF41, 0x006622, 0x00FF41, 0x88FF88}, }; const int NUM_THEMES = sizeof(themes) / sizeof(themes[0]); Theme currentTheme; // populated in setup() from NVS // ----------------------------- GLOBALS ------------------------------- TFT_eSPI tft = TFT_eSPI(); WebServer http(80); DNSServer dnsServer; Preferences prefs; uint8_t fanPct = DEFAULT_PCT; uint32_t currentRpm = 0; volatile uint32_t tachPulses = 0; uint32_t lastBtnTime = 0; uint32_t lastRpmCalc = 0; bool wifiUp = false; bool portalMode = false; char apName[40]; uint8_t prevPct = 255; uint32_t prevRpm = 0xFFFFFFFFU; // LVGL display buffer — partial rendering. Smaller buffer = shorter SPI flush // bursts that don't tie up the loop while WebServer is handling requests. #define LV_BUF_LINES 20 static uint8_t lvBuf[SCR_W * LV_BUF_LINES * 2]; static lv_display_t* lvDisplay; // Widget pointers come from SquareLine (ui_Screen1, ui_Title, ui_Arc1, ui_FanVal, etc.) // Declared in ui.h — no local handles needed. // Max RPM for the right gauge scale (TL-9015B is rated 2700; round up for headroom) #define MAX_RPM_DISPLAY 3000 // ----------------------------- ISR ----------------------------------- void IRAM_ATTR tachISR() { tachPulses++; } // ------------------------- FAN CONTROL ------------------------------- void setFanPct(int pct) { if (pct < MIN_PCT) pct = MIN_PCT; if (pct > MAX_PCT) pct = MAX_PCT; fanPct = (uint8_t)pct; ledcWrite(PWM_PIN, map(fanPct, 0, 100, 0, (1 << PWM_RES) - 1)); } void saveFanPct() { prefs.putUChar("pct", fanPct); } // ----------------------------- LVGL ---------------------------------- static void lvglFlushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { uint32_t w = (area->x2 - area->x1 + 1); uint32_t h = (area->y2 - area->y1 + 1); uint32_t count = w * h; uint16_t* pixels = (uint16_t*)px_map; // No R/B swap here. The panel ends up in RGB mode in this build (LVGL bypasses // TFT_eSPI's drawString path that would apply TFT_RGB_ORDER). Just byte-swap // (handled by tft.setSwapBytes(true) in setup) and push raw RGB565. tft.startWrite(); tft.setAddrWindow(area->x1, area->y1, w, h); tft.pushPixels(pixels, count); tft.endWrite(); lv_display_flush_ready(disp); } static uint32_t lvglTickCb(void) { return millis(); } // applyTheme() — overrides SquareLine's exported colors/fonts with the CyberNetView // "default" palette. Called once after ui_init() so the SquareLine project file stays // vanilla and re-exports won't clobber these tweaks. void loadTheme() { Preferences ui; ui.begin("ui", true); String name = ui.getString("theme", "default"); ui.end(); for (int i = 0; i < NUM_THEMES; i++) { if (name.equals(themes[i].id)) { currentTheme = themes[i]; Serial.printf("[theme] loaded '%s'\n", currentTheme.id); return; } } currentTheme = themes[0]; Serial.printf("[theme] '%s' not found, using '%s'\n", name.c_str(), currentTheme.id); } // Forward declarations for matrix rain (defined after applyTheme). struct Chain; // forward — Chain struct is defined further down static void initRain(); static void destroyRain(); static void rainStep(); static void buildChain(Chain* c); void applyTheme() { const Theme& t = currentTheme; // Screen background lv_obj_set_style_bg_color(ui_Screen1, lv_color_hex(t.bg), LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(ui_Screen1, LV_OPA_COVER, LV_PART_MAIN | LV_STATE_DEFAULT); // Title lv_obj_set_style_text_color(ui_Title, lv_color_hex(t.title), LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_text_font(ui_Title, &lv_font_montserrat_18, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_width(ui_Title, LV_SIZE_CONTENT); // Subtitle lv_obj_set_style_text_color(ui_Subtitle, lv_color_hex(t.subtitle), LV_PART_MAIN | LV_STATE_DEFAULT); // Both arcs share the same track/indicator/knob styling lv_obj_t* arcs[2] = { ui_Arc1, ui_Arc2 }; for (int i = 0; i < 2; i++) { lv_obj_t* arc = arcs[i]; lv_obj_set_style_arc_color(arc, lv_color_hex(t.track), LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_arc_width(arc, 6, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_arc_rounded(arc, true, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_arc_color(arc, lv_color_hex(t.indicator), LV_PART_INDICATOR | LV_STATE_DEFAULT); lv_obj_set_style_arc_width(arc, 6, LV_PART_INDICATOR | LV_STATE_DEFAULT); lv_obj_set_style_arc_rounded(arc, true, LV_PART_INDICATOR | LV_STATE_DEFAULT); lv_obj_set_style_bg_color(arc, lv_color_hex(t.knob_bg), LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_bg_opa(arc, LV_OPA_COVER, LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_outline_color(arc, lv_color_hex(t.knob_ring), LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_outline_width(arc, 3, LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_outline_pad(arc, 1, LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_outline_opa(arc, LV_OPA_COVER, LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_pad_all(arc, 4, LV_PART_KNOB | LV_STATE_DEFAULT); lv_obj_set_style_radius(arc, LV_RADIUS_CIRCLE, LV_PART_KNOB | LV_STATE_DEFAULT); } lv_arc_set_range(ui_Arc2, 0, MAX_RPM_DISPLAY); // Numbers lv_obj_set_style_text_color(ui_FanVal, lv_color_hex(t.num), 0); lv_obj_set_style_text_font(ui_FanVal, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(ui_RPM, lv_color_hex(t.num), 0); lv_obj_set_style_text_font(ui_RPM, &lv_font_montserrat_24, 0); // Unit labels lv_obj_set_style_text_color(ui_FanUnit, lv_color_hex(t.unit), 0); lv_obj_set_style_text_color(ui_RpmUnit, lv_color_hex(t.unit), 0); lv_label_set_text(ui_RpmUnit, "RPM"); // Hostname + IP lv_obj_set_style_text_color(ui_HostLabel, lv_color_hex(t.host), 0); lv_obj_set_style_text_color(ui_IpLabel, lv_color_hex(t.ip), 0); // Matrix rain — only when matrix theme is active if (strcmp(t.id, "matrix") == 0) initRain(); else destroyRain(); } // ----------------------- MATRIX RAIN --------------------------------- // 10 columns of multi-character chains, evenly spaced (32 px apart) across // the 320 px screen. Each chain has random length, random startup delay, // and rebuilds with new chars + new delay when it exits the bottom. #define RAIN_COLS 10 #define RAIN_COL_W 32 // 320 / 10 #define RAIN_LINE_H 14 // vertical pixels per glyph in the chain #define MIN_CHAIN_LEN 4 #define MAX_CHAIN_LEN 12 #define MAX_TEXT_BUF 100 // chain text buffer (UTF-8 bytes) static const char* RAIN_GLYPHS[] = { "ア","イ","ウ","エ","オ","カ","キ","ク","ケ","コ", "サ","シ","ス","セ","ソ","タ","チ","ツ","テ","ト", "ナ","ニ","ヌ","ネ","ノ","ハ","ヒ","フ","ヘ","ホ", "マ","ミ","ム","メ","モ","ヤ","ユ","ヨ","ラ","リ", "ル","レ","ロ","ワ","ヲ","ン", "日","月","火","水","木","金","土","山","川","人", "0","1","2","3","4","5","6","7","8","9" }; static const int RAIN_GLYPH_COUNT = sizeof(RAIN_GLYPHS) / sizeof(RAIN_GLYPHS[0]); struct Chain { lv_obj_t* label; int16_t y; // top-of-label Y position int8_t length; // chars in this chain int8_t delay; // ticks remaining before chain starts (re)falling char text[MAX_TEXT_BUF]; }; static Chain chains[RAIN_COLS]; static bool rainActive = false; static uint32_t lastRainTick = 0; static void buildChain(Chain* c) { c->length = MIN_CHAIN_LEN + random(MAX_CHAIN_LEN - MIN_CHAIN_LEN + 1); c->delay = random(20); // 0-19 ticks of pause c->y = -c->length * RAIN_LINE_H; // start above screen size_t pos = 0; c->text[0] = 0; for (int i = 0; i < c->length; i++) { const char* g = RAIN_GLYPHS[random(RAIN_GLYPH_COUNT)]; size_t glen = strlen(g); if (pos + glen + 2 >= sizeof(c->text)) break; strcpy(c->text + pos, g); pos += glen; if (i < c->length - 1) { c->text[pos++] = '\n'; c->text[pos] = 0; } } lv_label_set_text(c->label, c->text); } static void initRain() { if (rainActive) return; randomSeed(esp_random()); for (int i = 0; i < RAIN_COLS; i++) { chains[i].label = lv_label_create(ui_Screen1); lv_obj_set_style_text_color(chains[i].label, lv_color_hex(0x004422), 0); lv_obj_set_style_text_font(chains[i].label, &lv_font_source_han_sans_sc_14_cjk, 0); lv_obj_set_style_text_line_space(chains[i].label, -2, 0); // tighter stack buildChain(&chains[i]); int x = i * RAIN_COL_W + (RAIN_COL_W - 14) / 2; // center in column slot lv_obj_set_pos(chains[i].label, x, chains[i].y); lv_obj_move_background(chains[i].label); } rainActive = true; } static void destroyRain() { if (!rainActive) return; for (int i = 0; i < RAIN_COLS; i++) { if (chains[i].label) lv_obj_del(chains[i].label); chains[i].label = NULL; } rainActive = false; } static void rainStep() { if (!rainActive) return; for (int i = 0; i < RAIN_COLS; i++) { Chain* c = &chains[i]; if (c->delay > 0) { c->delay--; continue; } c->y += RAIN_LINE_H; // step down by one glyph if (c->y > 170) { // chain fully exited bottom buildChain(c); // rebuild with new chars + delay } int x = i * RAIN_COL_W + (RAIN_COL_W - 14) / 2; lv_obj_set_pos(c->label, x, c->y); } } void updatePctUI(uint8_t pct) { lv_arc_set_value(ui_Arc1, pct); char buf[8]; snprintf(buf, sizeof(buf), "%d", pct); lv_label_set_text(ui_FanVal, buf); } void updateRpmUI(uint32_t rpm) { uint32_t shown = (rpm > MAX_RPM_DISPLAY) ? MAX_RPM_DISPLAY : rpm; lv_arc_set_value(ui_Arc2, (int16_t)shown); char buf[8]; snprintf(buf, sizeof(buf), "%u", (unsigned)rpm); lv_label_set_text(ui_RPM, buf); } void updateWifiUI() { const Theme& t = currentTheme; if (portalMode) { lv_label_set_text(ui_HostLabel, apName); lv_obj_set_style_text_color(ui_HostLabel, lv_color_hex(t.host), 0); lv_label_set_text(ui_IpLabel, "192.168.4.1"); lv_obj_set_style_text_color(ui_IpLabel, lv_color_hex(t.ip), 0); lv_label_set_text(ui_Title, "SETUP MODE"); lv_label_set_text(ui_Subtitle, "Join AP, browse to IP"); } else if (wifiUp) { lv_label_set_text(ui_HostLabel, displayFqdn); lv_obj_set_style_text_color(ui_HostLabel, lv_color_hex(t.host), 0); char buf[24]; IPAddress ip = WiFi.localIP(); snprintf(buf, sizeof(buf), "%u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); lv_label_set_text(ui_IpLabel, buf); } else { lv_label_set_text(ui_HostLabel, "WIFI: connecting..."); lv_obj_set_style_text_color(ui_HostLabel, lv_color_hex(t.subtitle), 0); lv_label_set_text(ui_IpLabel, ""); } } // --------------------- WIFI SETUP / CAPTIVE PORTAL ------------------- // Build the portal HTML dynamically so the theme dropdown reflects the // current themes[] array and pre-selects whatever's saved in NVS. String renderPortalHtml() { Preferences ui; ui.begin("ui", true); String currentThemeId = ui.getString("theme", "default"); ui.end(); String html; html.reserve(4096); html += F( "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">" "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">" "<title>FAN CTRL @@@PH100X@@@ "*{box-sizing:border-box;margin:0;padding:0}" "body{background:#000;color:#e8e8e8;font-family:'Share Tech Mono','Consolas',monospace;padding:24px;min-height:100vh}" ".wrap{max-width:480px;margin:0 auto}" "h1{color:#00d4ff;letter-spacing:4px;font-weight:normal;font-size:22px;margin-bottom:8px;text-shadow:0 0 8px #00d4ff}" ".sub{color:#888;letter-spacing:2px;font-size:11px;margin-bottom:32px}" "label{display:block;color:#888;font-size:11px;letter-spacing:2px;margin:16px 0 6px}" "input,select{width:100%;padding:14px;background:#111;border:1px solid #333;color:#fff;font-family:inherit;font-size:16px;outline:none}" "input:focus,select:focus{border-color:#00d4ff}" "button{width:100%;padding:16px;margin-top:24px;background:transparent;border:1px solid #00d4ff;color:#00d4ff;font-family:inherit;font-size:14px;letter-spacing:3px;cursor:pointer;text-shadow:0 0 4px #00d4ff}" "button:hover{background:rgba(0,212,255,.1);box-shadow:0 0 12px rgba(0,212,255,.4)}" ".note{color:#666;font-size:11px;line-height:1.6;margin-top:32px;padding-top:16px;border-top:1px solid #222}" "</style></head><body><div class=\"wrap\">" "<h1>FAN CTRL</h1><div class=\"sub\">@@@PH101X@@@ "<form action=\"/save\" method=\"POST\">" "<label>WIFI SSID</label>" "<input name=\"ssid\" required maxlength=\"32\" autocapitalize=\"off\" autocorrect=\"off\">" "<label>PASSWORD</label>" "<input name=\"pass\" type=\"password\" maxlength=\"63\">" "<label>THEME</label>" "<select name=\"theme\">" ); for (int i = 0; i < NUM_THEMES; i++) { html += "<option value=\""; html += themes[i].id; html += "\""; if (currentThemeId.equals(themes[i].id)) html += " selected"; html += ">"; html += themes[i].label; html += "</option>"; } html += F( "</select>" "<button type=\"submit\">SAVE & REBOOT</button></form>" "<div class=\"note\">" "The device will save these to NVS, restart, and join your network. " "After it joins, the TFT will show the new IP and your selected theme.<br><br>" "To re-run setup later, hold BOOT for 5s during boot to wipe saved credentials." "</div></div></body></html>" ); return html; } bool tryConnectSTA() { Preferences w; w.begin("wifi", true); String ssid = w.getString("ssid", ""); String pass = w.getString("pass", ""); w.end(); if (ssid.length() == 0) return false; Serial.print("[wifi] connecting to "); Serial.println(ssid); WiFi.mode(WIFI_STA); WiFi.setHostname(HOSTNAME); WiFi.begin(ssid.c_str(), pass.c_str()); uint32_t t0 = millis(); while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) { delay(150); lv_timer_handler(); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { wifiUp = true; Serial.print("[wifi] IP: "); Serial.println(WiFi.localIP()); if (MDNS.begin(HOSTNAME)) { MDNS.addService("http", "tcp", 80); Serial.printf("[mdns] http:@@@PH102X@@@ } return true; } Serial.println("[wifi] connect failed"); return false; } static void handleConfigSave() { String ssid = http.arg("ssid"); String pass = http.arg("pass"); String theme = http.arg("theme"); Serial.printf("[portal] saving ssid='%s', theme='%s'\n", ssid.c_str(), theme.c_str()); Preferences w; w.begin("wifi", false); w.putString("ssid", ssid); w.putString("pass", pass); w.end(); if (theme.length() > 0) { Preferences ui; ui.begin("ui", false); ui.putString("theme", theme); ui.end(); } http.send(200, "text/html", "<html><body style=\"background:#000;color:#00d4ff;font-family:monospace;padding:40px;text-align:center\">" "<h1 style=\"letter-spacing:4px\">SAVED</h1>" "<p style=\"color:#888;margin-top:16px\">Rebooting and joining your WiFi...</p>" "</body></html>"); delay(1500); ESP.restart(); } void wipeWifiCreds() { Preferences w; w.begin("wifi", false); w.clear(); w.end(); Serial.println("[portal] wifi credentials wiped"); } void startConfigPortal() { portalMode = true; WiFi.mode(WIFI_AP); WiFi.softAP(apName); delay(500); IPAddress ip = WiFi.softAPIP(); Serial.printf("[portal] AP '%s' on %u.%u.%u.%u\n", apName, ip[0], ip[1], ip[2], ip[3]); dnsServer.start(53, "*", ip); http.on("/", HTTP_GET, [](){ http.send(200, "text/html", renderPortalHtml()); }); http.on("/save", HTTP_POST, handleConfigSave); http.onNotFound([](){ // Captive portal — redirect every other request to the config page http.sendHeader("Location", "http:@@@PH104X@@@ http.send(302, "text/plain", ""); }); http.begin(); updateWifiUI(); lv_timer_handler(); } @@@PH105X@@@ const char INDEX_HTML[] PROGMEM = @@@PH0X@@@; @@@PH106X@@@ static void sendCors() { http.sendHeader("Access-Control-Allow-Origin", "*"); http.sendHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); } static void handleRoot() { http.send_P(200, "text/html", INDEX_HTML); } static void handleStatus() { sendCors(); char buf[80]; snprintf(buf, sizeof(buf), "{\"pct\":%u,\"rpm\":%u}", fanPct, currentRpm); http.send(200, "application/json", buf); } static void handleSet() { sendCors(); if (!http.hasArg("pct")) { http.send(400, "application/json", "{\"ok\":false}"); return; } setFanPct(http.arg("pct").toInt()); saveFanPct(); updatePctUI(fanPct); prevPct = fanPct; char buf[64]; snprintf(buf, sizeof(buf), "{\"ok\":true,\"pct\":%u,\"rpm\":%u}", fanPct, currentRpm); http.send(200, "application/json", buf); } static void handleOptions() { sendCors(); http.send(204); } static void handleThemeGet() { sendCors(); String json; json.reserve(800); json += "{\"current\":\""; json += currentTheme.id; json += "\",\"themes\":["; for (int i = 0; i < NUM_THEMES; i++) { if (i) json += ","; json += "{\"id\":\""; json += themes[i].id; json += "\",\"label\":\""; json += themes[i].label; json += "\"}"; } json += "]}"; http.send(200, "application/json", json); } static void handleThemeSet() { sendCors(); String name = http.arg("name"); if (name.length() == 0) { http.send(400, "application/json", "{\"ok\":false,\"err\":\"missing name\"}"); return; } bool found = false; for (int i = 0; i < NUM_THEMES; i++) { if (name.equals(themes[i].id)) { found = true; break; } } if (!found) { http.send(400, "application/json", "{\"ok\":false,\"err\":\"unknown theme\"}"); return; } Preferences ui; ui.begin("ui", false); ui.putString("theme", name); ui.end(); loadTheme(); // re-populate currentTheme from NVS applyTheme(); // re-style widgets live String json = "{\"ok\":true,\"current\":\""; json += currentTheme.id; json += "\"}"; http.send(200, "application/json", json); } // ----------------------------- SETUP --------------------------------- void setup() { Serial.begin(115200); delay(100); Serial.println("\n[fanctrl-lvgl] boot"); // Buttons pinMode(BTN_UP_PIN, INPUT_PULLUP); pinMode(BTN_DN_PIN, INPUT_PULLUP); // PWM ledcAttach(PWM_PIN, PWM_FREQ, PWM_RES); // NVS prefs.begin("fan", false); fanPct = prefs.getUChar("pct", DEFAULT_PCT); setFanPct(fanPct); // Tach pinMode(TACH_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(TACH_PIN), tachISR, FALLING); // TFT — landscape tft.begin(); tft.setRotation(3); // 320x170 landscape, USB-C on the right tft.setSwapBytes(true); // Required for LVGL: pushPixels sends MSB-first (big-endian) // so the panel reads each 16-bit pixel correctly. Without this, // colors come out scrambled (purple/green/random) on every value. tft.fillScreen(TFT_BLACK); // LVGL init lv_init(); lv_tick_set_cb(lvglTickCb); lvDisplay = lv_display_create(SCR_W, SCR_H); lv_display_set_flush_cb(lvDisplay, lvglFlushCb); lv_display_set_buffers(lvDisplay, lvBuf, NULL, sizeof(lvBuf), LV_DISPLAY_RENDER_MODE_PARTIAL); ui_init(); // SquareLine builds the screen loadTheme(); // pull selected theme name from NVS into currentTheme applyTheme(); // apply currentTheme's colors to the LVGL widgets updatePctUI(fanPct); updateRpmUI(0); updateWifiUI(); lv_timer_handler(); // first paint // Build derived names (AP and display FQDN both keyed off HOSTNAME) snprintf(apName, sizeof(apName), "%s%s", HOSTNAME, AP_SUFFIX); snprintf(displayFqdn, sizeof(displayFqdn), "%s.local", HOSTNAME); // Hold-BOOT-to-wipe-creds: if BOOT is held LOW for 5 seconds at startup, // clear saved WiFi credentials and force the captive portal. if (digitalRead(BTN_DN_PIN) == LOW) { Serial.println("[portal] BOOT held — checking for 5s hold to wipe creds"); uint32_t holdStart = millis(); while (digitalRead(BTN_DN_PIN) == LOW && millis() - holdStart < 5500) { delay(50); lv_timer_handler(); } if (millis() - holdStart >= 5000) { wipeWifiCreds(); } } // Try saved credentials. If absent or connect fails, fall into the portal. if (!tryConnectSTA()) { Serial.println("[wifi] no creds or connect failed -> starting captive portal"); startConfigPortal(); } else { // Normal operation — register the live web UI handlers http.on("/", HTTP_GET, handleRoot); http.on("/api/status", HTTP_GET, handleStatus); http.on("/api/set", HTTP_POST, handleSet); http.on("/api/set", HTTP_OPTIONS, handleOptions); http.on("/api/theme", HTTP_GET, handleThemeGet); http.on("/api/theme", HTTP_POST, handleThemeSet); http.on("/api/theme", HTTP_OPTIONS, handleOptions); http.begin(); Serial.println("[http] :80 ready"); } updateWifiUI(); } // ----------------------------- LOOP ---------------------------------- void loop() { uint32_t now = millis(); // ── Captive portal mode: handle DNS + HTTP, skip the rest ── if (portalMode) { dnsServer.processNextRequest(); http.handleClient(); lv_timer_handler(); return; } // Buttons if (now - lastBtnTime > 150) { bool changed = false; if (digitalRead(BTN_UP_PIN) == LOW) { setFanPct(fanPct + STEP_SIZE); changed = true; } if (digitalRead(BTN_DN_PIN) == LOW) { setFanPct(fanPct - STEP_SIZE); changed = true; } if (changed) { lastBtnTime = now; saveFanPct(); updatePctUI(fanPct); prevPct = fanPct; } } // RPM tick if (now - lastRpmCalc >= 1000) { noInterrupts(); uint32_t p = tachPulses; tachPulses = 0; interrupts(); currentRpm = (p * 60) / 2; lastRpmCalc = now; if (currentRpm != prevRpm) { updateRpmUI(currentRpm); prevRpm = currentRpm; } } // WiFi watchdog static uint32_t lastWifiCheck = 0; if (now - lastWifiCheck > 10000) { lastWifiCheck = now; bool nowUp = (WiFi.status() == WL_CONNECTED); if (nowUp != wifiUp) { wifiUp = nowUp; updateWifiUI(); } if (!wifiUp) WiFi.reconnect(); } // External pct change (e.g., from web) if (fanPct != prevPct) { updatePctUI(fanPct); prevPct = fanPct; } // Matrix rain step (only does work if rain is active) if (rainActive && now - lastRainTick > 120) { lastRainTick = now; rainStep(); } http.handleClient(); lv_timer_handler(); }
SQUARELINE UI HEADER
ui.h
// This file was generated by SquareLine Studio // SquareLine Studio version: SquareLine Studio 1.6.1 // LVGL version: 9.3 // Project name: SquareLine_Project #ifndef _SQUARELINE_PROJECT_UI_H #define _SQUARELINE_PROJECT_UI_H #ifdef __cplusplus extern "C" { #endif #include <lvgl.h> #include "ui_helpers.h" #include "ui_events.h" ///////////////////// SCREENS //////////////////// #include "ui_Screen1.h" ///////////////////// VARIABLES //////////////////// // EVENTS extern lv_obj_t * ui____initial_actions0; // UI INIT void ui_init(void); void ui_destroy(void); #ifdef __cplusplus } /*extern "C"*/ #endif #endif
SCREEN 1 WIDGET HANDLES
ui_Screen1.h
// This file was generated by SquareLine Studio // SquareLine Studio version: SquareLine Studio 1.6.1 // LVGL version: 9.3 // Project name: SquareLine_Project #ifndef UI_SCREEN1_H #define UI_SCREEN1_H #ifdef __cplusplus extern "C" { #endif // SCREEN: ui_Screen1 extern void ui_Screen1_screen_init(void); extern void ui_Screen1_screen_destroy(void); extern lv_obj_t * ui_Screen1; extern lv_obj_t * ui_Title; extern lv_obj_t * ui_Subtitle; extern lv_obj_t * ui_Arc1; extern lv_obj_t * ui_FanVal; extern lv_obj_t * ui_FanUnit; extern lv_obj_t * ui_Arc2; extern lv_obj_t * ui_RPM; extern lv_obj_t * ui_RpmUnit; extern lv_obj_t * ui_HostLabel; extern lv_obj_t * ui_IpLabel; // CUSTOM VARIABLES extern lv_obj_t * uic_Title; #ifdef __cplusplus } /*extern "C"*/ #endif #endif
TFT_ESPI CONFIG (User_Setup.h)
User_Setup.h (overrides only)
// ====== TFT_eSPI User_Setup.h overrides for Ideaspark 1.9" ST7789 ====== // Located at: Documents\Arduino\libraries\TFT_eSPI\User_Setup.h // (or use User_Setup_Select.h to point at a custom setup file) #define ST7789_DRIVER #define TFT_WIDTH 170 #define TFT_HEIGHT 320 // SPI pins for the Ideaspark board #define TFT_MOSI 23 #define TFT_SCLK 18 #define TFT_CS 15 #define TFT_DC 2 #define TFT_RST 4 #define TFT_BL 32 // backlight control pin #define TFT_BACKLIGHT_ON HIGH // <- HIGH (Ideaspark backlight is active-high) // Color order: this panel is wired BGR. Without this, cyan renders as yellow. #define TFT_RGB_ORDER TFT_BGR // Fonts (only what's used) #define LOAD_GLCD #define LOAD_FONT2 #define LOAD_FONT4 #define SMOOTH_FONT #define SPI_FREQUENCY 40000000 #define SPI_READ_FREQUENCY 20000000
LVGL CONFIG (lv_conf.h)
lv_conf.h (overrides only)
// ====== lv_conf.h overrides required for this sketch ====== // Located at: Documents\Arduino\libraries\lv_conf.h // Memory: 48KB is enough for this UI; default 128KB overflows DRAM on ESP32 #define LV_MEM_SIZE (48 * 1024U) // Color depth + byte order #define LV_COLOR_DEPTH 16 // Widgets used by SquareLine ui_helpers.c — both must be enabled or you get // 'lv_keyboard_set_textarea was not declared' / 'lv_spinbox_*' link errors #define LV_USE_KEYBOARD 1 #define LV_USE_SPINBOX 1 // Built-in fonts the sketch uses #define LV_FONT_MONTSERRAT_18 1 #define LV_FONT_MONTSERRAT_24 1 // Bundled CJK font for the Matrix theme's kanji rain #define LV_FONT_SOURCE_HAN_SANS_SC_14_CJK 1
SETUP INSTRUCTIONS
- SquareLine export: The
ui_*.c/ui_*.hfiles plusui_helpers.*andui_events.*were generated by SquareLine Studio 1.6.1 (LVGL 9.3 export). Drop the entire export contents into the sketch folder — flatten thescreens/andcomponents/subfolders into the sketch root, since Arduino does not recurse into arbitrary subdirectories. - WiFi setup: First boot brings up an AP called
PVE-FANCTRL-Setup. Connect with a phone, the captive portal opens automatically athttp://192.168.4.1/. Enter your SSID, password, and pick a starting theme — the device saves to NVS and reboots into station mode. - mDNS: After it joins your network, browse to
http://pve-fanctrl.local/for the cyberpunk web UI with live slider, presets, and theme picker. - Factory reset: Hold the
BOOTbutton (GPIO 0) for 5 seconds at power-on to wipe saved WiFi credentials and re-trigger the captive portal. - NVS namespaces:
wifistores SSID/pass,uistores selected theme id,fanstores last fan percentage. Wiping creds only clearswifi— the theme and last fan speed survive.
REQUIRED LIBRARIES
- TFT_eSPI — configured for the Ideaspark 1.9" ST7789 board (see
User_Setup.hoverrides above). TheTFT_BGRcolor order andTFT_BACKLIGHT_ON HIGHare mandatory for this panel. - lvgl v9.x (tested on 9.4) — install via Arduino Library Manager, then place a customized
lv_conf.hinDocuments\Arduino\libraries\. Do not leave it inside thelvgl/folder or it will be wiped on library updates. - Preferences — bundled with the ESP32 Arduino core. Used for NVS storage of WiFi creds, theme, and fan percentage.
- WebServer / DNSServer / ESPmDNS / WiFi — all bundled with the ESP32 Arduino core.
DNSServerwildcards*to192.168.4.1for captive portal redirect.
ARDUINO IDE BOARD SETTINGS
| Setting | Value |
|---|---|
| Board | ESP32 Dev Module |
| Upload Speed | 921600 |
| CPU Frequency | 240MHz (WiFi/BT) |
| Flash Frequency | 80MHz |
| Flash Mode | QIO |
| Flash Size | 4MB (32Mb) |
| Partition Scheme | Huge APP (3MB No OTA / 1MB SPIFFS) |
| PSRAM | Disabled |
| Core Debug Level | None |
The Huge APP partition scheme is mandatory — the LVGL build with embedded HTML easily exceeds the default 1.2MB sketch slot.
WIRING DIAGRAM
| Display (Ideaspark 1.9" ST7789) | ESP32 GPIO |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL (SCLK) | GPIO 18 |
| SDA (MOSI) | GPIO 23 |
| RES (RST) | GPIO 4 |
| DC | GPIO 2 |
| CS | GPIO 15 |
| BLK (Backlight) | GPIO 32 (active HIGH) |
| 4-Pin PWM Fan (TL-9015B style) | Connection |
|---|---|
| Pin 1 — GND (black) | PSU 12V GND |
| Pin 2 — +12V (yellow) | PSU +12V |
| Pin 3 — TACH (green) | ESP32 GPIO 27 (10kΩ pull-up to 3.3V) |
| Pin 4 — PWM (blue) | ESP32 GPIO 26 (25 kHz, 8-bit) |
The ESP32 and the fan share a common ground with the 12V PSU. The fan never gets 5V or 3.3V on its power pins. The TACH line needs an external 10kΩ pull-up to 3.3V because PC fans are open-collector on tach.
TROUBLESHOOTING
- Compile error:
xtensa-esp-elf/include/stdint.h: error: unknown opcode or format name 'typedef'— Arduino is trying to assemble LVGL's Helium SIMD.Sfile as if it were ESP32 Xtensa assembly. Renamelibraries/lvgl/src/draw/sw/blend/neon/lv_blend_helium.Stolv_blend_helium.S.disabledso the Arduino glob skips it. - Compile error:
region 'dram0_0_seg' overflowed by ~76000 bytes— LVGL's defaultLV_MEM_SIZEof 128KB does not fit in ESP32 DRAM alongside WiFi. Drop it to(48 * 1024U)inlv_conf.has shown above. - Link error:
'lv_keyboard_set_textarea' was not declared in this scope— SquareLine'sui_helpers.creferences both keyboard and spinbox helpers even when the project doesn't use those widgets. SetLV_USE_KEYBOARD 1andLV_USE_SPINBOX 1inlv_conf.h. - Link error:
undefined reference to ui_Screen1_screen_init— SquareLine putsui_Screen1.cin ascreens/subfolder. Arduino does not recursively compile arbitrary subdirectories. Movescreens/*.candcomponents/*.cinto the sketch root and fix the#includepaths ("screens/ui_Screen1.h"→"ui_Screen1.h","../ui.h"→"ui.h"). - Sketch too big:
text section exceeds available space— switch the partition scheme to Huge APP (3MB No OTA / 1MB SPIFFS). - Display backlight stays dark: the Ideaspark backlight is active HIGH, not low. Set
#define TFT_BACKLIGHT_ON HIGHinUser_Setup.h. - Cyan renders as yellow / colors look swapped: the panel is wired BGR. Set
#define TFT_RGB_ORDER TFT_BGRinUser_Setup.h. - LVGL renders chaotic purple/green pixels everywhere: 16-bit pixel byte order. Add
tft.setSwapBytes(true);aftertft.begin(). Do not also do per-pixel R/B swaps in the LVGL flush callback — pick one or the other. - Compile error:
'lv_font_source_han_sans_sc_14_cjk' was not declared— the bundled CJK font must be enabled with#define LV_FONT_SOURCE_HAN_SANS_SC_14_CJK 1inlv_conf.h. Required for the Matrix theme's kanji rain. - Compile error:
'Chain' was not declared in this scope— Arduino's auto-generated function prototypes reference theChainstruct before it's defined. Addstruct Chain;as a forward declaration before the first function that takes aChain*argument.
BUILD CHECKLIST
- [ ] Install
TFT_eSPIandlvgllibraries via Library Manager - [ ] Apply the
User_Setup.hoverrides above (BGR mode, BL HIGH, pinout) - [ ] Place a customized
lv_conf.hatlibraries\lv_conf.hwith the overrides shown - [ ] Rename
lv_blend_helium.Sto.S.disabledinside the lvgl library - [ ] Drop SquareLine export into sketch folder, flatten
screens/andcomponents/subfolders - [ ] Wire the 1.9" TFT and 4-pin fan per the wiring diagram above
- [ ] Select ESP32 Dev Module + Huge APP partition scheme
- [ ] Compile, upload, and watch for the
PVE-FANCTRL-SetupAP on first boot - [ ] Browse
http://192.168.4.1/, enter creds + pick a theme, save and reboot - [ ] After reboot, browse
http://pve-fanctrl.local/for the live web UI