diff --git a/src/DirectoryNode.cpp b/src/DirectoryNode.cpp index 2ab500f..c8817c4 100644 --- a/src/DirectoryNode.cpp +++ b/src/DirectoryNode.cpp @@ -234,9 +234,24 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath) mp3Files.reserve(fileNames.size()); ids.reserve(fileNames.size()); - // Create subdirectories in alphabetical order - for (const String &dirName : dirNames) + // Add MP3 files in alphabetical order first to free fileNames memory before recursing + for (const String &fileName : fileNames) { + mp3Files.push_back(fileName); + ids.push_back(getNextId()); + } + // Free memory used by fileNames vector + { + std::vector empty; + fileNames.swap(empty); + } + + // Create subdirectories in alphabetical order + // Use index loop and std::move to free strings in dirNames as we go, reducing stack memory usage during recursion + for (size_t i = 0; i < dirNames.size(); ++i) + { + String dirName = std::move(dirNames[i]); // Move string content out of vector + DirectoryNode *newNode = new DirectoryNode(dirName); if (!newNode) { @@ -261,13 +276,6 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath) newNode->buildDirectoryTree(childPath.c_str()); } - - // Add MP3 files in alphabetical order (store only filenames; build full paths on demand) - for (const String &fileName : fileNames) - { - mp3Files.push_back(fileName); - ids.push_back(getNextId()); - } } void DirectoryNode::printDirectoryTree(int level) const @@ -648,50 +656,3 @@ DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal) Serial.println(F("no more nodes found")); return this; } - -/** - * @brief Not used anymore due to new - * backpressure-safe, low-heap HTML streaming solution to prevent AsyncTCP cbuf resize OOM during /directory. - * - * @param out - */ -void DirectoryNode::streamDirectoryHTML(Print &out) const { -#ifdef DEBUG - Serial.printf("StreamDirectoryHTML name=%s numOfFiles=%i\n", name, mp3Files.size()); -#endif - - if (name == "/") { - out.println(F("")); - yield(); // Final yield before completing - } -} diff --git a/src/DirectoryNode.h b/src/DirectoryNode.h index 362ce9a..4242347 100644 --- a/src/DirectoryNode.h +++ b/src/DirectoryNode.h @@ -66,7 +66,6 @@ public: void buildFlatMP3List(std::vector>& allMP3s); DirectoryNode* advanceToMP3(const uint16_t id); void advanceToFirstMP3InThisNode(); - void streamDirectoryHTML(Print &out) const; DirectoryNode* findFirstDirectoryWithMP3s(); }; diff --git a/src/DirectoryWalker.h b/src/DirectoryWalker.h new file mode 100644 index 0000000..434c998 --- /dev/null +++ b/src/DirectoryWalker.h @@ -0,0 +1,115 @@ +#ifndef DIRECTORY_WALKER_H +#define DIRECTORY_WALKER_H + +#include +#include +#include "DirectoryNode.h" + +struct WalkerState { + const DirectoryNode* node; + uint8_t phase; // 0: Start, 1: Files, 2: Subdirs, 3: End + size_t idx; // Index for vectors + + WalkerState(const DirectoryNode* n) : node(n), phase(0), idx(0) {} +}; + +class DirectoryWalker { +private: + std::vector stack; + String pending; + size_t pendingOffset; + + void generateNext() { + if (stack.empty()) return; + + WalkerState& state = stack.back(); + const DirectoryNode* node = state.node; + + switch (state.phase) { + case 0: // Start + if (node->getName() == "/") { + pending += F("
    \r\n"); + } else { + pending += F("
  • getId()); + pending += F("\">"); + pending += node->getName(); + pending += F("
  • \r\n"); + } + state.phase = 1; + state.idx = 0; + break; + + case 1: // Files + if (state.idx < node->getMP3Files().size()) { + pending += F("
  • getFileIdAt(state.idx)); + pending += F("\">"); + pending += node->getMP3Files()[state.idx]; + pending += F("
  • \r\n"); + state.idx++; + } else { + state.phase = 2; + state.idx = 0; + } + break; + + case 2: // Subdirs + if (state.idx < node->getSubdirectories().size()) { + // Push child + const DirectoryNode* child = node->getSubdirectories()[state.idx]; + state.idx++; // Advance index for when we return + stack.emplace_back(child); + // Next loop will process the child (Phase 0) + } else { + state.phase = 3; + } + break; + + case 3: // End + if (node->getName() == "/") { + pending += F("
\r\n"); + } + stack.pop_back(); + break; + } + } + +public: + DirectoryWalker(const DirectoryNode* root) : pendingOffset(0) { + if (root) { + stack.emplace_back(root); + // Reserve some space for pending string to avoid frequent reallocations + pending.reserve(256); + } + } + + size_t read(uint8_t* buffer, size_t maxLen) { + size_t written = 0; + + while (written < maxLen) { + // If pending buffer is empty or fully consumed, generate more + if (pending.length() == 0 || pendingOffset >= pending.length()) { + pending = ""; // Reset string content (capacity is kept) + pendingOffset = 0; + + if (stack.empty()) { + break; // Done + } + generateNext(); + } + + // Copy from pending to output buffer + if (pending.length() > pendingOffset) { + size_t available = pending.length() - pendingOffset; + size_t toCopy = std::min(available, maxLen - written); + memcpy(buffer + written, pending.c_str() + pendingOffset, toCopy); + written += toCopy; + pendingOffset += toCopy; + } + } + return written; + } +}; + +#endif diff --git a/src/main.cpp b/src/main.cpp index 8ab8a60..f44a12c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,8 +21,10 @@ #include "globals.h" #include "DirectoryNode.h" +#include "DirectoryWalker.h" #include "config.h" #include "main.h" +#include // webrequest_blockings is a simple watchdog counter that tracks how long at least one HTTP request has been “active” (not yet disconnected) according to the AsyncWebServer. @@ -1306,22 +1308,19 @@ void init_webserver() { server.on("/directory", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); + // Use shared_ptr to manage Walker lifecycle, ensuring it persists as long as the response needs it + // and is automatically deleted when the response is finished/destroyed. + std::shared_ptr walker = std::make_shared(&rootNode); + request->onDisconnect([](){ webreq_exit(); }); #ifdef DEBUG Serial.printf("Serving /directory heap=%u webreq_cnt=%u numOfFiles=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt, rootNode.getNumOfFiles()); #endif - // True chunked response: re-generate output deterministically and skip 'index' bytes each call + // True chunked response using stateful walker AsyncWebServerResponse *response = request->beginChunkedResponse( txt_html_charset, - [](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { - ChunkedSkipBufferPrint sink(buffer, maxLen, index); - // Generate HTML directly into the sink (no large intermediate buffers) - rootNode.streamDirectoryHTML(sink); - // finished? - if (index >= sink.totalProduced()) { - return 0; - } - return sink.bytesWritten(); + [walker](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { + return walker->read(buffer, maxLen); } ); // Optional headers: @@ -1436,6 +1435,14 @@ void init_webserver() { server.on("/move_file", HTTP_GET, handleMoveFile); server.on("/delete_file", HTTP_GET, handleDeleteFile); + + server.on("/reset_wifi", HTTP_POST, [](AsyncWebServerRequest *request) + { + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); + request->send(200, txt_plain, F("WiFi reset. Device will restart...")); + asyncReset = true; + }); } void setup() @@ -1638,6 +1645,16 @@ void loop() webrequest_blockings = 0; } + if (asyncReset) + { + asyncReset = false; + delay(1000); + Serial.println(F("Disconnecting WiFi and resetting...")); + WiFi.disconnect(true, true); + + ESP.restart(); + } + if (audio.isRunning()) { if (asyncStop) diff --git a/src/main.h b/src/main.h index 9e610d8..ecaad5e 100644 --- a/src/main.h +++ b/src/main.h @@ -79,6 +79,8 @@ bool asyncNext = false; bool asyncPrev = false; +bool asyncReset = false; + bool SDActive = false; bool RFIDActive = false; diff --git a/web/index.html b/web/index.html index 68bc744..5731476 100644 --- a/web/index.html +++ b/web/index.html @@ -13,7 +13,10 @@

HannaBox

-
+
+
+
+
@@ -148,6 +151,11 @@ + +

System

+
+ +
diff --git a/web/script.js b/web/script.js index 829867f..78b6889 100644 --- a/web/script.js +++ b/web/script.js @@ -115,6 +115,24 @@ function loadDirectory() { xhr.send(); } +function resetWifi() { + if (!confirm('Are you sure you want to reset WiFi settings? The device will restart and create an access point.')) { + return; + } + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/reset_wifi', true); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status >= 200 && xhr.status < 300) { + alert('WiFi settings reset. Device is restarting...'); + } else { + alert('Reset failed: ' + (xhr.responseText || 'Unknown error')); + } + } + }; + xhr.send(); +} + function loadMapping() { var el = document.getElementById('mappingList'); if (!el) return; @@ -241,6 +259,25 @@ function displayState(state) { var voltageEl = document.getElementById("voltage"); if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV'; + // Update header battery indicator + var headerBattery = document.getElementById("batteryStatus"); + if (headerBattery) { + var mv = state['voltage'] || 0; + if (mv > 0) { + // Estimate percentage for single cell LiPo (approx 3.3V - 4.2V) + var pct = Math.round((mv - 3300) / (4200 - 3300) * 100); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + + headerBattery.innerHTML = + '' + + '' + pct + '%'; + headerBattery.title = mv + ' mV'; + } else { + headerBattery.innerHTML = ''; + } + } + var heapEl = document.getElementById("heap"); if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap'; diff --git a/web/style.css b/web/style.css index a59cab8..267b88c 100644 --- a/web/style.css +++ b/web/style.css @@ -62,6 +62,19 @@ a { color: var(--accent); text-decoration: none; } margin-top: 2px; } +.battery-status { + font-size: 0.9rem; + font-weight: 600; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; + background: rgba(255,255,255,0.5); + padding: 4px 8px; + border-radius: 8px; + border: 1px solid rgba(0,0,0,0.05); +} + /* Status (current song) */ .status { color: var(--muted);