瀏覽代碼

Add playlist feature (#5)

### Added

#### Playlist mode

- Added UI selection mode for single pattern run, create a playlist, and run a playlist. The UI is work in progress and will be changed in the near future.
- Created playlist will be saved as a JSON file on disk. There are options to:
  - Run the playlist once or on an indefinite loop
  - Shuffle the playlist
  - Add a clear pattern between files that will run immmidiately before each pattern. If you would like to customize the clear pattern, select None here and add clear patterns manually.
  - Add a pause time (in second) between each pattern.

#### Spirograph mode

- Added support for Spirograph mode for the arduino with DRV8825 or TMC2209 motor drivers
- Can be used if optional hardware (two potentiometers and a button) is connected.

### Changed

- Fixed a bug that created conflicting threads, leading to serial errors
- Fixed a bug that caused the speed setting functionality to not work
- Fixed a bug that caused the ball to move in slightly when a pattern starts at the perimeter and theta != 0

### Known issues

- Patterns with theta does not start with 0 will behave abnormally. To get around this, be sure to select start from center or perimeter when creating your pattern in sandify.org.
Tuan Nguyen 1 年之前
父節點
當前提交
ea87692a97
共有 8 個文件被更改,包括 897 次插入101 次删除
  1. 2 1
      .gitignore
  2. 342 16
      app.py
  3. 40 2
      arduino_code/arduino_code.ino
  4. 42 4
      arduino_code_TMC2209/arduino_code_TMC2209.ino
  5. 13 7
      esp32/esp32.ino
  6. 0 0
      patterns/clear_sideway.thr
  7. 1 1
      static/style.css
  8. 457 70
      templates/index.html

+ 2 - 1
.gitignore

@@ -3,4 +3,5 @@ __pycache__/
 *.pyc
 *.pyo
 .env
-.idea
+.idea
+playlists.json

+ 342 - 16
app.py

@@ -2,15 +2,22 @@ from flask import Flask, request, jsonify, render_template
 import os
 import serial
 import time
+import random
 import threading
 import serial.tools.list_ports
 import math
+import json
 
 app = Flask(__name__)
 
 # Configuration
 THETA_RHO_DIR = './patterns'
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+CLEAR_PATTERNS = {
+    "clear_from_in":  "./patterns/clear_from_in.thr",
+    "clear_from_out": "./patterns/clear_from_out.thr",
+    "clear_sideway":  "./patterns/clear_sideway.thr"
+}
 os.makedirs(THETA_RHO_DIR, exist_ok=True)
 
 # Serial connection (First available will be selected by default)
@@ -19,6 +26,13 @@ ser_port = None  # Global variable to store the serial port name
 stop_requested = False
 serial_lock = threading.Lock()
 
+PLAYLISTS_FILE = os.path.join(os.getcwd(), "playlists.json")
+
+# Ensure the file exists and contains at least an empty JSON object
+if not os.path.exists(PLAYLISTS_FILE):
+    with open(PLAYLISTS_FILE, "w") as f:
+        json.dump({}, f, indent=2)
+
 def list_serial_ports():
     """Return a list of available serial ports."""
     ports = serial.tools.list_ports.comports()
@@ -67,7 +81,7 @@ def restart_serial(port, baudrate=115200):
 def parse_theta_rho_file(file_path):
     """
     Parse a theta-rho file and return a list of (theta, rho) pairs.
-    Optionally apply transformations (rotation and mirroring).
+    Normalizes the list so the first theta is always 0.
     """
     coordinates = []
     try:
@@ -84,8 +98,24 @@ def parse_theta_rho_file(file_path):
                     coordinates.append((theta, rho))
                 except ValueError:
                     print(f"Skipping invalid line: {line}")
+                    continue
     except Exception as e:
         print(f"Error reading file: {e}")
+        return coordinates
+
+    # ---- Normalization Step ----
+    if coordinates:
+        # Take the first coordinate's theta
+        first_theta = coordinates[0][0]
+
+        # Shift all thetas so the first coordinate has theta=0
+        normalized = []
+        for (theta, rho) in coordinates:
+            normalized.append((theta - first_theta, rho))
+
+        # Replace original list with normalized data
+        coordinates = normalized
+
     return coordinates
 
 def send_coordinate_batch(ser, coordinates):
@@ -99,13 +129,13 @@ def send_command(command):
     ser.write(f"{command}\n".encode())
     print(f"Sent: {command}")
 
-    # Wait for "DONE" acknowledgment from Arduino
+    # Wait for "R" acknowledgment from Arduino
     while True:
         with serial_lock:
             if ser.in_waiting > 0:
                 response = ser.readline().decode().strip()
                 print(f"Arduino response: {response}")
-                if response == "DONE":
+                if response == "R":
                     print("Command execution completed.")
                     break
 
@@ -144,6 +174,86 @@ def run_theta_rho_file(file_path):
     # Reset theta after execution or stopping
     reset_theta()
     ser.write("FINISHED\n".encode())
+    
+def get_clear_pattern_file(pattern_name):
+    """Return a .thr file path based on pattern_name."""
+    if pattern_name == "random":
+        # Randomly pick one of the three known patterns
+        return random.choice(list(CLEAR_PATTERNS.values()))
+    # If pattern_name is invalid or absent, default to 'clear_from_in'
+    return CLEAR_PATTERNS.get(pattern_name, CLEAR_PATTERNS["clear_from_in"])
+
+def run_theta_rho_files(
+    file_paths,
+    pause_time=0,
+    clear_pattern=None,
+    run_mode="single",
+    shuffle=False
+):
+    """
+    Runs multiple .thr files in sequence with options for pausing, clearing, shuffling, and looping.
+
+    Parameters:
+    - file_paths (list): List of file paths to run.
+    - pause_time (float): Seconds to pause between patterns.
+    - clear_pattern (str): Specific clear pattern to run ("clear_in", "clear_out", "clear_sideway", or "random").
+    - run_mode (str): "single" for one-time run or "indefinite" for looping.
+    - shuffle (bool): Whether to shuffle the playlist before running.
+    """
+    global stop_requested
+    stop_requested = False  # Reset stop flag at the start
+
+    if shuffle:
+        random.shuffle(file_paths)
+        print("Playlist shuffled.")
+
+    while True:
+        for idx, path in enumerate(file_paths):
+            if stop_requested:
+                print("Execution stopped before starting next pattern.")
+                return
+
+            # Run the main pattern
+            print(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
+            run_theta_rho_file(path)
+            
+            if idx < len(file_paths) -1:
+                if stop_requested:
+                    print("Execution stopped before running the next clear pattern.")
+                    return
+                # Pause after each pattern if requested
+                if pause_time > 0:
+                    print(f"Pausing for {pause_time} seconds...")
+                    time.sleep(pause_time)
+
+            if clear_pattern:
+                if stop_requested:
+                    print("Execution stopped before running the next clear pattern.")
+                    return
+
+                # Determine the clear pattern to run
+                clear_file_path = get_clear_pattern_file(clear_pattern)
+                print(f"Running clear pattern: {clear_file_path}")
+                run_theta_rho_file(clear_file_path)
+
+        # After completing the playlist
+        if run_mode == "indefinite":
+            print("Playlist completed. Restarting as per 'indefinite' run mode.")
+            if pause_time > 0:
+                print(f"Pausing for {pause_time} seconds before restarting...")
+                time.sleep(pause_time)
+            if shuffle:
+                random.shuffle(file_paths)
+                print("Playlist reshuffled for the next loop.")
+            continue
+        else:
+            print("Playlist completed.")
+            break
+
+    # Reset theta after execution or stopping
+    reset_theta()
+    ser.write("FINISHED\n".encode())
+    print("All requested patterns completed (or stopped).")
 
 def reset_theta():
     """Reset theta on the Arduino."""
@@ -220,10 +330,11 @@ def upload_theta_rho():
         return jsonify({'success': True})
     return jsonify({'success': False})
 
+
 @app.route('/run_theta_rho', methods=['POST'])
 def run_theta_rho():
     file_name = request.json.get('file_name')
-    pre_execution = request.json.get('pre_execution')  # New parameter for pre-execution action
+    pre_execution = request.json.get('pre_execution')  # 'clear_in', 'clear_out', 'clear_sideway', or 'none'
 
     if not file_name:
         return jsonify({'error': 'No file name provided'}), 400
@@ -233,21 +344,32 @@ def run_theta_rho():
         return jsonify({'error': 'File not found'}), 404
 
     try:
-        # Handle pre-execution actions
+        # Build a list of files to run in sequence
+        files_to_run = []
+        
         if pre_execution == 'clear_in':
-            clear_in_thread = threading.Thread(target=run_theta_rho_file, args=('./patterns/clear_from_in.thr',))
-            clear_in_thread.start()
-            clear_in_thread.join()  # Wait for completion before proceeding
+            files_to_run.append('./patterns/clear_from_in.thr')
         elif pre_execution == 'clear_out':
-            clear_out_thread = threading.Thread(target=run_theta_rho_file, args=('./patterns/clear_from_out.thr',))
-            clear_out_thread.start()
-            clear_out_thread.join()  # Wait for completion before proceeding
+            files_to_run.append('./patterns/clear_from_out.thr')
+        elif pre_execution == 'clear_sideway':
+            files_to_run.append('./patterns/clear_sideway.thr')
         elif pre_execution == 'none':
             pass  # No pre-execution action required
 
-        # Start the main pattern execution
-        threading.Thread(target=run_theta_rho_file, args=(file_path,)).start()
+        # Finally, add the main file
+        files_to_run.append(file_path)
+
+        # Run them in one shot using run_theta_rho_files (blocking call)
+        threading.Thread(
+            target=run_theta_rho_files,
+            args=(files_to_run,),
+            kwargs={
+                'pause_time': 0,
+                'clear_pattern': None
+            }
+        ).start()
         return jsonify({'success': True})
+
     except Exception as e:
         return jsonify({'error': str(e)}), 500
 
@@ -376,7 +498,214 @@ def serial_status():
         'connected': ser.is_open if ser else False,
         'port': ser_port  # Include the port name
     })
+    
+if not os.path.exists(PLAYLISTS_FILE):
+    with open(PLAYLISTS_FILE, "w") as f:
+        json.dump({}, f, indent=2)
+
+def load_playlists():
+    """
+    Load the entire playlists dictionary from the JSON file.
+    Returns something like: {
+        "My Playlist": ["file1.thr", "file2.thr"],
+        "Another": ["x.thr"]
+    }
+    """
+    with open(PLAYLISTS_FILE, "r") as f:
+        return json.load(f)
+
+def save_playlists(playlists_dict):
+    """
+    Save the entire playlists dictionary back to the JSON file.
+    """
+    with open(PLAYLISTS_FILE, "w") as f:
+        json.dump(playlists_dict, f, indent=2)
+
+@app.route("/list_all_playlists", methods=["GET"])
+def list_all_playlists():
+    """
+    Returns a list of all playlist names.
+    Example return: ["My Playlist", "Another Playlist"]
+    """
+    playlists_dict = load_playlists()
+    playlist_names = list(playlists_dict.keys())
+    return jsonify(playlist_names)
+
+@app.route("/get_playlist", methods=["GET"])
+def get_playlist():
+    """
+    GET /get_playlist?name=My%20Playlist
+    Returns: { "name": "My Playlist", "files": [... ] }
+    """
+    playlist_name = request.args.get("name", "")
+    if not playlist_name:
+        return jsonify({"error": "Missing playlist 'name' parameter"}), 400
+
+    playlists_dict = load_playlists()
+    if playlist_name not in playlists_dict:
+        return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
+
+    files = playlists_dict[playlist_name]  # e.g. ["file1.thr", "file2.thr"]
+    return jsonify({
+        "name": playlist_name,
+        "files": files
+    })
+
+@app.route("/create_playlist", methods=["POST"])
+def create_playlist():
+    """
+    POST /create_playlist
+    Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
+    Creates or overwrites a playlist with the given name.
+    """
+    data = request.get_json()
+    if not data or "name" not in data or "files" not in data:
+        return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
+
+    playlist_name = data["name"]
+    files = data["files"]
+
+    # Load all playlists
+    playlists_dict = load_playlists()
+
+    # Overwrite or create new
+    playlists_dict[playlist_name] = files
+
+    # Save changes
+    save_playlists(playlists_dict)
+
+    return jsonify({
+        "success": True,
+        "message": f"Playlist '{playlist_name}' created/updated"
+    })
+
+@app.route("/modify_playlist", methods=["POST"])
+def modify_playlist():
+    """
+    POST /modify_playlist
+    Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
+    Updates (or creates) the existing playlist with a new file list.
+    You can 404 if you only want to allow modifications to existing playlists.
+    """
+    data = request.get_json()
+    if not data or "name" not in data or "files" not in data:
+        return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
+
+    playlist_name = data["name"]
+    files = data["files"]
+
+    # Load all playlists
+    playlists_dict = load_playlists()
 
+    # Optional: If you want to disallow creating a new playlist here:
+    # if playlist_name not in playlists_dict:
+    #     return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
+
+    # Overwrite or create new
+    playlists_dict[playlist_name] = files
+
+    # Save
+    save_playlists(playlists_dict)
+
+    return jsonify({"success": True, "message": f"Playlist '{playlist_name}' updated"})
+
+@app.route("/delete_playlist", methods=["DELETE"])
+def delete_playlist():
+    """
+    DELETE /delete_playlist
+    Body: { "name": "My Playlist" }
+    Removes the playlist from the single JSON file.
+    """
+    data = request.get_json()
+    if not data or "name" not in data:
+        return jsonify({"success": False, "error": "Missing 'name' field"}), 400
+
+    playlist_name = data["name"]
+
+    playlists_dict = load_playlists()
+    if playlist_name not in playlists_dict:
+        return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
+
+    # Remove from dict
+    del playlists_dict[playlist_name]
+    save_playlists(playlists_dict)
+
+    return jsonify({
+        "success": True,
+        "message": f"Playlist '{playlist_name}' deleted"
+    })
+    
+@app.route("/run_playlist", methods=["POST"])
+def run_playlist():
+    """
+    POST /run_playlist
+    Body (JSON):
+    {
+        "playlist_name": "My Playlist",
+        "pause_time": 1.0,                # Optional: seconds to pause between patterns
+        "clear_pattern": "random",         # Optional: "clear_in", "clear_out", "clear_sideway", or "random"
+        "run_mode": "single",              # 'single' or 'indefinite'
+        "shuffle": True                    # true or false
+    }
+    """
+    data = request.get_json()
+
+    # Validate input
+    if not data or "playlist_name" not in data:
+        return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
+
+    playlist_name = data["playlist_name"]
+    pause_time = data.get("pause_time", 0)
+    clear_pattern = data.get("clear_pattern", None)
+    run_mode = data.get("run_mode", "single")  # Default to 'single' run
+    shuffle = data.get("shuffle", False)       # Default to no shuffle
+
+    # Validate pause_time
+    if not isinstance(pause_time, (int, float)) or pause_time < 0:
+        return jsonify({"success": False, "error": "'pause_time' must be a non-negative number"}), 400
+
+    # Validate clear_pattern
+    valid_patterns = ["clear_in", "clear_out", "clear_sideway", "random"]
+    if clear_pattern not in valid_patterns:
+        clear_pattern = None
+
+    # Validate run_mode
+    if run_mode not in ["single", "indefinite"]:
+        return jsonify({"success": False, "error": "'run_mode' must be 'single' or 'indefinite'"}), 400
+
+    # Validate shuffle
+    if not isinstance(shuffle, bool):
+        return jsonify({"success": False, "error": "'shuffle' must be a boolean value"}), 400
+
+    # Load playlists
+    playlists = load_playlists()
+
+    if playlist_name not in playlists:
+        return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
+
+    file_paths = playlists[playlist_name]
+    file_paths = [os.path.join(THETA_RHO_DIR, file) for file in file_paths]
+
+    if not file_paths:
+        return jsonify({"success": False, "error": f"Playlist '{playlist_name}' is empty"}), 400
+
+    # Start the playlist execution in a separate thread
+    try:
+        threading.Thread(
+            target=run_theta_rho_files,
+            args=(file_paths,),
+            kwargs={
+                'pause_time': pause_time,
+                'clear_pattern': clear_pattern,
+                'run_mode': run_mode,
+                'shuffle': shuffle
+            },
+            daemon=True  # Daemonize thread to exit with the main program
+        ).start()
+        return jsonify({"success": True, "message": f"Playlist '{playlist_name}' is now running."})
+    except Exception as e:
+        return jsonify({"success": False, "error": str(e)}), 500
+    
 @app.route('/set_speed', methods=['POST'])
 def set_speed():
     """Set the speed for the Arduino."""
@@ -405,7 +734,4 @@ def set_speed():
 if __name__ == '__main__':
     # Auto-connect to serial
     connect_to_serial()
-    
-    # Start the Flask app
     app.run(debug=True, host='0.0.0.0', port=8080)
-

+ 40 - 2
arduino_code/arduino_code.ino

@@ -182,7 +182,43 @@ void appMode()
         // Ignore invalid messages
         if (input != "HOME" && input != "RESET_THETA" && !input.startsWith("SET_SPEED") && !input.endsWith(";"))
         {
-            Serial.println("IGNORED");
+            Serial.print("IGNORED: ");
+            Serial.println(input);
+            return;
+        }
+
+        // Example: The user calls "SET_SPEED 60" => 60% of maxSpeed
+        if (input.startsWith("SET_SPEED"))
+        {
+            // Parse out the speed value from the command string
+            int spaceIndex = input.indexOf(' ');
+            if (spaceIndex != -1)
+            {
+                String speedStr = input.substring(spaceIndex + 1);
+                float speedPercentage = speedStr.toFloat();
+
+                // Make sure the percentage is valid
+                if (speedPercentage >= 1.0 && speedPercentage <= 100.0)
+                {
+                    // Convert percentage to actual speed
+                    long newSpeed = (speedPercentage / 100.0) * maxSpeed;
+
+                    // Set the stepper speeds
+                    rotStepper.setMaxSpeed(newSpeed);
+                    inOutStepper.setMaxSpeed(newSpeed);
+
+                    Serial.println("SPEED_SET");  
+                    Serial.println("R");
+                }
+                else
+                {
+                    Serial.println("INVALID_SPEED");
+                }
+            }
+            else
+            {
+                Serial.println("INVALID_COMMAND");
+            }
             return;
         }
 
@@ -336,7 +372,9 @@ void movePolar(float theta, float rho)
     totalRevolutions += (theta - currentTheta) / (2.0 * M_PI);
 
     // Apply the offset to the inout axis
-    inOutSteps += offsetSteps;
+    if (!isFirstCoordinates) {
+        inOutSteps -= offsetSteps;
+    }
 
     // Define target positions for both motors
     long targetPositions[2];

+ 42 - 4
arduino_code_TMC2209/arduino_code_TMC2209.ino

@@ -40,7 +40,7 @@ float currentTheta = 0.0; // Current theta in radians
 float currentRho = 0.0;   // Current rho (0 to 1)
 bool isFirstCoordinates = true;
 float totalRevolutions = 0.0; // Tracks cumulative revolutions
-float maxSpeed = 1000;
+long maxSpeed = 1000;
 float maxAcceleration = 50;
 long interpolationResolution = 0.001;
 float userDefinedSpeed = maxSpeed; // Store user-defined speed
@@ -183,7 +183,8 @@ void appMode()
         // Ignore invalid messages
         if (input != "HOME" && input != "RESET_THETA" && !input.startsWith("SET_SPEED") && !input.endsWith(";"))
         {
-            Serial.println("IGNORED");
+            Serial.print("IGNORED: ");
+            Serial.println(input);
             return;
         }
 
@@ -231,6 +232,41 @@ void appMode()
             return;
         }
 
+        // Example: The user calls "SET_SPEED 60" => 60% of maxSpeed
+        if (input.startsWith("SET_SPEED"))
+        {
+            // Parse out the speed value from the command string
+            int spaceIndex = input.indexOf(' ');
+            if (spaceIndex != -1)
+            {
+                String speedStr = input.substring(spaceIndex + 1);
+                float speedPercentage = speedStr.toFloat();
+
+                // Make sure the percentage is valid
+                if (speedPercentage >= 1.0 && speedPercentage <= 100.0)
+                {
+                    // Convert percentage to actual speed
+                    long newSpeed = (speedPercentage / 100.0) * maxSpeed;
+
+                    // Set the stepper speeds
+                    rotStepper.setMaxSpeed(newSpeed);
+                    inOutStepper.setMaxSpeed(newSpeed);
+
+                    Serial.println("SPEED_SET");  
+                    Serial.println("R");
+                }
+                else
+                {
+                    Serial.println("INVALID_SPEED");
+                }
+            }
+            else
+            {
+                Serial.println("INVALID_COMMAND");
+            }
+            return;
+        }
+
 
         // If not a command, assume it's a batch of theta-rho pairs
         if (!batchComplete)
@@ -281,8 +317,8 @@ void appMode()
 
                 currentTheta = buffer[0][0];
                 totalRevolutions = 0;
-                isFirstCoordinates = false; // Reset the flag after the first movement
                 movePolar(buffer[0][0], buffer[0][1]);
+                isFirstCoordinates = false; // Reset the flag after the first movement
             }
               else
               {
@@ -338,7 +374,9 @@ void movePolar(float theta, float rho)
     totalRevolutions += (theta - currentTheta) / (2.0 * M_PI);
 
     // Apply the offset to the inout axis
-    inOutSteps -= offsetSteps;
+    if (!isFirstCoordinates) {
+        inOutSteps -= offsetSteps;
+    }
 
     // Define target positions for both motors
     long targetPositions[2];

+ 13 - 7
esp32/esp32.ino

@@ -81,21 +81,27 @@ void loop()
             return;
         }
 
-
+        // Example: The user calls "SET_SPEED 60" => 60% of maxSpeed
         if (input.startsWith("SET_SPEED"))
         {
-            // Parse and set the speed
+            // Parse out the speed value from the command string
             int spaceIndex = input.indexOf(' ');
             if (spaceIndex != -1)
             {
                 String speedStr = input.substring(spaceIndex + 1);
-                double speed = speedStr.toDouble();
+                float speedPercentage = speedStr.toFloat();
 
-                if (speed > 0) // Ensure valid speed
+                // Make sure the percentage is valid
+                if (speedPercentage >= 1.0 && speedPercentage <= 100.0)
                 {
-                    rotStepper.setMaxSpeed(speed);
-                    inOutStepper.setMaxSpeed(speed);
-                    Serial.println("SPEED_SET");
+                    // Convert percentage to actual speed
+                    long newSpeed = (speedPercentage / 100.0) * maxSpeed;
+
+                    // Set the stepper speeds
+                    rotStepper.setMaxSpeed(newSpeed);
+                    inOutStepper.setMaxSpeed(newSpeed);
+
+                    Serial.println("SPEED_SET");  
                     Serial.println("R");
                 }
                 else

+ 0 - 0
patterns/side_wiper.thr → patterns/clear_sideway.thr


+ 1 - 1
static/style.css

@@ -163,7 +163,7 @@ li.selected {
 }
 
 #theta_rho_files {
-    max-height: 507px;
+    max-height: 400px;
     overflow-y: auto;
     border: 1px solid #ddd;
     border-radius: 5px;

+ 457 - 70
templates/index.html

@@ -3,12 +3,12 @@
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Sand Table Controller</title>
+    <title>Dune Weaver Controller</title>
     <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
     <link rel="stylesheet" href="../static/style.css">
 </head>
 <body>
-    <h1>Sand Table Controller</h1>
+    <h1>Dune Weaver Controller</h1>
     <div class="container">
         <!-- Left Column -->
         <div class="left-column">
@@ -24,8 +24,24 @@
                     <button onclick="disconnectSerial()">Disconnect</button>
                     <button onclick="restartSerial()">Restart</button>
                 </div>
+                <div class="button-group">
+                    <label for="speed_input">Speed:</label>
+                    <input type="number" id="speed_input" placeholder="1-100" min="1" step="1" max="5000">
+                    <button class="small-button"  onclick="changeSpeed()">Set Speed</button>
+                </div>
+            </div>
+            <div class="section"> 
+                <h2>Mode</h2>
+                <label>
+                <input type="radio" name="mode" class="modeSelector" value="single_run" checked> Single Run
+                </label>
+                <label>
+                <input type="radio" name="mode" class="modeSelector" value="create_playlist"> Create Playlist
+                </label>
+                <label>
+                <input type="radio" name="mode" class="modeSelector" value="run_playlist"> Run Playlist
+                </label>
             </div>
-
             <div class="section">
                 <h2>Quick Actions</h2>
                 <div class="button-group">
@@ -34,6 +50,7 @@
                     <button onclick="moveToPerimeter()">Move to Perimeter</button>
                     <button onclick="runClearIn()">Clear from In</button>
                     <button onclick="runClearOut()">Clear from Out</button>
+                    <button onclick="runSideway()">Clear Sideway</button>
                     <button onclick="stopExecution()" class="delete-button">Stop</button>
                 </div>
                 <div class="coordinate-input button-group">
@@ -43,11 +60,6 @@
                     <input type="number" id="rho_input" placeholder="Rho">
                     <button class="small-button" onclick="sendCoordinate()">Send to coordinate</button>
                 </div>
-                <div class="button-group">
-                    <label for="speed_input">Speed:</label>
-                    <input type="number" id="speed_input" placeholder="1-5000" min="1" step="1" max="5000">
-                    <button class="small-button"  onclick="changeSpeed()">Set Speed</button>
-                </div>
             </div>
             <div class="section">
                 <h2>Preview</h2>
@@ -59,54 +71,124 @@
 
         <!-- Right Column -->
         <div class="right-column">
-            <div class="section">
-                <h2>Pattern Files</h2>
-                <input type="text" id="search_pattern" placeholder="Search files..." oninput="searchPatternFiles()">
-                <ul id="theta_rho_files"></ul>
-                <div class="pre-execution-toggles">
-                    <h3>Pre-Execution Action</h3>
-                    <label>
-                        <input type="radio" name="pre_execution" value="clear_in" id="clear_in"> Clear from In
-                    </label>
-                    <label>
-                        <input type="radio" name="pre_execution" value="clear_out" id="clear_out"> Clear from Out
-                    </label>
-                    <label>
-                        <input type="radio" name="pre_execution" value="none" id="no_action" checked> None
-                    </label>
+
+                <div class="section modeSection single_run create_playlist">
+                    <h2 class="modeSection single_run">Single Run</h2>
+                    <h2 class="modeSection create_playlist" style="display: none;"> Create Playlist</h2>
+                    <h2 class="modeSection run_playlist" style="display: none;"> Run Playlist</h2>
+                    <input class="modeSection single_run create_playlist" type="text" id="search_pattern" placeholder="Search files..." oninput="searchPatternFiles()">
+                    <ul  class="modeSection single_run create_playlist" id="theta_rho_files"></ul>
+                    <div class="pre-execution-toggles modeSection single_run">
+                        <h3>Pre-Execution Action</h3>
+                        <label>
+                            <input type="radio" name="pre_execution" value="clear_in" id="clear_in"> Clear from In
+                        </label>
+                        <label>
+                            <input type="radio" name="pre_execution" value="clear_out" id="clear_out"> Clear from Out
+                        </label>
+                        <label>
+                            <input type="radio" name="pre_execution" value="clear_sideway" id="clear_out"> Clear sideway
+                        </label>
+                        <label>
+                            <input type="radio" name="pre_execution" value="none" id="no_action" checked> None
+                        </label>
+                    </div>
+                    <div class="button-group modeSection single_run">
+                        <button id="run_button" disabled>Run Selected File</button>
+                    </div>
+                    <div class="button-group modeSection create_playlist" style="display: none;">
+                        <button onclick="addToPlaylist()">Add File to Playlist</button>
+                    </div>
                 </div>
-                <div class="button-group">
-                    <button id="run_button" disabled>Run Selected Pattern</button>
+                <div class="section modeSection single_run">
+                    <h2>Upload new files</h2>
+                    <div class="button-group">
+                        <input type="file" id="upload_file">
+                        <div class="button-group">
+                            <button onclick="uploadThetaRho()">Upload</button>
+                            <button id="delete_selected_button" class="delete-button" onclick="deleteSelectedFile()" disabled>Delete Selected File</button>
+                        </div>
+                    </div>
                 </div>
-            </div>
-            <div class="section">
-                <h2>Upload new files</h2>
-                <div class="button-group">
-                    <input type="file" id="upload_file">
+                <div class="section modeSection create_playlist run_playlist" style="display: none;">
+                    <h2>Create Playlist</h2>
+                    <!-- A list showing the files that have been added to the playlist -->
+                    <ul id="playlist_items"></ul>
+                  
+                    <!-- Buttons to reorder files in the playlist -->
                     <div class="button-group">
-                        <button onclick="uploadThetaRho()">Upload</button>
-                        <button id="delete_selected_button" class="delete-button" onclick="deleteSelectedFile()" disabled>Delete Selected File</button>
+                      <button onclick="movePlaylistItemUp()">Up</button>
+                      <button onclick="movePlaylistItemDown()">Down</button>
+                      <button onclick="removeFromPlaylist()" class="delete-button">Remove</button>
+                    </div>
+                                        
+                    <div class="playlist-name-input">
+                        <label for="playlist_name">Playlist Name:</label>
+                        <input type="text" id="playlist_name" placeholder="Enter playlist name" />
+                      </div>
+                    
+                      <div class="button-group">
+                        <!-- Button to add the selected file (from #theta_rho_files) to the playlist -->
+                    
+                        <!-- Button to create/finalize the playlist -->
+                        <button onclick="savePlaylist()">Create Playlist</button>
                     </div>
                 </div>
+                <div class="section modeSection create_playlist run_playlist" style="display: none;">
+                    <h2>Playlists</h2>
+                    
+                    <!-- Parameter Inputs -->
+                    <div class="playlist-parameters">
+                        <label for="pause_time">Pause Time (seconds):</label>
+                        <input type="number" id="pause_time" min="0" step="0.1" value="0">
+                        
+                        <label for="clear_pattern">Clear Pattern:</label>
+                        <select id="clear_pattern">
+                            <option value="none">None</option>
+                            <option value="clear_in">Clear from In</option>
+                            <option value="clear_out">Clear from Out</option>
+                            <option value="clear_sideway">Clear Sideway</option>
+                            <option value="random">Random</option>
+                        </select>
+                        
+                        <!-- New Parameters -->
+                        <div class="run-options">
+                            <label>Run Mode:</label>
+                            <label>
+                                <input type="radio" name="run_mode" value="single" checked> Single Run
+                            </label>
+                            <label>
+                                <input type="radio" name="run_mode" value="indefinite"> Indefinite Run
+                            </label>
+                            
+                            <label>
+                                <input type="checkbox" id="shuffle_playlist"> Shuffle Playlist
+                            </label>
+                        </div>
+                    </div>
+                    
+                    <!-- Playlists List -->
+                    <ul id="all_playlists"></ul>
+                </div>
             </div>
         </div>
-
-        <div class="footer">
-            <div id="github">
-                <span>Help us improve! <a href="https://github.com/tuanchris/dune-weaver/pulls" target="_blank">Submit a Pull Request</a> or <a href="https://github.com/tuanchris/dune-weaver/issues/new" target="_blank">Report a Bug</a>.</span>
-                <a href="https://github.com/tuanchris/dune-weaver/issues" target="_blank">
-                    <img src="https://img.shields.io/github/issues/tuanchris/dune-weaver?style=flat-square" alt="GitHub Issues">
-                </a>
-            </div>
-
-            <button id="debug_button" class="small-button" onclick="toggleDebugLog()">
-                Show Debug log
-            </button>
+    </div>
+    
+    <div class="footer">
+        <div id="github">
+            <span>Help us improve! <a href="https://github.com/tuanchris/dune-weaver/pulls" target="_blank">Submit a Pull Request</a> or <a href="https://github.com/tuanchris/dune-weaver/issues/new" target="_blank">Report a Bug</a>.</span>
+            <a href="https://github.com/tuanchris/dune-weaver/issues" target="_blank">
+                <img src="https://img.shields.io/github/issues/tuanchris/dune-weaver?style=flat-square" alt="GitHub Issues">
+            </a>
         </div>
 
-        <div id="status_log">
-            <!-- Messages will be appended here -->
-        </div>
+        <button id="debug_button" class="small-button" onclick="toggleDebugLog()">
+            Show Debug log
+        </button>
+    </div>
+
+    <div id="status_log">
+        <!-- Messages will be appended here -->
     </div>
     <script>
         let selectedFile = null;
@@ -300,23 +382,6 @@
             }
         }
 
-        async function runClearIn() {
-            await runFile('clear_from_in.thr');
-        }
-
-        async function runClearOut() {
-            await runFile('clear_from_out.thr');
-        }
-
-        async function runFile(fileName) {
-            const response = await fetch(`/run_theta_rho_file/${fileName}`, { method: 'POST' });
-            const result = await response.json();
-            if (result.success) {
-                logMessage(`Running file: ${fileName}`);
-            } else {
-                logMessage(`Failed to run file: ${fileName}`);
-            }
-        }
 
         let allFiles = []; // Store all files for filtering
 
@@ -528,6 +593,56 @@
             }
         }
 
+        async function runFile(fileName) {
+            const response = await fetch(`/run_theta_rho_file/${fileName}`, { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                logMessage(`Running file: ${fileName}`);
+            } else {
+                logMessage(`Failed to run file: ${fileName}`);
+            }
+        }
+
+        async function runClearIn() {
+            await runFile('clear_from_in.thr');
+        }
+
+        async function runClearOut() {
+            await runFile('clear_from_out.thr');
+        }
+
+        async function runSideway() {
+            await runFile('clear_sideway.thr');
+        }
+        
+        // Call this function on page load
+        checkSerialStatus();
+
+        // Initial load of serial ports and Theta-Rho files
+        loadSerialPorts();
+        loadThetaRhoFiles();
+
+        document.getElementById('run_button').onclick = runThetaRho;
+        
+        document.querySelectorAll('.modeSelector').forEach((radio) => {
+            radio.addEventListener('change', function () {
+              // Hide all .modeSection first
+              document.querySelectorAll('.modeSection').forEach((section) => {
+                section.style.display = 'none';
+              });
+          
+              // Figure out which mode was selected
+              const selectedValue = this.value; // or document.querySelector('.modeSelector:checked').value
+          
+              // Show every element that has .modeSection AND the selected mode's class
+              document
+                .querySelectorAll(`.modeSection.${selectedValue}`)
+                .forEach((elem) => {
+                  elem.style.display = 'block';
+                });
+            });
+          });
+
         async function changeSpeed() {
             const speedInput = document.getElementById('speed_input');
             const speed = parseFloat(speedInput.value);
@@ -552,16 +667,288 @@
                 logMessage(`Failed to set speed: ${result.error}`);
             }
         }
+        // Keep track of the files in the new playlist
+        let playlist = [];
+        // Currently selected item in the playlist
+        let selectedPlaylistIndex = null;
+        
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        //  PART A: Loading / listing playlists from the server
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        
+        async function loadAllPlaylists() {
+            try {
+            const response = await fetch('/list_all_playlists'); // GET
+            const allPlaylists = await response.json();          // e.g. ["My Playlist", "Summer", ...]
+            displayAllPlaylists(allPlaylists);
+            } catch (err) {
+            logMessage(`Error loading playlists: ${err}`);
+            }
+        }
+        
+        // Function to display all playlists with Load, Run, and Delete buttons
+        function displayAllPlaylists(allPlaylists) {
+            const ul = document.getElementById('all_playlists');
+            ul.innerHTML = ''; // Clear current list
 
-        // Call this function on page load
-        checkSerialStatus();
+            allPlaylists.forEach((playlistName) => {
+                const li = document.createElement('li');
+                li.classList.add('playlist-item'); // For styling
+
+                // Playlist Name
+                const nameSpan = document.createElement('span');
+                nameSpan.textContent = playlistName;
+                li.appendChild(nameSpan);
+
+                // "Load" button
+                const loadBtn = document.createElement('button');
+                loadBtn.textContent = 'Load';
+                loadBtn.onclick = () => loadPlaylist(playlistName);
+                loadBtn.classList.add('load-button'); // For styling
+                li.appendChild(loadBtn);
+
+                // "Run" button
+                const runBtn = document.createElement('button');
+                runBtn.textContent = 'Run';
+                runBtn.onclick = () => runPlaylist(playlistName);
+                runBtn.classList.add('run-button'); // For styling
+                li.appendChild(runBtn);
+
+                // "Delete" button
+                const deleteBtn = document.createElement('button');
+                deleteBtn.textContent = 'Delete';
+                deleteBtn.onclick = () => deletePlaylist(playlistName);
+                deleteBtn.classList.add('delete-button'); // For styling
+                li.appendChild(deleteBtn);
 
-        // Initial load of serial ports and Theta-Rho files
-        loadSerialPorts();
-        loadThetaRhoFiles();
+                ul.appendChild(li);
+            });
+        }
 
-        document.getElementById('run_button').onclick = runThetaRho;
+        // Function to run the selected playlist with specified parameters
+        async function runPlaylist(playlistName) {
+            if (!playlistName) {
+                logMessage("No playlist selected to run.");
+                return;
+            }
+
+            // Get the parameters from the UI
+            const pauseTimeInput = document.getElementById('pause_time').value;
+            const clearPatternSelect = document.getElementById('clear_pattern').value;
+            const runMode = document.querySelector('input[name="run_mode"]:checked').value;
+            const shuffle = document.getElementById('shuffle_playlist').checked;
 
+            // Validate pause time
+            const pauseTime = parseFloat(pauseTimeInput);
+            if (isNaN(pauseTime) || pauseTime < 0) {
+                logMessage("Invalid pause time. Please enter a non-negative number.");
+                return;
+            }
+
+            // Map clear_pattern select value to backend expected values
+            let clearPattern = clearPatternSelect;
+            if (clearPatternSelect === "none") {
+                clearPattern = null;
+            }
+
+            logMessage(`Running playlist: ${playlistName} with pause_time=${pauseTime}, clear_pattern=${clearPattern || "None"}, run_mode=${runMode}, shuffle=${shuffle}`);
+
+            try {
+                const response = await fetch('/run_playlist', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({
+                        playlist_name: playlistName,
+                        pause_time: pauseTime,
+                        clear_pattern: clearPattern,
+                        run_mode: runMode, // 'single' or 'indefinite'
+                        shuffle: shuffle      // true or false
+                    })
+                });
+
+                const result = await response.json();
+
+                if (result.success) {
+                    logMessage(`Playlist "${playlistName}" is now running.`);
+                } else {
+                    logMessage(`Failed to run playlist "${playlistName}": ${result.error}`);
+                }
+            } catch (error) {
+                logMessage(`Error running playlist "${playlistName}": ${error}`);
+            }
+        }
+        
+        async function loadPlaylist(playlistName) {
+            // This fetches the named playlist from /get_playlist?name=...
+            try {
+            const response = await fetch(`/get_playlist?name=${encodeURIComponent(playlistName)}`);
+            const data = await response.json(); // { "name": "XYZ", "files": [...] }
+        
+            if (data.error) {
+                logMessage(`Error loading playlist "${playlistName}": ${data.error}`);
+                return;
+            }
+        
+            // Clear current local playlist; replace with loaded data
+            playlist = data.files || [];
+            selectedPlaylistIndex = null;
+            document.getElementById('playlist_name').value = data.name; // Fill in the name field
+        
+            refreshPlaylistUI();
+            logMessage(`Loaded playlist: ${data.name} with ${playlist.length} file(s).`);
+            } catch (err) {
+            logMessage(`Error loading playlist: ${err}`);
+            }
+        }
+        
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        //  PART B: Creating or Saving (Overwriting) a Playlist
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        
+        // Instead of separate create/modify functions, we’ll unify them:
+        async function savePlaylist() {
+            const name = document.getElementById('playlist_name').value.trim();
+            if (!name) {
+            logMessage("Please enter a playlist name.");
+            return;
+            }
+            if (playlist.length === 0) {
+            logMessage("No files in this playlist. Add files first.");
+            return;
+            }
+        
+            logMessage(`Saving playlist "${name}" with ${playlist.length} file(s)...`);
+        
+            try {
+            // We can use /create_playlist or /modify_playlist. They do roughly the same in our single-file approach.
+            // Let's use /create_playlist to always overwrite or create anew.
+            const response = await fetch('/create_playlist', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({
+                name: name,
+                files: playlist
+                })
+            });
+            const result = await response.json();
+            if (result.success) {
+                logMessage(result.message);
+                // Reload the entire list of playlists to reflect changes
+                loadAllPlaylists();
+            } else {
+                logMessage(`Failed to save playlist: ${result.error}`);
+            }
+            } catch (err) {
+            logMessage(`Error saving playlist: ${err}`);
+            }
+        }
+        
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        //  PART C: Deleting a playlist
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        
+        async function deletePlaylist(playlistName) {
+            if (!confirm(`Delete playlist "${playlistName}"? This cannot be undone.`)) {
+            return;
+            }
+            try {
+            const response = await fetch('/delete_playlist', {
+                method: 'DELETE',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ name: playlistName })
+            });
+            const result = await response.json();
+            if (result.success) {
+                logMessage(result.message);
+                loadAllPlaylists(); // Refresh the UI
+            } else {
+                logMessage(`Failed to delete playlist: ${result.error}`);
+            }
+            } catch (err) {
+            logMessage(`Error deleting playlist: ${err}`);
+            }
+        }
+        
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        //  PART D: Local playlist array UI
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        
+        // Called when the user clicks "Add File to Playlist"
+        function addToPlaylist() {
+            if (!selectedFile) {
+            logMessage("No file selected to add to the playlist.");
+            return;
+            }
+            // Add the current selectedFile to the local array
+            playlist.push(selectedFile);
+            logMessage(`Added "${selectedFile}" to the playlist.`);
+            refreshPlaylistUI();
+        }
+        
+        function refreshPlaylistUI() {
+            const ul = document.getElementById('playlist_items');
+            ul.innerHTML = '';
+        
+            playlist.forEach((file, index) => {
+            const li = document.createElement('li');
+            li.textContent = file;
+        
+            // When you click on a file in the playlist, mark it as selected
+            li.onclick = () => selectPlaylistItem(index);
+        
+            // Highlight the currently selected playlist item
+            if (index === selectedPlaylistIndex) {
+                li.classList.add('selected');
+            }
+            ul.appendChild(li);
+            });
+        }
+        
+        function selectPlaylistItem(index) {
+            selectedPlaylistIndex = index;
+            refreshPlaylistUI();
+            logMessage(`Selected playlist file: ${playlist[index]}`);
+        }
+        
+        function movePlaylistItemUp() {
+            if (selectedPlaylistIndex === null || selectedPlaylistIndex <= 0) return;
+            const temp = playlist[selectedPlaylistIndex - 1];
+            playlist[selectedPlaylistIndex - 1] = playlist[selectedPlaylistIndex];
+            playlist[selectedPlaylistIndex] = temp;
+            selectedPlaylistIndex--;
+            refreshPlaylistUI();
+        }
+        
+        function movePlaylistItemDown() {
+            if (selectedPlaylistIndex === null || selectedPlaylistIndex >= playlist.length - 1) return;
+            const temp = playlist[selectedPlaylistIndex + 1];
+            playlist[selectedPlaylistIndex + 1] = playlist[selectedPlaylistIndex];
+            playlist[selectedPlaylistIndex] = temp;
+            selectedPlaylistIndex++;
+            refreshPlaylistUI();
+        }
+        
+        function removeFromPlaylist() {
+            if (selectedPlaylistIndex === null) {
+            logMessage("No item selected in the playlist to remove.");
+            return;
+            }
+            const removedFile = playlist[selectedPlaylistIndex];
+            playlist.splice(selectedPlaylistIndex, 1);
+            selectedPlaylistIndex = null;
+            refreshPlaylistUI();
+            logMessage(`Removed "${removedFile}" from the playlist.`);
+        }
+        
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        //  PART E: Page load initialization
+        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+        
+        // Make sure we load the existing playlists list on page load
+        document.addEventListener('DOMContentLoaded', () => {
+            loadAllPlaylists();
+        });
+         
     </script>
 </body>
 </html>