#if defined(ESP8266) #include //https://github.com/esp8266/Arduino #else #include #endif #include //Local WebServer used to serve the configuration portal #include //https://github.com/tzapu/WiFiManager WiFi Configuration Magic #include "Audio.h" #include #include #include //RFID Reader #include #if defined(ESP32) #include #endif #include #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. int webrequest_blockings = 0; // Prebuilt system paths to avoid repeated String allocations static String SYS_PREFIX; static String PATH_INDEX, PATH_INDEX_GZ, PATH_STYLE, PATH_STYLE_GZ, PATH_SCRIPT, PATH_SCRIPT_GZ; static String PATH_MAPPING, PATH_PROGRESS, PATH_CONFIG, PATH_SLEEP, PATH_STARTUP; static inline void buildSystemPathsOnce() { if (!SYS_PREFIX.isEmpty()) return; SYS_PREFIX = "/"; SYS_PREFIX += sys_dir; SYS_PREFIX += "/"; auto make = [](const char* name) { String s; s = SYS_PREFIX; s += name; return s; }; PATH_INDEX = make(index_file); PATH_STYLE = make(style_file); PATH_SCRIPT = make(script_file); PATH_MAPPING = make(mapping_file); PATH_PROGRESS= make(progress_file); PATH_CONFIG = make(config_file); PATH_SLEEP = make(sleep_sound); PATH_STARTUP = make(startup_sound); PATH_INDEX_GZ = PATH_INDEX; PATH_INDEX_GZ += F(".gz"); PATH_STYLE_GZ = PATH_STYLE; PATH_STYLE_GZ += F(".gz"); PATH_SCRIPT_GZ = PATH_SCRIPT; PATH_SCRIPT_GZ += F(".gz"); } void activateSD() { if (SDActive) return; if (!SD.begin(CS_SDCARD)) { Serial.println(F("SD initialization failed!")); } SDActive = true; } void deactivateSD() { if (SDActive) { digitalWrite(CS_SDCARD, HIGH); SDActive = false; } } void activateRFID() { SPI.begin(-1, -1, -1, CS_RFID); rfid.PCD_Init(CS_RFID, RST_RFID); RFIDActive = true; } void deactivateRFID() { if (RFIDActive) { digitalWrite(CS_RFID, HIGH); RFIDActive = false; } } // Make size of files human readable // source: https://github.com/CelliesProjects/minimalUploadAuthESP32 String humanReadableSize(const size_t bytes) { if (bytes < 1024) return String(bytes) + " B"; else if (bytes < (1024 * 1024)) return String(bytes / 1024.0) + " KB"; else return String(bytes / 1024.0 / 1024.0) + " MB"; } void handleUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { if (!index) { // Validate filename and file extension if (filename.length() == 0) { request->send(400, txt_plain, F("Invalid filename")); return; } // Use const reference to avoid string copies const String &lowerFilename = filename; if (!lowerFilename.endsWith(".mp3") && !lowerFilename.endsWith(".wav") && !lowerFilename.endsWith(".m4a") && !lowerFilename.endsWith(".ogg")) { request->send(400, txt_plain, F("Invalid file type. Only audio files are allowed.")); return; } // More efficient space check using bit shift uint32_t freeSpace = (SD.cardSize() - SD.usedBytes()) >> 20; // Bit shift instead of division if (freeSpace < 10) { // Less than 10MB free request->send(507, txt_plain, F("Insufficient storage")); return; } // Ensure SD is active activateSD(); // Check if file already exists and create backup name if needed String filepath; filepath.reserve(1 + filename.length()); filepath = "/"; filepath += filename; if (SD.exists(filepath)) { request->send(500, txt_plain, F("File already exists.")); } // Open the file for writing (guard with simple mutex) sd_lock_acquire(); request->_tempFile = SD.open(filepath, FILE_WRITE); if (!request->_tempFile) { sd_lock_release(); request->send(500, txt_plain, F("Failed to create file")); return; } sd_lock_release(); } if (len) { // Check if file handle is valid if (!request->_tempFile) { request->send(500, txt_plain, F("File handle invalid")); return; } // Write data and verify bytes written (guard writes with simple mutex) sd_lock_acquire(); size_t bytesWritten = request->_tempFile.write(data, len); if (bytesWritten != len) { // ensure we close while holding the lock to keep SD state consistent request->_tempFile.close(); sd_lock_release(); request->send_P(500, txt_plain, PSTR("Write error")); return; } // Flush data periodically to ensure it's written if (index % buffer_size == 0) { // Flush every so often request->_tempFile.flush(); } sd_lock_release(); } if (final) { if (request->_tempFile) { sd_lock_acquire(); request->_tempFile.flush(); // Ensure all data is written request->_tempFile.close(); sd_lock_release(); Serial.print(F("Upload Complete: ")); Serial.print(filename); Serial.print(F(", size: ")); Serial.println(humanReadableSize(index + len)); // Rebuild directory tree to include new file (guarded) sd_lock_acquire(); rootNode.buildDirectoryTree("/"); sd_lock_release(); request->send_P(200, txt_plain, PSTR("Upload successful")); } else { request->send_P(500, txt_plain, PSTR("Upload failed")); } } } void handleMoveFile(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); String from = request->arg("from"); String to = request->arg("to"); if (SD.exists(from)) { sd_lock_acquire(); SD.rename(from, to); sd_lock_release(); Serial.print(F("Moved file: ")); Serial.print(from); Serial.print(F(" to ")); Serial.println(to); // Rebuild directory tree to update file list (guarded) sd_lock_acquire(); rootNode.buildDirectoryTree("/"); sd_lock_release(); request->send(200, txt_plain, F("File moved successfully.")); } else { Serial.print(F("File not found: ")); Serial.println(from); request->send_P(404, txt_plain, PSTR("File not found.")); } } void handleDeleteFile(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); String filename = request->arg("filename"); if (SD.exists(filename)) { sd_lock_acquire(); SD.remove(filename.c_str()); sd_lock_release(); Serial.print(F("Deleted file: ")); Serial.println(filename); // Rebuild directory tree to update file list (guarded) sd_lock_acquire(); rootNode.buildDirectoryTree("/"); sd_lock_release(); request->send(200, txt_plain, F("File deleted.")); } else { Serial.print(F("File not found: ")); Serial.println(filename); request->send_P(404, txt_plain, PSTR("File not found.")); } } uint32_t getBatteryVoltageMv() { uint32_t voltage = analogReadMilliVolts(BAT_VOLTAGE_PIN); voltage *= 2; //*2 because of the voltage divider. return voltage; } void playSongById(uint16_t id, uint32_t continueSeconds = 0) { currentNode = rootNode.advanceToMP3(id); if (currentNode == nullptr) { Serial.print(F("No node found for ID: ")); Serial.println(id); return; } // Check if the current playing song is valid if (currentNode->getCurrentPlaying().isEmpty()) { currentNode = nullptr; Serial.print(F("No song found for ID: ")); Serial.println(id); return; } String mp3File = currentNode->getCurrentPlaying(); if (mp3File.length() == 0) { currentNode = nullptr; Serial.print(F("Empty file path for ID: ")); Serial.println(id); return; } Serial.print(F("Playing by ID: ")); Serial.println(id); Serial.println(mp3File); deactivateRFID(); activateSD(); if (!playFile(mp3File.c_str())) { Serial.print(F("Failed to play file: ")); Serial.println(mp3File); currentNode = nullptr; return; } if (continueSeconds > 0) { pendingSeekSeconds = continueSeconds; pendingSeek = true; } } void playSongByName(const String &song) { if (song.length() == 0) { Serial.println(F("Empty song name provided")); return; } currentNode = rootNode.advanceToMP3(song); if (currentNode == nullptr) { Serial.print(F("No node found for song: ")); Serial.println(song); return; } // Check if the current playing song is valid if (currentNode->getCurrentPlaying().isEmpty()) { currentNode = nullptr; Serial.print(F("No song found for name: ")); Serial.println(song); return; } String mp3File = currentNode->getCurrentPlaying(); if (mp3File.length() == 0) { currentNode = nullptr; Serial.print(F("Empty file path for song: ")); Serial.println(song); return; } Serial.print(F("Playing song: ")); Serial.println(mp3File); deactivateRFID(); activateSD(); if (!playFile(mp3File.c_str())) { Serial.print(F("Failed to play file: ")); Serial.println(mp3File); currentNode = nullptr; return; } } void playSongByPath(const String &path) { playFile(path.c_str()); } void playSongByRFID(const String &id) { if (id.length() == 0) { Serial.println(F("Empty RFID ID provided")); return; } auto it = rfid_map.find(id); if (it == rfid_map.end()) { Serial.print(F("Song for UID not found: ")); Serial.println(id); return; } MappingEntry entry = it->second; if (entry.target.length() == 0) { Serial.print(F("Empty mapping target for UID: ")); Serial.println(id); return; } Serial.print(F("RFID mapping found. Target: ")); Serial.print(entry.target); Serial.print(" Mode: "); Serial.println(entry.mode); // Reset folder tracking folderFlatList.clear(); folderFlatIndex = -1; folderRootPath = ""; folderModeActive = false; // Set continuous mode based on mapping ('c' => continuous, otherwise not) continuousMode = (entry.mode == 'c'); // Try to locate the target in the directory tree currentNode = rootNode.advanceToMP3(entry.target); if (currentNode == nullptr) { Serial.print(F("No node/file found for mapping target: ")); Serial.println(entry.target); return; } String mp3File = currentNode->getCurrentPlaying(); if (mp3File.isEmpty()) { Serial.print(F("Empty file path for mapping target: ")); Serial.println(entry.target); return; } // Detect whether the mapping targeted a folder (matching a subdirectory name). // advanceToMP3 returns the directory node if a subdirectory name was matched. bool targetIsFolder = false; if (!entry.target.startsWith("/") && entry.target == currentNode->getName()) { targetIsFolder = true; } // If the mapping targets a folder (or explicitly 'f' or 'r' mode), activate folder tracking if (targetIsFolder || entry.mode == 'f' || entry.mode == 'r') { folderModeActive = true; folderRootNode = currentNode; // Build flat list of files inside this folder for sequential/looped playback folderFlatList.clear(); folderRootNode->buildFlatMP3List(folderFlatList); // If random-folder mode requested, shuffle the flat list once if (entry.mode == 'r' && folderFlatList.size() > 1) { // Fisher-Yates shuffle using Arduino random() for (int i = (int)folderFlatList.size() - 1; i > 0; --i) { int j = (int)random((long)(i + 1)); // 0..i auto tmp = folderFlatList[i]; folderFlatList[i] = folderFlatList[j]; folderFlatList[j] = tmp; } } if (entry.mode == 'r' && !folderFlatList.empty()) { // In random mode, pick a random start index and move it to front int startIdx = (int)random((long)folderFlatList.size()); if (startIdx != 0) { auto tmp = folderFlatList[0]; folderFlatList[0] = folderFlatList[startIdx]; folderFlatList[startIdx] = tmp; } folderFlatIndex = 0; DirectoryNode *startNode = folderFlatList[0].first; int fileIdx = folderFlatList[0].second; Serial.print(F("Shuffle start: ")); Serial.println(startNode->getMP3Files()[fileIdx]); startNode->setCurrentPlaying(startNode->getMP3Files()[fileIdx]); currentNode = startNode; mp3File = currentNode->getCurrentPlaying(); } else { // Find index of current playing file within the folder list uint16_t targetId = currentNode->getCurrentPlayingId(); for (size_t i = 0; i < folderFlatList.size(); i++) { DirectoryNode *node = folderFlatList[i].first; int fileIdx = folderFlatList[i].second; if (node == currentNode && node->getFileIdAt(fileIdx) == targetId) { folderFlatIndex = (int)i; break; } } Serial.print(F("RFID Folder Index: ")); Serial.println(folderFlatIndex); } // Compute root path for safety checks (path up to last '/') int lastSlash = mp3File.lastIndexOf('/'); if (lastSlash >= 0) { folderRootPath = mp3File.substring(0, lastSlash + 1); // include trailing slash } } Serial.print(F("Playing mapped target: ")); Serial.println(mp3File); deactivateRFID(); activateSD(); if (!playFile(mp3File.c_str())) { Serial.print(F("Failed to play mapped file: ")); Serial.println( mp3File); currentNode = nullptr; return; } } /** * @brief Wrapper, so that we can intercept each call for other stuff. * * @param filename * @param resumeFilePos * @return true * @return false */ bool playFile(const char *filename, uint32_t resumeFilePos) { if (filename == nullptr || strlen(filename) == 0) { Serial.println(F("filename empty.")); return false; } // Serialize access to SD when audio opens the file (short critical section) bool result = false; sd_lock_acquire(); result = audio.connecttoFS(SD, filename, resumeFilePos); sd_lock_release(); return result; } void playNextMp3() { stop(); // Do not force continuous mode here; respect current global state. if (currentNode == nullptr) { currentNode = rootNode.findFirstDirectoryWithMP3s(); if (currentNode) { currentNode->advanceToFirstMP3InThisNode(); } } else { currentNode = rootNode.advanceToNextMP3(currentNode->getCurrentPlaying()); } if (currentNode != nullptr) { currentNode->setSecondsPlayed(0); } Serial.print(F("Advancing to ")); String mp3File = currentNode->getCurrentPlaying(); // FIXME crash here if last song. if (mp3File.isEmpty()) { currentNode = rootNode.findFirstDirectoryWithMP3s(); return; } Serial.println(mp3File); deactivateRFID(); activateSD(); playFile(mp3File.c_str()); } void audio_info(const char *info) { // Serial.print("info "); Serial.println(info); } void mute() { if (audio.getVolume() != 0) { volume = audio.getVolume(); } audio.setVolume(0); } void unmute() { audio.setVolume(volume); } void writeSongProgress(const char *filename, uint16_t id, uint32_t seconds) { File file = SD.open(filename, FILE_WRITE); if (file) { file.print(id); file.print(" "); file.println(seconds); file.close(); #ifdef DEBUG Serial.print(F("Progress written: ID ")); Serial.print(id); Serial.print(F(", s ")); Serial.println(seconds); #endif } else { Serial.print(F("Error opening file for writing: ")); Serial.println(filename); } } boolean readSongProgress(const char *filename) { File file = SD.open(filename); if (!file) { Serial.print(F("Error opening file for reading: ")); Serial.println(filename); return false; } // Read file with size limit to prevent buffer overflow String data; data.reserve(64); // Increased reserve size for safety size_t bytesRead = 0; const size_t maxBytes = 50; // Limit file read size while (file.available() && bytesRead < maxBytes) { char character = file.read(); if (character == '\n' || character == '\r') { break; // Stop at first line ending } data += character; bytesRead++; } file.close(); // Validate data before parsing data.trim(); if (data.length() == 0) { Serial.println(F("Progress file empty")); return false; } // Use safer parsing with proper type specifiers int tempId = 0; unsigned long tempSeconds = 0; int parsed = sscanf(data.c_str(), "%d %lu", &tempId, &tempSeconds); if (parsed != 2) { Serial.print(F("Failed to parse progress data: ")); Serial.println(data); return false; } // Validate ranges before assignment if (tempId < 0 || tempId > 65535) { Serial.print(F("Invalid song in progress: ")); Serial.println(tempId); return false; } if (tempSeconds > 4294967295UL) { Serial.print(F("Invalid seconds in progress: ")); Serial.println(tempSeconds); return false; } currentSongId = (uint16_t)tempId; currentSongSeconds = (uint32_t)tempSeconds; #ifdef DEBUG Serial.print(F("Data read from file: ")); Serial.println(data); Serial.print(F("Parsed ID: ")); Serial.print(currentSongId); Serial.print(F(", s: ")); Serial.println(currentSongSeconds); #endif return true; } // Function to save the rfid_map to the mapping file void saveMappingToFile(const String &filename) { File file = SD.open(filename, FILE_WRITE); if (file) { for (const auto &pair : rfid_map) { // Format: UID=target|mode file.print(pair.first); file.print("="); file.print(pair.second.target); file.print("|"); file.println(pair.second.mode); } file.close(); Serial.println(F("Mapping saved to file.")); } else { Serial.println(F("Error opening file for writing.")); } } // Function to handle edit requests void editMapping(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); if (request->hasParam("rfid", true) && request->hasParam("song", true)) { String rfid = request->getParam("rfid", true)->value(); String song = request->getParam("song", true)->value(); rfid.trim(); song.trim(); char mode = 's'; if (request->hasParam("mode", true)) { String mStr = request->getParam("mode", true)->value(); if (mStr.length() > 0) mode = mStr.charAt(0); } rfid_map[rfid] = MappingEntry(song, mode); saveMappingToFile(PATH_MAPPING); request->send_P(200, txt_plain, PSTR("Mapping updated")); } else { request->send_P(400, txt_plain, PSTR("Invalid parameters")); } } void readDataFromFile(const String &filename) { File file = SD.open(filename); if (file) { while (file.available()) { // Read key and raw value from the file String line = file.readStringUntil('\n'); int separatorIndex = line.indexOf('='); if (separatorIndex != -1) { // Extract key and raw value String key = line.substring(0, separatorIndex); String raw = line.substring(separatorIndex + 1); key.trim(); raw.trim(); // Support optional mode delimited by '|' => target|mode String target = raw; char mode = 's'; int delim = raw.indexOf('|'); if (delim != -1) { target = raw.substring(0, delim); String mstr = raw.substring(delim + 1); mstr.trim(); if (mstr.length() > 0) mode = mstr.charAt(0); } #ifdef DEBUG Serial.print(F("found rfid mapping for ")); Serial.print(target); Serial.print(F(" mode ")); Serial.println(mode); #endif // Add key-value pair to the map rfid_map[key] = MappingEntry(target, mode); } } file.close(); } else { Serial.print(F("Error opening file ")); Serial.println(filename); } } String processor(const String &var) { if (var == "MAPPING") { auto htmlEscape = [](const String &s) -> String { String out; for (size_t i = 0; i < s.length(); ++i) { char c = s[i]; if (c == '&') out += "&"; else if (c == '<') out += ""; else if (c == '>') out += ""; else if (c == '"') out += ""; else if (c == '\'') out += ""; else out += c; } return out; }; String html; html.reserve(256); html.concat(F("")); for (const auto &pair : rfid_map) { html.concat(F("")); } html.concat(F("
RFIDSong
")); html.concat(htmlEscape(pair.first)); html.concat(F("")); // Show target and mode (e.g. "mysong.mp3|s") String mappingVal = pair.second.target; mappingVal += "|"; mappingVal += pair.second.mode; html.concat(htmlEscape(mappingVal)); html.concat(F("
")); return html; } 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 out.flush(); 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("}")); } struct ChunkedSkipBufferPrint : public Print { uint8_t* out; size_t maxLen; size_t pos; size_t skip; size_t seen; ChunkedSkipBufferPrint(uint8_t* o, size_t m, size_t s) : out(o), maxLen(m), pos(0), skip(s), seen(0) {} virtual size_t write(uint8_t c) { seen++; if (skip > 0) { skip--; return 1; } if (pos < maxLen) { out[pos++] = c; return 1; } // buffer full - keep counting to know total size return 1; } virtual size_t write(const uint8_t* buffer, size_t size) { size_t n = 0; while (n < size) { if (write(buffer[n]) != 1) break; n++; } return n; } size_t bytesWritten() const { return pos; } size_t totalProduced() const { return seen; } }; void stop() { if (audio.isRunning()) { Serial.println(F("stopping audio.")); audio.stopSong(); if (currentNode != NULL) { currentNode->setSecondsPlayed(0); } } } void start() { if (currentNode != NULL) { currentNode->setCurrentPlaying(""); currentNode = NULL; } playNextMp3(); } void togglePlayPause() { if (currentNode != NULL) { writeSongProgress(PATH_PROGRESS.c_str(), currentNode->getCurrentPlayingId(), currentNode->getSecondsPlayed()); audio.pauseResume(); } else { playNextMp3(); } lastInteraction = millis(); } void next() { playNextMp3(); lastInteraction = millis(); } void previous() { lastInteraction = millis(); if (currentNode == NULL) { Serial.println(F("previous(): currentNode is null")); return; } // Validate current state const String currentSong = currentNode->getCurrentPlaying(); if (currentSong == NULL) { #ifdef DEBUG Serial.println(F("previous(): currentPlaying is null, cannot go to previous")); #endif return; } Serial.print(F("previous(): Current song: ")); Serial.println(currentSong); // Use audio library's current time instead of tracked seconds for more accuracy uint32_t currentAudioTime = audio.getAudioCurrentTime(); Serial.print(F("previous(): Current audio time: ")); Serial.print(currentAudioTime); Serial.println(F(" seconds")); // Try to go to previous within current directory first DirectoryNode *newNode = currentNode->goToPreviousMP3(2); // Use 2 second threshold if (newNode != NULL) { // Check if we're restarting the same song or moving to a different song const String newSong = newNode->getCurrentPlaying(); if (currentSong == newSong && currentAudioTime > 2) { // Restart current song if it's been playing for more than 2 seconds Serial.println(F("previous(): Restarting current song")); audio.setAudioPlayPosition(0); currentNode->setSecondsPlayed(0); } else if (currentSong != newSong) { // Move to previous song in same directory Serial.print(F("previous(): Moving to previous song in directory: ")); Serial.println(newSong); currentNode = newNode; stop(); deactivateRFID(); activateSD(); playFile(currentNode->getCurrentPlaying().c_str()); } } else { // Need to find previous song globally (across directories) #ifdef DEBUG Serial.println(F("previous(): Looking for previous song globally")); #endif DirectoryNode *globalPrevNode = rootNode.findPreviousMP3Globally(currentSong); if (globalPrevNode != NULL) { const String globalPrevSong = globalPrevNode->getCurrentPlaying(); if (!globalPrevSong.isEmpty()) { Serial.print(F("previous(): Found previous song globally: ")); Serial.println(globalPrevSong); currentNode = globalPrevNode; stop(); playFile(globalPrevSong.c_str()); } #ifdef DEBUG else { Serial.println(F("prev: Global previous song is null")); } #endif } else { #ifdef DEBUG Serial.println(F("prev: No previous song found, beginning again")); #endif // Optionally restart current song or do nothing audio.setAudioPlayPosition(0); currentNode->setSecondsPlayed(0); } } } void audio_eof_mp3(const char *info) { Serial.println(F("audio file ended.")); #ifdef DEBUG if (folderModeActive) Serial.println("folder mode active"); #endif if (prepareSleepMode) return; // If folder-mode is active, advance only inside that folder. if (folderModeActive) { if (folderRootNode == nullptr) { #ifdef DEBUG Serial.println(F("DEBUG: folderRootNode was null, fixing...")); #endif folderRootNode = currentNode; } } if (folderModeActive && folderRootNode != nullptr) { // Ensure flat list is built if (folderFlatList.empty()) folderRootNode->buildFlatMP3List(folderFlatList); // Try to find current index if not set if (folderFlatIndex < 0 && currentNode != nullptr) { uint16_t currentId = currentNode->getCurrentPlayingId(); Serial.print(F("EOF: Searching for ID ")); Serial.println(currentId); for (size_t i = 0; i < folderFlatList.size(); i++) { if (folderFlatList[i].first == currentNode && folderFlatList[i].first->getFileIdAt(folderFlatList[i].second) == currentId) { folderFlatIndex = (int)i; #ifdef DEBUG Serial.print(F("EOF: Found at ")); Serial.println(folderFlatIndex); #endif break; } } if (folderFlatIndex < 0) { Serial.println(F("EOF: ID not found in flat list")); } } if (folderFlatIndex >= 0 && folderFlatIndex < (int)folderFlatList.size() - 1) { // Advance to next file in the folder folderFlatIndex++; DirectoryNode *nextNode = folderFlatList[folderFlatIndex].first; int fileIdx = folderFlatList[folderFlatIndex].second; nextNode->setCurrentPlaying(nextNode->getMP3Files()[fileIdx]); currentNode = nextNode; currentNode->setSecondsPlayed(0); deactivateRFID(); activateSD(); playFile(currentNode->getCurrentPlaying().c_str()); } else { // Reached end of folder list if (continuousMode && !folderFlatList.empty()) { // Loop back to first in folder folderFlatIndex = 0; DirectoryNode *nextNode = folderFlatList[folderFlatIndex].first; int fileIdx = folderFlatList[folderFlatIndex].second; nextNode->setCurrentPlaying(nextNode->getMP3Files()[fileIdx]); currentNode = nextNode; currentNode->setSecondsPlayed(0); deactivateRFID(); activateSD(); playFile(currentNode->getCurrentPlaying().c_str()); } else { // Stop playback and clear folder mode folderModeActive = false; folderRootNode = nullptr; folderFlatList.clear(); folderFlatIndex = -1; stop(); } } return; } // Default behavior: if continuous mode is enabled, go to next globally if (continuousMode && !prepareSleepMode) playNextMp3(); } /* not working, FIXME remove me! */ void IRAM_ATTR rfid_interrupt() { newRfidInt = true; } void readRFID() { rfid.PICC_ReadCardSerial(); String newUid = getRFIDString(rfid.uid.uidByte); if (newUid == lastUid) { return; } stop(); lastUid = newUid; Serial.print(F("Card UID: ")); Serial.println(lastUid); // rfid.PICC_DumpDetailsToSerial(&(rfid.uid)); playSongByRFID(lastUid); lastInteraction = millis(); } static void serveStaticFile(AsyncWebServerRequest *request, const String &plainPath, const String &gzPath, const char *contentType, const char *cacheControl, const __FlashStringHelper *notFoundMsg, bool allowGzip = true) { webreq_enter(); // Ensure SD is active and RFID is deactivated while serving files. deactivateRFID(); activateSD(); // Prefer gz if present bool useGz = allowGzip && SD.exists(gzPath); const String &sendPath = useGz ? gzPath : plainPath; if (SD.exists(sendPath)) { #ifdef DEBUG Serial.printf("Serving %s heap=%u webreq_cnt=%u\n", sendPath.c_str(), (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); #endif // Chunked streaming with short SD lock per read struct FileCtx { File f; }; FileCtx *ctx = new FileCtx(); ctx->f = SD.open(sendPath); if (!ctx->f) { delete ctx; request->send(500, txt_plain, F("Open failed")); webreq_exit(); return; } auto resp = request->beginChunkedResponse(contentType, [ctx](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { size_t toRead = maxLen; if (toRead > buffer_size) toRead = buffer_size; sd_lock_acquire(); size_t n = ctx->f.read(buffer, toRead); sd_lock_release(); if (n == 0) { ctx->f.close(); } return n; }); resp->addHeader(hdr_cache_control_key, cacheControl); resp->addHeader(hdr_connection_key, hdr_connection_val); if (useGz) { resp->addHeader(F("Content-Encoding"), F("gzip")); } // Ensure FileCtx cleanup even on aborted connections request->onDisconnect([ctx](){ sd_lock_acquire(); if (ctx->f) ctx->f.close(); sd_lock_release(); delete ctx; }); request->send(resp); webreq_exit(); } else { // Fallback: 404 for missing asset request->send(404, txt_plain, notFoundMsg); webreq_exit(); } } void init_webserver() { server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) { serveStaticFile(request, PATH_INDEX, PATH_INDEX_GZ, txt_html_charset, hdr_cache_control_val, F("ERROR: /system/index.html(.gz) not found!"), true); }); server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request) { serveStaticFile(request, PATH_STYLE, PATH_STYLE_GZ, "text/css", "public, max-age=300", F("ERROR: /system/style.css(.gz) not found!"), true); }); server.on("/script.js", HTTP_GET, [](AsyncWebServerRequest *request) { serveStaticFile(request, PATH_SCRIPT, PATH_SCRIPT_GZ, "application/javascript", "public, max-age=300", F("ERROR: /system/script.js(.gz) not found!"), true); }); // Dynamic endpoints to avoid template processing heap spikes 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 using stateful walker AsyncWebServerResponse *response = request->beginChunkedResponse( txt_html_charset, [walker](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { return walker->read(buffer, maxLen); } ); // Optional headers: response->addHeader(hdr_cache_control_key, hdr_cache_control_val); response->addHeader(hdr_connection_key, hdr_connection_val); request->send(response); }); server.on("/mapping", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit();}); #ifdef DEBUG Serial.printf("Serving /mapping heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); #endif // True chunked response using a deterministic generator with byte skipping based on 'index' AsyncWebServerResponse *response = request->beginChunkedResponse( txt_html_charset, [](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { ChunkedSkipBufferPrint sink(buffer, maxLen, index); streamMappingHTML(sink); // finished? if (index >= sink.totalProduced()) { return 0; } return sink.bytesWritten(); } ); // Optional headers: response->addHeader(hdr_cache_control_key, hdr_cache_control_val); response->addHeader(hdr_connection_key, hdr_connection_val); request->send(response); }); server.on("/state", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); #ifdef DEBUG Serial.printf("Serving /state heap=%u webreq_cnt=%u\n", (unsigned)xPortGetFreeHeapSize(), (unsigned)webreq_cnt); #endif // Stream JSON directly to avoid DynamicJsonDocument/String allocations AsyncResponseStream* stream = request->beginResponseStream(F("application/json; charset=UTF-8"), buffer_size); 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) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); request->send_P(200, txt_plain, PSTR("start")); start(); }); server.on("/toggleplaypause", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); request->send_P(200, txt_plain, PSTR("toggleplaypause")); togglePlayPause(); }); server.on("/stop", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); request->send_P(200, txt_plain, PSTR("stop")); stop(); }); server.on("/next", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); request->send_P(200, txt_plain, PSTR("next")); next(); }); server.on("/previous", HTTP_GET, [](AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); request->send_P(200, txt_plain, PSTR("previous")); previous(); }); server.on("/playbyid", HTTP_GET, id_song_action); server.on("/progress", HTTP_POST, progress_action); server.on("/volume", HTTP_POST, volume_action); server.on("/edit_mapping", HTTP_POST, editMapping); // run handleUpload function when any file is uploaded server.on("/upload", HTTP_POST, [](AsyncWebServerRequest *request) { webreq_enter(); // Ensure any in-progress upload file is closed on client abort to free the FD request->onDisconnect([request](){ // Close temporary upload file if still open if (request->_tempFile) { sd_lock_acquire(); request->_tempFile.close(); sd_lock_release(); } webreq_exit(); }); request->send(200); }, handleUpload); 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() { Serial.begin(115200); pinMode(BTN_START_STOP, INPUT_PULLUP); pinMode(BTN_NEXT, INPUT_PULLUP); pinMode(BTN_PREV, INPUT_PULLUP); /* setup the IRQ pin, not working because the pin is input only:*/ // pinMode(IRQ_RFID, INPUT_PULLUP); pinMode(CS_RFID, OUTPUT); pinMode(CS_SDCARD, OUTPUT); digitalWrite(CS_RFID, HIGH); digitalWrite(CS_SDCARD, HIGH); RFIDActive = false; SDActive = false; Serial.print(F("Initializing SD card...")); activateSD(); Serial.println(F("SD initialization done.")); buildSystemPathsOnce(); // Seed RNG for shuffle mode #if defined(ESP32) randomSeed(esp_random()); #else randomSeed((uint32_t)micros()); #endif // Load configuration from SD card Serial.println(F("Loading configuration...")); loadConfig(); // deep sleep wakeup esp_sleep_enable_ext0_wakeup((gpio_num_t)BTN_START_STOP, LOW); rootNode.buildDirectoryTree("/"); rootNode.printDirectoryTree(); Serial.printf("Heap after dir tree: %u\n", (unsigned)xPortGetFreeHeapSize()); readDataFromFile(PATH_MAPPING); String progressPath = PATH_PROGRESS; continuePlaying = config.startAtStoredProgress && readSongProgress(progressPath.c_str()); if (continuePlaying) { Serial.print(F("deleting ")); Serial.println(progressPath); SD.remove(progressPath); } deactivateSD(); activateRFID(); Serial.println(F("RFID")); // Init MFRC522 // Init SPI bus // SPI.begin(-1, -1, -1, CS_RFID); rfid.PCD_Init(CS_RFID, RST_RFID); // somehow this test stops rfid from working! /* if (rfid.PCD_PerformSelfTest()) { Serial.println("RFID OK"); } else { Serial.println("RFID Self Test failed!"); } */ audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); audio.setVolume(config.initialVolume); // Use config value volume = config.initialVolume; // Update global volume variable // Optimize audio buffer size to save heap (lower = less RAM, but risk of underflow on high bitrates) audio.setBufferSize(8192); Serial.println(F("Audio init")); lastVoltage = getBatteryVoltageMv(); free_heap = xPortGetFreeHeapSize(); AsyncWiFiManager wifiManager(&server, &dns); wifiManager.setDebugOutput(true); // Reduce timeouts to free memory faster wifiManager.setTimeout(180); // Reduced from 180 wifiManager.setConnectTimeout(20); // Faster connection attempts wifiManager.setConfigPortalTimeout(120); // Shorter portal timeout #ifdef DEBUG Serial.println(F("Deactivating Brownout detector...")); #endif WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); // disable brownout detector if (wifiManager.autoConnect("HannaBox")) { Serial.printf("Heap before init_webserver: %u\n", (unsigned)xPortGetFreeHeapSize()); init_webserver(); Serial.printf("Heap before server.begin: %u\n", (unsigned)xPortGetFreeHeapSize()); server.begin(); Serial.printf("Heap after server.begin: %u\n", (unsigned)xPortGetFreeHeapSize()); Serial.println(F("Wifi init")); } else { Serial.println(F("Wifi timed out. Fallback.")); } Serial.println(F("Activating Brownout detector...")); WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 1); // enable brownout detector xTaskCreatePinnedToCore( loop2, /* Function to implement the task */ "RFIDTask", /* Name of the task */ 2048, /* Stack size in words - reduced from 4096 to 2048 to free heap */ NULL, /* Task input parameter */ 0, /* Priority of the task */ &RfidTask, /* Task handle. */ 0); /* Core where the task should run */ lastInteraction = millis(); Serial.println(F("Init done.")); } void id_song_action(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); folderModeActive = true; for (int i = 0; i < params; i++) { const AsyncWebParameter *p = request->getParam(i); if (p->name() == "id") { playSongById(atoi(p->value().c_str())); } } if (currentNode != nullptr) { folderRootNode = currentNode; } lastInteraction = millis(); request->send_P(200, txt_plain, PSTR("ok")); } void progress_action(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); for (int i = 0; i < params; i++) { const AsyncWebParameter *p = request->getParam(i); if (p->name() == "value") { audio.setAudioPlayPosition(atoi(p->value().c_str())); } } lastInteraction = millis(); request->send_P(200, txt_plain, PSTR("ok")); } void volume_action(AsyncWebServerRequest *request) { webreq_enter(); request->onDisconnect([](){ webreq_exit(); }); int params = request->params(); for (int i = 0; i < params; i++) { const AsyncWebParameter *p = request->getParam(i); if (p->name() == "value") { audio.setVolume(atoi(p->value().c_str())); } } lastInteraction = millis(); request->send_P(200, txt_plain, PSTR("ok")); } void loop() { if (webreq_cnt > 0 && webrequest_blockings > MAX_WEBREQUEST_BLOCKINGS) { if (!server_reset_pending) { Serial.println(F("excessive webrequest blocking - scheduling server reset")); server_reset_pending = true; } // reset the counter to avoid repeated scheduling while still busy 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) { asyncStop = false; stop(); } audio.loop(); if (currentNode != nullptr && !prepareSleepMode) { currentNode->setSecondsPlayed(audio.getAudioCurrentTime()); } // Apply pending seek once decoder is ready (after header parsed and bitrate known) if (pendingSeek && audio.getBitRate(true) > 0 && audio.getAudioFileDuration() > 0) { audio.setAudioPlayPosition(pendingSeekSeconds); if (currentNode != nullptr) { currentNode->setSecondsPlayed(pendingSeekSeconds); } pendingSeek = false; } } else if (asyncStart && webreq_cnt == 0) { asyncStart = false; start(); } if (continuePlaying && webreq_cnt == 0) { continuePlaying = false; startupSoundPlayed = true; playSongById(currentSongId, currentSongSeconds); currentNode->setSecondsPlayed(currentSongSeconds); } else if (!startupSoundPlayed) { startupSoundPlayed = true; playSongByPath(PATH_STARTUP); } // send device to sleep: long now = millis(); if (!sleepSoundPlayed && now - lastInteraction > config.sleepMessageDelay) { sleepSoundPlayed = true; prepareSleepMode = true; if (currentNode != nullptr) { deactivateRFID(); activateSD(); writeSongProgress(PATH_PROGRESS.c_str(), currentNode->getCurrentPlayingId(), currentNode->getSecondsPlayed()); } playSongByPath(PATH_SLEEP); } if (now - lastInteraction > config.sleepDelay) { Serial.println(F("entering deep sleep..")); deactivateRFID(); deactivateSD(); esp_deep_sleep_start(); } if (asyncTogglePlayPause) { asyncTogglePlayPause = false; togglePlayPause(); } else if (asyncNext) { asyncNext = false; // If the play/start button is held, treat NEXT as volume up if (playButtonDown) { uint8_t vol = audio.getVolume(); if (vol < config.maxVolume) { vol++; audio.setVolume(vol); volume = vol; // update stored volume for mute/unmute volumeAdjustedDuringHold = true; } // do not play the startup sound when changing volume while holding play } else { if (audio.isRunning()) { next(); } else { uint8_t vol = audio.getVolume(); if (vol != config.maxVolume) { vol++; } audio.setVolume(vol); volume = vol; playSongByPath(PATH_STARTUP); } } } else if (asyncPrev) { asyncPrev = false; // If the play/start button is held, treat PREV as volume down if (playButtonDown) { uint8_t vol = audio.getVolume(); if (vol > 0) { vol--; audio.setVolume(vol); volume = vol; // update stored volume for mute/unmute volumeAdjustedDuringHold = true; } // do not play the startup sound when changing volume while holding play } else { if (audio.isRunning()) { previous(); } else { uint8_t vol = audio.getVolume(); if (vol != 0) { vol--; } audio.setVolume(vol); volume = vol; playSongByPath(PATH_STARTUP); } } } if (loopCounter % config.rfidLoopInterval == 0 && webreq_cnt == 0) { deactivateSD(); activateRFID(); if (rfid.PICC_IsNewCardPresent()) { readRFID(); } deactivateRFID(); activateSD(); } if (loopCounter % VOLTAGE_LOOP_INTERVAL == 0 && webreq_cnt == 0) { lastVoltage = getBatteryVoltageMv(); free_heap = xPortGetFreeHeapSize(); if (lastVoltage < config.minVoltage && config.minVoltage > 0) { if (voltage_threshold_counter > 3) { Serial.print(F("deep sleep due to low volts (")); Serial.print(lastVoltage); Serial.print(F(") min: ")); Serial.println(config.minVoltage); lastInteraction = millis() - config.sleepMessageDelay; voltage_threshold_counter = 0; } else { voltage_threshold_counter++; } } else { voltage_threshold_counter = 0; } } if (webreq_cnt>0) { webrequest_blockings++; } else { webrequest_blockings = 0; } // Perform deferred server reset when no active requests to avoid AsyncTCP stack corruption if (server_reset_pending && webreq_cnt == 0) { Serial.println(F("performing deferred server reset")); server.reset(); init_webserver(); server.begin(); server_reset_pending = false; } loopCounter++; vTaskDelay(1); } void loop2(void *parameter) { bool loggingDone = false; for (;;) { // Track whether the play/start button is currently held down and detect press/release bool currentDown = (digitalRead(BTN_START_STOP) == LOW); static bool prevDown = false; playButtonDown = currentDown; // On press: start hold tracking and reset volume-change marker if (currentDown && !prevDown) { playHoldActive = true; volumeAdjustedDuringHold = false; lastInteraction = millis(); } // On release: toggle only if no volume change occurred during hold if (!currentDown && prevDown) { if (playHoldActive) { if (!volumeAdjustedDuringHold) { asyncTogglePlayPause = true; } playHoldActive = false; volumeAdjustedDuringHold = false; } } prevDown = currentDown; if (buttonPressed(BTN_NEXT)) { asyncNext = true; } if (buttonPressed(BTN_PREV)) { asyncPrev = true; } if (!loggingDone) { Serial.println(F("loop2 started")); loggingDone = true; } vTaskDelay(1); } } boolean buttonPressed(const uint8_t pin) { if (digitalRead(pin) == LOW && buttontoignore != pin) { unsigned long now = millis(); if (now - lastStart > SHORT_PRESS_TIME) { lastStart = now; buttontoignore = pin; lastInteraction = now; return true; } } else if (digitalRead(pin) == HIGH && buttontoignore == pin) { buttontoignore = 0; } return false; }