diff --git a/platformio.ini b/platformio.ini index 61dabf4..46db8c4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -20,8 +20,9 @@ lib_deps = bblanchon/ArduinoJson@^6.21.3 monitor_speed = 115200 build_flags = - -Os ; Optimize for size - ; -DCORE_DEBUG_LEVEL=0 ; Disable all debug output + -Os ; Optimize for size +; -DDEBUG ; Hannabox Debugging + -DCORE_DEBUG_LEVEL=0 ; Disable all debug output -DARDUINO_LOOP_STACK_SIZE=3072 ; Further reduce from 4096 -DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack -DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack diff --git a/src/DirectoryNode.cpp b/src/DirectoryNode.cpp index ecd21e2..fc00f3c 100644 --- a/src/DirectoryNode.cpp +++ b/src/DirectoryNode.cpp @@ -37,12 +37,42 @@ const std::vector &DirectoryNode::getMP3Files() const return mp3Files; } +const String &DirectoryNode::getDirPath() const +{ + return dirPath; +} + +String DirectoryNode::getFullPathByIndex(size_t index) const +{ + if (index < mp3Files.size()) + { + return buildFullPath(mp3Files[index]); + } + return String(); +} + +String DirectoryNode::buildFullPath(const String &fileName) const +{ + if (dirPath == "/") + { + String p = "/"; + p += fileName; + return p; + } + String p = dirPath; + p += "/"; + p += fileName; + return p; +} + void DirectoryNode::setCurrentPlaying(const String &mp3File) { - currentPlaying = mp3File; + bool isAbs = (mp3File.length() > 0) && (mp3File.charAt(0) == '/'); + const String &fileName = isAbs ? mp3File.substring(mp3File.lastIndexOf('/') + 1) : mp3File; + currentPlaying = isAbs ? mp3File : buildFullPath(fileName); for (size_t i = 0; i < mp3Files.size(); i++) { - if (mp3Files[i] == mp3File && ids.size() > i) + if (mp3Files[i] == fileName && ids.size() > i) { currentPlayingId = ids[i]; break; @@ -102,6 +132,14 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath) mp3Files.shrink_to_fit(); ids.shrink_to_fit(); + // Set directory path for this node (normalize: keep "/" or remove trailing slash) + String path = String(currentPath); + if (path.length() > 1 && path.endsWith("/")) + { + path.remove(path.length() - 1); + } + dirPath = path; + // First collect entries so we can sort them alphabetically std::vector dirNames; std::vector fileNames; @@ -167,15 +205,10 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath) newNode->buildDirectoryTree(childPath.c_str()); } - // Add MP3 files in alphabetical order + // Add MP3 files in alphabetical order (store only filenames; build full paths on demand) for (const String &fileName : fileNames) { - String fullPath = String(currentPath); - if (!fullPath.endsWith("/")) - fullPath += "/"; - fullPath += fileName; - - mp3Files.push_back(std::move(fullPath)); + mp3Files.push_back(fileName); ids.push_back(getNextId()); } } @@ -194,7 +227,7 @@ void DirectoryNode::printDirectoryTree(int level) const { Serial.print(F(" ")); } - Serial.println(mp3File); + Serial.println(buildFullPath(mp3File)); } for (DirectoryNode *childNode : subdirectories) @@ -241,7 +274,7 @@ DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id) if (id == ids[i]) { // Found the current MP3 file - currentPlaying = mp3Files[i]; + currentPlaying = buildFullPath(mp3Files[i]); currentPlayingId = id; return this; } @@ -296,7 +329,7 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String &songName) { if (isAbsolutePath) { - if (mp3Files[i].equalsIgnoreCase(songName)) + if (buildFullPath(mp3Files[i]).equalsIgnoreCase(songName)) { setCurrentPlaying(mp3Files[i]); return this; @@ -317,13 +350,10 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String &songName) // Then search in subdirectories for (auto subdir : subdirectories) { - // Absolute folder target: match directory by its full path derived from its files - if (isAbsolutePath && subdir->mp3Files.size() > 0) + // Absolute folder target: match directory by its full path (dirPath) + if (isAbsolutePath) { - String anyFile = subdir->mp3Files[0]; - int lastSlash = anyFile.lastIndexOf('/'); - String subdirPath = (lastSlash >= 0) ? anyFile.substring(0, lastSlash) : String(); - if (subdirPath.equalsIgnoreCase(normalizedPath)) + if (subdir->getDirPath().equalsIgnoreCase(normalizedPath)) { subdir->advanceToFirstMP3InThisNode(); return subdir; @@ -342,7 +372,7 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String &songName) if (isAbsolutePath) { - if (subdir->mp3Files[i].equalsIgnoreCase(songName)) + if (subdir->buildFullPath(subdir->mp3Files[i]).equalsIgnoreCase(songName)) { subdir->setCurrentPlaying(subdir->mp3Files[i]); return subdir; @@ -401,7 +431,7 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds) int currentIndex = -1; for (size_t i = 0; i < mp3Files.size(); i++) { - if (currentPlaying == mp3Files[i]) + if (currentPlaying == buildFullPath(mp3Files[i])) { currentIndex = i; break; @@ -412,7 +442,7 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds) if (currentIndex > 0) { Serial.print(F("goToPreviousMP3: Moving to previous song in same directory: ")); - Serial.println(mp3Files[currentIndex - 1]); + Serial.println(buildFullPath(mp3Files[currentIndex - 1])); setCurrentPlaying(mp3Files[currentIndex - 1]); return this; } @@ -441,7 +471,7 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String ¤tGloba { DirectoryNode *node = allMP3s[i].first; int fileIndex = allMP3s[i].second; - if (node->mp3Files[fileIndex] == currentGlobal) + if (node->buildFullPath(node->mp3Files[fileIndex]) == currentGlobal) { currentGlobalIndex = i; break; @@ -455,7 +485,7 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String ¤tGloba int prevFileIndex = allMP3s[currentGlobalIndex - 1].second; Serial.print(F("findPreviousMP3Globally: Moving to previous song globally: ")); - Serial.println(prevNode->mp3Files[prevFileIndex]); + Serial.println(prevNode->buildFullPath(prevNode->mp3Files[prevFileIndex])); prevNode->setCurrentPlaying(prevNode->mp3Files[prevFileIndex]); return prevNode; @@ -495,7 +525,7 @@ DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal) { for (size_t i = 0; i < mp3Files.size(); i++) { - if (currentGlobal == mp3Files[i]) + if (currentGlobal == buildFullPath(mp3Files[i])) { // Found the current playing MP3 file if (i < mp3Files.size() - 1) @@ -525,7 +555,7 @@ DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal) // Have each subdirectory advance its song for (size_t i = 0; i < subdir->mp3Files.size(); i++) { - if (currentGlobal == subdir->mp3Files[i]) + if (currentGlobal == subdir->buildFullPath(subdir->mp3Files[i])) { // Found the current playing MP3 file if (i < subdir->mp3Files.size() - 1) @@ -573,10 +603,10 @@ void DirectoryNode::streamDirectoryHTML(Print &out) const { out.print(F("
  • ")); - out.print(mp3Files[i]); + out.print(buildFullPath(mp3Files[i])); out.println(F("
  • ")); #ifdef DEBUG - Serial.printf("stream song: %s\n",mp3Files[i].c_str()); + Serial.printf("stream song: %s\n", buildFullPath(mp3Files[i]).c_str()); #endif // Yield every few items to allow the async web server to send buffered data if (i % 5 == 4) { diff --git a/src/DirectoryNode.h b/src/DirectoryNode.h index 518b68b..626b7b2 100644 --- a/src/DirectoryNode.h +++ b/src/DirectoryNode.h @@ -14,6 +14,7 @@ class DirectoryNode { private: uint16_t id; String name; + String dirPath; std::vector subdirectories; std::vector mp3Files; std::vector ids; @@ -21,6 +22,7 @@ private: uint16_t currentPlayingId = 0; uint16_t secondsPlayed = 0; + String buildFullPath(const String& fileName) const; public: @@ -33,6 +35,8 @@ public: const uint16_t getId() const; const std::vector& getSubdirectories() const; const std::vector& getMP3Files() const; + const String& getDirPath() const; + String getFullPathByIndex(size_t index) const; size_t getNumOfFiles() const; diff --git a/src/globals.h b/src/globals.h index 6a51291..efd97cb 100644 --- a/src/globals.h +++ b/src/globals.h @@ -31,7 +31,7 @@ static const char* hdr_connection_key = "Connection"; static const char* hdr_connection_val = "close"; - +const size_t buffer_size = 256; /* diff --git a/src/main.cpp b/src/main.cpp index 65cf01a..bf7a1d5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,7 +6,6 @@ #include //Local WebServer used to serve the configuration portal #include //https://github.com/tzapu/WiFiManager WiFi Configuration Magic -#include #include "Audio.h" @@ -682,36 +681,6 @@ boolean readSongProgress(const char *filename) return true; } -String getState() -{ - // Use static buffer to avoid repeated allocations - static DynamicJsonDocument jsonState(512); - static String output; - - output.clear(); - output.reserve(512); // Pre-allocate string buffer - - jsonState["playing"] = audio.isRunning(); - - if (currentNode != nullptr && !currentNode->getCurrentPlaying().isEmpty()) - jsonState["title"] = currentNode->getCurrentPlaying(); - else - jsonState["title"] = "Stopped"; - jsonState["filepath"] = currentNode->getCurrentPlaying(); - - - jsonState["time"] = audio.getAudioCurrentTime(); - jsonState["volume"] = audio.getVolume(); - jsonState["length"] = audio.getAudioFileDuration(); - jsonState["voltage"] = lastVoltage; - jsonState["uid"] = lastUid; - jsonState["heap"] = free_heap; - - serializeJson(jsonState, output); - jsonState.clear(); - - return output; -} // Function to save the rfid_map to the mapping file void saveMappingToFile(const String filename) @@ -863,6 +832,113 @@ String processor(const String &var) return String(); // Return empty string instead of creating new String } +// Memory-optimized helpers and streamers to avoid large temporary Strings + +static inline void htmlEscapeAndPrint(Print &out, const String &s) +{ + for (size_t i = 0; i < s.length(); ++i) + { + char c = s[i]; + switch (c) + { + case '&': out.print(F("&")); break; + case '<': out.print(F("<")); break; + case '>': out.print(F(">")); break; + case '\"': out.print(F("\"")); break; + case '\'': out.print(F("'")); break; + default: out.print(c); break; + } + } +} + +static inline void jsonEscapeAndPrint(Print &out, const String &s) +{ + for (size_t i = 0; i < s.length(); ++i) + { + char c = s[i]; + switch (c) + { + case '\"': out.print(F("\\\"")); break; + case '\\': out.print(F("\\\\")); break; + case '\b': out.print(F("\\b")); break; + case '\f': out.print(F("\\f")); break; + case '\n': out.print(F("\\n")); break; + case '\r': out.print(F("\\r")); break; + case '\t': out.print(F("\\t")); break; + default: + if ((uint8_t)c < 0x20) { // control chars as \u00XX + out.print(F("\\u00")); + const char hex[] = "0123456789ABCDEF"; + out.print(hex[(c >> 4) & 0x0F]); + out.print(hex[c & 0x0F]); + } else { + out.print(c); + } + break; + } + } +} + +static void streamMappingHTML(Print &out) +{ + out.print(F("")); + for (const auto &pair : rfid_map) + { + out.print(F("")); + // Yield occasionally if async server is buffering + yield(); + } + out.print(F("
    RFIDSong
    ")); + htmlEscapeAndPrint(out, pair.first); + out.print(F("")); + // target|mode + htmlEscapeAndPrint(out, pair.second.target); + out.print(F("|")); + out.print(pair.second.mode); + out.print(F("
    ")); +} + +static void streamStateJSON(Print &out) +{ + const bool isRunning = audio.isRunning(); + static const String emptyStr; + const String ¤t = + (currentNode != nullptr) ? currentNode->getCurrentPlaying() : emptyStr; + + out.print(F("{\"playing\":")); + out.print(isRunning ? F("true") : F("false")); + + out.print(F(",\"title\":\"")); + if (!current.isEmpty()) jsonEscapeAndPrint(out, current); + else out.print(F("Stopped")); + out.print(F("\"")); + + out.print(F(",\"filepath\":\"")); + jsonEscapeAndPrint(out, current); + out.print(F("\"")); + + out.print(F(",\"time\":")); + out.print(audio.getAudioCurrentTime()); + + out.print(F(",\"volume\":")); + out.print(audio.getVolume()); + + out.print(F(",\"length\":")); + out.print(audio.getAudioFileDuration()); + + out.print(F(",\"voltage\":")); + out.print(lastVoltage); + + out.print(F(",\"uid\":\"")); + jsonEscapeAndPrint(out, lastUid); + out.print(F("\"")); + + out.print(F(",\"heap\":")); + out.print(free_heap); + + out.print(F("}")); +} + void stop() { if (audio.isRunning()) @@ -1132,7 +1208,7 @@ static void serveStaticFile(AsyncWebServerRequest *request, auto resp = request->beginChunkedResponse(contentType, [ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { size_t toRead = maxLen; - if (toRead > 512) toRead = 512; + if (toRead > buffer_size) toRead = buffer_size; sd_lock_acquire(); size_t n = ctx->f.read(buffer, toRead); sd_lock_release(); @@ -1217,32 +1293,32 @@ void init_webserver() { { webreq_enter(); request->onDisconnect([](){ webreq_exit();}); - // Stream mapping to avoid Content-Length mismatches and reduce heap spikes + // Stream mapping to avoid building a large HTML String AsyncResponseStream* stream = request->beginResponseStream(txt_html_charset, 512); #ifdef DEBUG Serial.printf("Serving /mapping heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); #endif stream->addHeader(hdr_cache_control_key, hdr_cache_control_val); stream->addHeader(hdr_connection_key, hdr_connection_val); - String html = processor(String("MAPPING")); - stream->print(html); + streamMappingHTML(*stream); request->send(stream); }); server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request) { - webreq_enter(); - request->onDisconnect([](){ webreq_exit(); }); - String state = getState(); + webreq_enter(); + request->onDisconnect([](){ webreq_exit(); }); #ifdef DEBUG - Serial.printf("Serving /state heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); + Serial.printf("Serving /state heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); #endif - - AsyncWebServerResponse* resp = request->beginResponse(200, F("application/json; charset=UTF-8"), state); - resp->addHeader(hdr_cache_control_key, hdr_cache_control_val); - resp->addHeader(hdr_connection_key, hdr_connection_val); - request->send(resp); }); + // Stream JSON directly to avoid DynamicJsonDocument/String allocations + AsyncResponseStream* stream = request->beginResponseStream(F("application/json; charset=UTF-8"), 256); + stream->addHeader(hdr_cache_control_key, hdr_cache_control_val); + stream->addHeader(hdr_connection_key, hdr_connection_val); + streamStateJSON(*stream); + request->send(stream); + }); server.on("/start", HTTP_GET, [](AsyncWebServerRequest *request) { @@ -1509,6 +1585,8 @@ void loop() if (webreq_cnt > 0 && webrequest_blockings > MAX_WEBREQUEST_BLOCKINGS) { Serial.println(F("excessive webrequest blocking - suppress reset")); // Avoid resetting server mid-response to prevent mixing headers/body or truncation + server.reset(); + init_webserver(); webreq_cnt = 0; webrequest_blockings = 0; } diff --git a/src/main.h b/src/main.h index 2018746..40a1b07 100644 --- a/src/main.h +++ b/src/main.h @@ -34,8 +34,6 @@ #define MAX_VOL 15 -//#define DEBUG TRUE - File root; File mp3File;