Compare commits
29 Commits
b6ac157207
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7512e1d94b | |||
| 6ecb54e5ee | |||
| 69bc259a6c | |||
| 9c937dc62d | |||
| 34c499bd49 | |||
| ea4461cc54 | |||
| c14624ef92 | |||
| b97eb79b91 | |||
| fd40b663a0 | |||
| 3a34b1b8d0 | |||
| 465e34e919 | |||
| 83e51e87fe | |||
| 7f120ae62d | |||
| c32eabf464 | |||
| e10ffcfd65 | |||
| 2069d36715 | |||
| e677a7a8bf | |||
| de44c789af | |||
| 7e20aa65e1 | |||
| cfa3feb7d2 | |||
| abfe564891 | |||
| 083dfd6e2a | |||
| dc735c044f | |||
| 33701636af | |||
| d0c9a7e482 | |||
| 626657a976 | |||
| 6b942a8e07 | |||
| 22f8d3eec3 | |||
| 0a08709160 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,5 +7,5 @@
|
|||||||
schema/hannabox/hannabox-backups/
|
schema/hannabox/hannabox-backups/
|
||||||
schema/hannabox/*.lck
|
schema/hannabox/*.lck
|
||||||
.copilot
|
.copilot
|
||||||
|
web/cleaned
|
||||||
.codegpt
|
.codegpt
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
|
||||||
// for the documentation about the extensions.json format
|
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"diegoomal.ollama-connection",
|
"diegoomal.ollama-connection",
|
||||||
|
"pioarduino.pioarduino-ide",
|
||||||
"platformio.platformio-ide"
|
"platformio.platformio-ide"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": [
|
"unwantedRecommendations": [
|
||||||
|
|||||||
@@ -4,6 +4,109 @@ This document summarizes the memory optimizations implemented to resolve out-of-
|
|||||||
|
|
||||||
## Implemented Optimizations
|
## Implemented Optimizations
|
||||||
|
|
||||||
|
|
||||||
|
### Improvement for /directory
|
||||||
|
Implemented a backpressure-safe, low-heap HTML streaming solution to prevent AsyncTCP cbuf resize OOM during /directory.
|
||||||
|
|
||||||
|
Root cause
|
||||||
|
|
||||||
|
- The previous implementation used AsyncResponseStream (a Print) and wrote faster than the TCP stack could drain. Under client/network backpressure, AsyncTCP’s cbuf tried to grow and failed: cbuf.resize() -> WebResponses write(): Failed to allocate.
|
||||||
|
|
||||||
|
Fix implemented
|
||||||
|
|
||||||
|
- Switched /directory to AsyncChunkedResponse with a stateful generator that only produces bytes when the TCP layer is ready.
|
||||||
|
- Generates one entry at a time, respecting maxLen provided by the framework. This prevents buffer growth and heap spikes.
|
||||||
|
- No yield() needed; backpressure is handled by the chunked response callback scheduling.
|
||||||
|
|
||||||
|
Code changes
|
||||||
|
|
||||||
|
1. Added a tiny accessor to fetch file id at index
|
||||||
|
|
||||||
|
- Header: src/DirectoryNode.h
|
||||||
|
|
||||||
|
- Added: uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
|
- Source: src/DirectoryNode.cpp
|
||||||
|
|
||||||
|
- Implemented: uint16_t DirectoryNode::getFileIdAt(size_t i) const { return (i < ids.size()) ? ids[i] : 0; }
|
||||||
|
|
||||||
|
2. Replaced /directory handler with AsyncChunkedResponse generator
|
||||||
|
|
||||||
|
- File: src/main.cpp
|
||||||
|
|
||||||
|
- New logic (high level):
|
||||||
|
|
||||||
|
- DirectoryHtmlStreamState holds an explicit traversal stack of frames {node, fileIdx, childIdx, headerDone}.
|
||||||
|
|
||||||
|
- next(buffer, maxLen) fills output up to maxLen with:
|
||||||
|
|
||||||
|
- Single top-level \n
|
||||||
|
- A name\n for non-root directories (kept original behavior—no nested per subdir)
|
||||||
|
- One filename\n per file
|
||||||
|
- Depth-first traversal across subdirectories
|
||||||
|
- Closes with \n when done
|
||||||
|
|
||||||
|
- Uses snprintf into the chunk buffer and a simple copy loop for filenames, avoiding extra heap allocations.
|
||||||
|
|
||||||
|
- Frees generator state when finished and also on client disconnect.
|
||||||
|
|
||||||
|
3. Minor improvements in the chunked generator
|
||||||
|
|
||||||
|
- Normalized newline literals to \n (not escaped).
|
||||||
|
- Used single quotes around HTML attribute values to simplify C string escaping and reduce mistakes.
|
||||||
|
|
||||||
|
What remains unchanged
|
||||||
|
|
||||||
|
- DirectoryNode::streamDirectoryHTML(Print&) is left intact but no longer used by /directory. Mapping/State endpoints continue using their existing streaming; they are small and safe.
|
||||||
|
|
||||||
|
Why this eliminates the crashes
|
||||||
|
|
||||||
|
- AsyncChunkedResponse only invokes the generator when there’s space to send more, so AsyncTCP’s cbuf won’t grow unbounded. The generator respects the maxLen and yields 0 on completion, eliminating the resize path that previously caused OOM.
|
||||||
|
|
||||||
|
Build and flash instructions
|
||||||
|
|
||||||
|
- Your environment doesn’t have PlatformIO CLI available. Options:
|
||||||
|
|
||||||
|
1. VSCode PlatformIO extension: Use the “Build” and “Upload” tasks from the PlatformIO toolbar.
|
||||||
|
|
||||||
|
2. Install PlatformIO CLI:
|
||||||
|
|
||||||
|
- python3 -m pip install --user platformio
|
||||||
|
- $HOME/.local/bin must be in PATH (or use full path).
|
||||||
|
- Then build: pio run -e d1_mini32
|
||||||
|
- Upload: pio run -e d1_mini32 -t upload
|
||||||
|
|
||||||
|
3. Arduino IDE/CLI: Import and build the sketch there if preferred.
|
||||||
|
|
||||||
|
Runtime test checklist
|
||||||
|
|
||||||
|
- Open serial monitor at 115200, reset device.
|
||||||
|
|
||||||
|
- Hit [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> in a browser; the page should render fully without OOM or crash.
|
||||||
|
|
||||||
|
- Simulate slow client backpressure:
|
||||||
|
|
||||||
|
- curl --limit-rate 5k [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> -v -o /dev/null
|
||||||
|
- Observe no “[E][cbuf.cpp:104] resize(): failed to allocate temporary buffer” or “WebResponses write(): Failed to allocate”
|
||||||
|
|
||||||
|
- Watch heap logs during serving; you should see stable heap with no large dips.
|
||||||
|
|
||||||
|
- If desired, repeat with multiple concurrent connections to /directory to verify robustness.
|
||||||
|
|
||||||
|
Optional follow-ups
|
||||||
|
|
||||||
|
- If mapping ever grows large, convert /mapping to AsyncChunkedResponse using the same pattern.
|
||||||
|
- If your ESP32 has PSRAM, enabling it can further reduce heap pressure, but the chunked approach is already robust.
|
||||||
|
- Consider enabling CONFIG_ASYNC_TCP_MAX_ACK_TIME tune if you want more aggressive backpressure timing; your platformio.ini already has some AsyncTCP stack tweaks noted.
|
||||||
|
|
||||||
|
Summary
|
||||||
|
|
||||||
|
- Replaced Print-based recursive streaming with a chunked, backpressure-aware generator for /directory.
|
||||||
|
- This removes the cbuf resize failure path and should stop the crashes you observed while still using minimal heap.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 2. DirectoryNode Structure Optimization (✅ COMPLETED)
|
### 2. DirectoryNode Structure Optimization (✅ COMPLETED)
|
||||||
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
|
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
|
||||||
- **Memory saved**: Reduces fragmentation and improves allocation efficiency
|
- **Memory saved**: Reduces fragmentation and improves allocation efficiency
|
||||||
|
|||||||
244
USER_MANUAL.md
Normal file
244
USER_MANUAL.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# HannaBox – User Manual
|
||||||
|
|
||||||
|
HannaBox is a child‑friendly 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 Wi‑Fi.
|
||||||
|
|
||||||
|
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, 8–32 GB recommended)
|
||||||
|
- Power source (USB power supply or internal battery charged)
|
||||||
|
- Optional: Home Wi‑Fi (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 (first‑time 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.
|
||||||
|
- You’ll hear start.mp3 if present.
|
||||||
|
|
||||||
|
4) Connect to Wi‑Fi (optional, for the web app)
|
||||||
|
- If the HannaBox isn’t already on your Wi‑Fi, it opens a temporary hotspot named “HannaBox”.
|
||||||
|
- Connect your phone or laptop to the “HannaBox” Wi‑Fi and follow the captive portal to select your home network and enter its password.
|
||||||
|
- After it connects, note the device’s IP address (shown in the portal or in your router’s device list).
|
||||||
|
|
||||||
|
Tip: The HannaBox works without Wi‑Fi for basic playback and figurines. Wi‑Fi 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 0–15 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 app’s Manager to map it (see next section).
|
||||||
|
|
||||||
|
### Auto‑sleep 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 it’s 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 Wi‑Fi. Open a browser to the HannaBox’s IP address (e.g., http://192.168.x.x). The page and styling are served from the SD card’s 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 what’s playing and control playback (Play/Pause/Next/Previous).
|
||||||
|
- Seek within a track using the time slider.
|
||||||
|
- Adjust volume (0–15) 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 app’s “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 on‑device playlist is built from the SD card content (alphabetical).
|
||||||
|
- The web app’s 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 (0–21): Startup volume (UI uses 0–15 by default).
|
||||||
|
- maxVolume (1–21): Maximum volume limit for buttons/web app.
|
||||||
|
- sleepDelay (milliseconds): No‑interaction 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; Wi‑Fi setup is handled by the on‑device “HannaBox” hotspot.
|
||||||
|
|
||||||
|
Edit config.txt on your computer if needed (with the HannaBox powered off) or let the defaults work.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Power, sleep, and waking
|
||||||
|
- Auto‑sleep: 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: You’ll 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 project’s web/ folder to the SD system/ folder (exact names, case sensitive).
|
||||||
|
- Refresh the page.
|
||||||
|
|
||||||
|
Can’t find the device’s web page
|
||||||
|
- Make sure you connected the HannaBox to your home Wi‑Fi using the “HannaBox” hotspot/captive portal.
|
||||||
|
- Check your router’s 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 Wi‑Fi.
|
||||||
|
|
||||||
|
Figurine doesn’t 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/.
|
||||||
|
- Power‑cycle 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 didn’t work after wake
|
||||||
|
- Ensure startAtStoredProgress=true in /system/config.txt.
|
||||||
|
- The device writes /system/progress.txt just before sleep; verify the SD card isn’t full.
|
||||||
|
|
||||||
|
Uploads fail or are slow
|
||||||
|
- Ensure there’s 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.
|
||||||
@@ -13,24 +13,24 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
|
|||||||
board = wemos_d1_mini32
|
board = wemos_d1_mini32
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
ESP32Async/AsyncTCP@3.3.8
|
ESP32Async/ESPAsyncWebServer@3.9.2
|
||||||
ESP32Async/ESPAsyncWebServer@3.7.9
|
|
||||||
alanswx/ESPAsyncWiFiManager@0.31
|
alanswx/ESPAsyncWiFiManager@0.31
|
||||||
miguelbalboa/MFRC522@^1.4.12
|
miguelbalboa/MFRC522@^1.4.12
|
||||||
bblanchon/ArduinoJson@^6.21.3
|
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
build_flags =
|
build_flags =
|
||||||
-Os ; Optimize for size
|
-Os ; Optimize for size
|
||||||
-DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
-DDEBUG ; Hannabox Debugging
|
||||||
-DARDUINO_LOOP_STACK_SIZE=3072 ; Further reduce from 4096
|
; -DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
||||||
-DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
; -DARDUINO_LOOP_STACK_SIZE=4096 ; Balanced to avoid stack canary without starving heap
|
||||||
-DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
; -DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
||||||
-DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
; -DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
||||||
-DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
; -DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
||||||
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=3000
|
; -DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
||||||
|
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; (keep default)
|
||||||
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
|
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
|
||||||
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ; (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_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
|
monitor_filters = esp32_exception_decoder
|
||||||
board_build.partitions = huge_app.csv
|
board_build.partitions = huge_app.csv
|
||||||
|
|||||||
275
scripts/minify_web.py
Normal file
275
scripts/minify_web.py
Normal 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()
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
#include "DirectoryNode.h"
|
#include "DirectoryNode.h"
|
||||||
#include "globals.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)
|
DirectoryNode::DirectoryNode(const String &nodeName)
|
||||||
: name(nodeName), currentPlaying(nullptr)
|
: name(nodeName), currentPlaying("")
|
||||||
{
|
{
|
||||||
id = DirectoryNode::idCounter;
|
id = DirectoryNode::idCounter;
|
||||||
DirectoryNode::idCounter++;
|
DirectoryNode::idCounter++;
|
||||||
@@ -36,19 +41,86 @@ const std::vector<String> &DirectoryNode::getMP3Files() const
|
|||||||
return mp3Files;
|
return mp3Files;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::setCurrentPlaying(const String *mp3File)
|
const String &DirectoryNode::getDirPath() const
|
||||||
{
|
{
|
||||||
currentPlaying = mp3File;
|
return dirPath;
|
||||||
for (int i = 0; i < mp3Files.size(); i++)
|
}
|
||||||
|
|
||||||
|
uint16_t DirectoryNode::getFileIdAt(size_t i) const
|
||||||
|
{
|
||||||
|
return (i < ids.size()) ? ids[i] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
String DirectoryNode::buildFullPath(const String &fileName) const
|
||||||
|
{
|
||||||
|
if (dirPath == "/")
|
||||||
{
|
{
|
||||||
if (mp3Files[i] == *mp3File && ids.size() > i)
|
String p = "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
String p = dirPath;
|
||||||
|
p += "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DirectoryNode::comparePathWithString(const char* path, const String& target) const
|
||||||
|
{
|
||||||
|
// Convert target to char* for comparison
|
||||||
|
const char* targetStr = target.c_str();
|
||||||
|
|
||||||
|
// Case-insensitive string comparison
|
||||||
|
return strcasecmp(path, targetStr) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::buildFullPath(const String &fileName, char* out, size_t n) const
|
||||||
|
{
|
||||||
|
if (n == 0) return;
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (dirPath == "/")
|
||||||
|
{
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
strlcpy(out, dirPath.c_str(), n);
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
strlcat(out, fileName.c_str(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::setCurrentPlaying(const String &mp3File)
|
||||||
|
{
|
||||||
|
bool isAbs = (mp3File.length() > 0) && (mp3File.charAt(0) == '/');
|
||||||
|
const String &fileName = isAbs ? mp3File.substring(mp3File.lastIndexOf('/') + 1) : mp3File;
|
||||||
|
|
||||||
|
if (isAbs)
|
||||||
|
{
|
||||||
|
currentPlaying = mp3File; // Already absolute path
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use buffer for building relative path
|
||||||
|
buildFullPath(fileName, buffer, buffer_size);
|
||||||
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
|
{
|
||||||
|
if (mp3Files[i] == fileName && ids.size() > i)
|
||||||
{
|
{
|
||||||
currentPlayingId = ids[i];
|
currentPlayingId = ids[i];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const String *DirectoryNode::getCurrentPlaying() const
|
const String &DirectoryNode::getCurrentPlaying() const
|
||||||
{
|
{
|
||||||
return currentPlaying;
|
return currentPlaying;
|
||||||
}
|
}
|
||||||
@@ -81,7 +153,7 @@ void DirectoryNode::setSecondsPlayed(const uint32_t seconds)
|
|||||||
secondsPlayed = seconds;
|
secondsPlayed = seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t DirectoryNode::getSecondsPlayed()
|
uint32_t DirectoryNode::getSecondsPlayed() const
|
||||||
{
|
{
|
||||||
return secondsPlayed;
|
return secondsPlayed;
|
||||||
}
|
}
|
||||||
@@ -97,12 +169,25 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
mp3Files.clear();
|
mp3Files.clear();
|
||||||
ids.clear();
|
ids.clear();
|
||||||
|
|
||||||
// Reserve memory to reduce heap fragmentation (optimization 3)
|
// Set directory path for this node (normalize: keep "/" or remove trailing slash)
|
||||||
subdirectories.reserve(8); // Reserve space for 8 subdirectories
|
String path = String(currentPath);
|
||||||
mp3Files.reserve(16); // Reserve space for 16 MP3 files
|
if (path.length() > 1 && path.endsWith("/"))
|
||||||
ids.reserve(16); // Reserve space for 16 IDs
|
{
|
||||||
|
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);
|
File rootDir = SD.open(currentPath);
|
||||||
|
if (!rootDir)
|
||||||
|
{
|
||||||
|
Serial.print(F("buildDirectoryTree: failed to open path: "));
|
||||||
|
Serial.println(currentPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
File entry = rootDir.openNextFile();
|
File entry = rootDir.openNextFile();
|
||||||
@@ -111,31 +196,93 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
break;
|
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());
|
dirNames.emplace_back(entry.name());
|
||||||
subdirectories.push_back(newNode);
|
|
||||||
newNode->buildDirectoryTree((String(currentPath) + entry.name()).c_str());
|
|
||||||
}
|
}
|
||||||
else if (String(entry.name()).endsWith(".mp3") || String(entry.name()).endsWith(".MP3"))
|
else
|
||||||
{
|
{
|
||||||
String fullPath = String(currentPath);
|
String entryName = entry.name();
|
||||||
if (!fullPath.endsWith("/"))
|
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
|
||||||
fullPath += "/";
|
{
|
||||||
fullPath += entry.name();
|
fileNames.push_back(std::move(entryName));
|
||||||
mp3Files.push_back(fullPath);
|
}
|
||||||
ids.push_back(getNextId());
|
|
||||||
}
|
}
|
||||||
entry.close();
|
entry.close();
|
||||||
}
|
}
|
||||||
rootDir.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
|
void DirectoryNode::printDirectoryTree(int level) const
|
||||||
{
|
{
|
||||||
for (int i = 0; i < level; i++)
|
for (int i = 0; i < level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(name);
|
Serial.println(name);
|
||||||
|
|
||||||
@@ -143,7 +290,7 @@ void DirectoryNode::printDirectoryTree(int level) const
|
|||||||
{
|
{
|
||||||
for (int i = 0; i <= level; i++)
|
for (int i = 0; i <= level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(mp3File);
|
Serial.println(mp3File);
|
||||||
}
|
}
|
||||||
@@ -180,7 +327,7 @@ void DirectoryNode::advanceToFirstMP3InThisNode()
|
|||||||
{
|
{
|
||||||
if (mp3Files.size() > 0)
|
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])
|
if (id == ids[i])
|
||||||
{
|
{
|
||||||
// Found the current MP3 file
|
// Found the current MP3 file
|
||||||
currentPlaying = &mp3Files[i];
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
currentPlayingId = id;
|
currentPlayingId = id;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -210,50 +358,79 @@ DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
|
|||||||
|
|
||||||
// Recursively search in subdirectory
|
// Recursively search in subdirectory
|
||||||
DirectoryNode *result = subdir->advanceToMP3(id);
|
DirectoryNode *result = subdir->advanceToMP3(id);
|
||||||
if (result != nullptr && result->getCurrentPlaying() != nullptr)
|
if (result != nullptr && !result->getCurrentPlaying().isEmpty())
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, no song with this ID was found
|
// If we get here, no song with this ID was found
|
||||||
Serial.println("advanceToMP3: No song found for ID: " + String(id));
|
Serial.print(F("advanceToMP3: No song found for ID: "));
|
||||||
|
Serial.println(id);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
DirectoryNode *DirectoryNode::advanceToMP3(const String &songName)
|
||||||
{
|
{
|
||||||
if (songName == nullptr)
|
if (songName.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("advanceToMP3: songName is null");
|
Serial.println(F("advanceToMP3: songName is empty"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the input is an absolute path (starts with '/') or just a filename
|
// Check if the input is an absolute path (starts with '/') or just a filename
|
||||||
bool isAbsolutePath = songName->startsWith("/");
|
bool isAbsolutePath = songName.startsWith("/");
|
||||||
|
// Normalize trailing slash for absolute folder path targets
|
||||||
|
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
|
// First, search in the current directory's MP3 files
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
if (isAbsolutePath)
|
if (isAbsolutePath)
|
||||||
{
|
{
|
||||||
if (*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;
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Then search in subdirectories
|
// Then search in subdirectories
|
||||||
for (auto subdir : 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();
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
return subdir;
|
return subdir;
|
||||||
@@ -265,22 +442,36 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
|
|
||||||
if (isAbsolutePath)
|
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;
|
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;
|
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
|
// If we get here, no matching song was found
|
||||||
Serial.println("advanceToMP3: No song found for: " + *songName);
|
Serial.print(F("advanceToMP3: No song found for: "));
|
||||||
|
Serial.println(songName);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,16 +486,16 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *songName)
|
|||||||
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
||||||
{
|
{
|
||||||
// Safety check for null pointer
|
// Safety check for null pointer
|
||||||
if (currentPlaying == nullptr)
|
if (currentPlaying.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("goToPreviousMP3: currentPlaying is null");
|
Serial.println(F("goToPreviousMP3: currentPlaying is empty"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've been playing for more than threshold seconds, restart current song
|
// If we've been playing for more than threshold seconds, restart current song
|
||||||
if (secondsPlayed > thresholdSeconds)
|
if (secondsPlayed > thresholdSeconds)
|
||||||
{
|
{
|
||||||
Serial.println("goToPreviousMP3: Restarting current song (played > threshold)");
|
Serial.println(F("goToPreviousMP3: Restarting current song (played > threshold)"));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +503,8 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
|||||||
int currentIndex = -1;
|
int currentIndex = -1;
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
if (*currentPlaying == mp3Files[i])
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentPlaying))
|
||||||
{
|
{
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
break;
|
break;
|
||||||
@@ -322,23 +514,23 @@ DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
|||||||
// If current song found and not the first song, move to previous
|
// If current song found and not the first song, move to previous
|
||||||
if (currentIndex > 0)
|
if (currentIndex > 0)
|
||||||
{
|
{
|
||||||
Serial.print("goToPreviousMP3: Moving to previous song in same directory: ");
|
Serial.print(F("goToPreviousMP3: Moving to previous song in same directory: "));
|
||||||
Serial.println(mp3Files[currentIndex - 1]);
|
Serial.println(mp3Files[currentIndex - 1]);
|
||||||
setCurrentPlaying(&mp3Files[currentIndex - 1]);
|
setCurrentPlaying(mp3Files[currentIndex - 1]);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're at the first song or song not found in current directory,
|
// If we're at the first song or song not found in current directory,
|
||||||
// we need to find the previous song globally
|
// we need to find the previous song globally
|
||||||
Serial.println("goToPreviousMP3: At first song or song not found, looking for previous globally");
|
Serial.println(F("goToPreviousMP3: At first song or song not found, looking for previous globally"));
|
||||||
return nullptr; // Let the caller handle global previous logic
|
return nullptr; // Let the caller handle global previous logic
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGlobal)
|
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String ¤tGlobal)
|
||||||
{
|
{
|
||||||
if (currentGlobal == nullptr)
|
if (currentGlobal.isEmpty())
|
||||||
{
|
{
|
||||||
Serial.println("findPreviousMP3Globally: currentGlobal is null");
|
Serial.println(F("findPreviousMP3Globally: currentGlobal is null"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +544,9 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
|
|||||||
{
|
{
|
||||||
DirectoryNode *node = allMP3s[i].first;
|
DirectoryNode *node = allMP3s[i].first;
|
||||||
int fileIndex = allMP3s[i].second;
|
int fileIndex = allMP3s[i].second;
|
||||||
if (node->mp3Files[fileIndex] == *currentGlobal)
|
|
||||||
|
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentGlobal))
|
||||||
{
|
{
|
||||||
currentGlobalIndex = i;
|
currentGlobalIndex = i;
|
||||||
break;
|
break;
|
||||||
@@ -365,23 +559,31 @@ DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String *currentGloba
|
|||||||
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
|
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
|
||||||
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
|
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
|
||||||
|
|
||||||
Serial.print("findPreviousMP3Globally: Moving to previous song globally: ");
|
prevNode->buildFullPath(prevNode->mp3Files[prevFileIndex], buffer, buffer_size);
|
||||||
Serial.println(prevNode->mp3Files[prevFileIndex]);
|
|
||||||
|
|
||||||
prevNode->setCurrentPlaying(&prevNode->mp3Files[prevFileIndex]);
|
Serial.print(F("findPreviousMP3Globally: Moving to previous song globally: "));
|
||||||
|
Serial.println(buffer);
|
||||||
|
|
||||||
|
prevNode->setCurrentPlaying(prevNode->mp3Files[prevFileIndex]);
|
||||||
return prevNode;
|
return prevNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.println("findPreviousMP3Globally: No previous song found globally");
|
Serial.println(F("findPreviousMP3Globally: No previous song found globally"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>> &allMP3s)
|
void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>> &allMP3s)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
#ifdef DEBUG
|
||||||
|
Serial.println("Building flat mp3 list for folder");
|
||||||
|
#endif
|
||||||
|
// Pre-reserve to reduce reallocations
|
||||||
|
allMP3s.reserve(allMP3s.size() + mp3Files.size());
|
||||||
// Add all MP3 files from this directory
|
// Add all MP3 files from this directory
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
allMP3s.push_back(std::make_pair(this, i));
|
allMP3s.emplace_back(this, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively add MP3 files from subdirectories
|
// Recursively add MP3 files from subdirectories
|
||||||
@@ -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;
|
return subdirectories.size();
|
||||||
Serial.println(currentGlobal->c_str());
|
}
|
||||||
if (currentGlobal != nullptr)
|
|
||||||
|
DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal)
|
||||||
|
{
|
||||||
|
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 = "";
|
||||||
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]);
|
|
||||||
return this;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We are either not playing, or we've exhausted all the MP3 files in this directory.
|
// If current song found and not the last one, move to next
|
||||||
// Therefore, we need to recursively look in our subdirectories.
|
if (currentIndex >= 0 && currentIndex < (int)allMP3s.size() - 1)
|
||||||
for (auto subdir : subdirectories)
|
|
||||||
{
|
{
|
||||||
|
DirectoryNode *nextNode = allMP3s[currentIndex + 1].first;
|
||||||
|
int nextFileIndex = allMP3s[currentIndex + 1].second;
|
||||||
|
|
||||||
if (useFirst && subdir->mp3Files.size() > 0)
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
{
|
return nextNode;
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[0]);
|
|
||||||
return subdir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Have each subdirectory advance its song
|
// If not playing anything (start), play first
|
||||||
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
|
if (currentIndex == -1 && currentGlobal.isEmpty())
|
||||||
{
|
{
|
||||||
if (*currentGlobal == subdir->mp3Files[i])
|
DirectoryNode *nextNode = allMP3s[0].first;
|
||||||
{
|
int nextFileIndex = allMP3s[0].second;
|
||||||
// Found the current playing MP3 file
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
if (i < subdir->mp3Files.size() - 1)
|
return nextNode;
|
||||||
{
|
|
||||||
// Advance to the next MP3 file in the same directory
|
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i + 1]);
|
|
||||||
return subdir;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
useFirst = true;
|
|
||||||
}
|
|
||||||
// Reached the end of the MP3 files in the directory
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, there were no MP3 files or subdirectories left to check
|
// If we get here, either we are at the last song, or the current song was not found
|
||||||
currentPlaying = nullptr;
|
currentPlaying = "";
|
||||||
Serial.println("no more nodes found");
|
Serial.println(F("no more nodes found"));
|
||||||
return this;
|
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 "";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#ifndef DIRECTORYNODE_H_
|
#ifndef DIRECTORYNODE_H_
|
||||||
#define DIRECTORYNODE_H_
|
#define DIRECTORYNODE_H_
|
||||||
|
class Print;
|
||||||
|
|
||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -13,13 +14,20 @@ class DirectoryNode {
|
|||||||
private:
|
private:
|
||||||
uint16_t id;
|
uint16_t id;
|
||||||
String name;
|
String name;
|
||||||
|
String dirPath;
|
||||||
std::vector<DirectoryNode*> subdirectories;
|
std::vector<DirectoryNode*> subdirectories;
|
||||||
std::vector<String> mp3Files;
|
std::vector<String> mp3Files;
|
||||||
std::vector<uint16_t> ids;
|
std::vector<uint16_t> ids;
|
||||||
const String* currentPlaying;
|
static const size_t path_size = 256;
|
||||||
|
String currentPlaying;
|
||||||
uint16_t currentPlayingId = 0;
|
uint16_t currentPlayingId = 0;
|
||||||
uint16_t secondsPlayed = 0;
|
uint16_t secondsPlayed = 0;
|
||||||
|
static const size_t buffer_size = path_size;
|
||||||
|
static char buffer[buffer_size];
|
||||||
|
|
||||||
|
String buildFullPath(const String& fileName) const;
|
||||||
|
void buildFullPath(const String &fileName, char* buffer, size_t bufferSize) const;
|
||||||
|
bool comparePathWithString(const char* path, const String& target) const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,13 +41,17 @@ public:
|
|||||||
const uint16_t getId() const;
|
const uint16_t getId() const;
|
||||||
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
||||||
const std::vector<String>& getMP3Files() const;
|
const std::vector<String>& getMP3Files() const;
|
||||||
|
const String& getDirPath() const;
|
||||||
|
uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
void setCurrentPlaying(const String* mp3File);
|
size_t getNumOfFiles() const;
|
||||||
const String* getCurrentPlaying() const;
|
|
||||||
|
void setCurrentPlaying(const String& mp3File);
|
||||||
|
const String& getCurrentPlaying() const;
|
||||||
const uint16_t getCurrentPlayingId() const;
|
const uint16_t getCurrentPlayingId() const;
|
||||||
|
|
||||||
void setSecondsPlayed(const uint32_t seconds);
|
void setSecondsPlayed(const uint32_t seconds);
|
||||||
uint32_t getSecondsPlayed();
|
uint32_t getSecondsPlayed() const;
|
||||||
|
|
||||||
uint16_t getNextId();
|
uint16_t getNextId();
|
||||||
|
|
||||||
@@ -47,18 +59,14 @@ public:
|
|||||||
void addMP3File(const String& mp3File);
|
void addMP3File(const String& mp3File);
|
||||||
void buildDirectoryTree(const char* currentPath);
|
void buildDirectoryTree(const char* currentPath);
|
||||||
void printDirectoryTree(int level = 0) const;
|
void printDirectoryTree(int level = 0) const;
|
||||||
DirectoryNode* advanceToMP3(const String* songName);
|
DirectoryNode* advanceToMP3(const String& songName);
|
||||||
DirectoryNode* advanceToNextMP3(const String* currentGlobal);
|
DirectoryNode* advanceToNextMP3(const String& currentGlobal);
|
||||||
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
||||||
DirectoryNode* findPreviousMP3Globally(const String* currentGlobal);
|
DirectoryNode* findPreviousMP3Globally(const String& currentGlobal);
|
||||||
void buildFlatMP3List(std::vector<std::pair<DirectoryNode*, int>>& allMP3s);
|
void buildFlatMP3List(std::vector<std::pair<DirectoryNode*, int>>& allMP3s);
|
||||||
DirectoryNode* advanceToMP3(const uint16_t id);
|
DirectoryNode* advanceToMP3(const uint16_t id);
|
||||||
void advanceToFirstMP3InThisNode();
|
void advanceToFirstMP3InThisNode();
|
||||||
String getDirectoryStructureHTML() const;
|
|
||||||
size_t calculateHTMLSize() const;
|
|
||||||
void appendIndentation(String& html, int level) const;
|
|
||||||
DirectoryNode* findFirstDirectoryWithMP3s();
|
DirectoryNode* findFirstDirectoryWithMP3s();
|
||||||
String getCurrentPlayingFilePath() const;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
115
src/DirectoryWalker.h
Normal file
115
src/DirectoryWalker.h
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#ifndef DIRECTORY_WALKER_H
|
||||||
|
#define DIRECTORY_WALKER_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
#include "DirectoryNode.h"
|
||||||
|
|
||||||
|
struct WalkerState {
|
||||||
|
const DirectoryNode* node;
|
||||||
|
uint8_t phase; // 0: Start, 1: Files, 2: Subdirs, 3: End
|
||||||
|
size_t idx; // Index for vectors
|
||||||
|
|
||||||
|
WalkerState(const DirectoryNode* n) : node(n), phase(0), idx(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
class DirectoryWalker {
|
||||||
|
private:
|
||||||
|
std::vector<WalkerState> stack;
|
||||||
|
String pending;
|
||||||
|
size_t pendingOffset;
|
||||||
|
|
||||||
|
void generateNext() {
|
||||||
|
if (stack.empty()) return;
|
||||||
|
|
||||||
|
WalkerState& state = stack.back();
|
||||||
|
const DirectoryNode* node = state.node;
|
||||||
|
|
||||||
|
switch (state.phase) {
|
||||||
|
case 0: // Start
|
||||||
|
if (node->getName() == "/") {
|
||||||
|
pending += F("<ul>\r\n");
|
||||||
|
} else {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getId());
|
||||||
|
pending += F("\"><b>");
|
||||||
|
pending += node->getName();
|
||||||
|
pending += F("</b></li>\r\n");
|
||||||
|
}
|
||||||
|
state.phase = 1;
|
||||||
|
state.idx = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Files
|
||||||
|
if (state.idx < node->getMP3Files().size()) {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getFileIdAt(state.idx));
|
||||||
|
pending += F("\">");
|
||||||
|
pending += node->getMP3Files()[state.idx];
|
||||||
|
pending += F("</li>\r\n");
|
||||||
|
state.idx++;
|
||||||
|
} else {
|
||||||
|
state.phase = 2;
|
||||||
|
state.idx = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Subdirs
|
||||||
|
if (state.idx < node->getSubdirectories().size()) {
|
||||||
|
// Push child
|
||||||
|
const DirectoryNode* child = node->getSubdirectories()[state.idx];
|
||||||
|
state.idx++; // Advance index for when we return
|
||||||
|
stack.emplace_back(child);
|
||||||
|
// Next loop will process the child (Phase 0)
|
||||||
|
} else {
|
||||||
|
state.phase = 3;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // End
|
||||||
|
if (node->getName() == "/") {
|
||||||
|
pending += F("</ul>\r\n");
|
||||||
|
}
|
||||||
|
stack.pop_back();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
DirectoryWalker(const DirectoryNode* root) : pendingOffset(0) {
|
||||||
|
if (root) {
|
||||||
|
stack.emplace_back(root);
|
||||||
|
// Reserve some space for pending string to avoid frequent reallocations
|
||||||
|
pending.reserve(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t read(uint8_t* buffer, size_t maxLen) {
|
||||||
|
size_t written = 0;
|
||||||
|
|
||||||
|
while (written < maxLen) {
|
||||||
|
// If pending buffer is empty or fully consumed, generate more
|
||||||
|
if (pending.length() == 0 || pendingOffset >= pending.length()) {
|
||||||
|
pending = ""; // Reset string content (capacity is kept)
|
||||||
|
pendingOffset = 0;
|
||||||
|
|
||||||
|
if (stack.empty()) {
|
||||||
|
break; // Done
|
||||||
|
}
|
||||||
|
generateNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from pending to output buffer
|
||||||
|
if (pending.length() > pendingOffset) {
|
||||||
|
size_t available = pending.length() - pendingOffset;
|
||||||
|
size_t toCopy = std::min(available, maxLen - written);
|
||||||
|
memcpy(buffer + written, pending.c_str() + pendingOffset, toCopy);
|
||||||
|
written += toCopy;
|
||||||
|
pendingOffset += toCopy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
198
src/config.cpp
198
src/config.cpp
@@ -1,19 +1,30 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "globals.h"
|
#include "globals.h"
|
||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <strings.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
// Global config instance
|
// Global config instance
|
||||||
Config config;
|
Config config;
|
||||||
|
|
||||||
const String getConfigFilePath() {
|
const char* getConfigFilePath() {
|
||||||
return "/" + sys_dir + "/config.txt";
|
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() {
|
void setDefaultConfig() {
|
||||||
config.initialVolume = 7;
|
config.initialVolume = 7;
|
||||||
config.maxVolume = 15;
|
config.maxVolume = 15;
|
||||||
config.sleepTime = 1800000;
|
config.sleepTime = 1800000;
|
||||||
config.minVoltage = 3200;
|
config.minVoltage = 3000;
|
||||||
config.voltage100Percent = 4200;
|
config.voltage100Percent = 4200;
|
||||||
config.sleepDelay = 1800000;
|
config.sleepDelay = 1800000;
|
||||||
config.sleepMessageDelay = 1798000;
|
config.sleepMessageDelay = 1798000;
|
||||||
@@ -24,17 +35,17 @@ void setDefaultConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool loadConfig() {
|
bool loadConfig() {
|
||||||
String configPath = getConfigFilePath();
|
const char* configPath = getConfigFilePath();
|
||||||
|
|
||||||
if (!SD.exists(configPath)) {
|
if (!SD.exists(configPath)) {
|
||||||
Serial.println("Config file not found, using defaults");
|
Serial.println(F("Config file not found, using defaults"));
|
||||||
setDefaultConfig();
|
setDefaultConfig();
|
||||||
return saveConfig(); // Create config file with defaults
|
return saveConfig(); // Create config file with defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
File file = SD.open(configPath, FILE_READ);
|
File file = SD.open(configPath, FILE_READ);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
Serial.println("Failed to open config file");
|
Serial.println(F("Failed to open config file"));
|
||||||
setDefaultConfig();
|
setDefaultConfig();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -42,110 +53,151 @@ bool loadConfig() {
|
|||||||
// Set defaults first
|
// Set defaults first
|
||||||
setDefaultConfig();
|
setDefaultConfig();
|
||||||
|
|
||||||
// Parse config file line by line
|
// Parse config file line by line using a fixed-size buffer (no dynamic allocation)
|
||||||
while (file.available()) {
|
char line[160];
|
||||||
String line = file.readStringUntil('\n');
|
while (true) {
|
||||||
line.trim();
|
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
|
// Skip empty lines and comments
|
||||||
if (line.length() == 0 || line.startsWith("#")) {
|
if (*s == '\0' || *s == '#') {
|
||||||
|
if (c < 0) break;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
int separatorIndex = line.indexOf('=');
|
// Split at '='
|
||||||
if (separatorIndex == -1) {
|
char* eq = strchr(s, '=');
|
||||||
|
if (!eq) {
|
||||||
|
if (c < 0) break;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
*eq = '\0';
|
||||||
|
char* key = s;
|
||||||
|
char* val = eq + 1;
|
||||||
|
|
||||||
String key = line.substring(0, separatorIndex);
|
// Trim key
|
||||||
String value = line.substring(separatorIndex + 1);
|
while (*key && isspace((unsigned char)*key)) ++key;
|
||||||
key.trim();
|
char* kend = key + strlen(key);
|
||||||
value.trim();
|
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
|
// Parse each configuration value
|
||||||
if (key == "initialVolume") {
|
if (strcmp(key, "initialVolume") == 0) {
|
||||||
config.initialVolume = constrain(value.toInt(), 0, 21);
|
long v = strtol(val, nullptr, 10);
|
||||||
} else if (key == "maxVolume") {
|
if (v < 0) v = 0; if (v > 21) v = 21;
|
||||||
config.maxVolume = constrain(value.toInt(), 1, 21);
|
config.initialVolume = (uint8_t)v;
|
||||||
} else if (key == "sleepTime") {
|
} else if (strcmp(key, "maxVolume") == 0) {
|
||||||
config.sleepTime = value.toInt();
|
long v = strtol(val, nullptr, 10);
|
||||||
} else if (key == "minVoltage") {
|
if (v < 1) v = 1; if (v > 21) v = 21;
|
||||||
config.minVoltage = value.toInt();
|
config.maxVolume = (uint8_t)v;
|
||||||
} else if (key == "voltage100Percent") {
|
} else if (strcmp(key, "sleepTime") == 0) {
|
||||||
config.voltage100Percent = value.toInt();
|
config.sleepTime = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
} else if (key == "sleepDelay") {
|
} else if (strcmp(key, "minVoltage") == 0) {
|
||||||
config.sleepDelay = value.toInt();
|
config.minVoltage = (uint16_t)strtoul(val, nullptr, 10);
|
||||||
} else if (key == "sleepMessageDelay") {
|
} else if (strcmp(key, "voltage100Percent") == 0) {
|
||||||
config.sleepMessageDelay = value.toInt();
|
config.voltage100Percent = (uint16_t)strtoul(val, nullptr, 10);
|
||||||
} else if (key == "rfidLoopInterval") {
|
} else if (strcmp(key, "sleepDelay") == 0) {
|
||||||
config.rfidLoopInterval = constrain(value.toInt(), 1, 255);
|
config.sleepDelay = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
} else if (key == "startAtStoredProgress") {
|
} else if (strcmp(key, "sleepMessageDelay") == 0) {
|
||||||
config.startAtStoredProgress = (value == "1" || value.equalsIgnoreCase("true"));
|
config.sleepMessageDelay = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
} else if (key == "wifiSSID") {
|
} else if (strcmp(key, "rfidLoopInterval") == 0) {
|
||||||
strncpy(config.wifiSSID, value.c_str(), sizeof(config.wifiSSID) - 1);
|
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';
|
config.wifiSSID[sizeof(config.wifiSSID) - 1] = '\0';
|
||||||
} else if (key == "wifiPassword") {
|
} else if (strcmp(key, "wifiPassword") == 0) {
|
||||||
strncpy(config.wifiPassword, value.c_str(), sizeof(config.wifiPassword) - 1);
|
strncpy(config.wifiPassword, val, sizeof(config.wifiPassword) - 1);
|
||||||
config.wifiPassword[sizeof(config.wifiPassword) - 1] = '\0';
|
config.wifiPassword[sizeof(config.wifiPassword) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (c < 0) break; // EOF after processing last line
|
||||||
}
|
}
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
Serial.println("Config loaded successfully");
|
Serial.println(F("Config loaded successfully"));
|
||||||
Serial.print("Initial Volume: "); Serial.println(config.initialVolume);
|
Serial.print(F("Initial Volume: ")); Serial.println(config.initialVolume);
|
||||||
Serial.print("Max Volume: "); Serial.println(config.maxVolume);
|
Serial.print(F("Max Volume: ")); Serial.println(config.maxVolume);
|
||||||
Serial.print("Sleep Delay: "); Serial.println(config.sleepDelay);
|
Serial.print(F("Sleep Delay: ")); Serial.println(config.sleepDelay);
|
||||||
Serial.print("RFID Interval: "); Serial.println(config.rfidLoopInterval);
|
Serial.print(F("RFID Interval: ")); Serial.println(config.rfidLoopInterval);
|
||||||
|
Serial.print(F("min Voltage: ")); Serial.println(config.minVoltage);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool saveConfig() {
|
bool saveConfig() {
|
||||||
String configPath = getConfigFilePath();
|
const char* configPath = getConfigFilePath();
|
||||||
|
|
||||||
File file = SD.open(configPath, FILE_WRITE);
|
File file = SD.open(configPath, FILE_WRITE);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
Serial.println("Failed to create config file");
|
Serial.println(F("Failed to create config file"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write config file with comments for user reference
|
// Write config file with comments for user reference
|
||||||
file.println("# HannaBox Configuration File");
|
file.println(F("# HannaBox Conf File"));
|
||||||
file.println("# Values are in the format: key=value");
|
file.println(F("# format: key=value"));
|
||||||
file.println("# Lines starting with # are comments");
|
file.println();
|
||||||
file.println("");
|
|
||||||
|
|
||||||
file.println("# Audio Settings");
|
file.println(F("# Audio"));
|
||||||
file.print("initialVolume="); file.println(config.initialVolume);
|
file.print(F("initialVolume=")); file.println(config.initialVolume);
|
||||||
file.print("maxVolume="); file.println(config.maxVolume);
|
file.print(F("maxVolume=")); file.println(config.maxVolume);
|
||||||
file.println("");
|
file.println();
|
||||||
|
|
||||||
file.println("# Power Management (times in milliseconds)");
|
file.println(F("# Power Management (in milliseconds)"));
|
||||||
file.print("sleepTime="); file.println(config.sleepTime);
|
file.print(F("sleepTime=")); file.println(config.sleepTime);
|
||||||
file.print("sleepDelay="); file.println(config.sleepDelay);
|
file.print(F("sleepDelay=")); file.println(config.sleepDelay);
|
||||||
file.print("sleepMessageDelay="); file.println(config.sleepMessageDelay);
|
file.print(F("sleepMessageDelay=")); file.println(config.sleepMessageDelay);
|
||||||
file.println("");
|
file.println();
|
||||||
|
|
||||||
file.println("# Battery Settings (voltage in millivolts)");
|
file.println(F("# Battery (in millivolts)"));
|
||||||
file.print("minVoltage="); file.println(config.minVoltage);
|
file.print(F("minVoltage=")); file.println(config.minVoltage);
|
||||||
file.print("voltage100Percent="); file.println(config.voltage100Percent);
|
file.print(F("voltage100Percent=")); file.println(config.voltage100Percent);
|
||||||
file.println("");
|
file.println();
|
||||||
|
|
||||||
file.println("# RFID Settings");
|
file.println(F("# RFID"));
|
||||||
file.print("rfidLoopInterval="); file.println(config.rfidLoopInterval);
|
file.print(F("rfidLoopInterval=")); file.println(config.rfidLoopInterval);
|
||||||
file.println("");
|
file.println();
|
||||||
|
|
||||||
file.println("# Playback Settings");
|
file.println(F("# Playback"));
|
||||||
file.print("startAtStoredProgress="); file.println(config.startAtStoredProgress ? "true" : "false");
|
file.print(F("startAtStoredProgress=")); file.println(config.startAtStoredProgress ? F("true") : F("false"));
|
||||||
file.println("");
|
file.println();
|
||||||
|
|
||||||
file.println("# WiFi Settings (leave empty to use current WiFiManager)");
|
file.println(F("# WiFi (leave empty to use current WiFiManager)"));
|
||||||
file.print("wifiSSID="); file.println(config.wifiSSID);
|
file.print(F("wifiSSID=")); file.println(config.wifiSSID);
|
||||||
file.print("wifiPassword="); file.println(config.wifiPassword);
|
file.print(F("wifiPassword=")); file.println(config.wifiPassword);
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
Serial.println("Config saved successfully");
|
Serial.println(F("Config saved successfully"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/config.h
40
src/config.h
@@ -3,19 +3,30 @@
|
|||||||
|
|
||||||
#include <Arduino.h>
|
#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 {
|
struct Config {
|
||||||
uint8_t initialVolume = 7;
|
// 32-bit values
|
||||||
uint8_t maxVolume = 15;
|
uint32_t sleepTime; // ms
|
||||||
uint32_t sleepTime = 1800000; // 30 minutes in ms
|
uint32_t sleepDelay; // ms
|
||||||
uint16_t minVoltage = 3200; // mV - minimum voltage before sleep
|
uint32_t sleepMessageDelay; // ms
|
||||||
uint16_t voltage100Percent = 4200; // mV - voltage representing 100% battery
|
|
||||||
uint32_t sleepDelay = 1800000; // 30 minutes in ms
|
// 16-bit values
|
||||||
uint32_t sleepMessageDelay = 1798000; // 2 seconds before sleep
|
uint16_t minVoltage; // mV
|
||||||
uint8_t rfidLoopInterval = 25; // RFID check interval
|
uint16_t voltage100Percent; // mV
|
||||||
bool startAtStoredProgress = true; // Resume from last position
|
|
||||||
char wifiSSID[32] = ""; // WiFi SSID (empty = use current mechanism)
|
// 8-bit values
|
||||||
char wifiPassword[64] = ""; // WiFi password (empty = use current mechanism)
|
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
|
// Global config instance
|
||||||
@@ -25,6 +36,9 @@ extern Config config;
|
|||||||
bool loadConfig();
|
bool loadConfig();
|
||||||
bool saveConfig();
|
bool saveConfig();
|
||||||
void setDefaultConfig();
|
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
|
#endif
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
#ifndef GLOBALS_H_
|
#ifndef GLOBALS_H_
|
||||||
#define 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;
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
1123
src/main.cpp
1123
src/main.cpp
File diff suppressed because it is too large
Load Diff
127
src/main.h
127
src/main.h
@@ -1,6 +1,101 @@
|
|||||||
#ifndef MAIN_H_
|
#ifndef MAIN_H_
|
||||||
#define 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 stop();
|
||||||
|
|
||||||
void start();
|
void start();
|
||||||
@@ -19,7 +114,6 @@ void init_webserver();
|
|||||||
|
|
||||||
boolean buttonPressed(const uint8_t pin);
|
boolean buttonPressed(const uint8_t pin);
|
||||||
|
|
||||||
const String getSysDir(const String filename);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper routine to dump a byte array as hex values to Serial.
|
* Helper routine to dump a byte array as hex values to Serial.
|
||||||
@@ -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 getRFIDString(byte uidByte[10])
|
||||||
{
|
{
|
||||||
String uidString = String(uidByte[0]) + " " + String(uidByte[1]) + " " +
|
String uidString = String(uidByte[0]) + " " + String(uidByte[1]) + " " +
|
||||||
@@ -84,10 +195,12 @@ boolean prepareSleepMode = false;
|
|||||||
|
|
||||||
class DirectoryNode;
|
class DirectoryNode;
|
||||||
|
|
||||||
// Mapping entry that stores target (file or folder) and playback mode:
|
/* Mapping entry that stores target (file or folder) and playback mode:
|
||||||
// 's' = single (default) - play only the selected song (or single file in folder)
|
* 's' = single (default) - play only the selected song (or single file in folder)
|
||||||
// 'f' = folder - play files inside the selected folder, then stop
|
* 'f' = folder - play files inside the selected folder, then stop
|
||||||
// 'c' = continuous - continuously play (like previous continuousMode)
|
* 'r' = random-folder - play files inside the selected folder in random order, then stop
|
||||||
|
* 'c' = continuous - continuously play (like previous continuousMode)
|
||||||
|
*/
|
||||||
struct MappingEntry {
|
struct MappingEntry {
|
||||||
String target;
|
String target;
|
||||||
char mode;
|
char mode;
|
||||||
@@ -99,9 +212,11 @@ std::map<String, MappingEntry> rfid_map;
|
|||||||
|
|
||||||
// Folder-play helper: when a mapping requests "folder only" playback we keep
|
// Folder-play helper: when a mapping requests "folder only" playback we keep
|
||||||
// track of the folder root node so EOF handling can advance only inside that folder.
|
// track of the folder root node so EOF handling can advance only inside that folder.
|
||||||
bool folderModeActive = false;
|
bool folderModeActive = true;
|
||||||
|
|
||||||
bool pendingSeek = false;
|
bool pendingSeek = false;
|
||||||
uint32_t pendingSeekSeconds = 0;
|
uint32_t pendingSeekSeconds = 0;
|
||||||
|
|
||||||
|
static const size_t MAX_DEPTH = 32;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<h1>HannaBox</h1>
|
<h1>HannaBox</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-status-group">
|
||||||
|
<div id="batteryStatus" class="battery-status" title="Battery"></div>
|
||||||
<div class="status" id="state">—</div>
|
<div class="status" id="state">—</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -118,8 +121,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="mode">Mode:</label>
|
<label for="mode">Mode:</label>
|
||||||
<select id="mode" name="mode">
|
<select id="mode" name="mode">
|
||||||
<option value="s">Single (play selected song / file)</option>
|
|
||||||
<option value="f">Folder (play selected folder, then stop)</option>
|
<option value="f">Folder (play selected folder, then stop)</option>
|
||||||
|
<option value="s">Single (play selected song / file)</option>
|
||||||
|
<option value="r">Random (randomize order in folder, then stop)</option>
|
||||||
<option value="c">Continuous (continuous playback / loop folder)</option>
|
<option value="c">Continuous (continuous playback / loop folder)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +151,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
|
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<h4>System</h4>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="action-btn" style="background-color: #ef4444;" onclick="resetWifi()">Reset WiFi Settings</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
235
web/script.js
235
web/script.js
@@ -1,6 +1,90 @@
|
|||||||
setInterval(getState, 4000);
|
setInterval(getState, 4000);
|
||||||
setInterval(updateProgress, 500); // Update progress every second
|
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) */
|
/* Dynamic content loaders for playlist and mapping (avoid heavy template processing on server) */
|
||||||
function bindPlaylistClicks() {
|
function bindPlaylistClicks() {
|
||||||
var container = document.getElementById('playlistContainer');
|
var container = document.getElementById('playlistContainer');
|
||||||
@@ -31,6 +115,24 @@ function loadDirectory() {
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetWifi() {
|
||||||
|
if (!confirm('Are you sure you want to reset WiFi settings? The device will restart and create an access point.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/reset_wifi', true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
alert('WiFi settings reset. Device is restarting...');
|
||||||
|
} else {
|
||||||
|
alert('Reset failed: ' + (xhr.responseText || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
function loadMapping() {
|
function loadMapping() {
|
||||||
var el = document.getElementById('mappingList');
|
var el = document.getElementById('mappingList');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -107,16 +209,22 @@ function getState() {
|
|||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.onreadystatechange = function() {
|
xhr.onreadystatechange = function() {
|
||||||
if (xhr.readyState === 4) {
|
if (xhr.readyState === 4) {
|
||||||
var state = JSON.parse(xhr.response);
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
isPlaying = state['playing'];
|
try {
|
||||||
|
var state = JSON.parse(xhr.responseText || xhr.response || '{}');
|
||||||
|
isPlaying = !!state['playing'];
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
songStartTime = Date.now() - state['time'] * 1000;
|
songStartTime = Date.now() - ((state['time'] || 0) * 1000);
|
||||||
currentSongLength = state['length'] * 1000;
|
currentSongLength = ((state['length'] || 0) * 1000);
|
||||||
}
|
}
|
||||||
lastStateUpdateTime = Date.now();
|
lastStateUpdateTime = Date.now();
|
||||||
displayState(state);
|
displayState(state);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors; will retry on next poll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
xhr.open("GET","/state", true);
|
xhr.open("GET","/state", true);
|
||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
@@ -151,13 +259,32 @@ function displayState(state) {
|
|||||||
var voltageEl = document.getElementById("voltage");
|
var voltageEl = document.getElementById("voltage");
|
||||||
if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV';
|
if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV';
|
||||||
|
|
||||||
|
// Update header battery indicator
|
||||||
|
var headerBattery = document.getElementById("batteryStatus");
|
||||||
|
if (headerBattery) {
|
||||||
|
var mv = state['voltage'] || 0;
|
||||||
|
if (mv > 0) {
|
||||||
|
// Estimate percentage for single cell LiPo (approx 3.3V - 4.2V)
|
||||||
|
var pct = Math.round((mv - 3300) / (4200 - 3300) * 100);
|
||||||
|
if (pct < 0) pct = 0;
|
||||||
|
if (pct > 100) pct = 100;
|
||||||
|
|
||||||
|
headerBattery.innerHTML =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.7"><path d="M16 4h-1V2a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/></svg>' +
|
||||||
|
'<span>' + pct + '%</span>';
|
||||||
|
headerBattery.title = mv + ' mV';
|
||||||
|
} else {
|
||||||
|
headerBattery.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var heapEl = document.getElementById("heap");
|
var heapEl = document.getElementById("heap");
|
||||||
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';
|
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';
|
||||||
|
|
||||||
var uidEl = document.getElementById("uid");
|
var uidEl = document.getElementById("uid");
|
||||||
if (uidEl) uidEl.innerHTML = 'Last NFC ID: ' + (state['uid'] || '');
|
if (uidEl) uidEl.innerHTML = 'Last NFC ID: ' + (state['uid'] || '');
|
||||||
|
|
||||||
/* ==== Autofill convenience fields ==== */
|
/* Autofill convenience fields */
|
||||||
var fm = document.getElementById('fileManager');
|
var fm = document.getElementById('fileManager');
|
||||||
if (state['filepath'] && fm && fm.style.display == 'none') {
|
if (state['filepath'] && fm && fm.style.display == 'none') {
|
||||||
var moveFrom = document.getElementById('moveFrom');
|
var moveFrom = document.getElementById('moveFrom');
|
||||||
@@ -213,7 +340,6 @@ function playNamedSong(song) {
|
|||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", "/playnamed");
|
xhr.open("POST", "/playnamed");
|
||||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
||||||
//application/x-www-form-urlencoded
|
|
||||||
var body = song;
|
var body = song;
|
||||||
xhr.send("title="+encodeURIComponent(body));
|
xhr.send("title="+encodeURIComponent(body));
|
||||||
}
|
}
|
||||||
@@ -239,8 +365,7 @@ function editMapping() {
|
|||||||
// Validate file before upload
|
// Validate file before upload
|
||||||
function validateFile(file) {
|
function validateFile(file) {
|
||||||
var maxSize = 50 * 1024 * 1024; // 50MB limit
|
var maxSize = 50 * 1024 * 1024; // 50MB limit
|
||||||
var allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp4', 'audio/ogg'];
|
var allowedExtensions = ['.mp3'];
|
||||||
var allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.ogg'];
|
|
||||||
|
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
return 'File too large. Maximum size is 50MB.';
|
return 'File too large. Maximum size is 50MB.';
|
||||||
@@ -250,7 +375,7 @@ function validateFile(file) {
|
|||||||
var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
|
var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
|
||||||
|
|
||||||
if (!hasValidExtension) {
|
if (!hasValidExtension) {
|
||||||
return 'Invalid file type. Only audio files (.mp3, .wav, .flac, .m4a, .ogg) are allowed.';
|
return 'Invalid file type';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // No error
|
return null; // No error
|
||||||
@@ -355,7 +480,7 @@ function resetUploadForm() {
|
|||||||
document.getElementById('uploadFile').value = '';
|
document.getElementById('uploadFile').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================= File Manager Functions ================= */
|
/* File Manager Functions */
|
||||||
|
|
||||||
function toggleFileManager() {
|
function toggleFileManager() {
|
||||||
var fm = document.getElementById('fileManager');
|
var fm = document.getElementById('fileManager');
|
||||||
@@ -411,93 +536,3 @@ function deleteFileOnServer() {
|
|||||||
};
|
};
|
||||||
xhr.send();
|
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);
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ a { color: var(--accent); text-decoration: none; }
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.battery-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Status (current song) */
|
/* Status (current song) */
|
||||||
.status {
|
.status {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user