Compare commits

..

29 Commits

Author SHA1 Message Date
7512e1d94b [ai] Memory Optimizations 2026-01-13 21:54:51 +01:00
6ecb54e5ee folder play / next song fixes 2025-12-13 23:18:48 +01:00
69bc259a6c lib updates 2025-11-30 20:44:37 +01:00
9c937dc62d [ai] memory optimizations, wifi reset, battery 2025-11-30 17:06:20 +01:00
34c499bd49 [ai] tcp optimizations 2025-11-08 23:08:49 +01:00
ea4461cc54 [ai] tcp optimizations 2025-11-04 21:58:57 +01:00
c14624ef92 [ai] memory optimizations 2025-11-03 22:49:35 +01:00
b97eb79b91 [ai] memory optimizations 2025-11-02 21:51:35 +01:00
fd40b663a0 [ai] further memory optimizations 2025-11-02 21:27:21 +01:00
3a34b1b8d0 [ai] quickwin memory optimiziations, wip 2025-11-02 20:31:05 +01:00
465e34e919 refactored some pointers 2025-11-02 19:57:21 +01:00
83e51e87fe [ai] refactorings 2025-11-02 13:07:52 +01:00
7f120ae62d [ai] refactoring, fixed crash 2025-11-02 00:22:02 +01:00
c32eabf464 [ai] memory optimized web serving 2025-11-02 00:04:31 +01:00
e10ffcfd65 vscode stuff 2025-11-01 23:55:44 +01:00
2069d36715 optimizations 2025-10-31 23:19:42 +01:00
e677a7a8bf fixed config, memory consumption, manual 2025-10-18 22:56:11 +02:00
de44c789af wifi timeouts, small changes for sd card access 2025-10-10 22:45:35 +02:00
7e20aa65e1 cleanups, memory savings 2025-10-07 21:35:39 +02:00
cfa3feb7d2 [ai] forgot cleanup script 2025-10-06 23:13:50 +02:00
abfe564891 [ai] Many (memory) improvements, cleanup script, still problems 2025-10-06 23:13:26 +02:00
083dfd6e2a [ai] dir streaming, still problems 2025-10-05 23:29:22 +02:00
dc735c044f [ai] refactoring, fixes, still a directory problem 2025-10-05 23:09:10 +02:00
33701636af [ai] random mode now really working 2025-10-05 18:26:15 +02:00
d0c9a7e482 [ai] random mode 2025-10-05 18:10:50 +02:00
626657a976 [ai] user manual 2025-09-25 11:14:02 +02:00
6b942a8e07 [ai] alphabetical order 2025-09-22 22:07:43 +02:00
22f8d3eec3 Improved Volume button handling, fix for music stopping 2025-08-18 23:00:21 +02:00
0a08709160 [ai] improve web stability 2025-08-18 22:34:23 +02:00
17 changed files with 2235 additions and 938 deletions

2
.gitignore vendored
View File

@@ -7,5 +7,5 @@
schema/hannabox/hannabox-backups/
schema/hannabox/*.lck
.copilot
web/cleaned
.codegpt

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

244
USER_MANUAL.md Normal file
View File

@@ -0,0 +1,244 @@
# HannaBox User Manual
HannaBox is a childfriendly music player powered by an ESP32. It plays audio files from a microSD card and can start playlists by tapping an RFID figurine. It has three robust buttons for everyday use and an optional web app for managing music and tags over WiFi.
This guide explains how to set it up, use it daily, map figurines, and troubleshoot common issues.
--------------------------------------------------------------------
## What you need
- HannaBox device (with speaker and RFID reader)
- microSD card (FAT32, 832 GB recommended)
- Power source (USB power supply or internal battery charged)
- Optional: Home WiFi (for the web app)
- Optional: RFID figurines/cards (13.56 MHz)
Safety: Keep volume at safe levels for children. Supervise charging. Avoid liquids and drops.
--------------------------------------------------------------------
## Quick start (firsttime setup)
1) Prepare the SD card (FAT32)
- Format your microSD card as FAT32 on your computer.
2) Create required folders and copy files
- On the SD card, create a folder named system.
- Copy the web files from this project into that folder:
- web/index.html → system/index.html
- web/style.css → system/style.css
- web/script.js → system/script.js
- Copy the optional system sounds:
- sounds/start.mp3 → system/start.mp3 (plays after successful startup)
- sounds/sleep.mp3 → system/sleep.mp3 (plays before going to sleep)
Resulting SD layout (example):
```
SD Card Root/
├── system/
│ ├── index.html
│ ├── style.css
│ ├── script.js
│ ├── start.mp3
│ └── sleep.mp3
├── Music/ (your folders optional names)
│ ├── AlbumA/
│ │ ├── 01.mp3
│ │ └── 02.mp3
│ └── AlbumB/
│ └── story.mp3
└── loose_song.mp3
```
3) Insert the SD card and power on the HannaBox
- On first start, the device initializes the SD card and loads defaults.
- Youll hear start.mp3 if present.
4) Connect to WiFi (optional, for the web app)
- If the HannaBox isnt already on your WiFi, it opens a temporary hotspot named “HannaBox”.
- Connect your phone or laptop to the “HannaBox” WiFi and follow the captive portal to select your home network and enter its password.
- After it connects, note the devices IP address (shown in the portal or in your routers device list).
Tip: The HannaBox works without WiFi for basic playback and figurines. WiFi is only needed for the web app (playlist view, uploads, tag mapping from a browser).
--------------------------------------------------------------------
## Daily use
### The three buttons
- Start/Stop (Play/Pause)
- Short press: Toggle play/pause.
- Hold to adjust volume with the other buttons (see below).
- Next
- Short press while playing: Skip to the next track.
- If stopped: increases volume by one step and plays the start sound as feedback.
- While holding Start/Stop: increases volume (no feedback sound).
- Previous
- Short press while playing: Go to the previous track. If the current track played for more than ~2 seconds, it restarts the current track first.
- If stopped: decreases volume by one step and plays the start sound as feedback.
- While holding Start/Stop: decreases volume (no feedback sound).
Volume range is 015 by default (configurable). Changes made with buttons and the web app are kept in sync.
### Using RFID figurines
- Put a figurine on top
- If the figurine has a mapping, the associated song or folder will play according to its mode (see “Mapping figurines” below).
- If nothing happens, the figurine may not be mapped yet. Use the web apps Manager to map it (see next section).
### Autosleep and resume
- After inactivity (defaults to 30 minutes), HannaBox plays system/sleep.mp3 and then goes into deep sleep to save battery.
- Press Start/Stop to wake it.
- By default it resumes the last track and position where you left off (can be changed in config).
### Battery behavior
- The device monitors battery voltage. If its too low, it will prepare to sleep and then go to deep sleep to protect the battery. Charge before use.
--------------------------------------------------------------------
## The web app (local control & management)
Requirements: The HannaBox must be connected to your WiFi. Open a browser to the HannaBoxs IP address (e.g., http://192.168.x.x). The page and styling are served from the SD cards system folder.
Main areas:
- Player card: Big Play/Pause, Next, Previous, current title, time bar, and a volume slider.
- Playlist: A clickable list of your folders and songs. Click any entry to start it.
- Manager (toggle button): Tools for uploads, file management, and RFID mapping.
What you can do:
- See whats playing and control playback (Play/Pause/Next/Previous).
- Seek within a track using the time slider.
- Adjust volume (015) with the volume slider.
- Browse and click items in the playlist to play them.
- Toggle Manager for advanced actions.
Manager features:
- Status indicators: Shows battery voltage (mV), last scanned NFC ID, and free memory (for info).
- Upload audio: Upload .mp3 (up to ~50 MB). If a file with the same name exists, the box will automatically choose a new name. Ensure your SD card has free space.
- Move/Rename files and Delete files.
- Refresh playlist view.
--------------------------------------------------------------------
## Mapping figurines (RFID → what to play)
Option A: Map through the web app (recommended)
1) Tap the figurine near the reader.
2) Open the web app and toggle Manager.
3) Copy “Last NFC ID” into the RFID field (it will autofill after tapping).
4) In “Song”, enter:
- A file name (e.g., story.mp3), or
- A folder name (e.g., AlbumA), or
- A full path (e.g., /Music/AlbumB/story.mp3)
Tip: Using a folder name makes mappings more robust if you later move files.
5) Choose Mode:
- Single (s): Play just the selected song/file once.
- Folder (f): Play all files in the selected folder once, then stop.
- Random (r): Play all files in the selected folder in random order once, then stop.
- Continuous (c): Loop through the selected folder continuously.
6) Click “Update Mapping”.
Option B: Edit the mapping file on SD (advanced)
- File: /system/mapping.txt
- Lines look like:
UID=target|mode
Example:
12 34 56 78=AlbumA|f
90 87 65 43=/Music/AlbumB/story.mp3|s
Notes:
- UID format is four bytes separated by spaces (copied from the web apps “Last NFC ID”).
- If you map to a folder, the player will progress inside that folder. With mode c it loops; with f it stops at the end.
--------------------------------------------------------------------
## Supported audio and playlist rules
- The ondevice playlist is built from the SD card content (alphabetical).
- The web apps Playlist panel lets you click directories and files to play them.
- Keep the system folder named exactly system. Do not put your music inside system.
--------------------------------------------------------------------
## Configuration (optional, for adults)
A configuration file is created automatically on first run:
- Location: /system/config.txt
- Format: key=value (with comments)
Common options:
- initialVolume (021): Startup volume (UI uses 015 by default).
- maxVolume (121): Maximum volume limit for buttons/web app.
- sleepDelay (milliseconds): Nointeraction time before deep sleep (default 1800000 = 30 min).
- sleepMessageDelay (milliseconds): When to play system/sleep.mp3 before sleep (default ~2 s earlier).
- minVoltage (mV): Battery level that triggers a sleep to protect the battery (default 3200).
- voltage100Percent (mV): Approximate voltage representing 100% (default 4200).
- rfidLoopInterval (ms): How often to check the reader (default 25).
- startAtStoredProgress (true/false): Resume from last position after wake/restart.
- wifiSSID / wifiPassword: Usually left empty; WiFi setup is handled by the ondevice “HannaBox” hotspot.
Edit config.txt on your computer if needed (with the HannaBox powered off) or let the defaults work.
--------------------------------------------------------------------
## Power, sleep, and waking
- Autosleep: After the sleepDelay with no interaction, the box plays system/sleep.mp3 and then sleeps.
- Waking: Press the Start/Stop button to wake.
- On wake: Youll hear system/start.mp3 (if present). If resume is enabled, playback continues where it stopped.
--------------------------------------------------------------------
## Troubleshooting
No sound
- Turn volume up: hold Start/Stop and press Next, or use the web app slider.
- Try playing a known good .mp3 file from the Playlist.
- Ensure speaker is connected and the device is powered.
- Confirm system/start.mp3 loads on startup (audible chime).
Web app looks unstyled or shows errors for style/script
- Ensure the SD card has:
- /system/index.html
- /system/style.css
- /system/script.js
- Copy the files from this projects web/ folder to the SD system/ folder (exact names, case sensitive).
- Refresh the page.
Cant find the devices web page
- Make sure you connected the HannaBox to your home WiFi using the “HannaBox” hotspot/captive portal.
- Check your routers device list for the IP address and enter it in the browser (e.g., http://192.168.0.25).
- Reboot the HannaBox to retrigger the hotspot if it cannot connect to a remembered WiFi.
Figurine doesnt start music
- Tap the figurine again and hold it briefly over the reader.
- In the web app (Manager), check “Last NFC ID”, then create or update a mapping.
- Ensure the target song or folder actually exists on the SD card.
SD card not detected / empty playlist
- SD card must be FAT32 and properly inserted.
- Use the required system/ folder and keep music outside system/.
- Powercycle the HannaBox after changing SD content.
Device sleeps too quickly
- Battery may be low; charge it.
- Increase sleepDelay in /system/config.txt if desired.
Resume didnt work after wake
- Ensure startAtStoredProgress=true in /system/config.txt.
- The device writes /system/progress.txt just before sleep; verify the SD card isnt full.
Uploads fail or are slow
- Ensure theres at least ~10 MB free on the SD card before uploading.
- Keep individual uploads ≤ about 50 MB.
- Supported upload types: .mp3
--------------------------------------------------------------------
## Tips
- Use folder targets for figurines you want to “play this collection” (e.g., AlbumA with Folder mode).
- Use Continuous mode for “bedtime loop” folders.
- Keep file and folder names simple (letters, numbers, underscores) to make mapping easier.
- Back up your SD card content occasionally.
--------------------------------------------------------------------
Enjoy the HannaBox!
If you ever need to rebuild the SD card layout, follow the “Quick start” steps above and remember to copy the three web files (index.html, style.css, script.js) into the system folder so the web app looks and works correctly.

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
-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 the stack size (default is 16K)
-DCONFIG_ASYNC_TCP_STACK_SIZE=4096 ; reduce AsyncTCP task stack (default is 16k)
monitor_filters = esp32_exception_decoder
board_build.partitions = huge_app.csv

275
scripts/minify_web.py Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
Simple build tool to minify web assets in the `web/` folder and generate gzipped versions.
Usage:
python3 scripts/minify_web.py
This script will read:
- web/index.html
- web/style.css
- web/script.js
and write minified outputs to:
- web/cleaned/index.html
- 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.
They are NOT as powerful as terser/clean-css/html-minifier but avoid external
package installs which may not be available on the build host.
If you want stronger/min-safe minification later, replace this script with an
npm-based toolchain (npx terser, html-minifier-terser, clean-css) or call those
tools from a Makefile.
"""
from pathlib import Path
import re
import sys
import os
import gzip
BASE = Path(__file__).resolve().parent.parent
WEB = BASE / "web"
CLEAN = WEB / "cleaned"
def ensure_clean_dir():
CLEAN.mkdir(parents=True, exist_ok=True)
# ----------------------
# HTML minifier
# ----------------------
def minify_html(src: str) -> str:
"""
- Preserve content inside <script>, <style>, <pre>, <code> tags by masking them.
- Remove HTML comments.
- Collapse whitespace between tags.
- Trim leading/trailing whitespace.
"""
# Mask blocks we don't want to touch
pattern = re.compile(r'(?is)<(script|style|pre|code)(\b[^>]*)?>(.*?)</\1>')
placeholders = []
def _mask(m):
placeholders.append(m.group(0))
return f"__HTML_PLACEHOLDER_{len(placeholders)-1}__"
masked = pattern.sub(_mask, src)
# Remove comments <!-- ... -->
masked = re.sub(r'(?is)<!--.*?-->', '', masked)
# Collapse whitespace between tags: > < => ><
masked = re.sub(r'>\s+<', '><', masked)
# Collapse multiple spaces to one
masked = re.sub(r'[ \t]{2,}', ' ', masked)
# Remove leading/trailing whitespace/newlines
masked = masked.strip()
# Re-insert placeholders (unchanged)
def _restore(m):
idx = int(m.group(1))
return placeholders[idx]
result = re.sub(r'__HTML_PLACEHOLDER_(\d+)__', _restore, masked)
return result
# ----------------------
# CSS minifier
# ----------------------
def minify_css(src: str) -> str:
"""
- Remove comments (/* ... */)
- Remove unnecessary whitespace
- Collapse semicolons & spaces where safe
"""
# Remove comments
s = re.sub(r'(?s)/\*.*?\*/', '', src)
# Remove whitespace around symbols
s = re.sub(r'\s*([{}:;,])\s*', r'\1', s)
# Collapse multiple semicolons
s = re.sub(r';;+', ';', s)
# Remove trailing semicolon before closing brace
s = re.sub(r';}', '}', s)
# Collapse multiple whitespace/newlines
s = re.sub(r'\s+', ' ', s)
return s.strip()
# ----------------------
# JS minifier (simple, conservative)
# ----------------------
def minify_js(src: str) -> str:
"""
More conservative JS minifier:
- Removes /* ... */ block comments that are not inside strings or template literals.
- Does NOT remove // line comments (they can be significant in JS and in regexes/URLs).
- Trims trailing spaces on each line and collapses multiple empty lines to a single newline.
- Preserves all other whitespace and token boundaries to avoid introducing syntax errors.
This approach is intentionally conservative to avoid unexpected tokens.
"""
out_chars = []
i = 0
L = len(src)
in_squote = False
in_dquote = False
in_bquote = False
esc = False
in_block_comment = False
# First pass: remove /* ... */ block comments but only when not inside strings/templates
while i < L:
c = src[i]
nxt = src[i+1] if i+1 < L else ''
if in_block_comment:
if c == '*' and nxt == '/':
in_block_comment = False
i += 2
continue
else:
i += 1
continue
# Handle string/template entry/exit
if c == "'" and not (in_dquote or in_bquote):
if not esc:
in_squote = not in_squote
out_chars.append(c)
esc = False
i += 1
continue
if c == '"' and not (in_squote or in_bquote):
if not esc:
in_dquote = not in_dquote
out_chars.append(c)
esc = False
i += 1
continue
if c == '`' and not (in_squote or in_dquote):
if not esc:
in_bquote = not in_bquote
out_chars.append(c)
esc = False
i += 1
continue
# Escape handling inside strings/templates
if (in_squote or in_dquote or in_bquote) and c == '\\' and not esc:
esc = True
out_chars.append(c)
i += 1
continue
if esc:
out_chars.append(c)
esc = False
i += 1
continue
# Detect block comment only when not inside a string/template
if not (in_squote or in_dquote or in_bquote) and c == '/' and nxt == '*':
in_block_comment = True
i += 2
continue
# Otherwise keep character
out_chars.append(c)
i += 1
code_no_block_comments = ''.join(out_chars)
# Second pass: line-wise trimming and blank-line collapse (very conservative)
lines = code_no_block_comments.splitlines()
trimmed_lines = []
prev_blank = False
for line in lines:
# remove trailing whitespace only
t = line.rstrip()
if t == '':
if not prev_blank:
trimmed_lines.append('')
prev_blank = True
else:
trimmed_lines.append(t)
prev_blank = False
result = '\n'.join(trimmed_lines).strip() + '\n' if trimmed_lines else ''
return result
# ----------------------
# File utilities
# ----------------------
def read_file(path: Path) -> str:
try:
return path.read_text(encoding='utf-8')
except Exception as e:
print(f"ERROR reading {path}: {e}", file=sys.stderr)
return ''
def write_file(path: Path, data: str):
try:
path.write_text(data, encoding='utf-8')
print(f"Wrote {path} ({len(data)} bytes)")
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()
# HTML
index = WEB / "index.html"
if index.exists():
print("Minifying HTML:", index)
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/")
# CSS
css = WEB / "style.css"
if css.exists():
print("Minifying CSS:", css)
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/")
# JS
js = WEB / "script.js"
if js.exists():
print("Minifying JS:", js)
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/")
print("Minification complete. Output placed in", CLEAN)
if __name__ == "__main__":
minify_all()

View File

@@ -1,8 +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++;
@@ -36,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
{
if (mp3Files[i] == *mp3File && ids.size() > i)
return (i < ids.size()) ? ids[i] : 0;
}
String DirectoryNode::buildFullPath(const String &fileName) const
{
if (dirPath == "/")
{
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;
}
@@ -81,7 +153,7 @@ void DirectoryNode::setSecondsPlayed(const uint32_t seconds)
secondsPlayed = seconds;
}
uint32_t DirectoryNode::getSecondsPlayed()
uint32_t DirectoryNode::getSecondsPlayed() const
{
return secondsPlayed;
}
@@ -97,12 +169,25 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
mp3Files.clear();
ids.clear();
// Reserve memory to reduce heap fragmentation (optimization 3)
subdirectories.reserve(8); // Reserve space for 8 subdirectories
mp3Files.reserve(16); // Reserve space for 16 MP3 files
ids.reserve(16); // Reserve space for 16 IDs
// 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,31 +196,93 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
break;
}
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir.c_str()))
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir) != 0)
{
DirectoryNode *newNode = new DirectoryNode(entry.name());
subdirectories.push_back(newNode);
newNode->buildDirectoryTree((String(currentPath) + entry.name()).c_str());
dirNames.emplace_back(entry.name());
}
else if (String(entry.name()).endsWith(".mp3") || String(entry.name()).endsWith(".MP3"))
else
{
String fullPath = String(currentPath);
if (!fullPath.endsWith("/"))
fullPath += "/";
fullPath += entry.name();
mp3Files.push_back(fullPath);
ids.push_back(getNextId());
String entryName = entry.name();
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
{
fileNames.push_back(std::move(entryName));
}
}
entry.close();
}
rootDir.close();
// Case-insensitive alphabetical sort without allocations
auto ciLess = [](const String &a, const String &b) {
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);
// Reserve memory to reduce heap fragmentation
subdirectories.reserve(dirNames.size());
mp3Files.reserve(fileNames.size());
ids.reserve(fileNames.size());
// Add MP3 files in alphabetical order first to free fileNames memory before recursing
for (const String &fileName : fileNames)
{
mp3Files.push_back(fileName);
ids.push_back(getNextId());
}
// Free memory used by fileNames vector
{
std::vector<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);
@@ -143,7 +290,7 @@ void DirectoryNode::printDirectoryTree(int level) const
{
for (int i = 0; i <= level; i++)
{
Serial.print(" ");
Serial.print(F(" "));
}
Serial.println(mp3File);
}
@@ -180,7 +327,7 @@ void DirectoryNode::advanceToFirstMP3InThisNode()
{
if (mp3Files.size() > 0)
{
setCurrentPlaying(&mp3Files[0]);
setCurrentPlaying(mp3Files[0]);
}
}
@@ -192,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;
}
@@ -210,50 +358,79 @@ 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;
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;
lowTarget.toLowerCase();
// First, search in the current directory's MP3 files
for (size_t i = 0; i < mp3Files.size(); i++)
{
if (isAbsolutePath)
{
if (*songName == mp3Files[i])
// 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 if (mp3Files[i].endsWith(*songName))
else
{
setCurrentPlaying(&mp3Files[i]);
// 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]);
return this;
}
}
}
// Then search in subdirectories
for (auto subdir : subdirectories)
{
if (!isAbsolutePath && subdir->getName() == *songName)
// Absolute folder target: match directory by its full path (dirPath)
if (isAbsolutePath)
{
if (subdir->getDirPath().equalsIgnoreCase(normalizedPath))
{
subdir->advanceToFirstMP3InThisNode();
return subdir;
}
}
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(songName))
{
subdir->advanceToFirstMP3InThisNode();
return subdir;
@@ -265,22 +442,36 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
if (isAbsolutePath)
{
if (*songName == subdir->mp3Files[i])
if (subdir->buildFullPath(subdir->mp3Files[i]).equalsIgnoreCase(songName))
{
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
subdir->setCurrentPlaying(subdir->mp3Files[i]);
return subdir;
}
}
else if (subdir->mp3Files[i].endsWith(*songName))
else
{
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
// 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]);
return subdir;
}
}
}
// Recurse into deeper subdirectories to support nested folders and files
DirectoryNode* deeper = subdir->advanceToMP3(songName);
if (deeper != nullptr)
{
return deeper;
}
}
// 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;
}
@@ -295,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;
}
@@ -312,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;
@@ -322,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;
}
@@ -352,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;
@@ -365,23 +559,31 @@ 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->buildFullPath(prevNode->mp3Files[prevFileIndex], buffer, buffer_size);
prevNode->setCurrentPlaying(&prevNode->mp3Files[prevFileIndex]);
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
@@ -391,155 +593,64 @@ void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>
}
}
DirectoryNode *DirectoryNode::advanceToNextMP3(const String *currentGlobal)
size_t DirectoryNode::getNumOfFiles() const
{
bool useFirst = false;
Serial.println(currentGlobal->c_str());
if (currentGlobal != nullptr)
return subdirectories.size();
}
DirectoryNode *DirectoryNode::advanceToNextMP3(const String &currentGlobal)
{
for (size_t i = 0; i < mp3Files.size(); i++)
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())
{
if (*currentGlobal == mp3Files[i])
{
// 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]);
Serial.println(F("advanceToNextMP3: No MP3s found in tree"));
currentPlaying = "";
return this;
}
useFirst = true;
// Reached the end of the MP3 files in the directory
int currentIndex = -1;
if (!currentGlobal.isEmpty())
{
for (size_t i = 0; i < allMP3s.size(); i++)
{
DirectoryNode *node = allMP3s[i].first;
int fileIndex = allMP3s[i].second;
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
if (comparePathWithString(buffer, currentGlobal))
{
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)
{
DirectoryNode *nextNode = allMP3s[currentIndex + 1].first;
int nextFileIndex = allMP3s[currentIndex + 1].second;
if (useFirst && subdir->mp3Files.size() > 0)
{
subdir->setCurrentPlaying(&subdir->mp3Files[0]);
return subdir;
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
return nextNode;
}
// Have each subdirectory advance its song
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
// If not playing anything (start), play first
if (currentIndex == -1 && currentGlobal.isEmpty())
{
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[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;
}
String DirectoryNode::getDirectoryStructureHTML() const {
// Calculate required size first (prevents reallocations)
size_t htmlSize = calculateHTMLSize();
String html;
html.reserve(htmlSize); // Precise allocation - no wasted RAM
// Helper lambda to append without temporary Strings
auto append = [&html](const __FlashStringHelper* fstr) {
html += fstr;
};
auto appendId = [&html](uint32_t id) {
html += id; // Direct numeric append (NO temporary String)
};
if (name == "/") {
append(F("<ul>\n"));
}
if (name != "/") {
append(F("<li data-id=\""));
appendId(id);
append(F("\"><b>"));
html += name; // Still uses String, but unavoidable for dynamic content
append(F("</b></li>\n"));
}
for (size_t i = 0; i < mp3Files.size(); i++) {
append(F("<li data-id=\""));
appendId(ids[i]);
append(F("\">"));
html += mp3Files[i]; // Dynamic file name
append(F("</li>\n"));
}
for (DirectoryNode* child : subdirectories) {
html += child->getDirectoryStructureHTML();
}
if (name == "/") {
append(F("</ul>\n"));
}
return html;
}
// NEW: Calculate exact required size first
size_t DirectoryNode::calculateHTMLSize() const {
size_t size = 0;
// Opening/closing tags
if (name == "/") size += 6; // <ul>\n
// Current directory entry
if (name != "/") {
size += 22 + name.length() + 10; // <li...><b></b></li>\n + ID digits (est)
}
// MP3 files
for (size_t i = 0; i < mp3Files.size(); i++) {
size += 16 + mp3Files[i].length() + 10; // <li...></li>\n + ID digits
}
// Subdirectories
for (DirectoryNode* child : subdirectories) {
size += child->calculateHTMLSize();
}
// Closing tag
if (name == "/") size += 7; // </ul>\n
return size;
}
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

@@ -1,5 +1,6 @@
#ifndef DIRECTORYNODE_H_
#define DIRECTORYNODE_H_
class Print;
#include <SD.h>
#include <vector>
@@ -13,13 +14,20 @@ 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;
@@ -33,13 +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;
void setCurrentPlaying(const String* mp3File);
const String* getCurrentPlaying() const;
size_t getNumOfFiles() 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();
@@ -47,18 +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();
String getDirectoryStructureHTML() const;
size_t calculateHTMLSize() 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

@@ -1,19 +1,30 @@
#include "config.h"
#include "globals.h"
#include <SD.h>
#include <string.h>
#include <ctype.h>
#include <strings.h>
#include <stdio.h>
// Global config instance
Config config;
const String getConfigFilePath() {
return "/" + sys_dir + "/config.txt";
const char* getConfigFilePath() {
static char path[64];
static bool init = false;
if (!init) {
// Build "/<sys_dir>/<config_file>" once into a static buffer
snprintf(path, sizeof(path), "/%s/%s", sys_dir ? sys_dir : "", config_file ? config_file : "");
init = true;
}
return path;
}
void setDefaultConfig() {
config.initialVolume = 7;
config.maxVolume = 15;
config.sleepTime = 1800000;
config.minVoltage = 3200;
config.minVoltage = 3000;
config.voltage100Percent = 4200;
config.sleepDelay = 1800000;
config.sleepMessageDelay = 1798000;
@@ -24,17 +35,17 @@ void setDefaultConfig() {
}
bool loadConfig() {
String configPath = getConfigFilePath();
const char* configPath = getConfigFilePath();
if (!SD.exists(configPath)) {
Serial.println("Config file not found, using defaults");
Serial.println(F("Config file not found, using defaults"));
setDefaultConfig();
return saveConfig(); // Create config file with defaults
}
File file = SD.open(configPath, FILE_READ);
if (!file) {
Serial.println("Failed to open config file");
Serial.println(F("Failed to open config file"));
setDefaultConfig();
return false;
}
@@ -42,110 +53,151 @@ bool loadConfig() {
// Set defaults first
setDefaultConfig();
// Parse config file line by line
while (file.available()) {
String line = file.readStringUntil('\n');
line.trim();
// Parse config file line by line using a fixed-size buffer (no dynamic allocation)
char line[160];
while (true) {
int idx = 0;
int c;
// Read characters until newline or EOF
while (idx < (int)sizeof(line) - 1 && (c = file.read()) >= 0) {
if (c == '\r') continue;
if (c == '\n') break;
line[idx++] = (char)c;
}
if (idx == 0 && c < 0) {
// EOF with no more data
break;
}
line[idx] = '\0';
// Trim leading/trailing whitespace
char* s = line;
while (*s && isspace((unsigned char)*s)) ++s;
char* end = s + strlen(s);
while (end > s && isspace((unsigned char)end[-1])) --end;
*end = '\0';
// Skip empty lines and comments
if (line.length() == 0 || line.startsWith("#")) {
if (*s == '\0' || *s == '#') {
if (c < 0) break;
continue;
}
int separatorIndex = line.indexOf('=');
if (separatorIndex == -1) {
// Split at '='
char* eq = strchr(s, '=');
if (!eq) {
if (c < 0) break;
continue;
}
*eq = '\0';
char* key = s;
char* val = eq + 1;
String key = line.substring(0, separatorIndex);
String value = line.substring(separatorIndex + 1);
key.trim();
value.trim();
// Trim key
while (*key && isspace((unsigned char)*key)) ++key;
char* kend = key + strlen(key);
while (kend > key && isspace((unsigned char)kend[-1])) --kend;
*kend = '\0';
// Trim val
while (*val && isspace((unsigned char)*val)) ++val;
char* vend = val + strlen(val);
while (vend > val && isspace((unsigned char)vend[-1])) --vend;
*vend = '\0';
// Parse each configuration value
if (key == "initialVolume") {
config.initialVolume = constrain(value.toInt(), 0, 21);
} else if (key == "maxVolume") {
config.maxVolume = constrain(value.toInt(), 1, 21);
} else if (key == "sleepTime") {
config.sleepTime = value.toInt();
} else if (key == "minVoltage") {
config.minVoltage = value.toInt();
} else if (key == "voltage100Percent") {
config.voltage100Percent = value.toInt();
} else if (key == "sleepDelay") {
config.sleepDelay = value.toInt();
} else if (key == "sleepMessageDelay") {
config.sleepMessageDelay = value.toInt();
} else if (key == "rfidLoopInterval") {
config.rfidLoopInterval = constrain(value.toInt(), 1, 255);
} else if (key == "startAtStoredProgress") {
config.startAtStoredProgress = (value == "1" || value.equalsIgnoreCase("true"));
} else if (key == "wifiSSID") {
strncpy(config.wifiSSID, value.c_str(), sizeof(config.wifiSSID) - 1);
if (strcmp(key, "initialVolume") == 0) {
long v = strtol(val, nullptr, 10);
if (v < 0) v = 0; if (v > 21) v = 21;
config.initialVolume = (uint8_t)v;
} else if (strcmp(key, "maxVolume") == 0) {
long v = strtol(val, nullptr, 10);
if (v < 1) v = 1; if (v > 21) v = 21;
config.maxVolume = (uint8_t)v;
} else if (strcmp(key, "sleepTime") == 0) {
config.sleepTime = (uint32_t)strtoul(val, nullptr, 10);
} else if (strcmp(key, "minVoltage") == 0) {
config.minVoltage = (uint16_t)strtoul(val, nullptr, 10);
} else if (strcmp(key, "voltage100Percent") == 0) {
config.voltage100Percent = (uint16_t)strtoul(val, nullptr, 10);
} else if (strcmp(key, "sleepDelay") == 0) {
config.sleepDelay = (uint32_t)strtoul(val, nullptr, 10);
} else if (strcmp(key, "sleepMessageDelay") == 0) {
config.sleepMessageDelay = (uint32_t)strtoul(val, nullptr, 10);
} else if (strcmp(key, "rfidLoopInterval") == 0) {
long v = strtol(val, nullptr, 10);
if (v < 1) v = 1; if (v > 255) v = 255;
config.rfidLoopInterval = (uint8_t)v;
} else if (strcmp(key, "startAtStoredProgress") == 0) {
bool b = (strcmp(val, "1") == 0) || (strcasecmp(val, "true") == 0);
config.startAtStoredProgress = b;
} else if (strcmp(key, "wifiSSID") == 0) {
strncpy(config.wifiSSID, val, sizeof(config.wifiSSID) - 1);
config.wifiSSID[sizeof(config.wifiSSID) - 1] = '\0';
} else if (key == "wifiPassword") {
strncpy(config.wifiPassword, value.c_str(), sizeof(config.wifiPassword) - 1);
} else if (strcmp(key, "wifiPassword") == 0) {
strncpy(config.wifiPassword, val, sizeof(config.wifiPassword) - 1);
config.wifiPassword[sizeof(config.wifiPassword) - 1] = '\0';
}
if (c < 0) break; // EOF after processing last line
}
file.close();
Serial.println("Config loaded successfully");
Serial.print("Initial Volume: "); Serial.println(config.initialVolume);
Serial.print("Max Volume: "); Serial.println(config.maxVolume);
Serial.print("Sleep Delay: "); Serial.println(config.sleepDelay);
Serial.print("RFID Interval: "); Serial.println(config.rfidLoopInterval);
Serial.println(F("Config loaded successfully"));
Serial.print(F("Initial Volume: ")); Serial.println(config.initialVolume);
Serial.print(F("Max Volume: ")); Serial.println(config.maxVolume);
Serial.print(F("Sleep Delay: ")); Serial.println(config.sleepDelay);
Serial.print(F("RFID Interval: ")); Serial.println(config.rfidLoopInterval);
Serial.print(F("min Voltage: ")); Serial.println(config.minVoltage);
return true;
}
bool saveConfig() {
String configPath = getConfigFilePath();
const char* configPath = getConfigFilePath();
File file = SD.open(configPath, FILE_WRITE);
if (!file) {
Serial.println("Failed to create config file");
Serial.println(F("Failed to create config file"));
return false;
}
// Write config file with comments for user reference
file.println("# HannaBox Configuration File");
file.println("# Values are in the format: key=value");
file.println("# Lines starting with # are comments");
file.println("");
file.println(F("# HannaBox Conf File"));
file.println(F("# format: key=value"));
file.println();
file.println("# Audio Settings");
file.print("initialVolume="); file.println(config.initialVolume);
file.print("maxVolume="); file.println(config.maxVolume);
file.println("");
file.println(F("# Audio"));
file.print(F("initialVolume=")); file.println(config.initialVolume);
file.print(F("maxVolume=")); file.println(config.maxVolume);
file.println();
file.println("# Power Management (times in milliseconds)");
file.print("sleepTime="); file.println(config.sleepTime);
file.print("sleepDelay="); file.println(config.sleepDelay);
file.print("sleepMessageDelay="); file.println(config.sleepMessageDelay);
file.println("");
file.println(F("# Power Management (in milliseconds)"));
file.print(F("sleepTime=")); file.println(config.sleepTime);
file.print(F("sleepDelay=")); file.println(config.sleepDelay);
file.print(F("sleepMessageDelay=")); file.println(config.sleepMessageDelay);
file.println();
file.println("# Battery Settings (voltage in millivolts)");
file.print("minVoltage="); file.println(config.minVoltage);
file.print("voltage100Percent="); file.println(config.voltage100Percent);
file.println("");
file.println(F("# Battery (in millivolts)"));
file.print(F("minVoltage=")); file.println(config.minVoltage);
file.print(F("voltage100Percent=")); file.println(config.voltage100Percent);
file.println();
file.println("# RFID Settings");
file.print("rfidLoopInterval="); file.println(config.rfidLoopInterval);
file.println("");
file.println(F("# RFID"));
file.print(F("rfidLoopInterval=")); file.println(config.rfidLoopInterval);
file.println();
file.println("# Playback Settings");
file.print("startAtStoredProgress="); file.println(config.startAtStoredProgress ? "true" : "false");
file.println("");
file.println(F("# Playback"));
file.print(F("startAtStoredProgress=")); file.println(config.startAtStoredProgress ? F("true") : F("false"));
file.println();
file.println("# WiFi Settings (leave empty to use current WiFiManager)");
file.print("wifiSSID="); file.println(config.wifiSSID);
file.print("wifiPassword="); file.println(config.wifiPassword);
file.println(F("# WiFi (leave empty to use current WiFiManager)"));
file.print(F("wifiSSID=")); file.println(config.wifiSSID);
file.print(F("wifiPassword=")); file.println(config.wifiPassword);
file.close();
Serial.println("Config saved successfully");
Serial.println(F("Config saved successfully"));
return true;
}

View File

@@ -3,19 +3,30 @@
#include <Arduino.h>
// Configuration structure - keep it minimal for memory efficiency
// Keep the structure compact to minimize padding and RAM usage.
// Order fields from wider to narrower types, and avoid inline default initializers.
// Defaults are applied in setDefaultConfig() to keep initialization simple and lightweight.
struct Config {
uint8_t initialVolume = 7;
uint8_t maxVolume = 15;
uint32_t sleepTime = 1800000; // 30 minutes in ms
uint16_t minVoltage = 3200; // mV - minimum voltage before sleep
uint16_t voltage100Percent = 4200; // mV - voltage representing 100% battery
uint32_t sleepDelay = 1800000; // 30 minutes in ms
uint32_t sleepMessageDelay = 1798000; // 2 seconds before sleep
uint8_t rfidLoopInterval = 25; // RFID check interval
bool startAtStoredProgress = true; // Resume from last position
char wifiSSID[32] = ""; // WiFi SSID (empty = use current mechanism)
char wifiPassword[64] = ""; // WiFi password (empty = use current mechanism)
// 32-bit values
uint32_t sleepTime; // ms
uint32_t sleepDelay; // ms
uint32_t sleepMessageDelay; // ms
// 16-bit values
uint16_t minVoltage; // mV
uint16_t voltage100Percent; // mV
// 8-bit values
uint8_t initialVolume;
uint8_t maxVolume;
uint8_t rfidLoopInterval;
// flags
bool startAtStoredProgress;
// WiFi credentials (fixed-size buffers, no heap allocations)
char wifiSSID[32]; // empty => use current mechanism
char wifiPassword[64]; // empty => use current mechanism
};
// Global config instance
@@ -25,6 +36,9 @@ extern Config config;
bool loadConfig();
bool saveConfig();
void setDefaultConfig();
const String getConfigFilePath();
// Returns a pointer to a static buffer with the absolute config file path.
// Avoids dynamic allocation/Arduino String to reduce heap fragmentation.
const char* getConfigFilePath();
#endif

View File

@@ -1,22 +1,37 @@
#ifndef GLOBALS_H_
#define GLOBALS_H_
static const char* sys_dir = "system";
static const char* sleep_sound = "sleep.mp3";
static const char* startup_sound = "start.mp3";
static const char* index_file = "index.html";
static const char* style_file = "style.css";
static const char* script_file = "script.js";
static const char* mapping_file = "mapping.txt";
const String sys_dir = "system";
static const char* progress_file = "progress.txt";
const String sleep_sound = "sleep.mp3";
static const char* config_file = "config.txt";
const String startup_sound = "start.mp3";
static const char* txt_html_charset = "text/html; charset=UTF-8";
const String mapping_file = "mapping.txt";
static const char* txt_plain = "text/plain; charset=UTF-8";
const String progress_file = "progress.txt";
static const char* hdr_cache_control_key = "Cache-Control";
static const char* hdr_cache_control_val = "no-store";
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

@@ -1,6 +1,101 @@
#ifndef MAIN_H_
#define MAIN_H_
// define pins for RFID
#define CS_RFID 32 // SIC, tried 4 and 32 but only this worked!
#define RST_RFID 33
// this does not work as the irq pin is input only:
#define IRQ_RFID 34
// Audio DAC
#define I2S_DOUT 26 // connect to DAC pin DIN
#define I2S_BCLK 27 // connect to DAC pin BCK
#define I2S_LRC 25 // connect to DAC pin LCK
#define BTN_START_STOP 4 // Button on XX and GND
#define BTN_NEXT 17
#define BTN_PREV 16
#define CS_SDCARD 22
#define BAT_VOLTAGE_PIN 35
#define RFID_LOOP_INTERVAL 25
#define VOLTAGE_LOOP_INTERVAL 5000
#define VOLTAGE_THRESHOLD 0
#define SHORT_PRESS_TIME 250
#define LONG_PRESS_TIME 1000
#define MAX_WEBREQUEST_BLOCKINGS 10000
#define MAX_VOL 15
File root;
File mp3File;
Audio audio;
uint volume = 7;
// Folder-play tracking: flattened list of files inside a mapped folder and current index
// Used when a mapping targets a folder (play folder once or loop folder)
static std::vector<std::pair<DirectoryNode *, int>> folderFlatList;
static int folderFlatIndex = -1;
static String folderRootPath = "";
// Pointer to the root DirectoryNode for active folder-mode playback
DirectoryNode *folderRootNode = nullptr;
AsyncWebServer server(80);
DNSServer dns;
// static variable has to be instantiated outside of class definition:
uint16_t DirectoryNode::idCounter = 0;
DirectoryNode rootNode("/");
DirectoryNode *currentNode = nullptr;
volatile bool newRfidInt = false;
volatile bool playButtonDown = false;
// Track if play button hold is active and if volume was adjusted during this hold
volatile bool playHoldActive = false;
volatile bool volumeAdjustedDuringHold = false;
volatile uint8_t sd_lock_flag = 0;
MFRC522 rfid(CS_RFID, RST_RFID); // instatiate a MFRC522 reader object.
TaskHandle_t RfidTask;
bool asyncStop = false;
bool asyncStart = false;
bool asyncTogglePlayPause = false;
bool asyncNext = false;
bool asyncPrev = false;
bool asyncReset = false;
bool SDActive = false;
bool RFIDActive = false;
// Web request concurrency counter and helpers (atomic via GCC builtins)
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;
size_t free_heap = 0;
void stop();
void start();
@@ -19,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.
@@ -33,6 +127,23 @@ void dump_byte_array(byte *buffer, byte bufferSize)
}
}
/* Simple spinlock using older GCC sync builtins (no libatomic required).
sd_lock_acquire() will block (with a small delay) until the lock is free.
sd_lock_release() releases the lock. This is sufficient for short SD ops. */
static inline void sd_lock_acquire()
{
while (__sync_lock_test_and_set(&sd_lock_flag, 1))
{
delay(1);
}
}
static inline void sd_lock_release()
{
__sync_lock_release(&sd_lock_flag);
}
String getRFIDString(byte uidByte[10])
{
String uidString = String(uidByte[0]) + " " + String(uidByte[1]) + " " +
@@ -84,10 +195,12 @@ boolean prepareSleepMode = false;
class DirectoryNode;
// Mapping entry that stores target (file or folder) and playback mode:
// 's' = single (default) - play only the selected song (or single file in folder)
// 'f' = folder - play files inside the selected folder, then stop
// 'c' = continuous - continuously play (like previous continuousMode)
/* Mapping entry that stores target (file or folder) and playback mode:
* 's' = single (default) - play only the selected song (or single file in folder)
* 'f' = folder - play files inside the selected folder, then stop
* 'r' = random-folder - play files inside the selected folder in random order, then stop
* 'c' = continuous - continuously play (like previous continuousMode)
*/
struct MappingEntry {
String target;
char mode;
@@ -99,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="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,9 @@
<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>
</div>
@@ -147,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

@@ -1,6 +1,90 @@
setInterval(getState, 4000);
setInterval(updateProgress, 500); // Update progress every second
/* Global single-flight queue for XMLHttpRequest
- Serializes all XHR to 1 at a time
- Adds timeouts (GET ~3.5s, POST ~6s, /upload 10min)
- Deduplicates idempotent GETs to same URL (drops duplicates)
This reduces concurrent load on the ESP32 web server and SD card. */
(function(){
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
var queue = [];
var active = null;
var inflightKeys = new Set();
function keyOf(xhr){ return (xhr.__method || 'GET') + ' ' + (xhr.__url || ''); }
function startNext(){
if (active || queue.length === 0) return;
var item = queue.shift();
active = item;
inflightKeys.add(item.key);
var xhr = item.xhr;
var timeoutMs = item.timeoutMs;
var timer = null;
function cleanup() {
active = null;
inflightKeys.delete(item.key);
if (timer) { clearTimeout(timer); timer = null; }
setTimeout(startNext, 0);
}
xhr.addEventListener('loadend', cleanup);
if (timeoutMs > 0 && !xhr.__skipTimeout) {
timer = setTimeout(function(){
try { xhr.abort(); } catch(e){}
}, timeoutMs);
}
item.origSend.call(xhr, item.body);
}
XMLHttpRequest.prototype.open = function(method, url, async){
this.__method = (method || 'GET').toUpperCase();
this.__url = url || '';
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body){
var key = keyOf(this);
var isIdempotentGET = (this.__method === 'GET');
var timeoutMs;
if ((this.__url || '').indexOf('/upload') !== -1) {
timeoutMs = 600000; // 10 minutes for uploads
} else if ((this.__url || '').indexOf('/directory') !== -1) {
timeoutMs = 30000; // 30 seconds for directory listing (can be large)
} else if (this.__method === 'GET') {
timeoutMs = 6000;
} else {
timeoutMs = 8000;
}
if (isIdempotentGET && inflightKeys.has(key)) {
// Drop duplicate GET to same resource
return;
}
if (isIdempotentGET) {
for (var i = 0; i < queue.length; i++) {
if (queue[i].key === key) {
// Already queued; keep most recent body if any
queue[i].body = body;
return;
}
}
}
var item = { xhr: this, body: body, key: key, timeoutMs: timeoutMs, origSend: origSend };
queue.push(item);
startNext();
};
})();
/* Dynamic content loaders for playlist and mapping (avoid heavy template processing on server) */
function bindPlaylistClicks() {
var container = document.getElementById('playlistContainer');
@@ -31,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;
@@ -107,16 +209,22 @@ function getState() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
var state = JSON.parse(xhr.response);
isPlaying = state['playing'];
if (xhr.status >= 200 && xhr.status < 300) {
try {
var state = JSON.parse(xhr.responseText || xhr.response || '{}');
isPlaying = !!state['playing'];
if (isPlaying) {
songStartTime = Date.now() - state['time'] * 1000;
currentSongLength = state['length'] * 1000;
songStartTime = Date.now() - ((state['time'] || 0) * 1000);
currentSongLength = ((state['length'] || 0) * 1000);
}
lastStateUpdateTime = Date.now();
displayState(state);
} catch (e) {
// Ignore parse errors; will retry on next poll
}
}
}
};
xhr.open("GET","/state", true);
xhr.send();
}
@@ -151,13 +259,32 @@ 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';
var uidEl = document.getElementById("uid");
if (uidEl) uidEl.innerHTML = 'Last NFC ID: ' + (state['uid'] || '');
/* ==== Autofill convenience fields ==== */
/* Autofill convenience fields */
var fm = document.getElementById('fileManager');
if (state['filepath'] && fm && fm.style.display == 'none') {
var moveFrom = document.getElementById('moveFrom');
@@ -213,7 +340,6 @@ function playNamedSong(song) {
var xhr = new XMLHttpRequest();
xhr.open("POST", "/playnamed");
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
//application/x-www-form-urlencoded
var body = song;
xhr.send("title="+encodeURIComponent(body));
}
@@ -239,8 +365,7 @@ function editMapping() {
// Validate file before upload
function validateFile(file) {
var maxSize = 50 * 1024 * 1024; // 50MB limit
var allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp4', 'audio/ogg'];
var allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.ogg'];
var allowedExtensions = ['.mp3'];
if (file.size > maxSize) {
return 'File too large. Maximum size is 50MB.';
@@ -250,7 +375,7 @@ function validateFile(file) {
var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
if (!hasValidExtension) {
return 'Invalid file type. Only audio files (.mp3, .wav, .flac, .m4a, .ogg) are allowed.';
return 'Invalid file type';
}
return null; // No error
@@ -355,7 +480,7 @@ function resetUploadForm() {
document.getElementById('uploadFile').value = '';
}
/* ================= File Manager Functions ================= */
/* File Manager Functions */
function toggleFileManager() {
var fm = document.getElementById('fileManager');
@@ -411,93 +536,3 @@ function deleteFileOnServer() {
};
xhr.send();
}
/* Ensure the site stylesheet loads reliably — retry loader if necessary
Improved detection: verify a computed style from CSS is applied (safer than just checking stylesheet href).
Retries with exponential backoff and deduplicates link tags we add. */
(function ensureCssLoaded(){
var retries = 0;
var maxRetries = 6;
// Check a computed style that the stylesheet defines.
// .status color in CSS is --muted: #6b7280 -> rgb(107, 114, 128)
function isStyleApplied() {
var el = document.querySelector('.status') || document.querySelector('.topbar');
if (!el) return false;
try {
var color = getComputedStyle(el).color;
// Expect "rgb(107, 114, 128)" when CSS is applied
if (!color) return false;
// Loose check for the three numeric components to be present
return color.indexOf('107') !== -1 && color.indexOf('114') !== -1 && color.indexOf('128') !== -1;
} catch (e) {
return false;
}
}
function removeOldRetryLinks() {
var links = Array.prototype.slice.call(document.querySelectorAll('link[data-retry-css]'));
links.forEach(function(l){ l.parentNode.removeChild(l); });
}
function tryLoad() {
if (isStyleApplied()) {
console.log('style.css appears applied');
return;
}
if (retries >= maxRetries) {
console.warn('style.css failed to apply after ' + retries + ' attempts');
return;
}
retries++;
// Remove previous retry-inserted links to avoid piling them up
removeOldRetryLinks();
var link = document.createElement('link');
link.rel = 'stylesheet';
link.setAttribute('data-retry-css', '1');
// cache-busting query to force a fresh fetch when retrying
link.href = 'style.css?cb=' + Date.now();
var timeout = 800 + retries * 300; // increasing timeout per attempt
var done = false;
function success() {
if (done) return;
done = true;
// Give browser a short moment to apply rules
setTimeout(function(){
if (isStyleApplied()) {
console.log('style.css loaded and applied (attempt ' + retries + ')');
} else {
console.warn('style.css loaded but styles not applied — retrying...');
setTimeout(tryLoad, timeout);
}
}, 200);
}
link.onload = success;
link.onerror = function() {
if (done) return;
done = true;
console.warn('style.css load error (attempt ' + retries + '), retrying...');
setTimeout(tryLoad, timeout);
};
// Append link to head
document.head.appendChild(link);
// Safety check: if onload/onerror doesn't fire, verify computed style after timeout
setTimeout(function(){
if (done) return;
if (isStyleApplied()) {
console.log('style.css appears applied (delayed check)');
} else {
console.warn('style.css still not applied after timeout (attempt ' + retries + '), retrying...');
setTimeout(tryLoad, timeout);
}
}, timeout + 300);
}
// Start after a short delay to let the browser initiate initial requests
setTimeout(tryLoad, 150);
})();

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);