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