Compare commits

...

15 Commits

13 changed files with 1066 additions and 595 deletions

View File

@@ -1,8 +1,7 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"diegoomal.ollama-connection",
"pioarduino.pioarduino-ide",
"platformio.platformio-ide"
],
"unwantedRecommendations": [

View File

@@ -4,6 +4,109 @@ This document summarizes the memory optimizations implemented to resolve out-of-
## 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, AsyncTCPs 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 theres space to send more, so AsyncTCPs cbuf wont 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 doesnt 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)
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
- **Memory saved**: Reduces fragmentation and improves allocation efficiency

View File

@@ -13,24 +13,24 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
board = wemos_d1_mini32
framework = arduino
lib_deps =
ESP32Async/AsyncTCP@3.3.8
ESP32Async/ESPAsyncWebServer@3.7.9
ESP32Async/ESPAsyncWebServer@3.9.2
alanswx/ESPAsyncWiFiManager@0.31
miguelbalboa/MFRC522@^1.4.12
bblanchon/ArduinoJson@^6.21.3
monitor_speed = 115200
build_flags =
-Os ; Optimize for size
-DCORE_DEBUG_LEVEL=0 ; Disable all debug output
-DARDUINO_LOOP_STACK_SIZE=3072 ; Further reduce from 4096
-DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
-DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
-DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
-DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=3000
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; (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_STACK_SIZE=4096 ; reduce the stack size (default is 16K)
-Os ; Optimize for size
-DDEBUG ; Hannabox Debugging
; -DCORE_DEBUG_LEVEL=0 ; Disable all debug output
; -DARDUINO_LOOP_STACK_SIZE=4096 ; Balanced to avoid stack canary without starving heap
; -DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
; -DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
; -DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
; -DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; (keep default)
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ;(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_STACK_SIZE=4096 ; reduce AsyncTCP task stack (default is 16k)
monitor_filters = esp32_exception_decoder
board_build.partitions = huge_app.csv

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Simple build tool to minify web assets in the `web/` folder.
Simple build tool to minify web assets in the `web/` folder and generate gzipped versions.
Usage:
python3 scripts/minify_web.py
@@ -15,6 +15,11 @@ and write minified outputs to:
- web/cleaned/style.css
- web/cleaned/script.js
Additionally, gzipped variants are produced to enable efficient on-device serving:
- web/cleaned/index.html.gz
- web/cleaned/style.css.gz
- web/cleaned/script.js.gz
The minifiers are intentionally conservative (no external deps) and aim to be
safe for typical static files used in this project. They remove comments,
collapse unnecessary whitespace and do small syntax-preserving transformations.
@@ -29,6 +34,7 @@ from pathlib import Path
import re
import sys
import os
import gzip
BASE = Path(__file__).resolve().parent.parent
WEB = BASE / "web"
@@ -218,6 +224,15 @@ def write_file(path: Path, data: str):
except Exception as e:
print(f"ERROR writing {path}: {e}", file=sys.stderr)
def write_gzip(path: Path, data: str):
"""Write UTF-8 text as gzip with deterministic mtime for reproducible builds."""
try:
gz_bytes = gzip.compress(data.encode('utf-8'), mtime=0)
path.write_bytes(gz_bytes)
print(f"Wrote {path} ({len(gz_bytes)} bytes, gzip)")
except Exception as e:
print(f"ERROR writing {path}: {e}", file=sys.stderr)
def minify_all():
ensure_clean_dir()
@@ -228,6 +243,7 @@ def minify_all():
s = read_file(index)
out = minify_html(s)
write_file(CLEAN / "index.html", out)
write_gzip(CLEAN / "index.html.gz", out)
else:
print("No index.html found in web/")
@@ -238,6 +254,7 @@ def minify_all():
s = read_file(css)
out = minify_css(s)
write_file(CLEAN / "style.css", out)
write_gzip(CLEAN / "style.css.gz", out)
else:
print("No style.css found in web/")
@@ -248,6 +265,7 @@ def minify_all():
s = read_file(js)
out = minify_js(s)
write_file(CLEAN / "script.js", out)
write_gzip(CLEAN / "script.js.gz", out)
else:
print("No script.js found in web/")

View File

@@ -1,9 +1,13 @@
#include "DirectoryNode.h"
#include "globals.h"
#include <algorithm>
#include <cstring> // strlen, strlcpy, strlcat
#include <strings.h> // strcasecmp
char DirectoryNode::buffer[DirectoryNode::buffer_size];
DirectoryNode::DirectoryNode(const String &nodeName)
: name(nodeName), currentPlaying(nullptr)
: name(nodeName), currentPlaying("")
{
id = DirectoryNode::idCounter;
DirectoryNode::idCounter++;
@@ -37,19 +41,86 @@ const std::vector<String> &DirectoryNode::getMP3Files() const
return mp3Files;
}
void DirectoryNode::setCurrentPlaying(const String *mp3File)
const String &DirectoryNode::getDirPath() const
{
currentPlaying = mp3File;
for (int i = 0; i < mp3Files.size(); i++)
return dirPath;
}
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];
break;
}
}
}
const String *DirectoryNode::getCurrentPlaying() const
const String &DirectoryNode::getCurrentPlaying() const
{
return currentPlaying;
}
@@ -82,7 +153,7 @@ void DirectoryNode::setSecondsPlayed(const uint32_t seconds)
secondsPlayed = seconds;
}
uint32_t DirectoryNode::getSecondsPlayed()
uint32_t DirectoryNode::getSecondsPlayed() const
{
return secondsPlayed;
}
@@ -98,11 +169,25 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
mp3Files.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
std::vector<String> dirNames;
std::vector<String> fileNames;
File rootDir = SD.open(currentPath);
if (!rootDir)
{
Serial.print(F("buildDirectoryTree: failed to open path: "));
Serial.println(currentPath);
return;
}
while (true)
{
File entry = rootDir.openNextFile();
@@ -111,29 +196,35 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
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
{
String entryName = entry.name();
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
{
fileNames.push_back(entryName);
fileNames.push_back(std::move(entryName));
}
}
entry.close();
}
rootDir.close();
// Case-insensitive alphabetical sort
// Case-insensitive alphabetical sort without allocations
auto ciLess = [](const String &a, const String &b) {
String al = a;
String bl = b;
al.toLowerCase();
bl.toLowerCase();
return al.compareTo(bl) < 0;
const char* pa = a.c_str();
const char* pb = b.c_str();
while (*pa && *pb) {
char ca = *pa++;
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(fileNames.begin(), fileNames.end(), ciLess);
@@ -143,50 +234,67 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
mp3Files.reserve(fileNames.size());
ids.reserve(fileNames.size());
// Create subdirectories in alphabetical order
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
// Add MP3 files in alphabetical order first to free fileNames memory before recursing
for (const String &fileName : fileNames)
{
String fullPath = String(currentPath);
if (!fullPath.endsWith("/"))
fullPath += "/";
fullPath += fileName;
mp3Files.push_back(fullPath);
mp3Files.push_back(fileName);
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
{
for (int i = 0; i < level; i++)
{
Serial.print(" ");
Serial.print(F(" "));
}
Serial.println(name);
for (const String &mp3File : mp3Files)
{
for (int i = 0; i <= level; i++)
{
Serial.print(" ");
Serial.print(F(" "));
}
Serial.println(mp3File);
}
for (DirectoryNode *childNode : subdirectories)
{
childNode->printDirectoryTree(level + 1);
@@ -219,7 +327,7 @@ void DirectoryNode::advanceToFirstMP3InThisNode()
{
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])
{
// 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;
return this;
}
@@ -249,57 +358,60 @@ DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
// Recursively search in subdirectory
DirectoryNode *result = subdir->advanceToMP3(id);
if (result != nullptr && result->getCurrentPlaying() != nullptr)
if (result != nullptr && !result->getCurrentPlaying().isEmpty())
{
return result;
}
}
// 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;
}
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;
}
// 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
String normalizedPath = *songName;
String normalizedPath = songName;
if (isAbsolutePath && normalizedPath.length() > 1 && normalizedPath.endsWith("/"))
{
normalizedPath.remove(normalizedPath.length() - 1);
}
// Lowercased copies for case-insensitive comparisons (FAT can uppercase names)
String lowTarget = *songName;
String lowTarget = songName;
lowTarget.toLowerCase();
String lowNormPath = normalizedPath;
lowNormPath.toLowerCase();
// First, search in the current directory's MP3 files
for (size_t i = 0; i < mp3Files.size(); i++)
{
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;
}
}
else
{
String f = mp3Files[i];
f.toLowerCase();
if (f.endsWith(lowTarget))
// Use static buffer for comparison without allocation
buildFullPath(mp3Files[i], buffer, buffer_size);
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;
}
}
@@ -308,20 +420,17 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
// Then search in subdirectories
for (auto subdir : subdirectories)
{
// Absolute folder target: match directory by its full path derived from its files
if (isAbsolutePath && subdir->mp3Files.size() > 0)
// Absolute folder target: match directory by its full path (dirPath)
if (isAbsolutePath)
{
String anyFile = subdir->mp3Files[0];
int lastSlash = anyFile.lastIndexOf('/');
String subdirPath = (lastSlash >= 0) ? anyFile.substring(0, lastSlash) : String();
if (subdirPath.equalsIgnoreCase(normalizedPath))
if (subdir->getDirPath().equalsIgnoreCase(normalizedPath))
{
subdir->advanceToFirstMP3InThisNode();
return subdir;
}
}
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(*songName))
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(songName))
{
subdir->advanceToFirstMP3InThisNode();
return subdir;
@@ -333,19 +442,21 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
if (isAbsolutePath)
{
if (subdir->mp3Files[i].equalsIgnoreCase(*songName))
if (subdir->buildFullPath(subdir->mp3Files[i]).equalsIgnoreCase(songName))
{
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
subdir->setCurrentPlaying(subdir->mp3Files[i]);
return subdir;
}
}
else
{
String f = subdir->mp3Files[i];
f.toLowerCase();
if (f.endsWith(lowTarget))
// Check suffix case-insensitively without creating new Strings
const String& fName = subdir->mp3Files[i];
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;
}
}
@@ -359,7 +470,8 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
}
// 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;
}
@@ -374,16 +486,16 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
{
// 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;
}
// If we've been playing for more than threshold seconds, restart current song
if (secondsPlayed > thresholdSeconds)
{
Serial.println("goToPreviousMP3: Restarting current song (played > threshold)");
Serial.println(F("goToPreviousMP3: Restarting current song (played > threshold)"));
return this;
}
@@ -391,7 +503,8 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
int currentIndex = -1;
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;
break;
@@ -401,23 +514,23 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
// If current song found and not the first song, move to previous
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]);
setCurrentPlaying(&mp3Files[currentIndex - 1]);
setCurrentPlaying(mp3Files[currentIndex - 1]);
return this;
}
// If we're at the first song or song not found in current directory,
// 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
}
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGlobal)
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String &currentGlobal)
{
if (currentGlobal == nullptr)
if (currentGlobal.isEmpty())
{
Serial.println("findPreviousMP3Globally: currentGlobal is null");
Serial.println(F("findPreviousMP3Globally: currentGlobal is null"));
return nullptr;
}
@@ -431,7 +544,9 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
{
DirectoryNode *node = allMP3s[i].first;
int fileIndex = allMP3s[i].second;
if (node->mp3Files[fileIndex] == *currentGlobal)
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
if (comparePathWithString(buffer, currentGlobal))
{
currentGlobalIndex = i;
break;
@@ -443,24 +558,32 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
{
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
Serial.print("findPreviousMP3Globally: Moving to previous song globally: ");
Serial.println(prevNode->mp3Files[prevFileIndex]);
prevNode->setCurrentPlaying(&prevNode->mp3Files[prevFileIndex]);
prevNode->buildFullPath(prevNode->mp3Files[prevFileIndex], buffer, buffer_size);
Serial.print(F("findPreviousMP3Globally: Moving to previous song globally: "));
Serial.println(buffer);
prevNode->setCurrentPlaying(prevNode->mp3Files[prevFileIndex]);
return prevNode;
}
Serial.println("findPreviousMP3Globally: No previous song found globally");
Serial.println(F("findPreviousMP3Globally: No previous song found globally"));
return nullptr;
}
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
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
@@ -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();
}
DirectoryNode *DirectoryNode::advanceToNextMP3(const String *currentGlobal)
DirectoryNode *DirectoryNode::advanceToNextMP3(const String &currentGlobal)
{
bool useFirst = false;
Serial.println(currentGlobal->c_str());
if (currentGlobal != nullptr)
Serial.println(currentGlobal.c_str());
// 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
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
currentIndex = (int)i;
break;
}
}
}
// We are either not playing, or we've exhausted all the MP3 files in this directory.
// Therefore, we need to recursively look in our subdirectories.
for (auto subdir : subdirectories)
// If current song found and not the last one, move to next
if (currentIndex >= 0 && currentIndex < (int)allMP3s.size() - 1)
{
if (useFirst && subdir->mp3Files.size() > 0)
{
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;
}
}
DirectoryNode *nextNode = allMP3s[currentIndex + 1].first;
int nextFileIndex = allMP3s[currentIndex + 1].second;
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
return nextNode;
}
// If not playing anything (start), play first
if (currentIndex == -1 && currentGlobal.isEmpty())
{
DirectoryNode *nextNode = allMP3s[0].first;
int nextFileIndex = allMP3s[0].second;
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
return nextNode;
}
// If we get here, there were no MP3 files or subdirectories left to check
currentPlaying = nullptr;
Serial.println("no more nodes found");
// 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;
}
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 "";
}

View File

@@ -14,15 +14,22 @@ class DirectoryNode {
private:
uint16_t id;
String name;
String dirPath;
std::vector<DirectoryNode*> subdirectories;
std::vector<String> mp3Files;
std::vector<uint16_t> ids;
const String* currentPlaying;
static const size_t path_size = 256;
String currentPlaying;
uint16_t currentPlayingId = 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;
public:
DirectoryNode(const String& nodeName);
@@ -34,15 +41,17 @@ public:
const uint16_t getId() const;
const std::vector<DirectoryNode*>& getSubdirectories() 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);
const String* getCurrentPlaying() const;
void setCurrentPlaying(const String& mp3File);
const String& getCurrentPlaying() const;
const uint16_t getCurrentPlayingId() const;
void setSecondsPlayed(const uint32_t seconds);
uint32_t getSecondsPlayed();
uint32_t getSecondsPlayed() const;
uint16_t getNextId();
@@ -50,17 +59,14 @@ public:
void addMP3File(const String& mp3File);
void buildDirectoryTree(const char* currentPath);
void printDirectoryTree(int level = 0) const;
DirectoryNode* advanceToMP3(const String* songName);
DirectoryNode* advanceToNextMP3(const String* currentGlobal);
DirectoryNode* advanceToMP3(const String& songName);
DirectoryNode* advanceToNextMP3(const String& currentGlobal);
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);
DirectoryNode* advanceToMP3(const uint16_t id);
void advanceToFirstMP3InThisNode();
void streamDirectoryHTML(Print &out) const;
void appendIndentation(String& html, int level) const;
DirectoryNode* findFirstDirectoryWithMP3s();
String getCurrentPlayingFilePath() const;
};

115
src/DirectoryWalker.h Normal file
View 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

View File

@@ -31,7 +31,7 @@ static const char* hdr_connection_key = "Connection";
static const char* hdr_connection_val = "close";
const size_t buffer_size = 80;
/*

File diff suppressed because it is too large Load Diff

View File

@@ -34,8 +34,6 @@
#define MAX_VOL 15
//#define DEBUG TRUE
File root;
File mp3File;
@@ -81,6 +79,8 @@ bool asyncNext = false;
bool asyncPrev = false;
bool asyncReset = false;
bool SDActive = false;
bool RFIDActive = false;
@@ -90,6 +90,7 @@ bool RFIDActive = false;
volatile uint32_t webreq_cnt = 0;
static inline void webreq_enter() { __sync_add_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;
@@ -113,7 +114,6 @@ void init_webserver();
boolean buttonPressed(const uint8_t pin);
const String getSysDir(const String filename);
/**
* Helper routine to dump a byte array as hex values to Serial.
@@ -139,7 +139,7 @@ static inline void sd_lock_acquire()
}
static inline void sd_lock_release()
{
{
__sync_lock_release(&sd_lock_flag);
}
@@ -212,9 +212,11 @@ std::map<String, MappingEntry> rfid_map;
// 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.
bool folderModeActive = false;
bool folderModeActive = true;
bool pendingSeek = false;
uint32_t pendingSeekSeconds = 0;
static const size_t MAX_DEPTH = 32;
#endif

View File

@@ -13,7 +13,10 @@
<h1>HannaBox</h1>
</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>
<main class="container">
@@ -118,8 +121,8 @@
<div>
<label for="mode">Mode:</label>
<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="s">Single (play selected song / file)</option>
<option value="r">Random (randomize order in folder, then stop)</option>
<option value="c">Continuous (continuous playback / loop folder)</option>
</select>
@@ -148,6 +151,11 @@
</div>
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
</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>
</aside>
</main>

View File

@@ -115,6 +115,24 @@ function loadDirectory() {
xhr.send();
}
function resetWifi() {
if (!confirm('Are you sure you want to reset WiFi settings? The device will restart and create an access point.')) {
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/reset_wifi', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) {
alert('WiFi settings reset. Device is restarting...');
} else {
alert('Reset failed: ' + (xhr.responseText || 'Unknown error'));
}
}
};
xhr.send();
}
function loadMapping() {
var el = document.getElementById('mappingList');
if (!el) return;
@@ -241,6 +259,25 @@ function displayState(state) {
var voltageEl = document.getElementById("voltage");
if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV';
// Update header battery indicator
var headerBattery = document.getElementById("batteryStatus");
if (headerBattery) {
var mv = state['voltage'] || 0;
if (mv > 0) {
// Estimate percentage for single cell LiPo (approx 3.3V - 4.2V)
var pct = Math.round((mv - 3300) / (4200 - 3300) * 100);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
headerBattery.innerHTML =
'<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");
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';

View File

@@ -62,6 +62,19 @@ a { color: var(--accent); text-decoration: none; }
margin-top: 2px;
}
.battery-status {
font-size: 0.9rem;
font-weight: 600;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.5);
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.05);
}
/* Status (current song) */
.status {
color: var(--muted);