Browse Source

Added working async web server debug mode

Toby Chui 9 months ago
parent
commit
89fd87a8b2

+ 2 - 2
firmware/cute_useless_robot/animation.ino

@@ -8,7 +8,7 @@ const String animationFolder = "/anime/"; //Need tailing slash
 void delayWithEarlyBreakout(int delayDuration) {
   delayDuration = delayDuration / 100;
   for (int i = 0; i < delayDuration; i++) {
-    delay(100);
+    vTaskDelay(pdMS_TO_TICKS(100));
     if (getAnimationCode() != previousAnicode) {
       break;
     }
@@ -21,7 +21,7 @@ void handleAnimationRendering(char anicode) {
     //SD card not exists. Use what is inside the buffer.
     Serial.println("SD card not exists");
     renderFrame();
-    delay(5000);
+    vTaskDelay(pdMS_TO_TICKS(5000));
     return;
   }
   if (previousAnicode != anicode) {

+ 11 - 6
firmware/cute_useless_robot/cute_useless_robot.ino

@@ -8,15 +8,17 @@
     Board Settings:
     ESP32 v2.014
     -> ESP32 Dev Module
+    -> Flash Mode: DIO
 */
 
 /* Libraries */
 #include <MD_MAX72xx.h>
 #include <SPI.h>
 #include <SD.h>
+#include <FS.h>
 #include <ESP32Servo.h> //Require ESP32Servo
 #include <WiFi.h>
-#include <WebServer.h>
+#include <ESPAsyncWebServer.h>
 #include <DNSServer.h>
 
 /* Pins Definations */
@@ -40,8 +42,11 @@
 
 /* WiFI AP Settings */
 #define AP_SSID "(´・ω・`)"
-WebServer server(80);
-DNSServer dnsServer; 
+#define ENABLE_WIFI_DEBUG true //Set to true to use WiFi Client mode for remote debugging
+#define DEBUG_SSID "" //Debug SSID, usually your home WiFi SSID
+#define DEBUG_PWD "" //Debug Password, your home WiFi Password
+AsyncWebServer server(80);
+DNSServer dnsServer;
 
 /* Calibrated offset for switch pusher servo, in degrees */
 #define SERVO_ALIGNMENT_OFFSET 1
@@ -85,13 +90,13 @@ void setup() {
 
   /* SD Card */
   // Initialize SD card
-  if (!SD.begin(SD_CS_PIN)) {
+  if (!SD.begin(SD_CS_PIN,SPI,4000000U,"/sd",10U,false)) {
     Serial.println("[Error] Unable to mount SD card");
     loadSDErrorToFrameBuffer(); //Render SD ERROR to display
     renderFrame();
-    SD_exists = false;
+    while(1);
   }
-  
+
   /* Start Dual-core processes */
   createSemaphore();
   startCoreTasks();

+ 123 - 0
firmware/cute_useless_robot/fs.ino

@@ -0,0 +1,123 @@
+/* File System API */
+
+//File delete API
+void handleFileDelete(AsyncWebServerRequest *request) {
+  String path = GetPara(request, "path");
+  if (path == "") {
+    request->send(400, "text/plain", "Missing 'path' parameter");
+    return;
+  }
+  Serial.print("Requested delete path: ");
+  Serial.println(path);
+
+  if (SD.exists(path)) {
+    if (SD.remove(path)) {
+      request->send(200, "text/plain", "File removed");
+    } else {
+      request->send(500, "text/plain", "Failed to delete file");
+    }
+  } else {
+    request->send(404, "text/plain", "File not found");
+  }
+
+}
+
+//File download API
+void handleFileDownload(AsyncWebServerRequest *request) {
+  String path = GetPara(request, "path");
+  if (path == "") {
+    request->send(404, "text/plain", "'path' parameter not given");
+    return;
+  }
+  Serial.print("Requested path: ");
+  Serial.println(path);
+  if (SD.exists(path)) {
+    String contentType = getMime(path);
+    request->send(SD, path, contentType, false);
+  } else {
+    request->send(404, "text/plain", "File not found");
+  }
+}
+
+//List dir API
+void handleListDir(AsyncWebServerRequest *request) {
+  //Get the folder path to be listed
+  //As ESP8266 dont have enough memory for proper struct to json conv, we are hacking a json string out of a single for-loop
+  String jsonString = "[";
+  String folderSubPath = GetPara(request, "dir");
+  String folderPath = "/" + folderSubPath;
+  if (SD.exists(folderPath)) {
+    File root = SD.open(folderPath);
+    bool firstObject = true;
+    if (root) {
+      while (true) {
+        File entry = root.openNextFile();
+        if (!entry) {
+          // No more files
+          break;
+        } else {
+          //There are more lines. Add a , to the end of the previous json object
+          if (!firstObject) {
+            jsonString = jsonString + ",";
+          } else {
+            firstObject = false;
+          }
+        }
+
+        String isDirString = "true";
+        if (!entry.isDirectory()) {
+          isDirString = "false";
+        }
+
+        jsonString = jsonString + "{\"Filename\":\"" + entry.name() + "\",\"Filesize\":" + String(entry.size()) + ",\"IsDir\":" + isDirString + "}";
+        entry.close();
+      }
+      root.close();
+
+      jsonString += "]";
+      request->send(200, "application/json", jsonString);
+    } else {
+      request->send(500, "text/plain", "500 - Path open error");
+    }
+  } else {
+    request->send(404, "text/plain", "404 - Path not found");
+  }
+  Serial.println(folderPath);
+}
+
+// Function to handle file uploads
+void handleFileUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
+  static File uploadFile;
+  static String uploadPath = "/";
+
+  if (request->hasParam("dir")) {
+    uploadPath = "/" + request->getParam("dir")->value() + "/";
+  }
+
+  if (index == 0) {
+    String path = uploadPath + filename;
+    Serial.printf("Upload Start: %s\n", path.c_str());
+    uploadFile = SD.open(path, FILE_WRITE);
+    if (!uploadFile) {
+      Serial.println("Failed to open file for writing");
+      return request->send(500, "text/plain", "File Upload Failed");
+    }
+  }
+
+  // Write the received data to the file
+  if (uploadFile) {
+    uploadFile.write(data, len);
+  }
+
+  if (final) {
+    // Close the file at the final chunk
+    if (uploadFile) {
+      uploadFile.close();
+      Serial.printf("Upload End: %s (%u)\n", filename.c_str(), index + len);
+      return request->send(200, "text/plain", "File Uploaded");
+    }
+    else {
+      return request->send(500, "text/plain", "File Upload Failed");
+    }
+  }
+}

+ 42 - 32
firmware/cute_useless_robot/tasks.ino

@@ -45,78 +45,88 @@ bool getSwitchState() {
 
 /* Multi-core process definations */
 void startCoreTasks() {
-  //core 1
   xTaskCreatePinnedToCore(
     AnimationController,   /* Task function. */
     "animator",     /* name of task. */
-    10000,       /* Stack size of task */
+    8192,       /* Stack size of task */
     NULL,        /* parameter of the task */
-    1,           /* priority of the task */
-    &animationTask,      /* Task handle to keep track of created task */
-    1
+    2,           /* priority of the task */
+    &animationTask,
+    0
   );
-  delay(500);
-
-  //core 0
+  
   xTaskCreatePinnedToCore(
     PrimaryController,   /* Task function. */
     "primary",     /* name of task. */
-    10000,       /* Stack size of task */
+    24576,       /* Stack size of task */
     NULL,        /* parameter of the task */
     1,           /* priority of the task */
     &primaryTask,      /* Task handle to keep track of created task */
-    0
-  );          /* pin task to core 0 */
+    1
+  );
+
+  vTaskStartScheduler();
 }
 
-//Core 0 code, for movement and primary logics
+//For movement and primary logics
 void PrimaryController( void * pvParameters ) {
   Serial.println("Primary logic process started on core " + String(xPortGetCoreID()));
   clearFrame();
   bool switchPushed = getSwitchState();
-  if (switchPushed) {
+  if (switchPushed || ENABLE_WIFI_DEBUG) {
     /* Switch was on when device power on. Start WiFi & Web Server */
     //Set display to AP icon
     setAnimationCode('w');
     //Start AP and web server
-    WiFi.softAP(AP_SSID, NULL);
-    Serial.print("Manual mode started. SSID=" + String(AP_SSID) + " listening on : ");
-    Serial.println(WiFi.softAPIP());
-    registerAPIEndpoints();
+    if (ENABLE_WIFI_DEBUG) {
+      //Use WiFi client mode
+      WiFi.mode(WIFI_STA); //Optional
+      WiFi.begin(DEBUG_SSID, DEBUG_PWD);
+      Serial.println("\nConnecting");
+      while (WiFi.status() != WL_CONNECTED) {
+        Serial.print(".");
+        delay(100);
+      }
+      Serial.println("\nConnected to the WiFi network");
+      Serial.print("Local IP: ");
+      Serial.println(WiFi.localIP());
+    } else {
+      WiFi.softAP(AP_SSID, NULL);
+      Serial.print("Manual mode started. SSID=" + String(AP_SSID) + " listening on : ");
+      Serial.println(WiFi.softAPIP());
+      //Setup DNS Server
+      dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
+      dnsServer.start(53, "*", WiFi.softAPIP());
+    }
 
-    //Setup DNS Server
-    dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
-    dnsServer.start(53, "*", WiFi.softAPIP());
-    // Start the server
-    server.begin(); 
-    for (;;) {
+    // Start the web server
+    registerAPIEndpoints();
+    server.begin();
+    while(1) {
       dnsServer.processNextRequest();
-      server.handleClient();
+      delay(1);
     }
   } else {
     /* Switch is off during power on. Use automatic mode */
     int seqCounter = 0; //Modify this value to change start state of seq
-    for (;;) {
+    while(1) {
       switchPushed = getSwitchState();
       if (switchPushed) {
         //Switch pushed
         executePushAnimationSequence(seqCounter);
         seqCounter++;
-      } else {
-        //Prevent crashing due to infinite loop
-        delay(100);
       }
+      delay(1);
     }
   }
-
-
 }
 
-//Core 1 code, for animation rendering
+//For animation rendering
 void AnimationController( void * pvParameters ) {
   Serial.println("Animation render started on core " + String(xPortGetCoreID()));
-  for (;;) {
+  while(1) {
     char anicode = getAnimationCode();
     handleAnimationRendering(anicode);
+    delay(1);
   }
 }

+ 64 - 56
firmware/cute_useless_robot/webserv.ino

@@ -1,78 +1,86 @@
 /* Web Server */
 const String webroot = "/web/"; //Require tailing slash
 
-void sendNotFound() {
-  server.send(404, "text/plain", "404 - Not found");
-}
-
-// Function to handle file uploads
-void handleFileUpload() {
-  HTTPUpload& upload = server.upload();
-  static File uploadFile;
-
-  if (upload.status == UPLOAD_FILE_START) {
-    String path = webroot + "uploads/" + upload.filename;
-    Serial.printf("UploadStart: %s\n", path.c_str());
-    uploadFile = SD.open(path, FILE_WRITE);
-    if (!uploadFile) {
-      Serial.println("Failed to open file for writing");
-      return;
-    }
-  } else if (upload.status == UPLOAD_FILE_WRITE) {
-    // Write the received data to the file
-    if (uploadFile) {
-      uploadFile.write(upload.buf, upload.currentSize);
-    }
-  } else if (upload.status == UPLOAD_FILE_END) {
-    // Close the file at the final chunk
-    if (uploadFile) {
-      uploadFile.close();
-      Serial.printf("UploadEnd: %s (%u)\n", upload.filename.c_str(), upload.totalSize);
-      server.send(200, "text/plain", "File Uploaded");
-    } else {
-      server.send(500, "text/plain", "File Upload Failed");
-    }
-  } else {
-    sendNotFound();
-  }
-}
-
 // Function to serve files
-void handleFileServe() {
-  String path = server.uri();
+void handleFileServe(AsyncWebServerRequest *request) {
+  String path = request->url();
   String filepath = path;
-  filepath.remove(0, 1); //Trim the prefix slash in uri
+  filepath.remove(0, 1); // Trim the prefix slash in uri
   filepath = webroot + filepath;
   Serial.println(filepath);
+  if (!SD_exists) {
+    // SD card not inserted
+    request->send(500, "text/plain", "500 - SD Error");
+    return;
+  }
   if (SD.exists(filepath)) {
-    File file = SD.open(filepath);
-    if (file) {
-      
-      server.streamFile(file, getMime(path));
-      file.close();
-    } else {
-      server.send(500, "text/plain", "500 - Internal Server Error");
-    }
+    request->send(SD, filepath, getMime(filepath));
   } else {
-    handleRootRedirect();
+    handleRootRedirect(request);
   }
 }
 
-//Redirect request to index.html
-void handleRootRedirect() {
-  server.sendHeader("Location", "/index.html", true);
-  server.send(302, "text/plain", "Redirecting to index.html");
+void sendNotFound(AsyncWebServerRequest *request) {
+  request->send(404, "text/plain", "404 - Not found");
+}
+
+// Redirect request to index.html
+void handleRootRedirect(AsyncWebServerRequest *request) {
+  request->redirect("/index.html");
 }
 
+//Register all the required API endpoint for web server
 void registerAPIEndpoints() {
-  server.on("/", HTTP_GET, handleRootRedirect);
-  server.onNotFound(handleFileServe);
-  server.on("/upload", HTTP_POST, []() {
-    server.send(200, "text/plain", "");  // Send empty response for the upload URL
+  /* Basic handlers */
+  server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) {
+    handleRootRedirect(request);
+  });
+  server.onNotFound([](AsyncWebServerRequest * request) {
+    handleFileServe(request);
+  });
+  server.on("/upload", HTTP_POST, [](AsyncWebServerRequest * request) {
+    request->send(200); // Send empty response for the upload URL
   }, handleFileUpload);
+
+  /* Application APIs */
+  server.on("/api/fs/listDir", HTTP_GET, handleListDir);
+  server.on("/api/fs/download", HTTP_GET, handleFileDownload);
+  server.on("/api/fs/delete", HTTP_GET, handleFileDelete);
+  server.on("/api/ipaddr", HTTP_GET, handleGetIPAddress);
+}
+//ip addr
+void handleGetIPAddress(AsyncWebServerRequest *request) {
+  String ipAddress;
+  if (!ENABLE_WIFI_DEBUG) {
+    ipAddress = WiFi.softAPIP().toString();
+  } else {
+    ipAddress = WiFi.localIP().toString();
+  }
+  request->send(200, "application/json", "{\"ip\":\"" + ipAddress + "\"}");
 }
 
 /* Utilities */
+String GetPara(AsyncWebServerRequest *request, String key) {
+  if (request->hasParam(key)) {
+    return request->getParam(key)->value();
+  }
+  return "";
+}
+
+
+//Get the filename from filepath
+String basename(const String& filePath) {
+  int lastSlashIndex = filePath.lastIndexOf('/');
+
+  // If no slash is found, return the original path
+  if (lastSlashIndex == -1) {
+    return filePath;
+  }
+
+  // Return the substring after the last slash
+  return filePath.substring(lastSlashIndex + 1);
+}
+
 String getMime(const String& path) {
   String _contentType = "text/plain";
   if (path.endsWith(".html")) _contentType = "text/html";

+ 7 - 2
sd_card/web/animator.html

@@ -109,7 +109,7 @@
 			padding-left: 1em;
 			padding-right: 1em;
 			cursor: pointer;
-			margin: 0.4em;
+			margin: 0.2em;
 			margin-top: 0.6em;
 		}
 
@@ -153,10 +153,11 @@
 				<button onclick="loadDefault();">Load Default</button>
 				<button onclick="downloadBinary();">Export Binary</button>
 				<button onclick="openFilePicker()">Import Binary</button>
+				<button onclick="exit()">Back</button>
 				<input type="file" id="fileInput" style="display: none;" accept=".bin" onchange="handleFile(event)">
 			</div>
 			<div style="padding-top: 0.8em;">
-				👾 Cute Useless Robot Animator
+				👾 Robot Animator
 			</div>
 			
 		</div>
@@ -262,6 +263,10 @@
 			$(".circle.active").removeClass("active");
 		}
 
+		function exit(){
+			window.location.href = "index.html";
+		}
+
 		//Import functions
 		function openFilePicker() {
             document.getElementById('fileInput').click();

+ 1 - 0
sd_card/web/img/down.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M440-800v487L216-537l-56 57 320 320 320-320-56-57-224 224v-487h-80Z"/></svg>

+ 1 - 0
sd_card/web/img/left.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg>

+ 1 - 0
sd_card/web/img/open.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M320-500h320L480-660 320-500ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-200v120h560v-120H200Zm0-80h560v-360H200v360Zm0 80v120-120Z"/></svg>

+ 1 - 0
sd_card/web/img/push.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z"/></svg>

+ 1 - 0
sd_card/web/img/right.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M647-440H160v-80h487L423-744l57-56 320 320-320 320-57-56 224-224Z"/></svg>

+ 1 - 0
sd_card/web/img/up.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M440-160v-487L216-423l-56-57 320-320 320 320-56 57-224-224v487h-80Z"/></svg>

+ 54 - 6
sd_card/web/index.html

@@ -1,7 +1,8 @@
 <!DOCTYPE HTML>
 <html>
   <head>
-    <title>(´・ω・`)</title>
+    <meta charset="utf-8"> 
+    <title>👾 Control Panel (`・ω・´)</title>
     <script src="./jquery.min.js"></script>
     <link rel="stylesheet" href="./main.css"/>
     <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -10,7 +11,7 @@
     <!-- Main contents of the webapp -->
     <div id="menu">
       <div class="container">
-        <span class="logo">(´・ω・`)</span>
+        <span class="logo">(`・ω・´)</span>
       </div>
       <button onclick="toggleSideMenu()" class="togglemenu button">
         <img class="btnicon" src="img/menu.svg">
@@ -22,11 +23,52 @@
         <span>Useless Robot Connected<br>
           <small>IP Address: <span id="ipaddr">Unknown</span></small></span>
       </div>
-      
       <div class="divider"></div>
       <h3>Quick Actions</h3>
-      <p>Hello World!</p>
+      <p>Display Emoji</p>
+      <select class="styled-dropdown">
+        <option value="option1">Option 1</option>
+        <option value="option2">Option 2</option>
+        <option value="option3">Option 3</option>
+        <option value="option4">Option 4</option>
+      </select>
+      
+      <!-- Other Actions -->
+      <div class="action-controls">
+        <button class="button">
+          <div class="header">
+            <img src="img/open.svg" >
+            <span>Open Cover<br>
+          </div>
+        </button>
+        <button class="button">
+          <div class="header">
+            <img src="img/push.svg" >
+            <span>Push Switch<br>
+          </div>
+        </button>
+      </div>
+
+      <!-- Direction Controls -->
+      <div class="direction-controls">
+        <button class="button">
+          <img src="img/up.svg">
+        </button>
+        <button class="button">
+          <img src="img/down.svg">
+        </button>
+        <button class="button">
+          <img src="img/left.svg">
+        </button>
+        <button class="button">
+          <img src="img/right.svg">
+        </button>
+      </div>
     </div>
+
+  </div>
+
+      
     
     <!-- Side bar for other functions-->
     <div id="sidebarWrapper">
@@ -39,10 +81,10 @@
         <a class="clickable item" href="animator.html">
           <img src="img/animation.svg"> Animation Editor
         </a>
-        <a class="clickable item">
+        <a class="clickable item" >
           <img src="img/action.svg"> Action Programmer
         </a>
-        <a class="clickable item">
+        <a class="clickable item" href="sd.html">
           <img src="img/folder.svg"> SD Browser
         </a>
         <a class="clickable item">
@@ -74,6 +116,12 @@
     $("#blurcover").on("click", function(){
       toggleSideMenu();
     })
+
+    //Get the device IP address
+    $.get("/api/ipaddr", function(data){
+      $("#ipaddr").text(data.ip);
+    })
+
   </script>
   </body>
 </html>

+ 180 - 1
sd_card/web/main.css

@@ -28,7 +28,7 @@
 
 body{
     margin: 0 !important;
-    font-family: 'mulishR';
+    font-family: 'mulishR' !important;
     color: var(--theme-text-color);
     background-color: var(--theme-dark);
 }
@@ -39,6 +39,9 @@ body{
     background-color: transparent;
     transition: background-color ease-in-out 0.1s, opacity ease-in-out 0.1s;
     cursor: pointer;
+    padding: 0.4em;
+    padding-left: 0.6em;
+    padding-right: 0.6em;
 }
 
 .button:hover{
@@ -46,6 +49,18 @@ body{
     opacity: 0.8;
 }
 
+
+.inverse.button{
+    border: 1px solid var(--theme-dark);
+    background-color: var(--theme-active);
+    color: white;
+}
+
+.inverse.button:hover{
+    background-color: var(--theme-secondary);
+}
+
+
 .button .btnicon{
     width: 100%;
     height: 100%;
@@ -60,6 +75,21 @@ body{
     box-sizing: border-box; 
 }
 
+@media (min-width: 700px) {
+    .container{
+        padding-left: 20%;
+        padding-right: 20%;
+    }
+}
+
+@media (min-width: 1024px) {
+    .container{
+        padding-left: 30%;
+        padding-right: 30%;
+    }
+}
+
+
 .header{
     display: flex;
     align-items: center;
@@ -148,3 +178,152 @@ a:link {
     width: 38px;
 }
 
+#menu .back.button{
+    height: 38px;
+    width: 38px;
+    margin: 0.6em;
+    margin-left: 1.2em;
+
+    border: 1px solid rgb(122, 122, 122);
+    border-radius: 0.4em;
+}
+
+/* Animation Selection */
+.styled-dropdown {
+    background-color: black;
+    color: white;
+    border: 1px solid white;
+    border-radius: 0.6em;
+    padding: 0.5em;
+    font-size: 1em;
+    outline: none;
+    width: 100%;
+  }
+  .styled-dropdown option {
+    background-color: black;
+    color: white;
+  }
+
+/* Action Control Buttons */
+.action-controls{
+    display: flex;
+    width: 100%;
+    margin-top: 0.6em;
+}
+
+.action-controls .button {
+    flex: 1;
+    padding: 10px;
+    margin: 5px;
+    text-align: center;
+    background-color: #7e68df;
+    color: white;
+    border: none;
+    cursor: pointer;
+    font-size: 16px;
+    text-align: center;
+}
+
+/* Direction Control Buttons */
+.direction-controls {
+    display: flex;
+    width: 100%;
+}
+
+.direction-controls .button {
+    flex: 1;
+    padding: 10px;
+    margin: 5px;
+    text-align: center;
+    background-color: #df6868;
+    color: white;
+    border: none;
+    cursor: pointer;
+    font-size: 16px;
+    border-radius: 0.1em;
+}
+
+.direction-controls .button img{
+    height: 40px;
+}
+
+.direction-controls .button.active {
+    background-color: #af5050;
+}
+
+/* SD Browser */
+.addrbar{
+    background-color: white;
+    padding: 0.6em;
+    color: var(--theme-dark);
+}
+
+.sdcontent{
+    height: 400px;
+    padding: 0.6em;
+    background-color: white;
+    color: var(--theme-dark);
+    overflow-y: auto;
+    margin-top: 0.6em;
+}
+
+.sdcontent ul{
+    list-style-type: none;
+    padding-left: 1em;
+}
+
+.sdcontent .fileobject{
+    cursor: pointer;
+}
+
+.segment{
+    background-color: white;
+    color: var(--theme-dark);
+    padding: 1.2em;
+    margin-top: 1em;
+}
+
+.upload-container .button{
+    float: right;
+    margin-top: -3px;
+}
+
+#uploadProgress{
+    margin-top: 0.6em;
+    width: 100%;
+    border: 1px solid var(--theme-dark);
+    height: 15px;
+}
+
+#uploadProgress .bar{
+    width: 0px;
+    background-color: var(--theme-secondary);
+    height: 15px;
+    text-align: right;
+    color: white;
+    font-size: 0.8em;
+}
+
+.fileopr.disabled{
+    opacity: 0.6;
+    pointer-events: none;
+    user-select: none;
+}
+
+#messagebox{
+    display:none;
+    position: fixed;
+    right: 1em;
+    bottom: 1em;
+    background-color: #9dd188;
+    min-width: 100px;
+    padding: 1em;
+    border-radius: 0.6em;
+}
+#messagebox.failed{
+    background-color: #d18888;
+}
+
+#messagebox p{
+    margin: 0;
+}

+ 230 - 0
sd_card/web/sd.html

@@ -0,0 +1,230 @@
+<!DOCTYPE HTML>
+<html>
+  <head>
+    <meta charset="utf-8"> 
+    <title>📁 SD Browser</title>
+    <script src="./jquery.min.js"></script>
+    <link rel="stylesheet" href="./main.css"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <style>
+        body{
+            background-color: rgb(238, 238, 238);
+        }
+    </style>
+  </head>
+  <body>
+    <!-- Main contents of the webapp -->
+    <div id="menu">
+        <div id="menu">
+            <button onclick="back()" class="back button">
+                <img class="btnicon" src="img/left.svg">
+            </button>
+        </div>
+    </div>
+    <div class="container">
+        <p class="addrbar">📁 /<span id="currentPath"></span></p>
+        <div class="sdcontent">
+            <ul id="folderContent" style="list-style-type:none;">
+                
+            </ul>  
+        </div>
+        <button onclick="parentDir();" class="inverse button" style="margin-top: 0.4em;">↩ Parent Dir</button>
+        <button onclick="refreshDir();" class="inverse button" style="margin-top: 0.4em;">⟳ Refresh</button>
+        
+        <div class="fileopr disabled segment">
+            <p>Selected file: <span id="selectedFilename"></span></p>
+            <button class="inverse button" onclick="openFile();">Open</button>
+            <button class="inverse button" onclick="deleteFile();">Delete</button>
+        </div>
+        <div class="uploadwrapper segment">
+            <div class="upload-container">
+                <input type="file" id="fileInput" multiple>
+                <button class="inverse button" onclick="uploadFiles()">Upload Files</button>
+            </div>
+            <div id="uploadProgress">
+                <div class="bar"></div>
+            </div>
+        </div>
+        <div class="ui divider"></div>
+        <small style="color: #1f1f1f;">Development SD Browser | imuslab</small>
+    </div>
+    <div id="messagebox">
+        <p>Hello World</p>
+    </div>
+    <script>
+        let currentPath = ""; //Do not need prefix or suffix slash
+        let selectedFile = undefined;
+
+        function formatBytes(bytes,decimals) {
+            if(bytes == 0) return '0 Bytes';
+            var k = 1024,
+                dm = decimals || 2,
+                sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
+                i = Math.floor(Math.log(bytes) / Math.log(k));
+            return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+        }
+
+        function listDir(path=""){
+            $("#currentPath").text(path);
+            currentPath = path;
+            selectedFile = undefined;
+            $("#selectedFilename").text("");
+            $(".fileopr").addClass('disabled');
+            $("#folderContent").html("<small>Loading...</small>");
+            $.ajax({
+                url: "/api/fs/listDir?dir=" + path,
+                method: "GET", 
+                success: function(data){
+                    $("#folderContent").html("");
+                    if (path != ""){
+                        //Not at root
+                        $("#folderContent").append(`<li><a class="fileobject" onclick="parentDir();">↼ Back</a></li>`);
+                    }
+                    data.forEach(sdfile => {
+                        let filename = sdfile.Filename;
+                        let filesize = sdfile.Filesize;
+                        let IsDir = sdfile.IsDir;
+
+                        $("#folderContent").append(`<li><a class="fileobject" isdir="${IsDir}" filename="${filename}" onclick="openthis(this);">${IsDir?"📁":"📄"} ${filename} (${formatBytes(filesize, 2)})</a></li>`);
+                    })
+                },
+                error: function(){
+                    $("#folderContent").html("❌ Path Error");
+                }
+            });
+        }
+        listDir();
+
+        function refreshDir(){
+            listDir(currentPath);
+        }
+
+        function msgbox(message, succ=true){
+            if (succ){
+                $("#messagebox").removeClass("failed");
+            }else{
+                $("#messagebox").addClass("failed");
+            }
+
+            $("#messagebox p").text(message);
+            $("#messagebox").stop().finish().fadeIn("fast").delay(5000).fadeOut("fast");
+        }
+
+        function openFile(){
+            if (selectedFile == undefined){
+                alert("No file selected");
+                return;
+            }
+
+            window.open("/api/fs/download?path=/" + selectedFile.Filepath);
+        }
+
+        function deleteFile(){
+            if (selectedFile == undefined){
+                alert("No file selected");
+                return;
+            }
+
+            $.ajax({
+                url: "/api/fs/delete?path=/" +selectedFile.Filepath,
+                method: "GET",
+                success: function(data){
+                    msgbox("File removed");
+                    refreshDir();
+                },
+                error: function(){
+                    msgbox("File remove failed", false);
+                }
+            });
+        }
+
+        function openthis(target){
+            let filename = $(target).attr("filename");
+            let isDir = $(target).attr("isdir") == "true";
+            if (isDir){
+                //Open directory
+                if (currentPath == ""){
+                    //Do not need prefix slash
+                    listDir(filename);
+                }else{
+                    listDir(currentPath + "/" + filename);
+                }
+                
+            }else{
+                //Selected a file
+                selectedFile = {
+                    Filename: filename,
+                    Filepath: currentPath + "/" + filename
+                }
+                $("#selectedFilename").text(filename);
+                $(".fileopr").removeClass('disabled');
+            }
+        }
+
+        function parentDir(){
+            let pathChunks = currentPath.split("/");
+            pathChunks.pop();
+            pathChunks = pathChunks.join("/");
+            listDir(pathChunks);
+        }
+
+        function back(){
+            window.location.href = "index.html";
+        }
+
+        function updateProgressBar(progress){
+            $("#uploadProgress .bar").css({
+                "width": progress + "%",
+            });
+            $("#uploadProgress .bar").text(progress + "%");
+        }
+        
+        function uploadFiles() {
+            var fileInput = document.getElementById('fileInput');
+            var files = fileInput.files;
+
+            if (files.length === 0) {
+                alert('No files selected.');
+                return;
+            }
+
+            // Create a FormData object to send files as multipart/form-data
+            var formData = new FormData();
+
+            // Append each file to the FormData object
+            for (var i = 0; i < files.length; i++) {
+                formData.append('files[]', files[i]);
+            }
+
+            // Send the FormData object via XMLHttpRequest
+            var xhr = new XMLHttpRequest();
+            xhr.open('POST', '/upload?dir=' + currentPath, true); // Replace '/upload' with your ESP32 server endpoint
+   
+            // Track upload progress
+            xhr.upload.addEventListener('progress', function(event) {
+                if (event.lengthComputable) {
+                    var percentComplete = (event.loaded / event.total) * 100;
+                    console.log('Upload progress: ' + percentComplete.toFixed(2) + '%');
+                    updateProgressBar(percentComplete.toFixed(2));
+                } else {
+                    console.log('Upload progress: unknown');
+                }
+            });
+
+            xhr.onload = function() {
+                if (xhr.status === 200) {
+                    msgbox('File uploaded successfully.');
+                } else {
+                    msgbox('Error writing files to disk.', false);
+                }
+                refreshDir();
+            };
+
+            xhr.onerror = function() {
+                msgbox('Error uploading files.');
+            };
+            xhr.send(formData);
+        }
+                
+    </script>
+</body>