/** * @file main.cpp * @brief Kode Dot FTP Explorer — ESP32-S3 FTP server serving microSD over Wi-Fi * * Reads Wi-Fi credentials from /sdcard/Wi-Fi.json, connects to Wi-Fi, * syncs time via NTP (Vienna timezone), and serves the SD card over FTP. * Status is shown on the AMOLED display via LVGL and on the NeoPixel LED. */ #include #include #include #include #include #include #include #include #include "SD_MMC.h" #include "FS.h" #include #include #include #include #include #include #include "ui.h" // --------------------------------------------------------------------------- // Global objects // --------------------------------------------------------------------------- // Display manager (LVGL + panel + touch) DisplayManager display; // IO Expander (TCA9555) static TCA9555 ioexp(IOEXP_I2C_ADDR); // NeoPixel status LED static Adafruit_NeoPixel pixel(NEO_PIXEL_COUNT, NEO_PIXEL_PIN, NEO_GRB + NEO_KHZ800); // FTP server FtpServer ftpSrv; // Battery fuel gauge (BQ27220 @ 0x55) static BQ27220 fuelGauge; // PMIC / Charger (BQ25896 @ 0x6B) static PMIC_BQ25896 pmic; // External RTC (MAX31329 @ 0xD0) static MAX31329 rtc; // --------------------------------------------------------------------------- // LED colour definitions // --------------------------------------------------------------------------- static const uint32_t COLOR_OFF = 0x000000; static const uint32_t COLOR_GREEN = Adafruit_NeoPixel::Color(0, 60, 0); // Wi-Fi OK, FTP idle static const uint32_t COLOR_BLUE = Adafruit_NeoPixel::Color(0, 0, 60); // FTP client connected static const uint32_t COLOR_PURPLE = Adafruit_NeoPixel::Color(40, 0, 60); // File transfer static const uint32_t COLOR_RED = Adafruit_NeoPixel::Color(60, 0, 0); // Error // --------------------------------------------------------------------------- // State tracking // --------------------------------------------------------------------------- static bool sd_ok = false; static bool wifi_connected = false; static volatile bool ftp_client_on = false; static volatile bool ftp_transfer = false; static bool rtc_ok = false; static bool gauge_ok = false; static bool pmic_ok = false; static unsigned long last_time_update = 0; static unsigned long last_batt_update = 0; // FTP task → main loop cross-core signalling (set by FTP callbacks, consumed in loop) enum FtpUiState : uint8_t { FTP_ST_IDLE, FTP_ST_CONNECTED, FTP_ST_TRANSFERRING }; static volatile FtpUiState ftp_ui_state = FTP_ST_IDLE; static volatile bool ftp_ui_dirty = false; // UI label + LED need refresh static volatile bool ftp_sd_dirty = false; // SD usage info needs refresh // --------------------------------------------------------------------------- // NeoPixel helpers // --------------------------------------------------------------------------- static void setLed(uint32_t color) { pixel.setPixelColor(0, color); pixel.show(); } /** Refresh LED colour based on current state */ static void updateLed() { if (ftp_transfer) { setLed(COLOR_PURPLE); } else if (ftp_client_on) { setLed(COLOR_BLUE); } else if (wifi_connected && sd_ok) { setLed(COLOR_GREEN); } else if (!sd_ok || !wifi_connected) { setLed(COLOR_RED); } else { setLed(COLOR_OFF); } } // --------------------------------------------------------------------------- // SD card helpers // --------------------------------------------------------------------------- /** Get used / total space on SD card in MiB and push to UI */ static uint64_t calcDirSize(fs::FS &fs, const char *dirname) { uint64_t total = 0; File root = fs.open(dirname); if (!root || !root.isDirectory()) return 0; File f; while ((f = root.openNextFile())) { if (f.isDirectory()) { /* On ESP32 Arduino 3.x, f.path() returns the full path */ String fullPath = String(f.path()); f.close(); total += calcDirSize(fs, fullPath.c_str()); } else { total += f.size(); f.close(); } } root.close(); return total; } static void updateSdInfo() { if (!sd_ok) return; uint64_t total = SD_MMC.totalBytes() / (1024ULL * 1024ULL); uint64_t used = SD_MMC.usedBytes() / (1024ULL * 1024ULL); /* Skip heavy filesystem walk while FTP is active (concurrent SD_MMC access from core 0 + core 1 causes null pointer crashes) */ if (used == 0 && total > 0 && !ftp_client_on) { uint64_t walked = calcDirSize(SD_MMC, "/"); used = walked / (1024ULL * 1024ULL); } ui_set_sd(used, total); } // --------------------------------------------------------------------------- // FTP callbacks (SimpleFTPServer v3.x) // --------------------------------------------------------------------------- /** * Called on FTP connect / disconnect events. * NOTE: Runs on the FTP task (core 0) — only set flags here, never call LVGL/UI. */ void ftpCallback(FtpOperation ftpOperation, uint32_t freeSpace, uint32_t totalSpace) { switch (ftpOperation) { case FTP_CONNECT: Serial.println("[FTP] Client connected"); ftp_client_on = true; ftp_transfer = false; ftp_ui_state = FTP_ST_CONNECTED; ftp_ui_dirty = true; break; case FTP_DISCONNECT: Serial.println("[FTP] Client disconnected"); ftp_client_on = false; ftp_transfer = false; ftp_ui_state = FTP_ST_IDLE; ftp_ui_dirty = true; break; case FTP_FREE_SPACE_CHANGE: Serial.printf("[FTP] Free space: %u / %u\n", freeSpace, totalSpace); ftp_sd_dirty = true; break; default: break; } } /** * Called during file transfer operations. * NOTE: Runs on the FTP task (core 0) — only set flags here, never call LVGL/UI. */ void ftpTransferCallback(FtpTransferOperation ftpOperation, const char *name, uint32_t transferredSize) { switch (ftpOperation) { case FTP_UPLOAD_START: Serial.printf("[FTP] Upload start: %s\n", name); ftp_transfer = true; ftp_ui_state = FTP_ST_TRANSFERRING; ftp_ui_dirty = true; break; case FTP_DOWNLOAD_START: Serial.printf("[FTP] Download start: %s\n", name); ftp_transfer = true; ftp_ui_state = FTP_ST_TRANSFERRING; ftp_ui_dirty = true; break; case FTP_UPLOAD: case FTP_DOWNLOAD: break; // Ongoing — silent to avoid serial flood case FTP_TRANSFER_STOP: Serial.printf("[FTP] Transfer done: %s (%u bytes)\n", name, transferredSize); ftp_transfer = false; ftp_ui_state = ftp_client_on ? FTP_ST_CONNECTED : FTP_ST_IDLE; ftp_ui_dirty = true; ftp_sd_dirty = true; break; case FTP_TRANSFER_ERROR: Serial.printf("[FTP] Transfer error: %s\n", name); ftp_transfer = false; ftp_ui_state = ftp_client_on ? FTP_ST_CONNECTED : FTP_ST_IDLE; ftp_ui_dirty = true; break; default: break; } } // --------------------------------------------------------------------------- // Battery monitoring // --------------------------------------------------------------------------- /** Update battery percentage and charging status every 5 seconds */ static void updateBattery() { unsigned long now = millis(); if (now - last_batt_update < 5000) return; last_batt_update = now; int soc = -1; bool charging = false; /* Read state-of-charge from fuel gauge */ if (gauge_ok) { soc = fuelGauge.readStateOfChargePercent(); if (soc < 0 || soc > 100) soc = -1; // sanity check } /* Read charging status from PMIC */ if (pmic_ok) { pmic.setCONV_START(true); // trigger ADC conversion uint8_t chrg_stat = pmic.get_VBUS_STAT_reg().chrg_stat; // chrg_stat: 0=Not Charging, 1=Pre-charge, 2=Fast Charging, 3=Charge Done charging = (chrg_stat == 1 || chrg_stat == 2); } ui_set_battery(soc, charging); } // --------------------------------------------------------------------------- // Time display // --------------------------------------------------------------------------- /** Update the UI time and date labels once per second */ static void updateTimeDisplay() { unsigned long now = millis(); if (now - last_time_update < 1000) return; last_time_update = now; struct tm timeinfo; if (getLocalTime(&timeinfo, 0)) { char buf[16]; strftime(buf, sizeof(buf), "%H:%M:%S", &timeinfo); ui_set_time(buf); char datebuf[32]; strftime(datebuf, sizeof(datebuf), "%a, %d %b %Y", &timeinfo); ui_set_date(datebuf); } } // --------------------------------------------------------------------------- // Initialisation helpers // --------------------------------------------------------------------------- /** Mount SD card via SDMMC 1-bit mode. Returns true on success. */ static bool initSdCard() { Serial.println("[SD] Mounting SD card (SDMMC 1-bit)..."); SD_MMC.setPins(SD_PIN_CLK, SD_PIN_CMD, SD_PIN_D0); if (!SD_MMC.begin(SD_MOUNT_POINT, true)) { // true = 1-bit mode Serial.println("[SD] ERROR: Mount failed"); return false; } uint8_t cardType = SD_MMC.cardType(); if (cardType == CARD_NONE) { Serial.println("[SD] ERROR: No card detected"); return false; } const char *typeStr = (cardType == CARD_MMC) ? "MMC" : (cardType == CARD_SD) ? "SD" : (cardType == CARD_SDHC) ? "SDHC" : "Unknown"; uint64_t totalMB = SD_MMC.totalBytes() / (1024ULL * 1024ULL); uint64_t usedMB = SD_MMC.usedBytes() / (1024ULL * 1024ULL); Serial.printf("[SD] Card type: %s | %llu MB used / %llu MB total\n", typeStr, usedMB, totalMB); return true; } /** * Read Wi-Fi credentials from /sdcard/Wi-Fi.json and attempt connection. * JSON format: [{"ssid":"SSID","pass":"PASSWORD"}, ...] * Returns true when connected. */ static bool initWiFi() { Serial.println("[WiFi] Reading credentials from /sdcard/Wi-Fi.json..."); File file = SD_MMC.open("/Wi-Fi.json", FILE_READ); if (!file) { Serial.println("[WiFi] ERROR: Cannot open Wi-Fi.json"); return false; } String json = file.readString(); file.close(); JsonDocument doc; DeserializationError err = deserializeJson(doc, json); if (err) { Serial.printf("[WiFi] ERROR: JSON parse failed: %s\n", err.c_str()); return false; } JsonArray networks = doc.as(); if (networks.size() == 0) { Serial.println("[WiFi] ERROR: No networks in Wi-Fi.json"); return false; } WiFi.mode(WIFI_STA); for (JsonObject net : networks) { const char *ssid = net["ssid"]; const char *pass = net["pass"]; if (!ssid) continue; Serial.printf("[WiFi] Trying \"%s\" ...\n", ssid); WiFi.begin(ssid, pass); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) { delay(250); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { Serial.printf("[WiFi] Connected to \"%s\" IP: %s\n", ssid, WiFi.localIP().toString().c_str()); ui_set_wifi(ssid, WiFi.localIP().toString().c_str()); return true; } Serial.printf("[WiFi] Failed to connect to \"%s\"\n", ssid); WiFi.disconnect(true); delay(200); } Serial.println("[WiFi] ERROR: Could not connect to any network"); return false; } /** Initialise I2C bus and battery / RTC peripherals */ static void initPeripherals() { Wire.begin(48, 47); // SDA=48, SCL=47 /* ── Fuel Gauge (BQ27220 @ 0x55) ──────────────────────────── */ gauge_ok = fuelGauge.begin(); if (gauge_ok) { int soc = fuelGauge.readStateOfChargePercent(); int mv = fuelGauge.readVoltageMillivolts(); Serial.printf("[BAT] Fuel gauge ready — SOC: %d%% Voltage: %d mV\n", soc, mv); } else { Serial.println("[BAT] Warning: BQ27220 fuel gauge not found"); } /* ── PMIC (BQ25896 @ 0x6B) ───────────────────────────────── */ pmic.begin(); pmic_ok = pmic.isConnected(); if (pmic_ok) { pmic.setCONV_START(true); // trigger first ADC conversion Serial.printf("[BAT] PMIC ready — VBUS: %d mV VBAT: %d mV\n", pmic.getVBUSV(), pmic.getBATV()); } else { Serial.println("[BAT] Warning: BQ25896 PMIC not found"); } /* ── External RTC (MAX31329 @ 0xD0) ──────────────────────── */ rtc.begin(); /* Try reading time — if year is valid (>2020), set ESP32 system clock */ if (rtc.readTime()) { if (rtc.t.year >= 2020 && rtc.t.year <= 2099) { rtc_ok = true; struct tm t = {}; t.tm_year = rtc.t.year - 1900; t.tm_mon = rtc.t.month - 1; t.tm_mday = rtc.t.day; t.tm_hour = rtc.t.hour; t.tm_min = rtc.t.minute; t.tm_sec = rtc.t.second; struct timeval tv = { .tv_sec = mktime(&t), .tv_usec = 0 }; settimeofday(&tv, NULL); Serial.printf("[RTC] External RTC time: %04d-%02d-%02d %02d:%02d:%02d\n", rtc.t.year, rtc.t.month, rtc.t.day, rtc.t.hour, rtc.t.minute, rtc.t.second); } else { Serial.printf("[RTC] External RTC has invalid year (%d) — will set from NTP\n", rtc.t.year); } } else { Serial.println("[RTC] Warning: MAX31329 external RTC not responding"); } } /** Write current system time to external RTC (call after NTP sync) */ static void syncRtcFromSystem() { if (!rtc_ok && !gauge_ok) return; // RTC not available struct tm timeinfo; if (!getLocalTime(&timeinfo, 0)) return; rtc.t.year = timeinfo.tm_year + 1900; rtc.t.month = timeinfo.tm_mon + 1; rtc.t.day = timeinfo.tm_mday; rtc.t.hour = timeinfo.tm_hour; rtc.t.minute = timeinfo.tm_min; rtc.t.second = timeinfo.tm_sec; rtc.t.dayOfWeek = timeinfo.tm_wday; if (rtc.writeTime()) { rtc_ok = true; Serial.printf("[RTC] Updated external RTC: %04d-%02d-%02d %02d:%02d:%02d\n", rtc.t.year, rtc.t.month, rtc.t.day, rtc.t.hour, rtc.t.minute, rtc.t.second); } else { Serial.println("[RTC] Warning: Failed to write time to external RTC"); } } /** Configure NTP with Vienna timezone and wait briefly for sync */ static void initNtp() { Serial.println("[NTP] Configuring time (Vienna CET/CEST)..."); configTzTime("CET-1CEST,M3.5.0,M10.5.0/3", "pool.ntp.org", "time.google.com"); struct tm timeinfo; for (int i = 0; i < 10; i++) { if (getLocalTime(&timeinfo, 500)) { char buf[32]; strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo); Serial.printf("[NTP] Time synced: %s\n", buf); /* Write NTP time to external RTC for persistence */ syncRtcFromSystem(); return; } } Serial.println("[NTP] Warning: time not yet synced (will update in background)"); } /** FTP task — runs handleFTP() in a dedicated loop on core 0 */ static void ftpTask(void *param) { (void)param; for (;;) { ftpSrv.handleFTP(); vTaskDelay(pdMS_TO_TICKS(1)); // yield ~1 ms between iterations } } /** Start FTP server with callbacks and launch dedicated task */ static void initFtp() { Serial.println("[FTP] Starting FTP server (user: kode, port: 21)..."); ftpSrv.setCallback(ftpCallback); ftpSrv.setTransferCallback(ftpTransferCallback); /* Set local IP so PASV mode sends the correct address to clients */ ftpSrv.setLocalIp(WiFi.localIP()); ftpSrv.begin("kode", "kode"); /* Run FTP in its own task on core 0 so it never blocks LVGL on core 1 */ xTaskCreatePinnedToCore(ftpTask, "ftp", 32768, NULL, 2, NULL, 0); Serial.printf("[FTP] FTP server ready on core 0 (PASV IP: %s, data port: 50009)\n", WiFi.localIP().toString().c_str()); ui_set_ftp("Idle"); } /** Check SD card presence via IO expander (best-effort) */ static bool checkSdPresence() { if (!ioexp.begin(INPUT)) { Serial.println("[IO] Warning: IO Expander not responding — skipping SD detect"); return true; // Assume present if expander unavailable } uint8_t val = ioexp.read1(EXPANDER_SD_CD); bool present = (val == LOW); // Card detect is active-low Serial.printf("[IO] SD card detect pin: %s\n", present ? "PRESENT" : "NOT PRESENT"); return present; } // --------------------------------------------------------------------------- // Arduino setup // --------------------------------------------------------------------------- void setup() { Serial.begin(115200); delay(100); Serial.println(); Serial.println("==================================="); Serial.println(" Kode Dot FTP Explorer"); Serial.println("==================================="); // NeoPixel off during init pixel.begin(); pixel.setBrightness(30); setLed(COLOR_OFF); // Initialise display + LVGL if (!display.init()) { Serial.println("[DISP] FATAL: Display init failed"); setLed(COLOR_RED); while (1) delay(1000); } Serial.println("[DISP] Display ready"); // Create the LVGL status UI ui_init(); // Initialise I2C peripherals (battery, RTC) initPeripherals(); // IO Expander — check SD card presence if (!checkSdPresence()) { Serial.println("[SD] WARNING: SD card not detected by IO expander"); ui_set_error("No SD card detected"); setLed(COLOR_RED); } // Mount SD card sd_ok = initSdCard(); if (!sd_ok) { ui_set_error("SD card mount failed"); setLed(COLOR_RED); Serial.println("[MAIN] Halting — SD card required"); while (1) { display.update(); delay(100); } } updateSdInfo(); // Connect to Wi-Fi wifi_connected = initWiFi(); if (!wifi_connected) { ui_set_error("Wi-Fi connection failed"); setLed(COLOR_RED); Serial.println("[MAIN] Halting — Wi-Fi required"); while (1) { display.update(); delay(100); } } updateLed(); // Sync NTP time initNtp(); // Start FTP server initFtp(); Serial.println("[MAIN] Setup complete — entering main loop"); Serial.printf("[MAIN] Connect FTP client to %s:21 (user: kode / pass: kode)\n", WiFi.localIP().toString().c_str()); } // --------------------------------------------------------------------------- // Arduino loop // --------------------------------------------------------------------------- void loop() { /* Process FTP status flags (set by callbacks on core 0, consumed here on core 1) */ if (ftp_ui_dirty) { ftp_ui_dirty = false; static const char *ftp_st_str[] = {"Idle", "Connected", "Transferring"}; ui_set_ftp(ftp_st_str[ftp_ui_state]); updateLed(); } if (ftp_sd_dirty) { ftp_sd_dirty = false; updateSdInfo(); } display.update(); updateTimeDisplay(); updateBattery(); delay(5); }