diff --git a/data/logo.png b/data/logo.png new file mode 100644 index 0000000..5d18f7d Binary files /dev/null and b/data/logo.png differ diff --git a/platformio.ini b/platformio.ini index 5516710..e8f3ba1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,6 +13,7 @@ board = esp32dev framework = arduino platform = espressif32 monitor_speed = 115200 +board_build.filesystem = littlefs lib_deps = tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.4.3 diff --git a/src/main.cpp b/src/main.cpp index bd9ec28..7ad2f38 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -326,6 +326,13 @@ static bool requireAuth() { // Web handlers // --------------------------------------------------------------------------- +static void handleLogo() { + File f = LittleFS.open("/logo.png", "r"); + if (!f) { server.send(404, "text/plain", "Not found"); return; } + server.streamFile(f, "image/png"); + f.close(); +} + static void handleRoot() { if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; } Serial.println("[HTTP] GET /"); @@ -376,7 +383,8 @@ static void handleRoot() { html += "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}"; html += "body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Oxygen,Ubuntu,sans-serif;background:#f2f4f8;color:#1a1a2e;display:flex;justify-content:center;align-items:flex-start;min-height:100vh;padding:24px 16px}"; html += ".card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:560px}"; - html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px;margin-bottom:24px}"; + html += ".hdr{display:flex;align-items:center;gap:16px;margin-bottom:24px}.hdr img{width:72px;height:72px;flex-shrink:0}"; + html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px}"; html += ".info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}"; html += ".info div{display:flex;justify-content:space-between;padding:4px 0}.info div span:first-child{color:#666}"; html += "label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}"; @@ -388,30 +396,44 @@ static void handleRoot() { html += "fieldset .row label{width:70px;margin-bottom:0;flex-shrink:0}"; html += "fieldset .row input{margin-bottom:0}"; html += ".btn{width:100%;margin-top:8px;padding:12px;background:#4361ee;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}"; - html += ".btn:hover{background:#3651d4}.mac{font-size:12px;color:#999;text-align:center;margin-top:20px}"; + html += ".btn:hover{background:#3651d4}.btn-danger{background:#dc2626}.btn-danger:hover{background:#b91c1c}"; + html += ".mac{font-size:12px;color:#999;text-align:center;margin-top:20px}"; html += ".tgl-lbl{margin-bottom:0!important;line-height:18px}.switch{position:relative;display:inline-block;width:32px;height:18px;margin-left:auto;flex-shrink:0}fieldset .row label.switch{width:32px}"; html += ".switch input{opacity:0;width:0;height:0;margin-bottom:0}.slid{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:18px;transition:.3s}"; html += ".slid::before{content:\"\";position:absolute;height:14px;width:14px;left:2px;top:2px;background:#fff;border-radius:50%;transition:.3s;box-shadow:0 1px 3px rgba(0,0,0,.2)}"; html += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.slid::before{transform:translateX(14px)}"; html += "
"; - html += "

Rotary Dial

Configuration
"; + html += "
logo

Rotary Dial

Configuration
"; html += "
Hostname" + String(hostname) + "
"; html += "
IP" + WiFi.localIP().toString() + "
"; html += "
"; - html += ""; + html += ""; html += authSection; html += mqttSection; html += "
"; + html += "
"; + html += "
"; html += "
" + WiFi.macAddress() + "
"; html += ""; server.send(200, "text/html", html); Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length()); } +static void handleReset() { + if (!requireAuth()) { Serial.println("[HTTP] POST /reset -> 401"); return; } + Serial.println("[HTTP] POST /reset -> restarting"); + server.send(200, "text/plain", "Restarting..."); + delay(200); + ESP.restart(); +} + static void handleConfig() { if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; } Serial.println("[HTTP] POST /config"); + char oldHostname[64]; + strlcpy(oldHostname, hostname, sizeof(oldHostname)); + if (server.hasArg("hostname")) { String val = server.arg("hostname"); val.trim(); @@ -419,6 +441,8 @@ static void handleConfig() { val.toCharArray(hostname, sizeof(hostname)); } + bool hostnameChanged = strcmp(hostname, oldHostname) != 0; + // Persist connection fields regardless of enable checkbox so edits made while // disabling MQTT aren't discarded. if (server.hasArg("mqtt_host")) @@ -454,24 +478,32 @@ static void handleConfig() { server.sendHeader("Location", "/", true); server.send(303); + + if (hostnameChanged) { + Serial.printf("[HTTP] hostname changed %s -> %s, restarting\n", oldHostname, hostname); + delay(200); + ESP.restart(); + } } static void startServer() { server.on("/", handleRoot); + server.on("/logo.png", handleLogo); server.on("/config", HTTP_POST, handleConfig); + server.on("/reset", HTTP_POST, handleReset); server.begin(); Serial.println("HTTP server started"); } // --------------------------------------------------------------------------- -// Factory reset (rapid reset 5x within a few seconds) +// Factory reset (5x = config only, 10x = config + WiFi credentials) // --------------------------------------------------------------------------- -static void factoryReset() { - Serial.println("[BOOT] factory reset triggered!"); +static void factoryReset(bool clearWifi) { + Serial.printf("[BOOT] factory reset triggered (clearWifi=%d)!\n", clearWifi); strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname)); - hostnameParam.setValue(hostname, strlen(hostname)); + hostnameParam.setValue(hostname, sizeof(hostname) - 1); authCfg.enabled = false; authCfg.pass[0] = '\0'; @@ -485,31 +517,35 @@ static void factoryReset() { saveConfig(); + if (clearWifi) { + WiFiManager wm; + wm.resetSettings(); + Serial.println("[BOOT] WiFi credentials erased"); + } + Serial.println("[BOOT] factory reset complete, restarting..."); delay(200); ESP.restart(); } -static void checkFactoryReset() { - const int THRESHOLD = 5; +static int incrementResetCount() { int count = 0; File f = LittleFS.open("/reset_count", "r"); - if (f) { - count = f.parseInt(); - f.close(); - } + if (f) { count = f.parseInt(); f.close(); } count++; - if (count >= THRESHOLD) { - LittleFS.remove("/reset_count"); - Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count); - factoryReset(); - return; - } f = LittleFS.open("/reset_count", "w"); - if (f) { - f.print(count); - f.close(); - Serial.printf("[BOOT] reset count %d/%d\n", count, THRESHOLD); + if (f) { f.print(count); f.close(); } + Serial.printf("[BOOT] reset count %d\n", count); + return count; +} + +static void applyFactoryResetIfNeeded(int count) { + const int THRESHOLD_CONFIG = 5; + const int THRESHOLD_WIFI = 10; + LittleFS.remove("/reset_count"); + if (count >= THRESHOLD_CONFIG) { + Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count); + factoryReset(count >= THRESHOLD_WIFI); } } @@ -520,18 +556,26 @@ static void checkFactoryReset() { void setup() { Serial.begin(115200); Serial.println("\n\n=== Rotary Dial boot ==="); - LittleFS.begin(true); + if (!LittleFS.begin(false)) { + Serial.println("[FS] mount failed, formatting..."); + LittleFS.begin(true); + } else { + Serial.println("[FS] mounted OK"); + File root = LittleFS.open("/"); + File f = root.openNextFile(); + while (f) { Serial.printf("[FS] %s (%u bytes)\n", f.name(), f.size()); f = root.openNextFile(); } + } loadConfig(); - hostnameParam.setValue(hostname, strlen(hostname)); + hostnameParam.setValue(hostname, sizeof(hostname) - 1); - checkFactoryReset(); + int resetCount = incrementResetCount(); - // Reset-counter settle window: if the device stays powered past this point it - // wasn't a rapid power-cycle, so clear the counter before the (possibly - // indefinitely blocking) WiFi portal. + // Settle window: if the device stays powered past this point it wasn't a + // rapid power-cycle. After the delay, act on the accumulated count (5x = + // config reset, 10x = config + WiFi reset) then clear the counter. delay(3000); - LittleFS.remove("/reset_count"); + applyFactoryResetIfNeeded(resetCount); pinMode(DIAL_PIN, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(DIAL_PIN), onDialPulse, RISING);