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 &amp; 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_*.h files plus ui_helpers.* and ui_events.* were generated by SquareLine Studio 1.6.1 (LVGL 9.3 export). Drop the entire export contents into the sketch folder — flatten the screens/ and components/ 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 at http://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 BOOT button (GPIO 0) for 5 seconds at power-on to wipe saved WiFi credentials and re-trigger the captive portal.
  • NVS namespaces: wifi stores SSID/pass, ui stores selected theme id, fan stores last fan percentage. Wiping creds only clears wifi — the theme and last fan speed survive.

REQUIRED LIBRARIES

  • TFT_eSPI — configured for the Ideaspark 1.9" ST7789 board (see User_Setup.h overrides above). The TFT_BGR color order and TFT_BACKLIGHT_ON HIGH are mandatory for this panel.
  • lvgl v9.x (tested on 9.4) — install via Arduino Library Manager, then place a customized lv_conf.h in Documents\Arduino\libraries\. Do not leave it inside the lvgl/ 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. DNSServer wildcards * to 192.168.4.1 for captive portal redirect.

ARDUINO IDE BOARD SETTINGS

SettingValue
BoardESP32 Dev Module
Upload Speed921600
CPU Frequency240MHz (WiFi/BT)
Flash Frequency80MHz
Flash ModeQIO
Flash Size4MB (32Mb)
Partition SchemeHuge APP (3MB No OTA / 1MB SPIFFS)
PSRAMDisabled
Core Debug LevelNone

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
VCC3.3V
GNDGND
SCL (SCLK)GPIO 18
SDA (MOSI)GPIO 23
RES (RST)GPIO 4
DCGPIO 2
CSGPIO 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 .S file as if it were ESP32 Xtensa assembly. Rename libraries/lvgl/src/draw/sw/blend/neon/lv_blend_helium.S to lv_blend_helium.S.disabled so the Arduino glob skips it.
  • Compile error: region 'dram0_0_seg' overflowed by ~76000 bytes — LVGL's default LV_MEM_SIZE of 128KB does not fit in ESP32 DRAM alongside WiFi. Drop it to (48 * 1024U) in lv_conf.h as shown above.
  • Link error: 'lv_keyboard_set_textarea' was not declared in this scope — SquareLine's ui_helpers.c references both keyboard and spinbox helpers even when the project doesn't use those widgets. Set LV_USE_KEYBOARD 1 and LV_USE_SPINBOX 1 in lv_conf.h.
  • Link error: undefined reference to ui_Screen1_screen_init — SquareLine puts ui_Screen1.c in a screens/ subfolder. Arduino does not recursively compile arbitrary subdirectories. Move screens/*.c and components/*.c into the sketch root and fix the #include paths ("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 HIGH in User_Setup.h.
  • Cyan renders as yellow / colors look swapped: the panel is wired BGR. Set #define TFT_RGB_ORDER TFT_BGR in User_Setup.h.
  • LVGL renders chaotic purple/green pixels everywhere: 16-bit pixel byte order. Add tft.setSwapBytes(true); after tft.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 1 in lv_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 the Chain struct before it's defined. Add struct Chain; as a forward declaration before the first function that takes a Chain* argument.

BUILD CHECKLIST

  • [ ] Install TFT_eSPI and lvgl libraries via Library Manager
  • [ ] Apply the User_Setup.h overrides above (BGR mode, BL HIGH, pinout)
  • [ ] Place a customized lv_conf.h at libraries\lv_conf.h with the overrides shown
  • [ ] Rename lv_blend_helium.S to .S.disabled inside the lvgl library
  • [ ] Drop SquareLine export into sketch folder, flatten screens/ and components/ 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-Setup AP 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