Ver código fonte

add more controls for FE and BE

Tuan Nguyen 1 ano atrás
pai
commit
58515219a5
4 arquivos alterados com 591 adições e 244 exclusões
  1. 193 0
      static/style.css
  2. 351 0
      templates/index.html
  3. 0 243
      templates/theta_rho_controller.html
  4. 47 1
      theta_rho_app.py

+ 193 - 0
static/style.css

@@ -0,0 +1,193 @@
+/* General Styling */
+body {
+    font-family: 'Roboto', sans-serif;
+    margin: 0;
+    padding: 0;
+    background-color: #f4f4f9;
+    color: #333;
+}
+
+h1 {
+    text-align: center;
+    color: #4A90E2;
+    margin: 20px 0;
+}
+
+h2 {
+    color: #4A90E2;
+    margin: 10px 0;
+}
+
+/* Container Layout */
+.container {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    gap: 20px;
+    width: 90%;
+    max-width: 1200px;
+    margin: 0 auto;
+}
+
+/* Columns */
+.left-column {
+    flex: 1;
+    min-width: 300px;
+}
+
+.right-column {
+    flex: 1;
+    min-width: 300px;
+}
+
+/* Sections */
+.section {
+    background: #fff;
+    border: 1px solid #ddd;
+    border-radius: 8px;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+    padding: 20px;
+    margin-bottom: 20px;
+}
+
+/* Buttons Inline */
+.button-group {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+    padding: 10px 15px;
+}
+
+button {
+    background: #4A90E2;
+    color: #fff;
+    cursor: pointer;
+    padding: 10px 15px;
+    font-size: 1em;
+    border: 1px solid #ddd;
+    border-radius: 5px;
+    outline: none;
+    transition: all 0.3s ease;
+}
+
+button:hover {
+    background: #357ABD;
+}
+
+/* Delete Button */
+.delete-button {
+    background: #e74c3c; /* Red color */
+    color: #fff;
+    cursor: pointer;
+    border: none;
+    padding: 10px 15px;
+    border-radius: 5px;
+    font-size: 1em;
+    transition: all 0.3s ease;
+}
+
+.delete-button:hover {
+    background: #c0392b; /* Darker red on hover */
+}
+
+select, input[type="file"] {
+    display: block;
+    width: 100%;
+    max-width: 300px;
+    padding: 10px;
+    margin: 10px 0;
+    font-size: 1em;
+    border: 1px solid #ddd;
+    border-radius: 5px;
+    outline: none;
+}
+
+/* File List */
+ul {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+}
+
+li {
+    padding: 10px;
+    border-bottom: 1px solid #ddd;
+    cursor: pointer;
+    transition: all 0.3s ease;
+}
+
+li:hover {
+    background: #f1f1f1;
+}
+
+li.selected {
+    background: #4A90E2;
+    color: white;
+    font-weight: bold;
+    padding: 5px;
+    border-radius: 5px;
+}
+
+/* Status Log */
+#status_log {
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background: #fff;
+    border-top: 1px solid #ddd;
+    padding: 10px;
+    max-height: 150px;
+    overflow-y: auto;
+    box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
+}
+
+
+#status_log p {
+    margin: 0.5em 0;
+    font-size: 0.9em;
+    color: #444;
+}
+
+#theta_rho_files {
+    max-height: 545px;
+    overflow-y: auto;
+    border: 1px solid #ddd;
+    border-radius: 5px;
+    background-color: #fff;
+    padding: 10px;
+}
+
+/* Search Bar */
+#search_pattern {
+    display: block;
+    width: 100%;
+    max-width: 300px;
+    padding: 10px;
+    margin: 10px 0;
+    font-size: 1em;
+    border: 1px solid #ddd;
+    border-radius: 5px;
+    outline: none;
+    transition: all 0.3s ease;
+}
+
+#search_pattern:focus {
+    border-color: #4A90E2;
+    box-shadow: 0 0 5px rgba(74, 144, 226, 0.5);
+}
+
+/* Responsive Layout for Small Screens */
+@media (max-width: 768px) {
+    .container {
+        flex-direction: column; /* Stack columns vertically */
+    }
+
+    .left-column, .right-column {
+        width: 100%;
+    }
+
+    #status_log {
+        max-height: 300px;
+    }
+}

+ 351 - 0
templates/index.html

@@ -0,0 +1,351 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Sand Table 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>
+    <div class="container">
+        <!-- Left Column -->
+        <div class="left-column">
+            <div class="section">
+                <h2>Serial Connection</h2>
+                <label for="serial_ports">Available Ports:</label>
+                <select id="serial_ports"></select>
+                <div class="button-group">
+                    <button onclick="connectSerial()">Connect</button>
+                    <button onclick="disconnectSerial()">Disconnect</button>
+                    <button onclick="restartSerial()">Restart</button>
+                </div>
+                <p id="serial_status" class="status">Status: Not connected</p>
+            </div>
+
+            <div class="section">
+                <h2>Quick Actions</h2>
+                <div class="button-group">
+                    <button onclick="sendHomeCommand()">Home Device</button>
+                    <button onclick="moveToCenter()">Move to Center</button>
+                    <button onclick="moveToPerimeter()">Move to Perimeter</button>
+                </br>
+                    <button onclick="runClearIn()">Clear from In</button>
+                    <button onclick="runClearOut()">Clear from Out</button>
+                   
+                </div>
+            </div>
+            <div class="section">
+                <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>
+
+        <!-- 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="button-group">
+                    <button id="run_button" disabled>Run Selected File</button>
+                    <button onclick="stopExecution()" class="delete-button">Stop</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div id="status_log">
+        <h2>Status Log</h2>
+        <!-- Messages will be appended here -->
+    </div>
+
+    <script>
+        let selectedFile = null;
+
+        function logMessage(message) {
+            const log = document.getElementById('status_log');
+            const entry = document.createElement('p');
+            entry.textContent = message;
+            log.appendChild(entry);
+            log.scrollTop = log.scrollHeight; // Keep log scrolled to the bottom
+        }
+
+        async function loadThetaRhoFiles() {
+            logMessage('Loading Theta-Rho files...');
+            const response = await fetch('/list_theta_rho_files');
+            const files = await response.json();
+            const ul = document.getElementById('theta_rho_files');
+            ul.innerHTML = ''; // Clear current list
+
+            files.forEach(file => {
+                const li = document.createElement('li');
+                li.textContent = file;
+
+                // Highlight the selected file when clicked
+                li.onclick = () => selectFile(file, li);
+
+                ul.appendChild(li);
+            });
+
+            logMessage('Theta-Rho files loaded successfully.');
+        }
+
+        function selectFile(file, listItem) {
+            selectedFile = file;
+
+            // Highlight the selected file
+            document.querySelectorAll('#theta_rho_files li').forEach(li => li.classList.remove('selected'));
+            listItem.classList.add('selected');
+
+            // Enable buttons
+            document.getElementById('run_button').disabled = false;
+            document.getElementById('delete_selected_button').disabled = false;
+
+            logMessage(`Selected file: ${file}`);
+        }
+
+        async function uploadThetaRho() {
+            const fileInput = document.getElementById('upload_file');
+            const file = fileInput.files[0];
+            if (!file) {
+                logMessage('No file selected for upload.');
+                return;
+            }
+
+            logMessage(`Uploading file: ${file.name}...`);
+            const formData = new FormData();
+            formData.append('file', file);
+
+            const response = await fetch('/upload_theta_rho', {
+                method: 'POST',
+                body: formData
+            });
+
+            const result = await response.json();
+            if (result.success) {
+                logMessage(`File uploaded successfully: ${file.name}`);
+                loadThetaRhoFiles();
+            } else {
+                logMessage(`Failed to upload file: ${file.name}`);
+            }
+        }
+
+        async function deleteSelectedFile() {
+            if (!selectedFile) {
+                logMessage("No file selected for deletion.");
+                return;
+            }
+
+            const userConfirmed = confirm(`Are you sure you want to delete the selected file "${selectedFile}"?`);
+            if (!userConfirmed) return;
+
+            logMessage(`Deleting file: ${selectedFile}...`);
+            const response = await fetch('/delete_theta_rho_file', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ file_name: selectedFile }),
+            });
+
+            const result = await response.json();
+            if (result.success) {
+                const ul = document.getElementById('theta_rho_files');
+                const selectedItem = Array.from(ul.children).find(li => li.classList.contains('selected'));
+                if (selectedItem) selectedItem.remove();
+
+                selectedFile = null;
+                document.getElementById('run_button').disabled = true;
+                document.getElementById('delete_selected_button').disabled = true;
+
+                logMessage(`File deleted successfully: ${result.file_name}`);
+            } else {
+                logMessage(`Failed to delete file: ${selectedFile}`);
+            }
+        }
+
+        async function runThetaRho() {
+            if (!selectedFile) {
+                logMessage("No file selected to run.");
+                return;
+            }
+
+            logMessage(`Running file: ${selectedFile}...`);
+            const response = await fetch('/run_theta_rho', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ file_name: selectedFile })
+            });
+
+            const result = await response.json();
+            if (result.success) {
+                logMessage(`File running: ${selectedFile}`);
+            } else {
+                logMessage(`Failed to run file: ${selectedFile}`);
+            }
+        }
+
+        async function stopExecution() {
+            logMessage('Stopping execution...');
+            const response = await fetch('/stop_execution', { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                logMessage('Execution stopped.');
+            } else {
+                logMessage('Failed to stop execution.');
+            }
+        }
+
+        async function loadSerialPorts() {
+            const response = await fetch('/list_serial_ports');
+            const ports = await response.json();
+            const select = document.getElementById('serial_ports');
+            select.innerHTML = '';
+            ports.forEach(port => {
+                const option = document.createElement('option');
+                option.value = port;
+                option.textContent = port;
+                select.appendChild(option);
+            });
+            logMessage('Serial ports loaded.');
+        }
+
+        async function connectSerial() {
+            const port = document.getElementById('serial_ports').value;
+            const response = await fetch('/connect_serial', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ port })
+            });
+            const result = await response.json();
+            if (result.success) {
+                document.getElementById('serial_status').textContent = `Status: Connected to ${port}`;
+                logMessage(`Connected to serial port: ${port}`);
+            } else {
+                logMessage(`Error connecting to serial port: ${result.error}`);
+            }
+        }
+
+        async function disconnectSerial() {
+            const response = await fetch('/disconnect_serial', { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                document.getElementById('serial_status').textContent = 'Status: Disconnected';
+                logMessage('Serial port disconnected.');
+            } else {
+                logMessage(`Error disconnecting: ${result.error}`);
+            }
+        }
+
+        async function restartSerial() {
+            const port = document.getElementById('serial_ports').value;
+            const response = await fetch('/restart_serial', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ port })
+            });
+            const result = await response.json();
+            if (result.success) {
+                document.getElementById('serial_status').textContent = `Status: Restarted connection to ${port}`;
+                logMessage('Serial connection restarted.');
+            } else {
+                logMessage(`Error restarting serial connection: ${result.error}`);
+            }
+        }
+        async function sendHomeCommand() {
+            const response = await fetch('/send_home', { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                logMessage('HOME command sent successfully.');
+            } else {
+                logMessage('Failed to send HOME command.');
+            }
+        }
+
+        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
+
+        async function loadThetaRhoFiles() {
+            logMessage('Loading Theta-Rho files...');
+            const response = await fetch('/list_theta_rho_files');
+            const files = await response.json();
+            allFiles = files; // Store the full list of files
+            displayFiles(allFiles); // Initially display all files
+            logMessage('Theta-Rho files loaded successfully.');
+        }
+
+        function displayFiles(files) {
+            const ul = document.getElementById('theta_rho_files');
+            ul.innerHTML = ''; // Clear current list
+
+            files.forEach(file => {
+                const li = document.createElement('li');
+                li.textContent = file;
+
+                // Highlight the selected file when clicked
+                li.onclick = () => selectFile(file, li);
+
+                ul.appendChild(li);
+            });
+        }
+
+        function searchPatternFiles() {
+            const searchInput = document.getElementById('search_pattern').value.toLowerCase();
+            const filteredFiles = allFiles.filter(file => file.toLowerCase().includes(searchInput));
+            displayFiles(filteredFiles); // Display only matching files
+        }
+
+        async function moveToCenter() {
+            logMessage('Moving to center...');
+            const response = await fetch('/move_to_center', { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                logMessage('Moved to center successfully.');
+            } else {
+                logMessage(`Failed to move to center: ${result.error}`);
+            }
+        }
+        
+        async function moveToPerimeter() {
+            logMessage('Moving to perimeter...');
+            const response = await fetch('/move_to_perimeter', { method: 'POST' });
+            const result = await response.json();
+            if (result.success) {
+                logMessage('Moved to perimeter successfully.');
+            } else {
+                logMessage(`Failed to move to perimeter: ${result.error}`);
+            }
+        }
+
+        // Initial load of serial ports and Theta-Rho files
+        loadSerialPorts();
+        loadThetaRhoFiles();
+        
+        document.getElementById('run_button').onclick = runThetaRho;
+
+    </script>
+</body>
+</html>

+ 0 - 243
templates/theta_rho_controller.html

@@ -1,243 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>Theta-Rho Controller</title>
-    <style>
-        #theta_rho_files li {
-            cursor: pointer;
-        }
-
-        #theta_rho_files li.selected {
-            font-weight: bold;
-            color: green;
-        }
-
-        .status {
-            margin-top: 1em;
-            font-weight: bold;
-        }
-
-        #serial_status {
-            color: blue;
-        }
-
-        #status_log {
-            margin-top: 2em;
-            padding: 1em;
-            border: 1px solid #ccc;
-            background: #f9f9f9;
-            max-height: 200px;
-            overflow-y: auto;
-        }
-
-        #status_log p {
-            margin: 0.5em 0;
-        }
-    </style>
-</head>
-<body>
-    <h1>Theta-Rho Controller</h1>
-
-    <h2>Serial Connection</h2>
-    <label for="serial_ports">Available Ports:</label>
-    <select id="serial_ports"></select>
-    <button onclick="connectSerial()">Connect</button>
-    <button onclick="disconnectSerial()">Disconnect</button>
-    <button onclick="restartSerial()">Restart</button>
-    <p id="serial_status" class="status">Status: Not connected</p>
-
-    <h2>Theta-Rho Files</h2>
-    <ul id="theta_rho_files"></ul>
-    <input type="file" id="upload_file">
-    <button onclick="uploadThetaRho()">Upload</button>
-
-    <h2>Run Theta-Rho</h2>
-    <button id="run_button" disabled>Run Selected File</button>
-    <button onclick="stopExecution()">Stop</button>
-
-    <h2>Quick Actions</h2>
-    <button onclick="sendHomeCommand()">Home Device</button>
-    <button onclick="runClearIn()">Clear from in</button>
-    <button onclick="runClearOut()">Clear from out</button>
-
-    <div id="status_log">
-        <h3>Status Log</h3>
-        <!-- Messages will be appended here -->
-    </div>
-
-    <script>
-        let selectedFile = null;
-
-        function logMessage(message) {
-            const log = document.getElementById('status_log');
-            const entry = document.createElement('p');
-            entry.textContent = message;
-            log.appendChild(entry);
-            log.scrollTop = log.scrollHeight; // Scroll to the bottom
-        }
-
-        async function loadSerialPorts() {
-            const response = await fetch('/list_serial_ports');
-            const ports = await response.json();
-            const select = document.getElementById('serial_ports');
-            select.innerHTML = '';
-            ports.forEach(port => {
-                const option = document.createElement('option');
-                option.value = port;
-                option.textContent = port;
-                select.appendChild(option);
-            });
-            logMessage('Serial ports loaded.');
-        }
-
-        async function connectSerial() {
-            const port = document.getElementById('serial_ports').value;
-            const response = await fetch('/connect_serial', {
-                method: 'POST',
-                headers: { 'Content-Type': 'application/json' },
-                body: JSON.stringify({ port })
-            });
-            const result = await response.json();
-            if (result.success) {
-                document.getElementById('serial_status').textContent = `Status: Connected to ${port}`;
-                logMessage(`Connected to serial port: ${port}`);
-            } else {
-                logMessage(`Error connecting to serial port: ${result.error}`);
-            }
-        }
-
-        async function disconnectSerial() {
-            const response = await fetch('/disconnect_serial', { method: 'POST' });
-            const result = await response.json();
-            if (result.success) {
-                document.getElementById('serial_status').textContent = 'Status: Disconnected';
-                logMessage('Serial port disconnected.');
-            } else {
-                logMessage(`Error disconnecting: ${result.error}`);
-            }
-        }
-
-        async function restartSerial() {
-            const port = document.getElementById('serial_ports').value;
-            const response = await fetch('/restart_serial', {
-                method: 'POST',
-                headers: { 'Content-Type': 'application/json' },
-                body: JSON.stringify({ port })
-            });
-            const result = await response.json();
-            if (result.success) {
-                document.getElementById('serial_status').textContent = `Status: Restarted connection to ${port}`;
-                logMessage('Serial connection restarted.');
-            } else {
-                logMessage(`Error restarting serial connection: ${result.error}`);
-            }
-        }
-
-        async function loadThetaRhoFiles() {
-            const response = await fetch('/list_theta_rho_files');
-            const files = await response.json();
-            const ul = document.getElementById('theta_rho_files');
-            ul.innerHTML = '';
-            files.forEach(file => {
-                const li = document.createElement('li');
-                li.textContent = file;
-                li.onclick = () => selectFile(file, li);
-                ul.appendChild(li);
-            });
-            logMessage('Theta-Rho files loaded.');
-        }
-
-        function selectFile(file, listItem) {
-            selectedFile = file;
-            document.querySelectorAll('#theta_rho_files li').forEach(li => li.classList.remove('selected'));
-            listItem.classList.add('selected');
-            document.getElementById('run_button').disabled = false;
-            logMessage(`File selected: ${file}`);
-        }
-
-        async function uploadThetaRho() {
-            const fileInput = document.getElementById('upload_file');
-            const file = fileInput.files[0];
-            const formData = new FormData();
-            formData.append('file', file);
-
-            const response = await fetch('/upload_theta_rho', {
-                method: 'POST',
-                body: formData
-            });
-
-            const result = await response.json();
-            if (result.success) {
-                logMessage('File uploaded successfully.');
-                loadThetaRhoFiles();
-            } else {
-                logMessage('Failed to upload file.');
-            }
-        }
-
-        async function runThetaRho() {
-            if (!selectedFile) return;
-
-            const response = await fetch('/run_theta_rho', {
-                method: 'POST',
-                headers: { 'Content-Type': 'application/json' },
-                body: JSON.stringify({ file_name: selectedFile })
-            });
-
-            const result = await response.json();
-            if (result.success) {
-                logMessage(`Running Theta-Rho file: ${selectedFile}`);
-            } else {
-                logMessage(`Error starting Theta-Rho execution: ${result.error}`);
-            }
-        }
-
-        async function stopExecution() {
-            const response = await fetch('/stop_execution', { method: 'POST' });
-            const result = await response.json();
-            if (result.success) {
-                logMessage('Execution stopped.');
-            } else {
-                logMessage('Failed to stop execution.');
-            }
-        }
-
-        async function sendHomeCommand() {
-            const response = await fetch('/send_home', { method: 'POST' });
-            const result = await response.json();
-            if (result.success) {
-                logMessage('HOME command sent successfully.');
-            } else {
-                logMessage('Failed to send HOME command.');
-            }
-        }
-
-        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}`);
-            }
-        }
-
-        // Initial load of serial ports and Theta-Rho files
-        loadSerialPorts();
-        loadThetaRhoFiles();
-
-        // Attach runThetaRho function to the Run button
-        document.getElementById('run_button').onclick = runThetaRho;
-    </script>
-</body>
-</html>

+ 47 - 1
theta_rho_app.py

@@ -128,7 +128,7 @@ def reset_theta():
 
 @app.route('/')
 def index():
-    return render_template('theta_rho_controller.html')
+    return render_template('index.html')
 
 @app.route('/list_serial_ports', methods=['GET'])
 def list_ports():
@@ -218,6 +218,52 @@ def run_specific_theta_rho_file(file_name):
     threading.Thread(target=run_theta_rho_file, args=(file_path,)).start()
     return jsonify({'success': True})
 
+@app.route('/delete_theta_rho_file', methods=['POST'])
+def delete_theta_rho_file():
+    data = request.json
+    file_name = data.get('file_name')
+
+    if not file_name:
+        return jsonify({"success": False, "error": "No file name provided"}), 400
+
+    file_path = os.path.join(THETA_RHO_DIR, file_name)
+
+    if not os.path.exists(file_path):
+        return jsonify({"success": False, "error": "File not found"}), 404
+
+    try:
+        os.remove(file_path)
+        return jsonify({"success": True})
+    except Exception as e:
+        return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/move_to_center', methods=['POST'])
+def move_to_center():
+    """Move the sand table to the center position."""
+    try:
+        if ser is None or not ser.is_open:
+            return jsonify({"success": False, "error": "Serial connection not established"}), 400
+
+        coordinates = [(0, 0)]  # Center position
+        send_coordinate_batch(ser, coordinates)
+        return jsonify({"success": True})
+    except Exception as e:
+        return jsonify({"success": False, "error": str(e)}), 500
+    
+@app.route('/move_to_perimeter', methods=['POST'])
+def move_to_perimeter():
+    """Move the sand table to the perimeter position."""
+    try:
+        if ser is None or not ser.is_open:
+            return jsonify({"success": False, "error": "Serial connection not established"}), 400
+
+        MAX_RHO = 1
+        coordinates = [(0, MAX_RHO)]  # Perimeter position
+        send_coordinate_batch(ser, coordinates)
+        return jsonify({"success": True})
+    except Exception as e:
+        return jsonify({"success": False, "error": str(e)}), 500
+
 # Expose files for download if needed
 @app.route('/download/<filename>', methods=['GET'])
 def download_file(filename):