/* Test for LaskaKit ESPlan V2 ESP32 LAN8720A RS485 PoE * ESP32 + LAN8720 + WebServer + SD + RS485 + SHT40 + BMP280 + NeoPixel * * Funkce: * - web rozhrani pres Ethernet * - stav ETH, SD, SHT40, BMP280, RS485 * - ovladani NeoPixel LED * - odesilani textu pres RS485 * - prijem dat z RS485 * - prochazeni adresaru na SD * - upload / stahnout / smazat souboru na SD * - vytvareni a mazani adresaru na SD * - automaticka reakce na vyndani / vlozeni SD karty * - po vlozeni se SD karta ihned znovu nacte * - SD panel se na webu obnovi jen pri zmene stavu SD karty * - periodicky debug do seriove konzole * - volba jazyka CZ / ENG * Board: kaKit ESPlan V2 https://www.laskakit.cz/laskakit-esplan-esp32-lan8720a-max485-poe/ * * * Email:podpora@laskakit.cz * Web:laskakit.cz */ #include #include #include #include #include #include #include #include #include #include #include #include "esp_system.h" #include "logo_web.h" // ===================================================== // PINOUT // ===================================================== // ---------- Ethernet LAN8720 ---------- #define ETH_PHY_TYPE ETH_PHY_LAN8720 #define ETH_PHY_ADDR 0 #define ETH_PHY_MDC 23 #define ETH_PHY_MDIO 18 #define ETH_PHY_POWER -1 #define ETH_CLK_MODE ETH_CLOCK_GPIO17_OUT // ---------- RS485 ---------- #define RS485_TX_PIN 4 #define RS485_RX_PIN 36 // ---------- I2C ---------- #define I2C_SDA_PIN 33 #define I2C_SCL_PIN 32 // ---------- BMP280 ---------- #define BMP280_ADDR_1 0x76 #define BMP280_ADDR_2 0x77 // ---------- SD karta ---------- #define SD_MISO_PIN 12 #define SD_MOSI_PIN 13 #define SD_SCK_PIN 14 #define SD_CS_PIN 2 #define SD_CD_PIN 34 #define SD_CD_ACTIVE_STATE HIGH // ---------- NeoPixel ---------- #define NEOPIXEL_PIN 0 #define NEOPIXEL_COUNT 1 // ===================================================== // JAZYK UI // ===================================================== enum UiLang { LANG_CZ, LANG_EN }; UiLang currentUiLang = LANG_CZ; // ===================================================== // STRUKTUROVANÝ LOG // ===================================================== struct LogEntry { uint8_t type; String a; String b; String c; }; enum LogType : uint8_t { LOG_TEXT = 0, LOG_SHT40_READ_FAILED, LOG_SHT40_NOT_FOUND, LOG_SHT40_DETECTED, LOG_BMP280_READ_FAILED, LOG_BMP280_DETECTED_76, LOG_BMP280_DETECTED_77, LOG_BMP280_NOT_FOUND, LOG_I2C_INIT, LOG_SD_NOT_INSERTED, LOG_SD_INIT, LOG_SD_BEGIN_FAILED, LOG_SD_NOT_FOUND, LOG_SD_OK, LOG_SD_SIZE_MB, LOG_SD_REMOVED, LOG_SD_INSERTED, LOG_RS485_INIT, LOG_RS485_PINS, LOG_ETH_STATUS_TITLE, LOG_ETH_STATUS_LINK, LOG_ETH_STATUS_IP, LOG_ETH_STATUS_MAC, LOG_ETH_START, LOG_ETH_CONNECTED, LOG_ETH_GOT_IP, LOG_ETH_DISCONNECTED, LOG_ETH_STOP, LOG_ETH_INIT, LOG_ETH_BEGIN_FAILED, LOG_ETH_OK, LOG_ETH_NO_IP, LOG_LED_SET, LOG_RS485_TX, LOG_SD_DELETE_OK, LOG_SD_DELETE_ERROR, LOG_MKDIR_INVALID, LOG_MKDIR_EXISTS, LOG_MKDIR_OK, LOG_MKDIR_ERROR, LOG_UPLOAD_INVALID_NAME, LOG_UPLOAD_START, LOG_UPLOAD_OPEN_WRITE_FAILED, LOG_UPLOAD_FINISHED, LOG_UPLOAD_ABORTED, LOG_WEB_STARTED, LOG_START, LOG_READY, LOG_OPEN_BROWSER, LOG_ETH_NO_IP_YET, LOG_RS485_RX, LOG_HEAP_TOTAL, LOG_HEAP_FREE, LOG_CPU_FREQ }; std::vector eventLog; // ===================================================== // GLOBÁLNÍ OBJEKTY // ===================================================== WebServer server(80); SPIClass sdSPI(VSPI); Adafruit_SHT4x sht4 = Adafruit_SHT4x(); Adafruit_BMP280 bmp280; Adafruit_NeoPixel pixel(NEOPIXEL_COUNT, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800); bool ethConnected = false; bool ethGotIP = false; bool sdOk = false; bool sht40Found = false; bool bmp280Found = false; bool sdCardInserted = false; float lastTemperature = NAN; float lastHumidity = NAN; float lastBmpTemperature = NAN; float lastPressure_hPa = NAN; uint8_t ledR = 0; uint8_t ledG = 0; uint8_t ledB = 0; uint8_t ledBrightness = 50; // 0-255 String rs485LastSent = ""; String rs485LastReceived = ""; uint32_t rs485RxVersion = 0; uint32_t rs485Baudrate = 9600; File uploadFile; unsigned long lastSensorRead = 0; unsigned long lastStatusPrint = 0; // SD detect debounce / hotplug bool lastSdDetectRaw = false; bool lastSdDetectStable = false; unsigned long lastSdDetectChangeMs = 0; // SD state version for web refresh uint32_t sdStateVersion = 0; // Heap baseline = total RAM chip heap uint32_t totalHeapBytes = 0; // ===================================================== // JAZYK HELPERY // ===================================================== UiLang parseLang(const String& s) { String v = s; v.toLowerCase(); return (v == "en") ? LANG_EN : LANG_CZ; } String langCode(UiLang lang) { return (lang == LANG_EN) ? "en" : "cz"; } UiLang getCurrentLang() { return parseLang(server.arg("lang")); } void updateCurrentLangFromRequest() { if (server.hasArg("lang")) { currentUiLang = getCurrentLang(); } } String tr(UiLang lang, const String& cz, const String& en) { return (lang == LANG_EN) ? en : cz; } String trCurrent(const String& cz, const String& en) { return tr(currentUiLang, cz, en); } // ===================================================== // LOG // ===================================================== String renderLogEntry(const LogEntry& e, UiLang lang) { switch (e.type) { case LOG_TEXT: return e.a; case LOG_SHT40_READ_FAILED: return tr(lang, "CHYBA: čtení SHT40 selhalo", "ERROR: SHT40 read failed"); case LOG_SHT40_NOT_FOUND: return tr(lang, "SHT40 nebyl nalezen", "SHT40 not found"); case LOG_SHT40_DETECTED: return tr(lang, "SHT40 detekován OK", "SHT40 detected OK"); case LOG_BMP280_READ_FAILED: return tr(lang, "CHYBA: čtení BMP280 selhalo", "ERROR: BMP280 read failed"); case LOG_BMP280_DETECTED_76: return tr(lang, "BMP280 detekován na adrese 0x76", "BMP280 detected at address 0x76"); case LOG_BMP280_DETECTED_77: return tr(lang, "BMP280 detekován na adrese 0x77", "BMP280 detected at address 0x77"); case LOG_BMP280_NOT_FOUND: return tr(lang, "BMP280 nebyl nalezen", "BMP280 not found"); case LOG_I2C_INIT: return tr(lang, "Inicializace I2C...", "Initializing I2C..."); case LOG_SD_NOT_INSERTED: return tr(lang, "SD karta není vložena", "SD card is not inserted"); case LOG_SD_INIT: return tr(lang, "Inicializace SD karty...", "Initializing SD card..."); case LOG_SD_BEGIN_FAILED: return tr(lang, "CHYBA: SD.begin() selhalo", "ERROR: SD.begin() failed"); case LOG_SD_NOT_FOUND: return tr(lang, "CHYBA: SD karta nenalezena", "ERROR: SD card not found"); case LOG_SD_OK: return tr(lang, "SD karta OK", "SD card OK"); case LOG_SD_SIZE_MB: return tr(lang, "Velikost karty: ", "Card size: ") + e.a + " MB"; case LOG_SD_REMOVED: return tr(lang, "SD karta byla vyjmuta", "SD card was removed"); case LOG_SD_INSERTED: return tr(lang, "SD karta byla vložena", "SD card was inserted"); case LOG_RS485_INIT: return tr(lang, "RS485 inicializováno", "RS485 initialized"); case LOG_RS485_PINS: return "TX=" + e.a + ", RX=" + e.b + ", baud=" + e.c; case LOG_ETH_STATUS_TITLE: return tr(lang, "Ethernet stav:", "Ethernet status:"); case LOG_ETH_STATUS_LINK: return " " + tr(lang, "Link: ", "Link: ") + e.a; case LOG_ETH_STATUS_IP: return " IP: " + e.a; case LOG_ETH_STATUS_MAC: return " MAC: " + e.a; case LOG_ETH_START: return "[ETH] " + tr(lang, "START", "START"); case LOG_ETH_CONNECTED: return "[ETH] " + tr(lang, "PŘIPOJENO", "CONNECTED"); case LOG_ETH_GOT_IP: return "[ETH] " + tr(lang, "ZÍSKÁNA IP: ", "GOT IP: ") + e.a; case LOG_ETH_DISCONNECTED: return "[ETH] " + tr(lang, "ODPOJENO", "DISCONNECTED"); case LOG_ETH_STOP: return "[ETH] " + tr(lang, "STOP", "STOP"); case LOG_ETH_INIT: return tr(lang, "Inicializace Ethernetu...", "Initializing Ethernet..."); case LOG_ETH_BEGIN_FAILED: return tr(lang, "CHYBA: ETH.begin() selhalo", "ERROR: ETH.begin() failed"); case LOG_ETH_OK: return tr(lang, "Ethernet OK", "Ethernet OK"); case LOG_ETH_NO_IP: return tr(lang, "Ethernet bez IP adresy", "Ethernet without IP address"); case LOG_LED_SET: return tr(lang, "LED nastavena: ", "LED set: ") + "R=" + e.a + " G=" + e.b + " B=" + e.c; case LOG_RS485_TX: return "RS485 TX: " + e.a; case LOG_SD_DELETE_OK: return tr(lang, "SD smazání OK: ", "SD delete OK: ") + e.a; case LOG_SD_DELETE_ERROR: return tr(lang, "SD smazání CHYBA: ", "SD delete ERROR: ") + e.a; case LOG_MKDIR_INVALID: return tr(lang, "MKDIR: neplatný název adresáře", "MKDIR: invalid directory name"); case LOG_MKDIR_EXISTS: return tr(lang, "MKDIR: adresář již existuje: ", "MKDIR: directory already exists: ") + e.a; case LOG_MKDIR_OK: return "MKDIR OK: " + e.a; case LOG_MKDIR_ERROR: return tr(lang, "MKDIR CHYBA: ", "MKDIR ERROR: ") + e.a; case LOG_UPLOAD_INVALID_NAME: return tr(lang, "Upload: neplatné jméno souboru", "Upload: invalid file name"); case LOG_UPLOAD_START: return tr(lang, "Upload start: ", "Upload start: ") + e.a; case LOG_UPLOAD_OPEN_WRITE_FAILED: return tr(lang, "Upload: nelze otevřít soubor pro zápis", "Upload: cannot open file for writing"); case LOG_UPLOAD_FINISHED: return tr(lang, "Upload dokončen: ", "Upload finished: ") + e.a + " B"; case LOG_UPLOAD_ABORTED: return tr(lang, "Upload přerušen", "Upload aborted"); case LOG_WEB_STARTED: return tr(lang, "Web server spuštěn na portu 80", "Web server started on port 80"); case LOG_START: return tr(lang, "START", "START"); case LOG_READY: return tr(lang, "PŘIPRAVENO", "READY"); case LOG_OPEN_BROWSER: return tr(lang, "Otevři v prohlížeči: http://", "Open in browser: http://") + e.a + "/"; case LOG_ETH_NO_IP_YET: return tr(lang, "Ethernet zatím nemá IP adresu", "Ethernet does not have an IP address yet"); case LOG_RS485_RX: return "RS485 RX: " + e.a; case LOG_HEAP_TOTAL: return tr(lang, "Heap celkem: ", "Heap total: ") + e.a + " B"; case LOG_HEAP_FREE: return tr(lang, "Heap volný: ", "Heap free: ") + e.a + " B (" + e.b + " %)"; case LOG_CPU_FREQ: return "CPU: " + e.a + " MHz"; default: return e.a; } } void pushLogEntry(uint8_t type, const String& a = "", const String& b = "", const String& c = "") { LogEntry e{type, a, b, c}; eventLog.push_back(e); const size_t maxEntries = 250; if (eventLog.size() > maxEntries) { eventLog.erase(eventLog.begin(), eventLog.begin() + (eventLog.size() - maxEntries)); } } void addLogEntry(uint8_t type, const String& a = "", const String& b = "", const String& c = "") { LogEntry e{type, a, b, c}; Serial.println(renderLogEntry(e, currentUiLang)); pushLogEntry(type, a, b, c); } void addLogText(const String& msg) { Serial.println(msg); pushLogEntry(LOG_TEXT, msg); } // ===================================================== // RESET REASON // ===================================================== void printResetReason() { esp_reset_reason_t reason = esp_reset_reason(); Serial.print("Reset reason: "); switch (reason) { case ESP_RST_UNKNOWN: Serial.println("UNKNOWN"); break; case ESP_RST_POWERON: Serial.println("POWERON"); break; case ESP_RST_EXT: Serial.println("EXTERNAL"); break; case ESP_RST_SW: Serial.println("SOFTWARE"); break; case ESP_RST_PANIC: Serial.println("PANIC"); break; case ESP_RST_INT_WDT: Serial.println("INT_WDT"); break; case ESP_RST_TASK_WDT: Serial.println("TASK_WDT"); break; case ESP_RST_WDT: Serial.println("WDT"); break; case ESP_RST_DEEPSLEEP: Serial.println("DEEPSLEEP"); break; case ESP_RST_BROWNOUT: Serial.println("BROWNOUT"); break; case ESP_RST_SDIO: Serial.println("SDIO"); break; default: Serial.println((int)reason); break; } } // ===================================================== // POMOCNÉ FUNKCE // ===================================================== String htmlEscape(const String& s) { String out; out.reserve(s.length() + 16); for (size_t i = 0; i < s.length(); i++) { char c = s[i]; switch (c) { case '&': out += F("&"); break; case '<': out += F("<"); break; case '>': out += F(">"); break; case '"': out += F("""); break; case '\'': out += F("'"); break; default: out += c; break; } } return out; } String jsonEscape(const String& s) { String out; out.reserve(s.length() + 16); for (size_t i = 0; i < s.length(); i++) { char c = s[i]; switch (c) { case '\\': out += F("\\\\"); break; case '"': out += F("\\\""); break; case '\b': out += F("\\b"); break; case '\f': out += F("\\f"); break; case '\n': out += F("\\n"); break; case '\r': out += F("\\r"); break; case '\t': out += F("\\t"); break; default: if ((uint8_t)c < 0x20) { char buf[7]; snprintf(buf, sizeof(buf), "\\u%04X", (uint8_t)c); out += buf; } else { out += c; } break; } } return out; } String urlEncode(const String& s) { String out; char hex[4]; for (size_t i = 0; i < s.length(); i++) { uint8_t c = (uint8_t)s[i]; if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') { out += (char)c; } else { snprintf(hex, sizeof(hex), "%%%02X", c); out += hex; } } return out; } String formatBytes(uint64_t bytes) { if (bytes < 1024ULL) return String((uint32_t)bytes) + " B"; if (bytes < 1024ULL * 1024ULL) return String((float)bytes / 1024.0f, 1) + " kB"; if (bytes < 1024ULL * 1024ULL * 1024ULL) return String((float)bytes / 1024.0f / 1024.0f, 1) + " MB"; return String((float)bytes / 1024.0f / 1024.0f / 1024.0f, 1) + " GB"; } String normalizePath(String path) { path.trim(); if (path.length() == 0) return "/"; if (!path.startsWith("/")) path = "/" + path; while (path.indexOf("//") >= 0) path.replace("//", "/"); return path; } String normalizeDirPath(String path) { path = normalizePath(path); if (path.length() > 1 && path.endsWith("/")) { path.remove(path.length() - 1); } return path; } bool isSafePath(String path) { if (!path.startsWith("/")) return false; if (path.indexOf("..") >= 0) return false; if (path.indexOf('\\') >= 0) return false; return true; } String parentPath(String path) { path = normalizeDirPath(path); if (path == "/") return "/"; int lastSlash = path.lastIndexOf('/'); if (lastSlash <= 0) return "/"; return path.substring(0, lastSlash); } String joinPath(String base, String name) { base = normalizeDirPath(base); if (base != "/" && base.endsWith("/")) { base.remove(base.length() - 1); } while (name.startsWith("/")) { name.remove(0, 1); } if (base == "/") return "/" + name; return base + "/" + name; } bool deleteRecursive(const String& path) { if (!SD.exists(path)) return false; File entry = SD.open(path); if (!entry) return false; bool isDir = entry.isDirectory(); entry.close(); if (!isDir) { return SD.remove(path); } File dir = SD.open(path); if (!dir || !dir.isDirectory()) { if (dir) dir.close(); return false; } File child = dir.openNextFile(); while (child) { String childPath = normalizePath(String(child.name())); bool childIsDir = child.isDirectory(); child.close(); if (childIsDir) { if (!deleteRecursive(childPath)) { dir.close(); return false; } } else { if (!SD.remove(childPath)) { dir.close(); return false; } } yield(); child = dir.openNextFile(); } dir.close(); return SD.rmdir(path); } String contentTypeFromFilename(const String& filename) { String lower = filename; lower.toLowerCase(); if (lower.endsWith(".htm") || lower.endsWith(".html")) return "text/html"; if (lower.endsWith(".css")) return "text/css"; if (lower.endsWith(".js")) return "application/javascript"; if (lower.endsWith(".txt")) return "text/plain"; if (lower.endsWith(".log")) return "text/plain"; if (lower.endsWith(".json")) return "application/json"; if (lower.endsWith(".csv")) return "text/csv"; if (lower.endsWith(".xml")) return "application/xml"; if (lower.endsWith(".pdf")) return "application/pdf"; if (lower.endsWith(".zip")) return "application/zip"; if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".png")) return "image/png"; if (lower.endsWith(".gif")) return "image/gif"; return "application/octet-stream"; } String colorToHex(uint8_t r, uint8_t g, uint8_t b) { char buf[8]; snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b); return String(buf); } float getFreeHeapPercent() { if (totalHeapBytes == 0) return 0.0f; return ((float)ESP.getFreeHeap() * 100.0f) / (float)totalHeapBytes; } void bumpSdStateVersion() { sdStateVersion++; } void setPixelColor(uint8_t r, uint8_t g, uint8_t b) { ledR = r; ledG = g; ledB = b; pixel.setBrightness(ledBrightness); pixel.setPixelColor(0, pixel.Color(r, g, b)); pixel.show(); } void redirectHome(UiLang lang) { server.sendHeader("Location", "/?lang=" + langCode(lang)); server.send(303); } void redirectToPath(const String& path, UiLang lang) { server.sendHeader( "Location", "/?path=" + urlEncode(normalizeDirPath(path)) + "&lang=" + langCode(lang) ); server.send(303); } // ===================================================== // SHT40 // ===================================================== void readSHT40() { if (!sht40Found) return; sensors_event_t humidity, temp; if (sht4.getEvent(&humidity, &temp)) { lastTemperature = temp.temperature; lastHumidity = humidity.relative_humidity; } else { addLogEntry(LOG_SHT40_READ_FAILED); } } void initSHT40() { if (!sht4.begin(&Wire)) { sht40Found = false; addLogEntry(LOG_SHT40_NOT_FOUND); return; } sht4.setPrecision(SHT4X_HIGH_PRECISION); sht4.setHeater(SHT4X_NO_HEATER); sht40Found = true; addLogEntry(LOG_SHT40_DETECTED); readSHT40(); } // ===================================================== // BMP280 // ===================================================== void readBMP280() { if (!bmp280Found) return; float t = bmp280.readTemperature(); float p = bmp280.readPressure(); if (isnan(t) || isnan(p) || p <= 0.0f) { addLogEntry(LOG_BMP280_READ_FAILED); return; } lastBmpTemperature = t; lastPressure_hPa = p / 100.0f; } void initBMP280() { bool ok = false; if (bmp280.begin(BMP280_ADDR_1)) { ok = true; addLogEntry(LOG_BMP280_DETECTED_76); } else if (bmp280.begin(BMP280_ADDR_2)) { ok = true; addLogEntry(LOG_BMP280_DETECTED_77); } if (!ok) { bmp280Found = false; addLogEntry(LOG_BMP280_NOT_FOUND); return; } bmp280.setSampling( Adafruit_BMP280::MODE_NORMAL, Adafruit_BMP280::SAMPLING_X2, Adafruit_BMP280::SAMPLING_X16, Adafruit_BMP280::FILTER_X16, Adafruit_BMP280::STANDBY_MS_500 ); bmp280Found = true; readBMP280(); } // ===================================================== // I2C senzory // ===================================================== void initI2CDevices() { addLogEntry(LOG_I2C_INIT); Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN); delay(50); initSHT40(); initBMP280(); } // ===================================================== // SD KARTA // ===================================================== bool isSdCardPresent() { pinMode(SD_CD_PIN, INPUT); return digitalRead(SD_CD_PIN) == SD_CD_ACTIVE_STATE; } void initSDCard() { sdCardInserted = isSdCardPresent(); if (!sdCardInserted) { sdOk = false; addLogEntry(LOG_SD_NOT_INSERTED); bumpSdStateVersion(); return; } addLogEntry(LOG_SD_INIT); SD.end(); sdSPI.end(); delay(10); sdSPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN); if (!SD.begin(SD_CS_PIN, sdSPI)) { sdOk = false; addLogEntry(LOG_SD_BEGIN_FAILED); bumpSdStateVersion(); return; } if (SD.cardType() == CARD_NONE) { sdOk = false; addLogEntry(LOG_SD_NOT_FOUND); SD.end(); sdSPI.end(); bumpSdStateVersion(); return; } sdOk = true; addLogEntry(LOG_SD_OK); addLogEntry(LOG_SD_SIZE_MB, String((uint32_t)(SD.cardSize() / (1024ULL * 1024ULL)))); bumpSdStateVersion(); } void handleSdHotplug() { bool raw = isSdCardPresent(); if (raw != lastSdDetectRaw) { lastSdDetectRaw = raw; lastSdDetectChangeMs = millis(); } if ((millis() - lastSdDetectChangeMs) < 150) { return; } if (raw == lastSdDetectStable) { return; } lastSdDetectStable = raw; sdCardInserted = raw; if (!raw) { if (uploadFile) { uploadFile.close(); } SD.end(); sdSPI.end(); sdOk = false; addLogEntry(LOG_SD_REMOVED); bumpSdStateVersion(); return; } addLogEntry(LOG_SD_INSERTED); delay(50); initSDCard(); } // ===================================================== // SD PANEL // ===================================================== String buildFileTable(const String& currentPath, UiLang lang) { if (!sdOk) { return String(F("

")) + tr(lang, "SD karta není dostupná.", "SD card is not available.") + F("

"); } String path = normalizeDirPath(currentPath); if (!isSafePath(path)) path = "/"; String fallbackPath = parentPath(path); File dir = SD.open(path); if (!dir || !dir.isDirectory()) { if (dir) dir.close(); String html; html += F("

"); html += tr(lang, "Chyba:", "Error:"); html += F(" "); html += tr(lang, "Nelze otevřít adresář.", "Cannot open directory."); html += F("

"); html += F("

"); html += tr(lang, "Požadovaná cesta:", "Requested path:"); html += F(" "); html += htmlEscape(path); html += F("

"); html += F("

↩️

"); return html; } String html; html += F("

"); html += tr(lang, "Aktuální adresář:", "Current directory:"); html += F(" "); html += htmlEscape(path); html += F("

"); html += F("
"); if (path != "/") { html += F("

↩️

"); } html += F(""); File file = dir.openNextFile(); bool any = false; while (file) { any = true; String childName = String(file.name()); int slashPos = childName.lastIndexOf('/'); if (slashPos >= 0) { childName = childName.substring(slashPos + 1); } String entryPath = joinPath(path, childName); String displayName = childName; if (displayName.startsWith(path) && path != "/") { displayName = displayName.substring(path.length()); if (!displayName.startsWith("/")) displayName = "/" + displayName; } if (displayName.startsWith("/")) displayName.remove(0, 1); if (displayName.length() == 0) displayName = "(bez názvu)"; bool isDir = file.isDirectory(); uint64_t size = isDir ? 0 : file.size(); String entryEsc = htmlEscape(displayName); String entryUrl = urlEncode(entryPath); String currentUrl = urlEncode(path); html += F(""); yield(); file = dir.openNextFile(); } if (!any) { html += F(""); } html += F("
"); html += tr(lang, "Typ", "Type"); html += F(""); html += tr(lang, "Název", "Name"); html += F(""); html += tr(lang, "Velikost", "Size"); html += F(""); html += tr(lang, "Akce", "Actions"); html += F("
"); html += isDir ? "📁" : "📄"; html += F(""); if (isDir) { html += F(""); html += entryEsc; html += F(""); } else { html += entryEsc; } html += F(""); html += isDir ? "-" : formatBytes(size); html += F(""); if (!isDir) { html += F("⬇️ | "); } html += F("🗑️"); html += F("
"); html += tr(lang, "Adresář je prázdný", "Directory is empty"); html += F("
"); dir.close(); return html; } String buildSdPanel(const String& currentPath, UiLang lang) { String html; html.reserve(18000); html += F("

"); html += tr(lang, "Správa souborů na SD", "SD file manager"); html += F("

"); if (sdOk) { html += F("
"); html += F("

"); html += tr(lang, "Nahrávání souborů", "File upload"); html += F("

" "
" "" "" "" "" "
"); html += F("

"); html += tr(lang, "Vytvořit adresář", "Create directory"); html += F("

" "
" "" "" "" "" "" "
"); html += F("
"); } else { html += F("

"); html += tr(lang, "SD karta není dostupná.", "SD card is not available."); html += F("

"); } html += buildFileTable(currentPath, lang); return html; } String buildSdStateJson() { String json = "{"; json += "\"inserted\":"; json += (sdCardInserted ? "true" : "false"); json += ",\"ok\":"; json += (sdOk ? "true" : "false"); json += ",\"version\":"; json += String(sdStateVersion); json += "}"; return json; } // ===================================================== // RS485 // ===================================================== bool isSupportedRs485Baud(uint32_t baud) { switch (baud) { case 1200: case 2400: case 4800: case 9600: case 19200: case 38400: case 57600: case 115200: return true; default: return false; } } void applyRS485Baudrate(uint32_t baud) { if (!isSupportedRs485Baud(baud)) return; if (rs485Baudrate == baud) return; Serial2.flush(); Serial2.end(); rs485Baudrate = baud; Serial2.begin(rs485Baudrate, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); delay(50); addLogText("RS485 baud changed to " + String(rs485Baudrate)); } void initRS485() { Serial2.begin(rs485Baudrate, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); delay(100); addLogEntry(LOG_RS485_INIT); addLogEntry(LOG_RS485_PINS, String(RS485_TX_PIN), String(RS485_RX_PIN), String(rs485Baudrate)); } // ===================================================== // ETHERNET // ===================================================== void printETHStatus() { addLogEntry(LOG_ETH_STATUS_TITLE); addLogEntry(LOG_ETH_STATUS_LINK, ethConnected ? "UP" : "DOWN"); addLogEntry(LOG_ETH_STATUS_IP, ethGotIP ? ETH.localIP().toString() : trCurrent("není", "none")); if (ethConnected || ethGotIP) { addLogEntry(LOG_ETH_STATUS_MAC, ETH.macAddress()); } } void WiFiEvent(WiFiEvent_t event) { switch (event) { case ARDUINO_EVENT_ETH_START: addLogEntry(LOG_ETH_START); ETH.setHostname("esp32-control"); break; case ARDUINO_EVENT_ETH_CONNECTED: addLogEntry(LOG_ETH_CONNECTED); ethConnected = true; break; case ARDUINO_EVENT_ETH_GOT_IP: addLogEntry(LOG_ETH_GOT_IP, ETH.localIP().toString()); ethGotIP = true; printETHStatus(); break; case ARDUINO_EVENT_ETH_DISCONNECTED: addLogEntry(LOG_ETH_DISCONNECTED); ethConnected = false; ethGotIP = false; break; case ARDUINO_EVENT_ETH_STOP: addLogEntry(LOG_ETH_STOP); ethConnected = false; ethGotIP = false; break; default: break; } } void initEthernet() { addLogEntry(LOG_ETH_INIT); WiFi.onEvent(WiFiEvent); bool ok = ETH.begin( ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_MDC, ETH_PHY_MDIO, ETH_PHY_POWER, ETH_CLK_MODE ); if (!ok) { addLogEntry(LOG_ETH_BEGIN_FAILED); return; } unsigned long start = millis(); while (millis() - start < 15000) { if (ethGotIP) { addLogEntry(LOG_ETH_OK); return; } delay(100); } addLogEntry(LOG_ETH_NO_IP); } // ===================================================== // HTML // ===================================================== String buildStatusPanel(UiLang lang) { String ip = ethGotIP ? ETH.localIP().toString() : tr(lang, "není", "none"); String mac = (ethConnected || ethGotIP) ? ETH.macAddress() : "-"; String temp = (sht40Found && !isnan(lastTemperature)) ? String(lastTemperature, 2) + " °C" : tr(lang, "není", "none"); String hum = (sht40Found && !isnan(lastHumidity)) ? String(lastHumidity, 2) + " %RH" : tr(lang, "není", "none"); String bmpTemp = (bmp280Found && !isnan(lastBmpTemperature)) ? String(lastBmpTemperature, 2) + " °C" : tr(lang, "není", "none"); String pressure = (bmp280Found && !isnan(lastPressure_hPa)) ? String(lastPressure_hPa, 2) + " hPa" : tr(lang, "není", "none"); String html; html.reserve(3000); html += F("

"); html += tr(lang, "Stav systému", "System status"); html += F("

"); html += F("

"); html += tr(lang, "Ethernet link:", "Ethernet link:"); html += F(" "); html += (ethConnected ? "UP" : "DOWN"); html += F("

IP: "); html += ip; html += F("

MAC: "); html += mac; html += F("

"); html += tr(lang, "SD karta:", "SD card:"); html += F(" "); html += (sdOk ? "OK" : "" + tr(lang, "CHYBA", "ERROR") + ""); html += F("

"); html += tr(lang, "SD detect:", "SD detect:"); html += F(" "); html += (sdCardInserted ? "" + tr(lang, "vložena", "inserted") + "" : "" + tr(lang, "není vložena", "not inserted") + ""); html += F("

SHT40: "); html += (sht40Found ? "OK" : "" + tr(lang, "není nalezen", "not found") + ""); html += F("

"); html += tr(lang, "SHT40 teplota:", "SHT40 temperature:"); html += F(" "); html += temp; html += F("

"); html += tr(lang, "Vlhkost:", "Humidity:"); html += F(" "); html += hum; html += F("

BMP280: "); html += (bmp280Found ? "OK" : "" + tr(lang, "není nalezen", "not found") + ""); html += F("

"); html += tr(lang, "BMP280 teplota:", "BMP280 temperature:"); html += F(" "); html += bmpTemp; html += F("

"); html += tr(lang, "Tlak:", "Pressure:"); html += F(" "); html += pressure; html += F("

FreeHeap: "); html += String(ESP.getFreeHeap()); html += F(" B ("); html += String(getFreeHeapPercent(), 1); html += F(" %)

"); html += tr(lang, "Heap celkem:", "Total heap:"); html += F(" "); html += String(totalHeapBytes); html += F(" B

"); html += F("

"); html += tr(lang, "Obnovit celou stránku", "Reload full page"); html += F("

"); return html; } String buildNeoPixelPanel(UiLang lang) { String html; html.reserve(7500); html += F("

NeoPixel

"); html += F("
"); html += F(""); html += F(""); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F(""); html += F("
"); html += F("
"); html += F("
"); html += F(""); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += tr(lang, "Aktuální barva:", "Current color:"); html += F(" R="); html += String(ledR); html += F(" G="); html += String(ledG); html += F(" B="); html += String(ledB); html += F("
"); html += F(""); html += F("
"); html += F("
" "" "" "" "" "" "
"); return html; } String buildRS485Panel(UiLang lang) { String html; html.reserve(3600); html += F("

RS485

" "
" "" "" ""); html += F("
"); html += F("
"); html += F("
" "
" "

"); html += tr(lang, "Poslední odesláno:", "Last sent:"); html += F("

"); html += htmlEscape(rs485LastSent); html += F("

"); html += tr(lang, "Poslední přijato:", "Last received:"); html += F("

"); html += htmlEscape(rs485LastReceived); html += F("
"); return html; } String buildRS485RxJson() { String json = "{"; json += "\"version\":"; json += String(rs485RxVersion); json += ",\"received\":\""; json += jsonEscape(rs485LastReceived); json += "\"}"; return json; } String buildLogPanel(UiLang lang) { String html; html.reserve(12000); html += F("

"); html += tr(lang, "Log", "Log"); html += F("

"); for (size_t i = 0; i < eventLog.size(); i++) { html += htmlEscape(renderLogEntry(eventLog[i], lang)); if (i + 1 < eventLog.size()) html += '\n'; } html += F("
"); return html; } String buildMainPage(const String& currentPath, UiLang lang) { String html; html.reserve(52000); html += F( "" "" "ESPLan v2.x Control Panel" "
" ); html += F("
"); html += F("
" "" "

ESPLan v2.x Control Panel

" "
"); html += F("
"); html += F(""); html += F(""); html += F("
"); html += F("
"); html += F("
"); html += F("
"); html += buildStatusPanel(lang); html += F("
"); html += F("
"); html += buildNeoPixelPanel(lang); html += F("
"); html += F("
"); html += buildRS485Panel(lang); html += F("
"); html += F("
"); html += buildSdPanel(currentPath, lang); html += F("
"); html += F("
"); html += buildLogPanel(lang); html += F("
"); html += F("
"); html += F(""); html += F("
"); return html; } // ===================================================== // HTTP HANDLERY // ===================================================== void handleRoot() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; String path = normalizeDirPath(server.arg("path")); if (!isSafePath(path)) path = "/"; server.send(200, "text/html; charset=utf-8", buildMainPage(path, lang)); } void handleStatus() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; server.send(200, "text/html; charset=utf-8", buildStatusPanel(lang)); } void handleRS485Panel() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; server.send(200, "text/html; charset=utf-8", buildRS485Panel(lang)); } void handleRS485Rx() { updateCurrentLangFromRequest(); server.send(200, "application/json; charset=utf-8", buildRS485RxJson()); } void handleLogPanel() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; server.send(200, "text/html; charset=utf-8", buildLogPanel(lang)); } void handleSdPanel() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; String path = normalizeDirPath(server.arg("path")); if (!isSafePath(path)) path = "/"; server.send(200, "text/html; charset=utf-8", buildSdPanel(path, lang)); } void handleSdState() { updateCurrentLangFromRequest(); server.send(200, "application/json; charset=utf-8", buildSdStateJson()); } void handleLed() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; int r = constrain(server.arg("r").toInt(), 0, 255); int g = constrain(server.arg("g").toInt(), 0, 255); int b = constrain(server.arg("b").toInt(), 0, 255); int brightness = constrain(server.arg("brightness").toInt(), 0, 255); ledBrightness = brightness; setPixelColor(r, g, b); addLogEntry(LOG_LED_SET, String(r), String(g), String(b)); redirectHome(lang); } void handleRS485Send() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; uint32_t requestedBaud = (uint32_t)server.arg("baud").toInt(); if (isSupportedRs485Baud(requestedBaud) && requestedBaud != rs485Baudrate) { applyRS485Baudrate(requestedBaud); } String msg = server.arg("msg"); msg.replace("\r", ""); if (msg.length() == 0) { redirectHome(lang); return; } Serial2.println(msg); Serial2.flush(); rs485LastSent = msg; addLogEntry(LOG_RS485_TX, msg); redirectHome(lang); } void handleDownload() { updateCurrentLangFromRequest(); if (!sdOk) { server.send(500, "text/plain", trCurrent("SD karta není dostupná", "SD card is not available")); return; } String name = normalizePath(server.arg("name")); if (!isSafePath(name)) { server.send(400, "text/plain", trCurrent("Neplatná cesta", "Invalid path")); return; } if (!SD.exists(name)) { server.send(404, "text/plain", trCurrent("Soubor neexistuje", "File does not exist")); return; } File file = SD.open(name, FILE_READ); if (!file) { server.send(500, "text/plain", trCurrent("Nelze otevřít soubor", "Cannot open file")); return; } if (file.isDirectory()) { file.close(); server.send(400, "text/plain", trCurrent("Adresář nelze stáhnout", "Directory cannot be downloaded")); return; } String downloadName = String(file.name()); downloadName = normalizePath(downloadName); if (downloadName.startsWith("/")) { downloadName = downloadName.substring(1); } server.sendHeader("Content-Disposition", "attachment; filename=\"" + downloadName + "\""); server.streamFile(file, contentTypeFromFilename(name)); file.close(); } void handleDelete() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; if (!sdOk) { server.send(500, "text/plain", trCurrent("SD karta není dostupná", "SD card is not available")); return; } String name = normalizePath(server.arg("name")); String currentPath = normalizeDirPath(server.arg("path")); if (!isSafePath(name) || !isSafePath(currentPath)) { server.send(400, "text/plain", trCurrent("Neplatná cesta", "Invalid path")); return; } if (!SD.exists(name)) { server.send(404, "text/plain", trCurrent("Soubor nebo adresář neexistuje", "File or directory does not exist")); return; } File entry = SD.open(name); if (!entry) { server.send(500, "text/plain", trCurrent("Nelze otevřít položku", "Cannot open item")); return; } bool isDir = entry.isDirectory(); entry.close(); bool ok = false; if (isDir) { ok = deleteRecursive(name); } else { ok = SD.remove(name); } if (ok) { addLogEntry(LOG_SD_DELETE_OK, name); } else { addLogEntry(LOG_SD_DELETE_ERROR, name); } bumpSdStateVersion(); redirectToPath(currentPath, lang); } void handleMkdir() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; if (!sdOk) { server.send(500, "text/plain", trCurrent("SD karta není dostupná", "SD card is not available")); return; } String currentPath = normalizeDirPath(server.arg("path")); String name = server.arg("name"); name.trim(); if (!isSafePath(currentPath)) currentPath = "/"; if (name.length() == 0) { redirectToPath(currentPath, lang); return; } name.replace("\\", "/"); while (name.startsWith("/")) name.remove(0, 1); while (name.endsWith("/")) name.remove(name.length() - 1); if (name.length() == 0 || name.indexOf("..") >= 0 || name.indexOf('/') >= 0) { addLogEntry(LOG_MKDIR_INVALID); redirectToPath(currentPath, lang); return; } String newDir = joinPath(currentPath, name); if (SD.exists(newDir)) { addLogEntry(LOG_MKDIR_EXISTS, newDir); redirectToPath(currentPath, lang); return; } if (SD.mkdir(newDir)) { addLogEntry(LOG_MKDIR_OK, newDir); } else { addLogEntry(LOG_MKDIR_ERROR, newDir); } bumpSdStateVersion(); redirectToPath(currentPath, lang); } void handleUploadFinished() { updateCurrentLangFromRequest(); UiLang lang = currentUiLang; String path = normalizeDirPath(server.arg("path")); if (!isSafePath(path)) path = "/"; bumpSdStateVersion(); redirectToPath(path, lang); } void handleFileUpload() { if (!sdOk) return; HTTPUpload& upload = server.upload(); static String uploadTargetPath = "/"; if (upload.status == UPLOAD_FILE_START) { updateCurrentLangFromRequest(); uploadTargetPath = normalizeDirPath(server.arg("path")); if (!isSafePath(uploadTargetPath)) uploadTargetPath = "/"; String cleanName = upload.filename; cleanName.replace("\\", "/"); int slashPos = cleanName.lastIndexOf('/'); if (slashPos >= 0) cleanName = cleanName.substring(slashPos + 1); String filename = joinPath(uploadTargetPath, cleanName); if (!isSafePath(filename)) { addLogEntry(LOG_UPLOAD_INVALID_NAME); return; } addLogEntry(LOG_UPLOAD_START, filename); if (SD.exists(filename)) { SD.remove(filename); } uploadFile = SD.open(filename, FILE_WRITE); if (!uploadFile) { addLogEntry(LOG_UPLOAD_OPEN_WRITE_FAILED); } } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile) { uploadFile.write(upload.buf, upload.currentSize); yield(); } } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { uploadFile.close(); addLogEntry(LOG_UPLOAD_FINISHED, String(upload.totalSize)); } } else if (upload.status == UPLOAD_FILE_ABORTED) { if (uploadFile) { uploadFile.close(); } addLogEntry(LOG_UPLOAD_ABORTED); } } void handleLogo() { server.sendHeader("Cache-Control", "public, max-age=86400"); server.send_P(200, "image/png", (PGM_P)logo_web_png, logo_web_png_len); } void handleNotFound() { updateCurrentLangFromRequest(); server.send(404, "text/plain", trCurrent("404 Nenalezeno", "404 Not Found")); } void setupWebServer() { server.on("/", HTTP_GET, handleRoot); server.on("/status", HTTP_GET, handleStatus); server.on("/rs485panel", HTTP_GET, handleRS485Panel); server.on("/rs485rx", HTTP_GET, handleRS485Rx); server.on("/logpanel", HTTP_GET, handleLogPanel); server.on("/sdpanel", HTTP_GET, handleSdPanel); server.on("/sdstate", HTTP_GET, handleSdState); server.on("/logo.png", HTTP_GET, handleLogo); server.on("/led", HTTP_GET, handleLed); server.on("/rs485", HTTP_POST, handleRS485Send); server.on("/download", HTTP_GET, handleDownload); server.on("/delete", HTTP_GET, handleDelete); server.on("/mkdir", HTTP_GET, handleMkdir); server.on("/upload", HTTP_POST, handleUploadFinished, handleFileUpload); server.onNotFound(handleNotFound); server.begin(); addLogEntry(LOG_WEB_STARTED); } // ===================================================== // SETUP / LOOP // ===================================================== void setup() { Serial.begin(115200); delay(500); printResetReason(); delay(500); addLogText(""); addLogText("======================================"); addLogEntry(LOG_START); addLogText("======================================"); pixel.begin(); pixel.clear(); pixel.show(); pixel.setBrightness(ledBrightness); setPixelColor(0, 255, 0); totalHeapBytes = ESP.getHeapSize(); addLogEntry(LOG_CPU_FREQ, String(ESP.getCpuFreqMHz())); addLogEntry(LOG_HEAP_TOTAL, String(totalHeapBytes)); addLogEntry(LOG_HEAP_FREE, String(ESP.getFreeHeap()), String(getFreeHeapPercent(), 1)); initI2CDevices(); lastSdDetectRaw = isSdCardPresent(); lastSdDetectStable = lastSdDetectRaw; lastSdDetectChangeMs = millis(); initSDCard(); initRS485(); initEthernet(); setupWebServer(); addLogEntry(LOG_READY); if (ethGotIP) { addLogEntry(LOG_OPEN_BROWSER, ETH.localIP().toString()); } else { addLogEntry(LOG_ETH_NO_IP_YET); } } void loop() { server.handleClient(); handleSdHotplug(); bool rs485RxChanged = false; static String rs485LineBuffer = ""; while (Serial2.available()) { char c = (char)Serial2.read(); Serial.write(c); rs485LastReceived += c; rs485LineBuffer += c; rs485RxChanged = true; if (rs485LastReceived.length() > 2000) { rs485LastReceived.remove(0, rs485LastReceived.length() - 2000); } if (c == '\n') { String line = rs485LineBuffer; line.trim(); if (line.length() > 0) { addLogEntry(LOG_RS485_RX, line); } rs485LineBuffer = ""; } if ((rs485LastReceived.length() & 0xFF) == 0) { yield(); } } if (rs485RxChanged) { rs485RxVersion++; } if (millis() - lastSensorRead > 10000) { lastSensorRead = millis(); if (sht40Found) { readSHT40(); } if (bmp280Found) { readBMP280(); } } if (millis() - lastStatusPrint > 5000) { lastStatusPrint = millis(); Serial.println(); Serial.println(F("----- STATUS -----")); Serial.printf("ETH link: %s\n", ethConnected ? "UP" : "DOWN"); Serial.printf("ETH IP: %s\n", ethGotIP ? ETH.localIP().toString().c_str() : "none"); Serial.printf("SD: %s\n", sdOk ? "OK" : "ERROR"); Serial.printf("SD detect: %s\n", sdCardInserted ? "inserted" : "not inserted"); Serial.printf("SD version: %lu\n", (unsigned long)sdStateVersion); Serial.printf("SHT40: %s\n", sht40Found ? "OK" : "not found"); if (sht40Found && !isnan(lastTemperature) && !isnan(lastHumidity)) { Serial.printf("SHT40 -> T=%.2f C, RH=%.2f %%\n", lastTemperature, lastHumidity); } Serial.printf("BMP280: %s\n", bmp280Found ? "OK" : "not found"); if (bmp280Found && !isnan(lastBmpTemperature) && !isnan(lastPressure_hPa)) { Serial.printf("BMP280 -> T=%.2f C, P=%.2f hPa\n", lastBmpTemperature, lastPressure_hPa); } Serial.printf("RS485 baud: %lu\n", (unsigned long)rs485Baudrate); Serial.printf("LED -> R=%u G=%u B=%u\n", ledR, ledG, ledB); Serial.printf("FreeHeap: %u B (%.1f %%)\n", ESP.getFreeHeap(), getFreeHeapPercent()); Serial.printf("Heap total: %u B\n", totalHeapBytes); Serial.println(F("------------------")); } delay(1); }