From 1e44745b7202c9bd6c236241db2669042d0b5aca Mon Sep 17 00:00:00 2001 From: Stefan Ostermann Date: Sun, 25 May 2025 22:38:22 +0200 Subject: [PATCH] [Ai] Improved File upload, some documentation --- .vscode/extensions.json | 1 + README.md | 4 + platformio.ini | 2 +- src/DirectoryNode.cpp | 9 +++ src/WebContent.h | 137 ++++++++++++++++++++++++++++++++++- src/css.h | 157 +++++++++++++++++++++++++++++++++++++++- src/globals.h | 2 +- src/main.cpp | 107 +++++++++++++++++++++++---- 8 files changed, 399 insertions(+), 20 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 080e70d..df6fb61 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ // See http://go.microsoft.com/fwlink/?LinkId=827846 // for the documentation about the extensions.json format "recommendations": [ + "diegoomal.ollama-connection", "platformio.platformio-ide" ], "unwantedRecommendations": [ diff --git a/README.md b/README.md index 7123a28..e35ba88 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ We use three buttons, preferably big and robust. They are grounded if closed, so Start / Stop -> 17 and GND Wake up / Next -> 04 and GND Previous -> 16 and GND + +#define BTN_START_STOP 4 // Button on XX and GND +#define BTN_NEXT 17 +#define BTN_PREV 16 ``` ## Battery Voltage diff --git a/platformio.ini b/platformio.ini index 8ce05c9..ed30f57 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ ; PlatformIO Project Configuration File -; +; ; Build options: build flags, source filter ; Upload options: custom upload port, speed and extra flags ; Library options: dependencies, extra library storages diff --git a/src/DirectoryNode.cpp b/src/DirectoryNode.cpp index 6af7cd6..9a7b994 100644 --- a/src/DirectoryNode.cpp +++ b/src/DirectoryNode.cpp @@ -87,6 +87,15 @@ uint32_t DirectoryNode::getSecondsPlayed() void DirectoryNode::buildDirectoryTree(const char *currentPath) { + // Clear existing data to prevent duplicates when rebuilding + for (DirectoryNode *childNode : subdirectories) + { + delete childNode; + } + subdirectories.clear(); + mp3Files.clear(); + ids.clear(); + File rootDir = SD.open(currentPath); while (true) { diff --git a/src/WebContent.h b/src/WebContent.h index 3619581..ebdc171 100644 --- a/src/WebContent.h +++ b/src/WebContent.h @@ -37,13 +37,23 @@ const char index_html[] PROGMEM = R"rawliteral(   --> -

+

🎶 Playlist 🎶

%DIRECTORY%

-
- +
+ + +
+ +
+

Edit RFID Mapping

@@ -203,6 +213,125 @@ const char index_html[] PROGMEM = R"rawliteral( }; } + // Validate file before upload + function validateFile(file) { + var maxSize = 50 * 1024 * 1024; // 50MB limit + var allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp4', 'audio/ogg']; + var allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.ogg']; + + if (file.size > maxSize) { + return 'File too large. Maximum size is 50MB.'; + } + + var fileName = file.name.toLowerCase(); + var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext)); + + if (!hasValidExtension) { + return 'Invalid file type. Only audio files (.mp3, .wav, .flac, .m4a, .ogg) are allowed.'; + } + + return null; // No error + } + + // Handle form submission with AJAX to show upload status + document.getElementById('uploadForm').addEventListener('submit', function(event) { + event.preventDefault(); // Prevent the default form submit + + var fileInput = document.getElementById('uploadFile'); + var file = fileInput.files[0]; + + if (!file) { + alert('Please select a file to upload.'); + return; + } + + var validationError = validateFile(file); + if (validationError) { + alert(validationError); + return; + } + + var form = event.target; + var formData = new FormData(form); + var uploadButton = document.getElementById('uploadButton'); + var uploadStatus = document.getElementById('uploadStatus'); + var uploadProgress = document.getElementById('uploadProgress'); + var progressFill = document.getElementById('progressFill'); + var progressText = document.getElementById('progressText'); + + // Disable upload button and show progress + uploadButton.disabled = true; + uploadButton.value = 'Uploading...'; + uploadProgress.style.display = 'block'; + uploadStatus.innerHTML = 'Preparing upload...'; + + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload', true); + + xhr.upload.onloadstart = function() { + uploadStatus.innerHTML = 'Upload started...'; + }; + + xhr.upload.onprogress = function(event) { + if (event.lengthComputable) { + var percentComplete = Math.round((event.loaded / event.total) * 100); + progressFill.style.width = percentComplete + '%'; + progressText.innerHTML = percentComplete + '%'; + uploadStatus.innerHTML = 'Uploading: ' + percentComplete + '% (' + + Math.round(event.loaded / 1024) + 'KB / ' + + Math.round(event.total / 1024) + 'KB)'; + } + }; + + xhr.upload.onerror = function() { + uploadStatus.innerHTML = 'Upload failed due to network error.'; + resetUploadForm(); + }; + + xhr.upload.onabort = function() { + uploadStatus.innerHTML = 'Upload was cancelled.'; + resetUploadForm(); + }; + + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { // Request is done + if (xhr.status >= 200 && xhr.status < 300) { // Success status code range + uploadStatus.innerHTML = 'Upload completed successfully!'; + progressFill.style.width = '100%'; + progressText.innerHTML = '100%'; + + setTimeout(function() { + alert('File uploaded successfully!'); + location.reload(); // Reload to get updated playlist + }, 1000); + } else { + var errorMsg = xhr.responseText || 'Unknown error occurred'; + uploadStatus.innerHTML = 'Upload failed: ' + errorMsg; + alert('Upload failed: ' + errorMsg); + resetUploadForm(); + } + } + }; + + xhr.send(formData); // Send the form data using XMLHttpRequest + }); + + function resetUploadForm() { + var uploadButton = document.getElementById('uploadButton'); + var uploadProgress = document.getElementById('uploadProgress'); + var progressFill = document.getElementById('progressFill'); + var progressText = document.getElementById('progressText'); + + uploadButton.disabled = false; + uploadButton.value = 'Upload'; + uploadProgress.style.display = 'none'; + progressFill.style.width = '0%'; + progressText.innerHTML = '0%'; + + // Clear file input + document.getElementById('uploadFile').value = ''; + } + -)rawliteral"; \ No newline at end of file +)rawliteral"; diff --git a/src/css.h b/src/css.h index 128a16b..a7db3e0 100644 --- a/src/css.h +++ b/src/css.h @@ -7,6 +7,15 @@ body { background-color: #f4f4f4; /* Light background */ } +.playlist-container { + max-height: 300px; + overflow-y: auto; + border: 1px solid #ccc; + padding: 10px; + margin-top: 20px; + text-align: left; /* Align playlist text to the left */ +} + li { cursor: pointer; list-style-type: none; @@ -74,4 +83,150 @@ li { margin: 20px 0; /* Space out elements */ } -)rawliteral"; \ No newline at end of file +/* Upload progress bar styles */ +.progress-bar { + width: 100%; + height: 20px; + background-color: #e0e0e0; + border-radius: 10px; + overflow: hidden; + margin: 10px 0; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #007bff, #0056b3); + width: 0%; + transition: width 0.3s ease; + border-radius: 10px; +} + +#uploadProgress { + margin: 15px 0; + text-align: center; +} + +#progressText { + font-weight: bold; + color: #007bff; + margin-left: 10px; +} + +#uploadStatus { + margin: 10px 0; + padding: 8px; + border-radius: 4px; + font-weight: bold; +} + +#uploadStatus:not(:empty) { + background-color: #e7f3ff; + border: 1px solid #007bff; + color: #0056b3; +} + +/* Form styling improvements */ +#uploadForm { + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 20px 0; +} + +#uploadButton { + background-color: #007bff; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + margin-left: 10px; + transition: background-color 0.3s ease; +} + +#uploadButton:hover:not(:disabled) { + background-color: #0056b3; +} + +#uploadButton:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +#uploadFile { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + margin-right: 10px; +} + +/* RFID mapping form styling */ +#editMappingForm { + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 20px 0; + text-align: left; +} + +#editMappingForm label { + display: block; + margin: 10px 0 5px 0; + font-weight: bold; +} + +#editMappingForm input[type="text"] { + width: 100%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + margin-bottom: 10px; + box-sizing: border-box; +} + +#editMappingForm button { + background-color: #28a745; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + margin-top: 10px; + transition: background-color 0.3s ease; +} + +#editMappingForm button:hover { + background-color: #218838; +} + +/* Responsive design improvements */ +@media (max-width: 600px) { + body { + padding: 10px; + } + + .slider { + width: 95%; + } + + #uploadForm, #editMappingForm { + padding: 15px; + } + + #uploadButton, #editMappingForm button { + width: 100%; + margin: 10px 0; + } + + #uploadFile { + width: 100%; + margin: 10px 0; + } +} + +)rawliteral"; diff --git a/src/globals.h b/src/globals.h index bce20aa..d7a165c 100644 --- a/src/globals.h +++ b/src/globals.h @@ -103,4 +103,4 @@ const long sleepMessageDelay = 1798000; const long sleepDelay = 1800000; //const long sleepDelay = 25000; -#endif \ No newline at end of file +#endif diff --git a/src/main.cpp b/src/main.cpp index ce7076f..f06269b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -150,27 +150,109 @@ String humanReadableSize(const size_t bytes) { void handleUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { String logmessage; + if (!index) { - logmessage = "Upload Start: " + String(filename); - // open the file on first call and store the file handle in the request object - request->_tempFile = SD.open("/"+filename, FILE_WRITE); + // Validate filename and file extension + if (filename.length() == 0) { + request->send(400, "text/plain", "Invalid filename"); + return; + } + + // Check if filename has valid extension (mp3, wav, flac, etc.) + String lowerFilename = filename; + lowerFilename.toLowerCase(); + if (!lowerFilename.endsWith(".mp3") && !lowerFilename.endsWith(".wav") && + !lowerFilename.endsWith(".flac") && !lowerFilename.endsWith(".m4a") && + !lowerFilename.endsWith(".ogg")) { + request->send(400, "text/plain", "Invalid file type. Only audio files are allowed."); + return; + } + + // Check available SD card space + uint64_t cardSize = SD.cardSize() / (1024 * 1024); + uint64_t usedBytes = SD.usedBytes() / (1024 * 1024); + uint64_t freeSpace = cardSize - usedBytes; + + if (freeSpace < 10) { // Less than 10MB free + request->send(507, "text/plain", "Insufficient storage space"); + return; + } + + logmessage = "Upload Start: " + String(filename) + " (Free space: " + String(freeSpace) + "MB)"; Serial.println(logmessage); + + // Ensure SD is active + activateSD(); + + // Check if file already exists and create backup name if needed + String filepath = "/" + filename; + if (SD.exists(filepath)) { + String baseName = filename.substring(0, filename.lastIndexOf('.')); + String extension = filename.substring(filename.lastIndexOf('.')); + int counter = 1; + do { + filepath = "/" + baseName + "_" + String(counter) + extension; + counter++; + } while (SD.exists(filepath) && counter < 100); + + if (counter >= 100) { + request->send(409, "text/plain", "Too many files with similar names"); + return; + } + Serial.println("File exists, using: " + filepath); + } + + // Open the file for writing + request->_tempFile = SD.open(filepath, FILE_WRITE); + if (!request->_tempFile) { + request->send(500, "text/plain", "Failed to create file on SD card"); + return; + } + } if (len) { - // stream the incoming chunk to the opened file - request->_tempFile.write(data, len); + // Check if file handle is valid + if (!request->_tempFile) { + request->send(500, "text/plain", "File handle invalid"); + return; + } - //logmessage = "Writing file: " + String(filename) + " index=" + String(index) + " len=" + String(len); - //Serial.println(logmessage); + // Write data and verify bytes written + size_t bytesWritten = request->_tempFile.write(data, len); + if (bytesWritten != len) { + request->_tempFile.close(); + request->send(500, "text/plain", "Write error - SD card may be full"); + return; + } + + // Flush data periodically to ensure it's written + if (index % 8192 == 0) { // Flush every 8KB + request->_tempFile.flush(); + } + + // Log progress every 100KB + if (index % 102400 == 0) { + logmessage = "Upload progress: " + String(filename) + " - " + humanReadableSize(index + len); + Serial.println(logmessage); + } } if (final) { - logmessage = "Upload Complete: " + String(filename) + ",size: " + String(index + len); - // close the file handle as the upload is now done - request->_tempFile.close(); - Serial.println(logmessage); - request->redirect("/"); + if (request->_tempFile) { + request->_tempFile.flush(); // Ensure all data is written + request->_tempFile.close(); + + logmessage = "Upload Complete: " + String(filename) + ", size: " + humanReadableSize(index + len); + Serial.println(logmessage); + + // Rebuild directory tree to include new file + rootNode.buildDirectoryTree("/"); + + request->send(200, "text/plain", "Upload successful"); + } else { + request->send(500, "text/plain", "Upload failed - file handle was invalid"); + } } } @@ -957,4 +1039,3 @@ boolean buttonPressed(const uint8_t pin) return false; } -