Compare commits
13 Commits
c32eabf464
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7512e1d94b | |||
| 6ecb54e5ee | |||
| 69bc259a6c | |||
| 9c937dc62d | |||
| 34c499bd49 | |||
| ea4461cc54 | |||
| c14624ef92 | |||
| b97eb79b91 | |||
| fd40b663a0 | |||
| 3a34b1b8d0 | |||
| 465e34e919 | |||
| 83e51e87fe | |||
| 7f120ae62d |
@@ -4,6 +4,109 @@ This document summarizes the memory optimizations implemented to resolve out-of-
|
|||||||
|
|
||||||
## Implemented Optimizations
|
## Implemented Optimizations
|
||||||
|
|
||||||
|
|
||||||
|
### Improvement for /directory
|
||||||
|
Implemented a backpressure-safe, low-heap HTML streaming solution to prevent AsyncTCP cbuf resize OOM during /directory.
|
||||||
|
|
||||||
|
Root cause
|
||||||
|
|
||||||
|
- The previous implementation used AsyncResponseStream (a Print) and wrote faster than the TCP stack could drain. Under client/network backpressure, AsyncTCP’s cbuf tried to grow and failed: cbuf.resize() -> WebResponses write(): Failed to allocate.
|
||||||
|
|
||||||
|
Fix implemented
|
||||||
|
|
||||||
|
- Switched /directory to AsyncChunkedResponse with a stateful generator that only produces bytes when the TCP layer is ready.
|
||||||
|
- Generates one entry at a time, respecting maxLen provided by the framework. This prevents buffer growth and heap spikes.
|
||||||
|
- No yield() needed; backpressure is handled by the chunked response callback scheduling.
|
||||||
|
|
||||||
|
Code changes
|
||||||
|
|
||||||
|
1. Added a tiny accessor to fetch file id at index
|
||||||
|
|
||||||
|
- Header: src/DirectoryNode.h
|
||||||
|
|
||||||
|
- Added: uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
|
- Source: src/DirectoryNode.cpp
|
||||||
|
|
||||||
|
- Implemented: uint16_t DirectoryNode::getFileIdAt(size_t i) const { return (i < ids.size()) ? ids[i] : 0; }
|
||||||
|
|
||||||
|
2. Replaced /directory handler with AsyncChunkedResponse generator
|
||||||
|
|
||||||
|
- File: src/main.cpp
|
||||||
|
|
||||||
|
- New logic (high level):
|
||||||
|
|
||||||
|
- DirectoryHtmlStreamState holds an explicit traversal stack of frames {node, fileIdx, childIdx, headerDone}.
|
||||||
|
|
||||||
|
- next(buffer, maxLen) fills output up to maxLen with:
|
||||||
|
|
||||||
|
- Single top-level \n
|
||||||
|
- A name\n for non-root directories (kept original behavior—no nested per subdir)
|
||||||
|
- One filename\n per file
|
||||||
|
- Depth-first traversal across subdirectories
|
||||||
|
- Closes with \n when done
|
||||||
|
|
||||||
|
- Uses snprintf into the chunk buffer and a simple copy loop for filenames, avoiding extra heap allocations.
|
||||||
|
|
||||||
|
- Frees generator state when finished and also on client disconnect.
|
||||||
|
|
||||||
|
3. Minor improvements in the chunked generator
|
||||||
|
|
||||||
|
- Normalized newline literals to \n (not escaped).
|
||||||
|
- Used single quotes around HTML attribute values to simplify C string escaping and reduce mistakes.
|
||||||
|
|
||||||
|
What remains unchanged
|
||||||
|
|
||||||
|
- DirectoryNode::streamDirectoryHTML(Print&) is left intact but no longer used by /directory. Mapping/State endpoints continue using their existing streaming; they are small and safe.
|
||||||
|
|
||||||
|
Why this eliminates the crashes
|
||||||
|
|
||||||
|
- AsyncChunkedResponse only invokes the generator when there’s space to send more, so AsyncTCP’s cbuf won’t grow unbounded. The generator respects the maxLen and yields 0 on completion, eliminating the resize path that previously caused OOM.
|
||||||
|
|
||||||
|
Build and flash instructions
|
||||||
|
|
||||||
|
- Your environment doesn’t have PlatformIO CLI available. Options:
|
||||||
|
|
||||||
|
1. VSCode PlatformIO extension: Use the “Build” and “Upload” tasks from the PlatformIO toolbar.
|
||||||
|
|
||||||
|
2. Install PlatformIO CLI:
|
||||||
|
|
||||||
|
- python3 -m pip install --user platformio
|
||||||
|
- $HOME/.local/bin must be in PATH (or use full path).
|
||||||
|
- Then build: pio run -e d1_mini32
|
||||||
|
- Upload: pio run -e d1_mini32 -t upload
|
||||||
|
|
||||||
|
3. Arduino IDE/CLI: Import and build the sketch there if preferred.
|
||||||
|
|
||||||
|
Runtime test checklist
|
||||||
|
|
||||||
|
- Open serial monitor at 115200, reset device.
|
||||||
|
|
||||||
|
- Hit [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> in a browser; the page should render fully without OOM or crash.
|
||||||
|
|
||||||
|
- Simulate slow client backpressure:
|
||||||
|
|
||||||
|
- curl --limit-rate 5k [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> -v -o /dev/null
|
||||||
|
- Observe no “[E][cbuf.cpp:104] resize(): failed to allocate temporary buffer” or “WebResponses write(): Failed to allocate”
|
||||||
|
|
||||||
|
- Watch heap logs during serving; you should see stable heap with no large dips.
|
||||||
|
|
||||||
|
- If desired, repeat with multiple concurrent connections to /directory to verify robustness.
|
||||||
|
|
||||||
|
Optional follow-ups
|
||||||
|
|
||||||
|
- If mapping ever grows large, convert /mapping to AsyncChunkedResponse using the same pattern.
|
||||||
|
- If your ESP32 has PSRAM, enabling it can further reduce heap pressure, but the chunked approach is already robust.
|
||||||
|
- Consider enabling CONFIG_ASYNC_TCP_MAX_ACK_TIME tune if you want more aggressive backpressure timing; your platformio.ini already has some AsyncTCP stack tweaks noted.
|
||||||
|
|
||||||
|
Summary
|
||||||
|
|
||||||
|
- Replaced Print-based recursive streaming with a chunked, backpressure-aware generator for /directory.
|
||||||
|
- This removes the cbuf resize failure path and should stop the crashes you observed while still using minimal heap.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 2. DirectoryNode Structure Optimization (✅ COMPLETED)
|
### 2. DirectoryNode Structure Optimization (✅ COMPLETED)
|
||||||
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
|
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
|
||||||
- **Memory saved**: Reduces fragmentation and improves allocation efficiency
|
- **Memory saved**: Reduces fragmentation and improves allocation efficiency
|
||||||
|
|||||||
@@ -13,24 +13,24 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
|
|||||||
board = wemos_d1_mini32
|
board = wemos_d1_mini32
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
ESP32Async/AsyncTCP@3.3.8
|
ESP32Async/ESPAsyncWebServer@3.9.2
|
||||||
ESP32Async/ESPAsyncWebServer@3.7.9
|
|
||||||
alanswx/ESPAsyncWiFiManager@0.31
|
alanswx/ESPAsyncWiFiManager@0.31
|
||||||
miguelbalboa/MFRC522@^1.4.12
|
miguelbalboa/MFRC522@^1.4.12
|
||||||
bblanchon/ArduinoJson@^6.21.3
|
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
build_flags =
|
build_flags =
|
||||||
-Os ; Optimize for size
|
-Os ; Optimize for size
|
||||||
-DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
-DDEBUG ; Hannabox Debugging
|
||||||
-DARDUINO_LOOP_STACK_SIZE=3072 ; Further reduce from 4096
|
; -DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
||||||
-DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
; -DARDUINO_LOOP_STACK_SIZE=4096 ; Balanced to avoid stack canary without starving heap
|
||||||
-DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
; -DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
||||||
-DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
; -DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
||||||
-DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
; -DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
||||||
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=3000
|
; -DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
||||||
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
|
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; (keep default)
|
||||||
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; (keep default)
|
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
|
||||||
-DCONFIG_ASYNC_TCP_RUNNING_CORE=1 ; force async_tcp task to be on same core as Arduino app (default is any core)
|
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ;(keep default)
|
||||||
-DCONFIG_ASYNC_TCP_STACK_SIZE=4096 ; reduce the stack size (default is 16K)
|
-DCONFIG_ASYNC_TCP_RUNNING_CORE=1 ;force async_tcp task to be on same core as Arduino app (default is any core)
|
||||||
|
-DCONFIG_ASYNC_TCP_STACK_SIZE=4096 ; reduce AsyncTCP task stack (default is 16k)
|
||||||
|
|
||||||
monitor_filters = esp32_exception_decoder
|
monitor_filters = esp32_exception_decoder
|
||||||
board_build.partitions = huge_app.csv
|
board_build.partitions = huge_app.csv
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
#include "DirectoryNode.h"
|
#include "DirectoryNode.h"
|
||||||
#include "globals.h"
|
#include "globals.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cstring> // strlen, strlcpy, strlcat
|
||||||
|
#include <strings.h> // strcasecmp
|
||||||
|
|
||||||
|
char DirectoryNode::buffer[DirectoryNode::buffer_size];
|
||||||
|
|
||||||
DirectoryNode::DirectoryNode(const String &nodeName)
|
DirectoryNode::DirectoryNode(const String &nodeName)
|
||||||
: name(nodeName), currentPlaying(nullptr)
|
: name(nodeName), currentPlaying("")
|
||||||
{
|
{
|
||||||
id = DirectoryNode::idCounter;
|
id = DirectoryNode::idCounter;
|
||||||
DirectoryNode::idCounter++;
|
DirectoryNode::idCounter++;
|
||||||
@@ -37,19 +41,86 @@ const std::vector<String> &DirectoryNode::getMP3Files() const
|
|||||||
return mp3Files;
|
return mp3Files;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::setCurrentPlaying(const String *mp3File)
|
const String &DirectoryNode::getDirPath() const
|
||||||
{
|
{
|
||||||
currentPlaying = mp3File;
|
return dirPath;
|
||||||
for (int i = 0; i < mp3Files.size(); i++)
|
}
|
||||||
|
|
||||||
|
uint16_t DirectoryNode::getFileIdAt(size_t i) const
|
||||||
|
{
|
||||||
|
return (i < ids.size()) ? ids[i] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
String DirectoryNode::buildFullPath(const String &fileName) const
|
||||||
|
{
|
||||||
|
if (dirPath == "/")
|
||||||
{
|
{
|
||||||
if (mp3Files[i] == *mp3File && ids.size() > i)
|
String p = "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
String p = dirPath;
|
||||||
|
p += "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DirectoryNode::comparePathWithString(const char* path, const String& target) const
|
||||||
|
{
|
||||||
|
// Convert target to char* for comparison
|
||||||
|
const char* targetStr = target.c_str();
|
||||||
|
|
||||||
|
// Case-insensitive string comparison
|
||||||
|
return strcasecmp(path, targetStr) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::buildFullPath(const String &fileName, char* out, size_t n) const
|
||||||
|
{
|
||||||
|
if (n == 0) return;
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (dirPath == "/")
|
||||||
|
{
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
strlcpy(out, dirPath.c_str(), n);
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
strlcat(out, fileName.c_str(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::setCurrentPlaying(const String &mp3File)
|
||||||
|
{
|
||||||
|
bool isAbs = (mp3File.length() > 0) && (mp3File.charAt(0) == '/');
|
||||||
|
const String &fileName = isAbs ? mp3File.substring(mp3File.lastIndexOf('/') + 1) : mp3File;
|
||||||
|
|
||||||
|
if (isAbs)
|
||||||
|
{
|
||||||
|
currentPlaying = mp3File; // Already absolute path
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use buffer for building relative path
|
||||||
|
buildFullPath(fileName, buffer, buffer_size);
|
||||||
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
|
{
|
||||||
|
if (mp3Files[i] == fileName && ids.size() > i)
|
||||||
{
|
{
|
||||||
currentPlayingId = ids[i];
|
currentPlayingId = ids[i];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const String *DirectoryNode::getCurrentPlaying() const
|
const String &DirectoryNode::getCurrentPlaying() const
|
||||||
{
|
{
|
||||||
return currentPlaying;
|
return currentPlaying;
|
||||||
}
|
}
|
||||||
@@ -82,7 +153,7 @@ void DirectoryNode::setSecondsPlayed(const uint32_t seconds)
|
|||||||
secondsPlayed = seconds;
|
secondsPlayed = seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t DirectoryNode::getSecondsPlayed()
|
uint32_t DirectoryNode::getSecondsPlayed() const
|
||||||
{
|
{
|
||||||
return secondsPlayed;
|
return secondsPlayed;
|
||||||
}
|
}
|
||||||
@@ -98,11 +169,25 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
mp3Files.clear();
|
mp3Files.clear();
|
||||||
ids.clear();
|
ids.clear();
|
||||||
|
|
||||||
|
// 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
|
// First collect entries so we can sort them alphabetically
|
||||||
std::vector<String> dirNames;
|
std::vector<String> dirNames;
|
||||||
std::vector<String> fileNames;
|
std::vector<String> fileNames;
|
||||||
|
|
||||||
File rootDir = SD.open(currentPath);
|
File rootDir = SD.open(currentPath);
|
||||||
|
if (!rootDir)
|
||||||
|
{
|
||||||
|
Serial.print(F("buildDirectoryTree: failed to open path: "));
|
||||||
|
Serial.println(currentPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
File entry = rootDir.openNextFile();
|
File entry = rootDir.openNextFile();
|
||||||
@@ -111,29 +196,35 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir))
|
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir) != 0)
|
||||||
{
|
{
|
||||||
dirNames.push_back(String(entry.name()));
|
dirNames.emplace_back(entry.name());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
String entryName = entry.name();
|
String entryName = entry.name();
|
||||||
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
|
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
|
||||||
{
|
{
|
||||||
fileNames.push_back(entryName);
|
fileNames.push_back(std::move(entryName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry.close();
|
entry.close();
|
||||||
}
|
}
|
||||||
rootDir.close();
|
rootDir.close();
|
||||||
|
|
||||||
// Case-insensitive alphabetical sort
|
// Case-insensitive alphabetical sort without allocations
|
||||||
auto ciLess = [](const String &a, const String &b) {
|
auto ciLess = [](const String &a, const String &b) {
|
||||||
String al = a;
|
const char* pa = a.c_str();
|
||||||
String bl = b;
|
const char* pb = b.c_str();
|
||||||
al.toLowerCase();
|
while (*pa && *pb) {
|
||||||
bl.toLowerCase();
|
char ca = *pa++;
|
||||||
return al.compareTo(bl) < 0;
|
char cb = *pb++;
|
||||||
|
if (ca >= 'A' && ca <= 'Z') ca += 'a' - 'A';
|
||||||
|
if (cb >= 'A' && cb <= 'Z') cb += 'a' - 'A';
|
||||||
|
if (ca < cb) return true;
|
||||||
|
if (ca > cb) return false;
|
||||||
|
}
|
||||||
|
return *pa < *pb;
|
||||||
};
|
};
|
||||||
std::sort(dirNames.begin(), dirNames.end(), ciLess);
|
std::sort(dirNames.begin(), dirNames.end(), ciLess);
|
||||||
std::sort(fileNames.begin(), fileNames.end(), ciLess);
|
std::sort(fileNames.begin(), fileNames.end(), ciLess);
|
||||||
@@ -143,38 +234,55 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
mp3Files.reserve(fileNames.size());
|
mp3Files.reserve(fileNames.size());
|
||||||
ids.reserve(fileNames.size());
|
ids.reserve(fileNames.size());
|
||||||
|
|
||||||
// Create subdirectories in alphabetical order
|
// Add MP3 files in alphabetical order first to free fileNames memory before recursing
|
||||||
for (const String &dirName : dirNames)
|
|
||||||
{
|
|
||||||
DirectoryNode *newNode = new DirectoryNode(dirName);
|
|
||||||
subdirectories.push_back(newNode);
|
|
||||||
|
|
||||||
String childPath = String(currentPath);
|
|
||||||
if (!childPath.endsWith("/"))
|
|
||||||
childPath += "/";
|
|
||||||
childPath += dirName;
|
|
||||||
|
|
||||||
newNode->buildDirectoryTree(childPath.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add MP3 files in alphabetical order
|
|
||||||
for (const String &fileName : fileNames)
|
for (const String &fileName : fileNames)
|
||||||
{
|
{
|
||||||
String fullPath = String(currentPath);
|
mp3Files.push_back(fileName);
|
||||||
if (!fullPath.endsWith("/"))
|
|
||||||
fullPath += "/";
|
|
||||||
fullPath += fileName;
|
|
||||||
|
|
||||||
mp3Files.push_back(fullPath);
|
|
||||||
ids.push_back(getNextId());
|
ids.push_back(getNextId());
|
||||||
}
|
}
|
||||||
|
// Free memory used by fileNames vector
|
||||||
|
{
|
||||||
|
std::vector<String> 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)
|
||||||
|
{
|
||||||
|
Serial.println(F("buildDirectoryTree: OOM creating DirectoryNode"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
subdirectories.push_back(newNode);
|
||||||
|
|
||||||
|
String childPath;
|
||||||
|
childPath.reserve(dirPath.length() + 1 + dirName.length());
|
||||||
|
if (dirPath == "/")
|
||||||
|
{
|
||||||
|
childPath = "/";
|
||||||
|
childPath += dirName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
childPath = dirPath;
|
||||||
|
childPath += "/";
|
||||||
|
childPath += dirName;
|
||||||
|
}
|
||||||
|
|
||||||
|
newNode->buildDirectoryTree(childPath.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::printDirectoryTree(int level) const
|
void DirectoryNode::printDirectoryTree(int level) const
|
||||||
{
|
{
|
||||||
for (int i = 0; i < level; i++)
|
for (int i = 0; i < level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(name);
|
Serial.println(name);
|
||||||
|
|
||||||
@@ -182,7 +290,7 @@ void DirectoryNode::printDirectoryTree(int level) const
|
|||||||
{
|
{
|
||||||
for (int i = 0; i <= level; i++)
|
for (int i = 0; i <= level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(mp3File);
|
Serial.println(mp3File);
|
||||||
}
|
}
|
||||||
@@ -219,7 +327,7 @@ void DirectoryNode::advanceToFirstMP3InThisNode()
|
|||||||
{
|
{
|
||||||
if (mp3Files.size() > 0)
|
if (mp3Files.size() > 0)
|
||||||
{
|
{
|
||||||
setCurrentPlaying(&mp3Files[0]);
|
setCurrentPlaying(mp3Files[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +339,8 @@ DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
|
|||||||
if (id == ids[i])
|
if (id == ids[i])
|
||||||
{
|
{
|
||||||
// Found the current MP3 file
|
// Found the current MP3 file
|
||||||
currentPlaying = &mp3Files[i];
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
currentPlayingId = id;
|
currentPlayingId = id;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -249,57 +358,60 @@ DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
|
|||||||
|
|
||||||
// Recursively search in subdirectory
|
// Recursively search in subdirectory
|
||||||
DirectoryNode *result = subdir->advanceToMP3(id);
|
DirectoryNode *result = subdir->advanceToMP3(id);
|
||||||
if (result != nullptr && result->getCurrentPlaying() != nullptr)
|
if (result != nullptr && !result->getCurrentPlaying().isEmpty())
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, no song with this ID was found
|
// If we get here, no song with this ID was found
|
||||||
Serial.println("advanceToMP3: No song found for ID: " + String(id));
|
Serial.print(F("advanceToMP3: No song found for ID: "));
|
||||||
|
Serial.println(id);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
DirectoryNode *DirectoryNode::advanceToMP3(const String &songName)
|
||||||
{
|
{
|
||||||
if (songName == nullptr)
|
if (songName.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("advanceToMP3: songName is null");
|
Serial.println(F("advanceToMP3: songName is empty"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the input is an absolute path (starts with '/') or just a filename
|
// Check if the input is an absolute path (starts with '/') or just a filename
|
||||||
bool isAbsolutePath = songName->startsWith("/");
|
bool isAbsolutePath = songName.startsWith("/");
|
||||||
// Normalize trailing slash for absolute folder path targets
|
// Normalize trailing slash for absolute folder path targets
|
||||||
String normalizedPath = *songName;
|
String normalizedPath = songName;
|
||||||
if (isAbsolutePath && normalizedPath.length() > 1 && normalizedPath.endsWith("/"))
|
if (isAbsolutePath && normalizedPath.length() > 1 && normalizedPath.endsWith("/"))
|
||||||
{
|
{
|
||||||
normalizedPath.remove(normalizedPath.length() - 1);
|
normalizedPath.remove(normalizedPath.length() - 1);
|
||||||
}
|
}
|
||||||
// Lowercased copies for case-insensitive comparisons (FAT can uppercase names)
|
// Lowercased copies for case-insensitive comparisons (FAT can uppercase names)
|
||||||
String lowTarget = *songName;
|
String lowTarget = songName;
|
||||||
lowTarget.toLowerCase();
|
lowTarget.toLowerCase();
|
||||||
String lowNormPath = normalizedPath;
|
|
||||||
lowNormPath.toLowerCase();
|
|
||||||
|
|
||||||
// First, search in the current directory's MP3 files
|
// First, search in the current directory's MP3 files
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
if (isAbsolutePath)
|
if (isAbsolutePath)
|
||||||
{
|
{
|
||||||
if (mp3Files[i].equalsIgnoreCase(*songName))
|
// Use static buffer for path building and comparison
|
||||||
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, songName))
|
||||||
{
|
{
|
||||||
setCurrentPlaying(&mp3Files[i]);
|
setCurrentPlaying(mp3Files[i]);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
String f = mp3Files[i];
|
// Use static buffer for comparison without allocation
|
||||||
f.toLowerCase();
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
if (f.endsWith(lowTarget))
|
size_t bufLen = strlen(buffer);
|
||||||
|
size_t targetLen = lowTarget.length();
|
||||||
|
if (bufLen >= targetLen && strcasecmp(buffer + bufLen - targetLen, lowTarget.c_str()) == 0)
|
||||||
{
|
{
|
||||||
setCurrentPlaying(&mp3Files[i]);
|
setCurrentPlaying(mp3Files[i]);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,20 +420,17 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
// Then search in subdirectories
|
// Then search in subdirectories
|
||||||
for (auto subdir : subdirectories)
|
for (auto subdir : subdirectories)
|
||||||
{
|
{
|
||||||
// Absolute folder target: match directory by its full path derived from its files
|
// Absolute folder target: match directory by its full path (dirPath)
|
||||||
if (isAbsolutePath && subdir->mp3Files.size() > 0)
|
if (isAbsolutePath)
|
||||||
{
|
{
|
||||||
String anyFile = subdir->mp3Files[0];
|
if (subdir->getDirPath().equalsIgnoreCase(normalizedPath))
|
||||||
int lastSlash = anyFile.lastIndexOf('/');
|
|
||||||
String subdirPath = (lastSlash >= 0) ? anyFile.substring(0, lastSlash) : String();
|
|
||||||
if (subdirPath.equalsIgnoreCase(normalizedPath))
|
|
||||||
{
|
{
|
||||||
subdir->advanceToFirstMP3InThisNode();
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(*songName))
|
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(songName))
|
||||||
{
|
{
|
||||||
subdir->advanceToFirstMP3InThisNode();
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
return subdir;
|
return subdir;
|
||||||
@@ -333,19 +442,21 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
|
|
||||||
if (isAbsolutePath)
|
if (isAbsolutePath)
|
||||||
{
|
{
|
||||||
if (subdir->mp3Files[i].equalsIgnoreCase(*songName))
|
if (subdir->buildFullPath(subdir->mp3Files[i]).equalsIgnoreCase(songName))
|
||||||
{
|
{
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
|
subdir->setCurrentPlaying(subdir->mp3Files[i]);
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
String f = subdir->mp3Files[i];
|
// Check suffix case-insensitively without creating new Strings
|
||||||
f.toLowerCase();
|
const String& fName = subdir->mp3Files[i];
|
||||||
if (f.endsWith(lowTarget))
|
size_t fLen = fName.length();
|
||||||
|
size_t targetLen = lowTarget.length();
|
||||||
|
if (fLen >= targetLen && strcasecmp(fName.c_str() + fLen - targetLen, lowTarget.c_str()) == 0)
|
||||||
{
|
{
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
|
subdir->setCurrentPlaying(subdir->mp3Files[i]);
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +470,8 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, no matching song was found
|
// If we get here, no matching song was found
|
||||||
Serial.println("advanceToMP3: No song found for: " + *songName);
|
Serial.print(F("advanceToMP3: No song found for: "));
|
||||||
|
Serial.println(songName);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,16 +486,16 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
||||||
{
|
{
|
||||||
// Safety check for null pointer
|
// Safety check for null pointer
|
||||||
if (currentPlaying == nullptr)
|
if (currentPlaying.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("goToPreviousMP3: currentPlaying is null");
|
Serial.println(F("goToPreviousMP3: currentPlaying is empty"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've been playing for more than threshold seconds, restart current song
|
// If we've been playing for more than threshold seconds, restart current song
|
||||||
if (secondsPlayed > thresholdSeconds)
|
if (secondsPlayed > thresholdSeconds)
|
||||||
{
|
{
|
||||||
Serial.println("goToPreviousMP3: Restarting current song (played > threshold)");
|
Serial.println(F("goToPreviousMP3: Restarting current song (played > threshold)"));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +503,8 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
|||||||
int currentIndex = -1;
|
int currentIndex = -1;
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
if (*currentPlaying == mp3Files[i])
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentPlaying))
|
||||||
{
|
{
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
break;
|
break;
|
||||||
@@ -401,23 +514,23 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
|||||||
// If current song found and not the first song, move to previous
|
// If current song found and not the first song, move to previous
|
||||||
if (currentIndex > 0)
|
if (currentIndex > 0)
|
||||||
{
|
{
|
||||||
Serial.print("goToPreviousMP3: Moving to previous song in same directory: ");
|
Serial.print(F("goToPreviousMP3: Moving to previous song in same directory: "));
|
||||||
Serial.println(mp3Files[currentIndex - 1]);
|
Serial.println(mp3Files[currentIndex - 1]);
|
||||||
setCurrentPlaying(&mp3Files[currentIndex - 1]);
|
setCurrentPlaying(mp3Files[currentIndex - 1]);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're at the first song or song not found in current directory,
|
// If we're at the first song or song not found in current directory,
|
||||||
// we need to find the previous song globally
|
// we need to find the previous song globally
|
||||||
Serial.println("goToPreviousMP3: At first song or song not found, looking for previous globally");
|
Serial.println(F("goToPreviousMP3: At first song or song not found, looking for previous globally"));
|
||||||
return nullptr; // Let the caller handle global previous logic
|
return nullptr; // Let the caller handle global previous logic
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGlobal)
|
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String ¤tGlobal)
|
||||||
{
|
{
|
||||||
if (currentGlobal == nullptr)
|
if (currentGlobal.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("findPreviousMP3Globally: currentGlobal is null");
|
Serial.println(F("findPreviousMP3Globally: currentGlobal is null"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +544,9 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
|
|||||||
{
|
{
|
||||||
DirectoryNode *node = allMP3s[i].first;
|
DirectoryNode *node = allMP3s[i].first;
|
||||||
int fileIndex = allMP3s[i].second;
|
int fileIndex = allMP3s[i].second;
|
||||||
if (node->mp3Files[fileIndex] == *currentGlobal)
|
|
||||||
|
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentGlobal))
|
||||||
{
|
{
|
||||||
currentGlobalIndex = i;
|
currentGlobalIndex = i;
|
||||||
break;
|
break;
|
||||||
@@ -444,23 +559,31 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
|
|||||||
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
|
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
|
||||||
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
|
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
|
||||||
|
|
||||||
Serial.print("findPreviousMP3Globally: Moving to previous song globally: ");
|
prevNode->buildFullPath(prevNode->mp3Files[prevFileIndex], buffer, buffer_size);
|
||||||
Serial.println(prevNode->mp3Files[prevFileIndex]);
|
|
||||||
|
|
||||||
prevNode->setCurrentPlaying(&prevNode->mp3Files[prevFileIndex]);
|
Serial.print(F("findPreviousMP3Globally: Moving to previous song globally: "));
|
||||||
|
Serial.println(buffer);
|
||||||
|
|
||||||
|
prevNode->setCurrentPlaying(prevNode->mp3Files[prevFileIndex]);
|
||||||
return prevNode;
|
return prevNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.println("findPreviousMP3Globally: No previous song found globally");
|
Serial.println(F("findPreviousMP3Globally: No previous song found globally"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>> &allMP3s)
|
void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>> &allMP3s)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
#ifdef DEBUG
|
||||||
|
Serial.println("Building flat mp3 list for folder");
|
||||||
|
#endif
|
||||||
|
// Pre-reserve to reduce reallocations
|
||||||
|
allMP3s.reserve(allMP3s.size() + mp3Files.size());
|
||||||
// Add all MP3 files from this directory
|
// Add all MP3 files from this directory
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
allMP3s.push_back(std::make_pair(this, i));
|
allMP3s.emplace_back(this, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively add MP3 files from subdirectories
|
// Recursively add MP3 files from subdirectories
|
||||||
@@ -470,126 +593,64 @@ void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const size_t DirectoryNode::getNumOfFiles()
|
size_t DirectoryNode::getNumOfFiles() const
|
||||||
{
|
{
|
||||||
return subdirectories.size();
|
return subdirectories.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToNextMP3(const String *currentGlobal)
|
DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal)
|
||||||
{
|
{
|
||||||
bool useFirst = false;
|
Serial.println(currentGlobal.c_str());
|
||||||
Serial.println(currentGlobal->c_str());
|
|
||||||
if (currentGlobal != nullptr)
|
// Build a flat list of all MP3 files in order to correctly find the next one across directories
|
||||||
|
std::vector<std::pair<DirectoryNode *, int>> allMP3s;
|
||||||
|
buildFlatMP3List(allMP3s);
|
||||||
|
|
||||||
|
if (allMP3s.empty())
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
Serial.println(F("advanceToNextMP3: No MP3s found in tree"));
|
||||||
|
currentPlaying = "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentIndex = -1;
|
||||||
|
if (!currentGlobal.isEmpty())
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < allMP3s.size(); i++)
|
||||||
{
|
{
|
||||||
if (*currentGlobal == mp3Files[i])
|
DirectoryNode *node = allMP3s[i].first;
|
||||||
|
int fileIndex = allMP3s[i].second;
|
||||||
|
|
||||||
|
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentGlobal))
|
||||||
{
|
{
|
||||||
// Found the current playing MP3 file
|
currentIndex = (int)i;
|
||||||
if (i < mp3Files.size() - 1)
|
|
||||||
{
|
|
||||||
// Advance to the next MP3 file in the same directory
|
|
||||||
setCurrentPlaying(&mp3Files[i + 1]);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
useFirst = true;
|
|
||||||
// Reached the end of the MP3 files in the directory
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We are either not playing, or we've exhausted all the MP3 files in this directory.
|
// If current song found and not the last one, move to next
|
||||||
// Therefore, we need to recursively look in our subdirectories.
|
if (currentIndex >= 0 && currentIndex < (int)allMP3s.size() - 1)
|
||||||
for (auto subdir : subdirectories)
|
|
||||||
{
|
{
|
||||||
|
DirectoryNode *nextNode = allMP3s[currentIndex + 1].first;
|
||||||
|
int nextFileIndex = allMP3s[currentIndex + 1].second;
|
||||||
|
|
||||||
if (useFirst && subdir->mp3Files.size() > 0)
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
{
|
return nextNode;
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[0]);
|
|
||||||
return subdir;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Have each subdirectory advance its song
|
|
||||||
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
|
|
||||||
{
|
|
||||||
if (*currentGlobal == subdir->mp3Files[i])
|
|
||||||
{
|
|
||||||
// Found the current playing MP3 file
|
|
||||||
if (i < subdir->mp3Files.size() - 1)
|
|
||||||
{
|
|
||||||
// Advance to the next MP3 file in the same directory
|
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i + 1]);
|
|
||||||
return subdir;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
useFirst = true;
|
|
||||||
}
|
|
||||||
// Reached the end of the MP3 files in the directory
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, there were no MP3 files or subdirectories left to check
|
// If not playing anything (start), play first
|
||||||
currentPlaying = nullptr;
|
if (currentIndex == -1 && currentGlobal.isEmpty())
|
||||||
Serial.println("no more nodes found");
|
{
|
||||||
|
DirectoryNode *nextNode = allMP3s[0].first;
|
||||||
|
int nextFileIndex = allMP3s[0].second;
|
||||||
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
|
return nextNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, either we are at the last song, or the current song was not found
|
||||||
|
currentPlaying = "";
|
||||||
|
Serial.println(F("no more nodes found"));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void DirectoryNode::streamDirectoryHTML(Print &out) const {
|
|
||||||
if (name == "/") {
|
|
||||||
out.println(F("<ul>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name != "/") {
|
|
||||||
out.print(F("<li data-id=\""));
|
|
||||||
out.print(id);
|
|
||||||
out.print(F("\"><b>"));
|
|
||||||
out.print(name);
|
|
||||||
out.println(F("</b></li>"));
|
|
||||||
delay(0); // Yield to allow network stack to send buffered data
|
|
||||||
}
|
|
||||||
|
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++) {
|
|
||||||
out.print(F("<li data-id=\""));
|
|
||||||
out.print(ids[i]);
|
|
||||||
out.print(F("\">"));
|
|
||||||
out.print(mp3Files[i]);
|
|
||||||
out.println(F("</li>"));
|
|
||||||
|
|
||||||
// Yield every few items to allow the async web server to send buffered data
|
|
||||||
if (i % 5 == 4) {
|
|
||||||
delay(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (DirectoryNode* child : subdirectories) {
|
|
||||||
child->streamDirectoryHTML(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name == "/") {
|
|
||||||
out.println(F("</ul>"));
|
|
||||||
delay(0); // Final yield before completing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
void DirectoryNode::appendIndentation(String &html, int level) const
|
|
||||||
{
|
|
||||||
for (int i = 0; i < level; i++)
|
|
||||||
{
|
|
||||||
html.concat(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String DirectoryNode::getCurrentPlayingFilePath() const
|
|
||||||
{
|
|
||||||
if (currentPlaying != nullptr)
|
|
||||||
{
|
|
||||||
return *currentPlaying;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ class DirectoryNode {
|
|||||||
private:
|
private:
|
||||||
uint16_t id;
|
uint16_t id;
|
||||||
String name;
|
String name;
|
||||||
|
String dirPath;
|
||||||
std::vector<DirectoryNode*> subdirectories;
|
std::vector<DirectoryNode*> subdirectories;
|
||||||
std::vector<String> mp3Files;
|
std::vector<String> mp3Files;
|
||||||
std::vector<uint16_t> ids;
|
std::vector<uint16_t> ids;
|
||||||
const String* currentPlaying;
|
static const size_t path_size = 256;
|
||||||
|
String currentPlaying;
|
||||||
uint16_t currentPlayingId = 0;
|
uint16_t currentPlayingId = 0;
|
||||||
uint16_t secondsPlayed = 0;
|
uint16_t secondsPlayed = 0;
|
||||||
|
static const size_t buffer_size = path_size;
|
||||||
|
static char buffer[buffer_size];
|
||||||
|
|
||||||
|
String buildFullPath(const String& fileName) const;
|
||||||
|
void buildFullPath(const String &fileName, char* buffer, size_t bufferSize) const;
|
||||||
|
bool comparePathWithString(const char* path, const String& target) const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -34,15 +41,17 @@ public:
|
|||||||
const uint16_t getId() const;
|
const uint16_t getId() const;
|
||||||
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
||||||
const std::vector<String>& getMP3Files() const;
|
const std::vector<String>& getMP3Files() const;
|
||||||
|
const String& getDirPath() const;
|
||||||
|
uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
const size_t getNumOfFiles();
|
size_t getNumOfFiles() const;
|
||||||
|
|
||||||
void setCurrentPlaying(const String* mp3File);
|
void setCurrentPlaying(const String& mp3File);
|
||||||
const String* getCurrentPlaying() const;
|
const String& getCurrentPlaying() const;
|
||||||
const uint16_t getCurrentPlayingId() const;
|
const uint16_t getCurrentPlayingId() const;
|
||||||
|
|
||||||
void setSecondsPlayed(const uint32_t seconds);
|
void setSecondsPlayed(const uint32_t seconds);
|
||||||
uint32_t getSecondsPlayed();
|
uint32_t getSecondsPlayed() const;
|
||||||
|
|
||||||
uint16_t getNextId();
|
uint16_t getNextId();
|
||||||
|
|
||||||
@@ -50,17 +59,14 @@ public:
|
|||||||
void addMP3File(const String& mp3File);
|
void addMP3File(const String& mp3File);
|
||||||
void buildDirectoryTree(const char* currentPath);
|
void buildDirectoryTree(const char* currentPath);
|
||||||
void printDirectoryTree(int level = 0) const;
|
void printDirectoryTree(int level = 0) const;
|
||||||
DirectoryNode* advanceToMP3(const String* songName);
|
DirectoryNode* advanceToMP3(const String& songName);
|
||||||
DirectoryNode* advanceToNextMP3(const String* currentGlobal);
|
DirectoryNode* advanceToNextMP3(const String& currentGlobal);
|
||||||
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
||||||
DirectoryNode* findPreviousMP3Globally(const String* currentGlobal);
|
DirectoryNode* findPreviousMP3Globally(const String& currentGlobal);
|
||||||
void buildFlatMP3List(std::vector<std::pair<DirectoryNode*, int>>& allMP3s);
|
void buildFlatMP3List(std::vector<std::pair<DirectoryNode*, int>>& allMP3s);
|
||||||
DirectoryNode* advanceToMP3(const uint16_t id);
|
DirectoryNode* advanceToMP3(const uint16_t id);
|
||||||
void advanceToFirstMP3InThisNode();
|
void advanceToFirstMP3InThisNode();
|
||||||
void streamDirectoryHTML(Print &out) const;
|
|
||||||
void appendIndentation(String& html, int level) const;
|
|
||||||
DirectoryNode* findFirstDirectoryWithMP3s();
|
DirectoryNode* findFirstDirectoryWithMP3s();
|
||||||
String getCurrentPlayingFilePath() const;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
115
src/DirectoryWalker.h
Normal file
115
src/DirectoryWalker.h
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#ifndef DIRECTORY_WALKER_H
|
||||||
|
#define DIRECTORY_WALKER_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
#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<WalkerState> 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("<ul>\r\n");
|
||||||
|
} else {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getId());
|
||||||
|
pending += F("\"><b>");
|
||||||
|
pending += node->getName();
|
||||||
|
pending += F("</b></li>\r\n");
|
||||||
|
}
|
||||||
|
state.phase = 1;
|
||||||
|
state.idx = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Files
|
||||||
|
if (state.idx < node->getMP3Files().size()) {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getFileIdAt(state.idx));
|
||||||
|
pending += F("\">");
|
||||||
|
pending += node->getMP3Files()[state.idx];
|
||||||
|
pending += F("</li>\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("</ul>\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
|
||||||
@@ -31,7 +31,7 @@ static const char* hdr_connection_key = "Connection";
|
|||||||
|
|
||||||
static const char* hdr_connection_val = "close";
|
static const char* hdr_connection_val = "close";
|
||||||
|
|
||||||
|
const size_t buffer_size = 80;
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
861
src/main.cpp
861
src/main.cpp
File diff suppressed because it is too large
Load Diff
10
src/main.h
10
src/main.h
@@ -34,8 +34,6 @@
|
|||||||
|
|
||||||
#define MAX_VOL 15
|
#define MAX_VOL 15
|
||||||
|
|
||||||
//#define DEBUG TRUE
|
|
||||||
|
|
||||||
File root;
|
File root;
|
||||||
File mp3File;
|
File mp3File;
|
||||||
|
|
||||||
@@ -81,6 +79,8 @@ bool asyncNext = false;
|
|||||||
|
|
||||||
bool asyncPrev = false;
|
bool asyncPrev = false;
|
||||||
|
|
||||||
|
bool asyncReset = false;
|
||||||
|
|
||||||
bool SDActive = false;
|
bool SDActive = false;
|
||||||
|
|
||||||
bool RFIDActive = false;
|
bool RFIDActive = false;
|
||||||
@@ -90,6 +90,7 @@ bool RFIDActive = false;
|
|||||||
volatile uint32_t webreq_cnt = 0;
|
volatile uint32_t webreq_cnt = 0;
|
||||||
static inline void webreq_enter() { __sync_add_and_fetch(&webreq_cnt, 1); }
|
static inline void webreq_enter() { __sync_add_and_fetch(&webreq_cnt, 1); }
|
||||||
static inline void webreq_exit() { __sync_sub_and_fetch(&webreq_cnt, 1); }
|
static inline void webreq_exit() { __sync_sub_and_fetch(&webreq_cnt, 1); }
|
||||||
|
volatile bool server_reset_pending = false;
|
||||||
|
|
||||||
uint16_t voltage_threshold_counter = 0;
|
uint16_t voltage_threshold_counter = 0;
|
||||||
|
|
||||||
@@ -113,7 +114,6 @@ void init_webserver();
|
|||||||
|
|
||||||
boolean buttonPressed(const uint8_t pin);
|
boolean buttonPressed(const uint8_t pin);
|
||||||
|
|
||||||
const String getSysDir(const String filename);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper routine to dump a byte array as hex values to Serial.
|
* Helper routine to dump a byte array as hex values to Serial.
|
||||||
@@ -212,9 +212,11 @@ std::map<String, MappingEntry> rfid_map;
|
|||||||
|
|
||||||
// Folder-play helper: when a mapping requests "folder only" playback we keep
|
// Folder-play helper: when a mapping requests "folder only" playback we keep
|
||||||
// track of the folder root node so EOF handling can advance only inside that folder.
|
// track of the folder root node so EOF handling can advance only inside that folder.
|
||||||
bool folderModeActive = false;
|
bool folderModeActive = true;
|
||||||
|
|
||||||
bool pendingSeek = false;
|
bool pendingSeek = false;
|
||||||
uint32_t pendingSeekSeconds = 0;
|
uint32_t pendingSeekSeconds = 0;
|
||||||
|
|
||||||
|
static const size_t MAX_DEPTH = 32;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<h1>HannaBox</h1>
|
<h1>HannaBox</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="state">—</div>
|
<div class="header-status-group">
|
||||||
|
<div id="batteryStatus" class="battery-status" title="Battery"></div>
|
||||||
|
<div class="status" id="state">—</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -118,8 +121,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="mode">Mode:</label>
|
<label for="mode">Mode:</label>
|
||||||
<select id="mode" name="mode">
|
<select id="mode" name="mode">
|
||||||
<option value="s">Single (play selected song / file)</option>
|
|
||||||
<option value="f">Folder (play selected folder, then stop)</option>
|
<option value="f">Folder (play selected folder, then stop)</option>
|
||||||
|
<option value="s">Single (play selected song / file)</option>
|
||||||
<option value="r">Random (randomize order in folder, then stop)</option>
|
<option value="r">Random (randomize order in folder, then stop)</option>
|
||||||
<option value="c">Continuous (continuous playback / loop folder)</option>
|
<option value="c">Continuous (continuous playback / loop folder)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -148,6 +151,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
|
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<h4>System</h4>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="action-btn" style="background-color: #ef4444;" onclick="resetWifi()">Reset WiFi Settings</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -115,6 +115,24 @@ function loadDirectory() {
|
|||||||
xhr.send();
|
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() {
|
function loadMapping() {
|
||||||
var el = document.getElementById('mappingList');
|
var el = document.getElementById('mappingList');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -241,6 +259,25 @@ function displayState(state) {
|
|||||||
var voltageEl = document.getElementById("voltage");
|
var voltageEl = document.getElementById("voltage");
|
||||||
if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV';
|
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 =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.7"><path d="M16 4h-1V2a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/></svg>' +
|
||||||
|
'<span>' + pct + '%</span>';
|
||||||
|
headerBattery.title = mv + ' mV';
|
||||||
|
} else {
|
||||||
|
headerBattery.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var heapEl = document.getElementById("heap");
|
var heapEl = document.getElementById("heap");
|
||||||
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';
|
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ a { color: var(--accent); text-decoration: none; }
|
|||||||
margin-top: 2px;
|
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 (current song) */
|
||||||
.status {
|
.status {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user