ソースを参照

refactor backend to fastapi, use websocket for status update, remove schedule functionaility (supported through HA integration)

Tuan Nguyen 11 ヶ月 前
コミット
7cc8d58333

+ 0 - 0
dune_weaver_flask/__init__.py → __init__.py


+ 537 - 6
app.py

@@ -1,8 +1,539 @@
-"""
-entrypoint for the flask app
-the actual source of the app resides in dune-weaver-flask
-"""
+from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect
+from fastapi.responses import JSONResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+from typing import List, Optional, Tuple, Dict, Any, Union
+import atexit
+import os
+import logging
+from datetime import datetime, time
+from modules.connection import connection_manager
+from modules.core import pattern_manager
+from modules.core import playlist_manager
+from modules.update import update_manager
+from modules.core.state import state
+from modules import mqtt
+import signal
+import sys
+import asyncio
 
-from dune_weaver_flask import app
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
+    handlers=[
+        logging.StreamHandler(),
+    ]
+)
 
-app.entrypoint()
+logger = logging.getLogger(__name__)
+
+app = FastAPI()
+templates = Jinja2Templates(directory="templates")
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+# Pydantic models for request/response validation
+class ConnectRequest(BaseModel):
+    port: Optional[str] = None
+
+class CoordinateRequest(BaseModel):
+    theta: float
+    rho: float
+
+class PlaylistRequest(BaseModel):
+    playlist_name: str
+    pause_time: float = 0
+    clear_pattern: Optional[str] = None
+    run_mode: str = "single"
+    shuffle: bool = False
+
+class PlaylistRunRequest(BaseModel):
+    playlist_name: str
+    pause_time: Optional[float] = 0
+    clear_pattern: Optional[str] = None
+    run_mode: Optional[str] = "single"
+    shuffle: Optional[bool] = False
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+
+class SpeedRequest(BaseModel):
+    speed: float
+
+class WLEDRequest(BaseModel):
+    wled_ip: Optional[str] = None
+
+# Store active WebSocket connections
+active_status_connections = set()
+
+@app.websocket("/ws/status")
+async def websocket_status_endpoint(websocket: WebSocket):
+    await websocket.accept()
+    active_status_connections.add(websocket)
+    try:
+        while True:
+            # Keep the connection alive and handle any incoming messages
+            data = await websocket.receive_text()
+            if data == "get_status":
+                status = pattern_manager.get_status()
+                await websocket.send_json(status)
+    except WebSocketDisconnect:
+        active_status_connections.remove(websocket)
+
+async def broadcast_status_update(status: dict):
+    """Broadcast status update to all connected clients."""
+    disconnected = set()
+    for websocket in active_status_connections:
+        try:
+            await websocket.send_json(status)
+        except WebSocketDisconnect:
+            disconnected.add(websocket)
+    
+    # Clean up disconnected clients
+    active_status_connections.difference_update(disconnected)
+
+# FastAPI routes
+@app.get("/")
+async def index():
+    return templates.TemplateResponse("index.html", {"request": {}})
+
+@app.get("/list_serial_ports")
+async def list_ports():
+    logger.debug("Listing available serial ports")
+    return connection_manager.list_serial_ports()
+
+@app.post("/connect")
+async def connect(request: ConnectRequest):
+    if not request.port:
+        state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
+        connection_manager.device_init()
+        logger.info('Successfully connected to websocket ws://fluidnc.local:81')
+        return {"success": True}
+
+    try:
+        state.conn = connection_manager.SerialConnection(request.port)
+        connection_manager.device_init()
+        logger.info(f'Successfully connected to serial port {request.port}')
+        return {"success": True}
+    except Exception as e:
+        logger.error(f'Failed to connect to serial port {request.port}: {str(e)}')
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/disconnect")
+async def disconnect():
+    try:
+        state.conn.close()
+        logger.info('Successfully disconnected from serial port')
+        return {"success": True}
+    except Exception as e:
+        logger.error(f'Failed to disconnect serial: {str(e)}')
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/restart_connection")
+async def restart(request: ConnectRequest):
+    if not request.port:
+        logger.warning("Restart serial request received without port")
+        raise HTTPException(status_code=400, detail="No port provided")
+
+    try:
+        logger.info(f"Restarting connection on port {request.port}")
+        connection_manager.restart_connection()
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to restart serial on port {request.port}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/list_theta_rho_files")
+async def list_theta_rho_files():
+    logger.debug("Listing theta-rho files")
+    files = pattern_manager.list_theta_rho_files()
+    return sorted(files)
+
+@app.post("/upload_theta_rho")
+async def upload_theta_rho(file: UploadFile = File(...)):
+    custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, 'custom_patterns')
+    os.makedirs(custom_patterns_dir, exist_ok=True)
+    logger.debug(f'Ensuring custom patterns directory exists: {custom_patterns_dir}')
+
+    if file:
+        file_path = os.path.join(custom_patterns_dir, file.filename)
+        contents = await file.read()
+        with open(file_path, "wb") as f:
+            f.write(contents)
+        logger.info(f'Successfully uploaded theta-rho file: {file.filename}')
+        return {"success": True}
+    
+    logger.warning('Upload theta-rho request received without file')
+    return {"success": False}
+
+class ThetaRhoRequest(BaseModel):
+    file_name: str
+    pre_execution: Optional[str] = "none"
+
+@app.post("/run_theta_rho")
+async def run_theta_rho(request: ThetaRhoRequest, background_tasks: BackgroundTasks):
+    if not request.file_name:
+        logger.warning('Run theta-rho request received without file name')
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, request.file_name)
+    if not os.path.exists(file_path):
+        logger.error(f'Theta-rho file not found: {file_path}')
+        raise HTTPException(status_code=404, detail="File not found")
+
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to run a pattern without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+        
+        if pattern_manager.pattern_lock.locked():
+            logger.warning("Attempted to run a pattern while another is already running")
+            raise HTTPException(status_code=409, detail="Another pattern is already running")
+            
+        files_to_run = [file_path]
+        logger.info(f'Running theta-rho file: {request.file_name} with pre_execution={request.pre_execution}')
+        background_tasks.add_task(
+            pattern_manager.run_theta_rho_files,
+            files_to_run,
+            clear_pattern=request.pre_execution if request.pre_execution != "none" else None
+        )
+        return {"success": True}
+    except Exception as e:
+        logger.error(f'Failed to run theta-rho file {request.file_name}: {str(e)}')
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/stop_execution")
+async def stop_execution():
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to stop without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+    pattern_manager.stop_actions()
+    return {"success": True}
+
+@app.post("/send_home")
+async def send_home():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to home without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+        connection_manager.home()
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to send home command: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/run_theta_rho_file/{file_name}")
+async def run_specific_theta_rho_file(file_name: str):
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
+    if not os.path.exists(file_path):
+        raise HTTPException(status_code=404, detail="File not found")
+        
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to run a pattern without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    pattern_manager.run_theta_rho_file(file_path)
+    return {"success": True}
+
+class DeleteFileRequest(BaseModel):
+    file_name: str
+
+@app.post("/delete_theta_rho_file")
+async def delete_theta_rho_file(request: DeleteFileRequest):
+    if not request.file_name:
+        logger.warning("Delete theta-rho file request received without filename")
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, request.file_name)
+    if not os.path.exists(file_path):
+        logger.error(f"Attempted to delete non-existent file: {file_path}")
+        raise HTTPException(status_code=404, detail="File not found")
+
+    try:
+        os.remove(file_path)
+        logger.info(f"Successfully deleted theta-rho file: {request.file_name}")
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to delete theta-rho file {request.file_name}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/move_to_center")
+async def move_to_center():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to center without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+
+        logger.info("Moving device to center position")
+        pattern_manager.reset_theta()
+        pattern_manager.move_polar(0, 0)
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to move to center: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/move_to_perimeter")
+async def move_to_perimeter():
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to move to perimeter without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+        pattern_manager.reset_theta()
+        pattern_manager.move_polar(0, 1)
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to move to perimeter: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/preview_thr")
+async def preview_thr(request: DeleteFileRequest):
+    if not request.file_name:
+        logger.warning("Preview theta-rho request received without filename")
+        raise HTTPException(status_code=400, detail="No file name provided")
+
+    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, request.file_name)
+    if not os.path.exists(file_path):
+        logger.error(f"Attempted to preview non-existent file: {file_path}")
+        raise HTTPException(status_code=404, detail="File not found")
+
+    try:
+        coordinates = pattern_manager.parse_theta_rho_file(file_path)
+        return {"success": True, "coordinates": coordinates}
+    except Exception as e:
+        logger.error(f"Failed to generate preview for {request.file_name}: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/send_coordinate")
+async def send_coordinate(request: CoordinateRequest):
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to send coordinate without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    try:
+        logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
+        pattern_manager.move_polar(request.theta, request.rho)
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Failed to send coordinate: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/download/{filename}")
+async def download_file(filename: str):
+    return FileResponse(
+        os.path.join(pattern_manager.THETA_RHO_DIR, filename),
+        filename=filename
+    )
+
+@app.get("/serial_status")
+async def serial_status():
+    connected = state.conn.is_connected() if state.conn else False
+    port = state.port
+    logger.debug(f"Serial status check - connected: {connected}, port: {port}")
+    return {
+        "connected": connected,
+        "port": port
+    }
+
+@app.post("/pause_execution")
+async def pause_execution():
+    if pattern_manager.pause_execution():
+        return {"success": True, "message": "Execution paused"}
+    raise HTTPException(status_code=500, detail="Failed to pause execution")
+
+@app.get("/status")
+async def get_status():
+    status = pattern_manager.get_status()
+    await broadcast_status_update(status)
+    return status
+
+@app.post("/resume_execution")
+async def resume_execution():
+    if pattern_manager.resume_execution():
+        return {"success": True, "message": "Execution resumed"}
+    raise HTTPException(status_code=500, detail="Failed to resume execution")
+
+# Playlist endpoints
+@app.get("/list_all_playlists")
+async def list_all_playlists():
+    playlist_names = playlist_manager.list_all_playlists()
+    return playlist_names
+
+@app.get("/get_playlist")
+async def get_playlist(name: str):
+    if not name:
+        raise HTTPException(status_code=400, detail="Missing playlist name parameter")
+
+    playlist = playlist_manager.get_playlist(name)
+    if not playlist:
+        raise HTTPException(status_code=404, detail=f"Playlist '{name}' not found")
+
+    return playlist
+
+@app.post("/create_playlist")
+async def create_playlist(request: PlaylistRequest):
+    success = playlist_manager.create_playlist(request.playlist_name, request.files)
+    return {
+        "success": success,
+        "message": f"Playlist '{request.playlist_name}' created/updated"
+    }
+
+@app.post("/modify_playlist")
+async def modify_playlist(request: PlaylistRequest):
+    success = playlist_manager.modify_playlist(request.playlist_name, request.files)
+    return {
+        "success": success,
+        "message": f"Playlist '{request.playlist_name}' updated"
+    }
+
+@app.delete("/delete_playlist")
+async def delete_playlist(request: DeleteFileRequest):
+    success = playlist_manager.delete_playlist(request.file_name)
+    if not success:
+        raise HTTPException(
+            status_code=404,
+            detail=f"Playlist '{request.file_name}' not found"
+        )
+
+    return {
+        "success": True,
+        "message": f"Playlist '{request.file_name}' deleted"
+    }
+
+class AddToPlaylistRequest(BaseModel):
+    playlist_name: str
+    pattern: str
+
+@app.post("/add_to_playlist")
+async def add_to_playlist(request: AddToPlaylistRequest):
+    success = playlist_manager.add_to_playlist(request.playlist_name, request.pattern)
+    if not success:
+        raise HTTPException(status_code=404, detail="Playlist not found")
+    return {"success": True}
+
+@app.post("/run_playlist")
+async def run_playlist_endpoint(request: PlaylistRequest):
+    """Run a playlist with specified parameters."""
+    try:
+        if not os.path.exists(os.path.join(PLAYLISTS_DIR, f"{request.playlist_name}.json")):
+            raise HTTPException(status_code=404, detail=f"Playlist '{request.playlist_name}' not found")
+
+        # Start the playlist execution
+        asyncio.create_task(playlist_manager.run_playlist(
+            request.playlist_name,
+            pause_time=request.pause_time,
+            clear_pattern=request.clear_pattern,
+            run_mode=request.run_mode,
+            shuffle=request.shuffle
+        ))
+
+        return {"message": f"Started playlist: {request.playlist_name}"}
+    except Exception as e:
+        logger.error(f"Error running playlist: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/set_speed")
+async def set_speed(request: SpeedRequest):
+    try:
+        if not (state.conn.is_connected() if state.conn else False):
+            logger.warning("Attempted to change speed without a connection")
+            raise HTTPException(status_code=400, detail="Connection not established")
+        
+        if request.speed <= 0:
+            logger.warning(f"Invalid speed value received: {request.speed}")
+            raise HTTPException(status_code=400, detail="Invalid speed value")
+        
+        state.speed = request.speed
+        return {"success": True, "speed": request.speed}
+    except Exception as e:
+        logger.error(f"Failed to set speed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/check_software_update")
+async def check_updates():
+    update_info = update_manager.check_git_updates()
+    return update_info
+
+@app.post("/update_software")
+async def update_software():
+    logger.info("Starting software update process")
+    success, error_message, error_log = update_manager.update_software()
+    
+    if success:
+        logger.info("Software update completed successfully")
+        return {"success": True}
+    else:
+        logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
+        raise HTTPException(
+            status_code=500,
+            detail={
+                "error": error_message,
+                "details": error_log
+            }
+        )
+
+@app.post("/set_wled_ip")
+async def set_wled_ip(request: WLEDRequest):
+    state.wled_ip = request.wled_ip
+    state.save()
+    logger.info(f"WLED IP updated: {request.wled_ip}")
+    return {"success": True, "wled_ip": state.wled_ip}
+
+@app.get("/get_wled_ip")
+async def get_wled_ip():
+    if not state.wled_ip:
+        raise HTTPException(status_code=404, detail="No WLED IP set")
+    return {"success": True, "wled_ip": state.wled_ip}
+
+def on_exit():
+    """Function to execute on application shutdown."""
+    logger.info("Shutting down gracefully, please wait for execution to complete")
+    pattern_manager.stop_actions()
+    state.save()
+    mqtt.cleanup_mqtt()
+
+def signal_handler(signum, frame):
+    """Handle shutdown signals gracefully but forcefully."""
+    logger.info("Received shutdown signal, cleaning up...")
+    try:
+        # Set a short timeout for cleanup operations
+        import threading
+        cleanup_thread = threading.Thread(target=lambda: [
+            pattern_manager.stop_actions(),
+            state.save(),
+            mqtt.cleanup_mqtt()
+        ])
+        cleanup_thread.daemon = True  # Make thread daemonic so it won't block exit
+        cleanup_thread.start()
+        cleanup_thread.join(timeout=10.0)  # Wait up to 2 seconds for cleanup
+    except Exception as e:
+        logger.error(f"Error during cleanup: {str(e)}")
+    finally:
+        logger.info("Forcing exit...")
+        os._exit(0)  # Force exit regardless of other threads
+
+@app.on_event("startup")
+async def startup_event():
+    logger.info("Starting Dune Weaver application...")
+    # Register signal handlers
+    signal.signal(signal.SIGINT, signal_handler)
+    signal.signal(signal.SIGTERM, signal_handler)
+    
+    try:
+        connection_manager.connect_device()
+    except Exception as e:
+        logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
+        
+    try:
+        mqtt_handler = mqtt.init_mqtt()
+    except Exception as e:
+        logger.warning(f"Failed to initialize MQTT: {str(e)}")
+
+@app.on_event("shutdown")
+async def shutdown_event():
+    on_exit()
+
+def entrypoint():
+    import uvicorn
+    atexit.register(on_exit)
+    logger.info("Starting FastAPI server on port 8080...")
+    uvicorn.run(app, host="0.0.0.0", port=8080, workers=1)  # Set workers to 1 to avoid multiple signal handlers

+ 0 - 498
dune_weaver_flask/app.py

@@ -1,498 +0,0 @@
-from flask import Flask, request, jsonify, render_template, send_from_directory
-import atexit
-import os
-import logging
-from datetime import datetime
-from .modules.connection import connection_manager
-from dune_weaver_flask.modules.core import pattern_manager
-from dune_weaver_flask.modules.core import playlist_manager
-from .modules.update import update_manager
-from dune_weaver_flask.modules.core.state import state
-from dune_weaver_flask.modules import mqtt
-
-
-# Configure logging
-logging.basicConfig(
-    level=logging.INFO,
-    format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
-    handlers=[
-        logging.StreamHandler(),
-        # disable file logging for now, to not gobble up resources
-        # logging.FileHandler('dune_weaver.log')
-    ]
-)
-
-logger = logging.getLogger(__name__)
-
-app = Flask(__name__)
-
-# Flask API Endpoints
-@app.route('/')
-def index():
-    return render_template('index.html')
-
-@app.route('/list_serial_ports', methods=['GET'])
-def list_ports():
-    logger.debug("Listing available serial ports")
-    return jsonify(connection_manager.list_serial_ports())
-
-@app.route('/connect', methods=['POST'])
-def connect():
-    port = request.json.get('port')
-    if not port:
-        state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
-        connection_manager.device_init()
-        logger.info(f'Successfully connected to websocket ws://fluidnc.local:81')
-        return jsonify({'success': True})
-
-    try:
-        state.conn = connection_manager.SerialConnection(port)
-        connection_manager.device_init()
-        logger.info(f'Successfully connected to serial port {port}')
-        return jsonify({'success': True})
-    except Exception as e:
-        logger.error(f'Failed to connect to serial port {port}: {str(e)}')
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/disconnect', methods=['POST'])
-def disconnect():
-    try:
-        state.conn.close()
-        logger.info('Successfully disconnected from serial port')
-        return jsonify({'success': True})
-    except Exception as e:
-        logger.error(f'Failed to disconnect serial: {str(e)}')
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/restart_connection', methods=['POST'])
-def restart():
-    port = request.json.get('port')
-    if not port:
-        logger.warning("Restart serial request received without port")
-        return jsonify({'error': 'No port provided'}), 400
-
-    try:
-        logger.info(f"Restarting connection on port {port}")
-        connection_manager.restart_connection()
-        return jsonify({'success': True})
-    except Exception as e:
-        logger.error(f"Failed to restart serial on port {port}: {str(e)}")
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/list_theta_rho_files', methods=['GET'])
-def list_theta_rho_files():
-    logger.debug("Listing theta-rho files")
-    files = pattern_manager.list_theta_rho_files()
-    return jsonify(sorted(files))
-
-@app.route('/upload_theta_rho', methods=['POST'])
-def upload_theta_rho():
-    custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, 'custom_patterns')
-    os.makedirs(custom_patterns_dir, exist_ok=True)
-    logger.debug(f'Ensuring custom patterns directory exists: {custom_patterns_dir}')
-
-    file = request.files['file']
-    if file:
-        file_path = os.path.join(custom_patterns_dir, file.filename)
-        file.save(file_path)
-        logger.info(f'Successfully uploaded theta-rho file: {file.filename}')
-        return jsonify({'success': True})
-    
-    logger.warning('Upload theta-rho request received without file')
-    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')
-
-    if not file_name:
-        logger.warning('Run theta-rho request received without file name')
-        return jsonify({'error': 'No file name provided'}), 400
-
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
-    if not os.path.exists(file_path):
-        logger.error(f'Theta-rho file not found: {file_path}')
-        return jsonify({'error': 'File not found'}), 404
-
-    try:
-            
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to run a pattern without a connection")
-            return jsonify({"success": False, "error": "Connection not established"}), 400
-        files_to_run = [file_path]
-        logger.info(f'Running theta-rho file: {file_name} with pre_execution={pre_execution}')
-        pattern_manager.run_theta_rho_files(files_to_run, clear_pattern=pre_execution)
-        return jsonify({'success': True})
-    except Exception as e:
-        logger.error(f'Failed to run theta-rho file {file_name}: {str(e)}')
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/stop_execution', methods=['POST'])
-def stop_execution():
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to stop without a connection")
-        return jsonify({"success": False, "error": "Connection not established"}), 400
-    pattern_manager.stop_actions()
-    return jsonify({'success': True})
-
-@app.route('/send_home', methods=['POST'])
-def send_home():
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to home without a connection")
-            return jsonify({"success": False, "error": "Connection not established"}), 400
-        connection_manager.home()
-        return jsonify({'success': True})
-    except Exception as e:
-        logger.error(f"Failed to send home command: {str(e)}")
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
-def run_specific_theta_rho_file(file_name):
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
-    if not os.path.exists(file_path):
-        return jsonify({'error': 'File not found'}), 404
-        
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to run a pattern without a connection")
-        return jsonify({"success": False, "error": "Connection not established"}), 400
-
-    pattern_manager.run_theta_rho_file(file_path)
-    return jsonify({'success': True})
-
-@app.route('/delete_theta_rho_file', methods=['POST'])
-def delete_theta_rho_file():
-    file_name = request.json.get('file_name')
-    if not file_name:
-        logger.warning("Delete theta-rho file request received without filename")
-        return jsonify({"success": False, "error": "No file name provided"}), 400
-
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
-    if not os.path.exists(file_path):
-        logger.error(f"Attempted to delete non-existent file: {file_path}")
-        return jsonify({"success": False, "error": "File not found"}), 404
-
-    try:
-        os.remove(file_path)
-        logger.info(f"Successfully deleted theta-rho file: {file_name}")
-        return jsonify({"success": True})
-    except Exception as e:
-        logger.error(f"Failed to delete theta-rho file {file_name}: {str(e)}")
-        return jsonify({"success": False, "error": str(e)}), 500
-
-@app.route('/move_to_center', methods=['POST'])
-def move_to_center():
-    global current_theta
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to center without a connection")
-            return jsonify({"success": False, "error": "Connection not established"}), 400
-
-        logger.info("Moving device to center position")
-        pattern_manager.reset_theta()
-        pattern_manager.move_polar(0, 0)
-        return jsonify({"success": True})
-    except Exception as e:
-        logger.error(f"Failed to move to center: {str(e)}")
-        return jsonify({"success": False, "error": str(e)}), 500
-
-@app.route('/move_to_perimeter', methods=['POST'])
-def move_to_perimeter():
-    global current_theta
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to move to perimeter without a connection")
-            return jsonify({"success": False, "error": "Connection not established"}), 400
-        pattern_manager.reset_theta()
-        pattern_manager.move_polar(0,1)
-        return jsonify({"success": True})
-    except Exception as e:
-        logger.error(f"Failed to move to perimeter: {str(e)}")
-        return jsonify({"success": False, "error": str(e)}), 500
-
-@app.route('/preview_thr', methods=['POST'])
-def preview_thr():
-    file_name = request.json.get('file_name')
-    if not file_name:
-        logger.warning("Preview theta-rho request received without filename")
-        return jsonify({'error': 'No file name provided'}), 400
-
-    file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
-    if not os.path.exists(file_path):
-        logger.error(f"Attempted to preview non-existent file: {file_path}")
-        return jsonify({'error': 'File not found'}), 404
-
-    try:
-        coordinates = pattern_manager.parse_theta_rho_file(file_path)
-        return jsonify({'success': True, 'coordinates': coordinates})
-    except Exception as e:
-        logger.error(f"Failed to generate preview for {file_name}: {str(e)}")
-        return jsonify({'error': str(e)}), 500
-
-@app.route('/send_coordinate', methods=['POST'])
-def send_coordinate():
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to send coordinate without a connection")
-        return jsonify({"success": False, "error": "connection not established"}), 400
-
-    try:
-        data = request.json
-        theta = data.get('theta')
-        rho = data.get('rho')
-
-        if theta is None or rho is None:
-            logger.warning("Send coordinate request missing theta or rho values")
-            return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
-
-        logger.debug(f"Sending coordinate: theta={theta}, rho={rho}")
-        pattern_manager.move_polar(theta, rho)
-        return jsonify({"success": True})
-    except Exception as e:
-        logger.error(f"Failed to send coordinate: {str(e)}")
-        return jsonify({"success": False, "error": str(e)}), 500
-
-@app.route('/download/<filename>', methods=['GET'])
-def download_file(filename):
-    return send_from_directory(pattern_manager.THETA_RHO_DIR, filename)
-
-@app.route('/serial_status', methods=['GET'])
-def serial_status():
-    connected = state.conn.is_connected() if state.conn else False
-    port = state.port
-    logger.debug(f"Serial status check - connected: {connected}, port: {port}")
-    return jsonify({
-        'connected': connected,
-        'port': port
-    })
-
-@app.route('/pause_execution', methods=['POST'])
-def pause_execution():
-    if pattern_manager.pause_execution():
-        return jsonify({'success': True, 'message': 'Execution paused'})
-
-@app.route('/status', methods=['GET'])
-def get_status():
-    return jsonify(pattern_manager.get_status())
-
-@app.route('/resume_execution', methods=['POST'])
-def resume_execution():
-    if pattern_manager.resume_execution():
-        return jsonify({'success': True, 'message': 'Execution resumed'})
-
-# Playlist endpoints
-@app.route("/list_all_playlists", methods=["GET"])
-def list_all_playlists():
-    playlist_names = playlist_manager.list_all_playlists()
-    return jsonify(playlist_names)
-
-@app.route("/get_playlist", methods=["GET"])
-def get_playlist():
-    playlist_name = request.args.get("name", "")
-    if not playlist_name:
-        return jsonify({"error": "Missing playlist 'name' parameter"}), 400
-
-    playlist = playlist_manager.get_playlist(playlist_name)
-    if not playlist:
-        return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
-
-    return jsonify(playlist)
-
-@app.route("/create_playlist", methods=["POST"])
-def create_playlist():
-    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
-
-    success = playlist_manager.create_playlist(data["name"], data["files"])
-    return jsonify({
-        "success": success,
-        "message": f"Playlist '{data['name']}' created/updated"
-    })
-
-@app.route("/modify_playlist", methods=["POST"])
-def modify_playlist():
-    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
-
-    success = playlist_manager.modify_playlist(data["name"], data["files"])
-    return jsonify({"success": success, "message": f"Playlist '{data['name']}' updated"})
-
-@app.route("/delete_playlist", methods=["DELETE"])
-def delete_playlist():
-    data = request.get_json()
-    if not data or "name" not in data:
-        return jsonify({"success": False, "error": "Missing 'name' field"}), 400
-
-    success = playlist_manager.delete_playlist(data["name"])
-    if not success:
-        return jsonify({"success": False, "error": f"Playlist '{data['name']}' not found"}), 404
-
-    return jsonify({
-        "success": True,
-        "message": f"Playlist '{data['name']}' deleted"
-    })
-
-@app.route('/add_to_playlist', methods=['POST'])
-def add_to_playlist():
-    data = request.json
-    playlist_name = data.get('playlist_name')
-    pattern = data.get('pattern')
-
-    success = playlist_manager.add_to_playlist(playlist_name, pattern)
-    if not success:
-        return jsonify(success=False, error='Playlist not found'), 404
-    return jsonify(success=True)
-
-@app.route("/run_playlist", methods=["POST"])
-def run_playlist():
-    data = request.get_json()
-    if not data or "playlist_name" not in data:
-        logger.warning("Run playlist request received without playlist name")
-        return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
-
-        
-    if not (state.conn.is_connected() if state.conn else False):
-        logger.warning("Attempted to run a playlist without a connection")
-        return jsonify({"success": False, "error": "Connection not established"}), 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")
-    shuffle = data.get("shuffle", False)
-    
-    schedule_hours = None
-    start_time = data.get("start_time")
-    end_time = data.get("end_time")
-    
-    if start_time and end_time:
-        try:
-            start_time_obj = datetime.strptime(start_time, "%H:%M").time()
-            end_time_obj = datetime.strptime(end_time, "%H:%M").time()
-            if start_time_obj >= end_time_obj:
-                logger.error(f"Invalid schedule times: start_time {start_time} >= end_time {end_time}")
-                return jsonify({"success": False, "error": "'start_time' must be earlier than 'end_time'"}), 400
-            schedule_hours = (start_time_obj, end_time_obj)
-            logger.info(f"Playlist {playlist_name} scheduled to run between {start_time} and {end_time}")
-        except ValueError:
-            logger.error(f"Invalid time format provided: start_time={start_time}, end_time={end_time}")
-            return jsonify({"success": False, "error": "Invalid time format. Use HH:MM (e.g., '09:30')"}), 400
-
-    logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
-    success, message = playlist_manager.run_playlist(
-        playlist_name,
-        pause_time=pause_time,
-        clear_pattern=clear_pattern,
-        run_mode=run_mode,
-        shuffle=shuffle,
-        schedule_hours=schedule_hours
-    )
-
-    if not success:
-        logger.error(f"Failed to run playlist '{playlist_name}': {message}")
-        return jsonify({"success": False, "error": message}), 500
-    
-    return jsonify({"success": True, "message": message})
-
-# Firmware endpoints
-@app.route('/set_speed', methods=['POST'])
-def set_speed():
-    try:
-        if not (state.conn.is_connected() if state.conn else False):
-            logger.warning("Attempted to change speed without a connection")
-            return jsonify({"success": False, "error": "Connection not established"}), 400
-        
-        data = request.json
-        new_speed = data.get('speed')
-
-        if new_speed is None:
-            logger.warning("Set speed request received without speed value")
-            return jsonify({"success": False, "error": "Speed is required"}), 400
-
-        if not isinstance(new_speed, (int, float)) or new_speed <= 0:
-            logger.warning(f"Invalid speed value received: {new_speed}")
-            return jsonify({"success": False, "error": "Invalid speed value"}), 400
-        state.speed = new_speed
-        return jsonify({"success": True, "speed": new_speed})
-    except Exception as e:
-        logger.error(f"Failed to set speed: {str(e)}")
-        return jsonify({"success": False, "error": str(e)}), 500
-
-
-@app.route('/check_software_update', methods=['GET'])
-def check_updates():
-    update_info = update_manager.check_git_updates()
-    return jsonify(update_info)
-
-@app.route('/update_software', methods=['POST'])
-def update_software():
-    logger.info("Starting software update process")
-    success, error_message, error_log = update_manager.update_software()
-    
-    if success:
-        logger.info("Software update completed successfully")
-        return jsonify({"success": True})
-    else:
-        logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
-        return jsonify({
-            "success": False,
-            "error": error_message,
-            "details": error_log
-        }), 500
-        
-@app.route('/set_wled_ip', methods=['POST'])
-def set_wled_ip():
-    """Save the WLED IP address to state"""
-    data = request.json
-    wled_ip = data.get("wled_ip")
-    
-    # Save to state
-    state.wled_ip = wled_ip
-    state.save()
-    logger.info(f"WLED IP updated: {wled_ip}")
-
-    return jsonify({"success": True, "wled_ip": state.wled_ip})
-
-
-@app.route('/get_wled_ip', methods=['GET'])
-def get_wled_ip():
-    """Retrieve the saved WLED IP address"""
-    if not state.wled_ip:
-        return jsonify({"success": False, "error": "No WLED IP set"}), 404
-
-    return jsonify({"success": True, "wled_ip": state.wled_ip})
-
-def on_exit():
-    """Function to execute on application shutdown."""
-    logger.info("Shutting down gracefully, please wait for execution to complete")
-    pattern_manager.stop_actions()
-    state.save()
-    mqtt.cleanup_mqtt()
-
-def entrypoint():
-    logger.info("Starting Dune Weaver application...")
-    
-    # Register the on_exit function
-    atexit.register(on_exit)
-    # Auto-connect to serial
-    try:
-        connection_manager.connect_device()
-    except Exception as e:
-        logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
-        
-    try:
-        mqtt_handler = mqtt.init_mqtt()
-    except Exception as e:
-        logger.warning(f"Failed to initialize MQTT: {str(e)}")
-
-    try:
-        logger.info("Starting Flask server on port 8080...")
-        app.run(debug=False, host='0.0.0.0', port=8080)
-    except KeyboardInterrupt:
-        logger.info("Keyboard interrupt received. Shutting down.")
-    except Exception as e:
-        logger.critical(f"Unexpected error during server startup: {str(e)}")
-    finally:
-        on_exit()

+ 0 - 363
dune_weaver_flask/modules/core/pattern_manager.py

@@ -1,363 +0,0 @@
-import os
-import threading
-import time
-import random
-import logging
-from datetime import datetime
-from tqdm import tqdm
-from dune_weaver_flask.modules.connection import connection_manager
-from dune_weaver_flask.modules.core.state import state
-from math import pi
-
-# Configure logging
-logger = logging.getLogger(__name__)
-
-# Global state
-THETA_RHO_DIR = './patterns'
-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)
-
-def list_theta_rho_files():
-    files = []
-    for root, _, filenames in os.walk(THETA_RHO_DIR):
-        for file in filenames:
-            relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
-            files.append(relative_path)
-    logger.debug(f"Found {len(files)} theta-rho files")
-    return files
-
-def parse_theta_rho_file(file_path):
-    """Parse a theta-rho file and return a list of (theta, rho) pairs."""
-    coordinates = []
-    try:
-        logger.debug(f"Parsing theta-rho file: {file_path}")
-        with open(file_path, 'r') as file:
-            for line in file:
-                line = line.strip()
-                if not line or line.startswith("#"):
-                    continue
-                try:
-                    theta, rho = map(float, line.split())
-                    coordinates.append((theta, rho))
-                except ValueError:
-                    logger.warning(f"Skipping invalid line: {line}")
-                    continue
-    except Exception as e:
-        logger.error(f"Error reading file: {e}")
-        return coordinates
-
-    # Normalization Step
-    if coordinates:
-        first_theta = coordinates[0][0]
-        normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
-        coordinates = normalized
-        logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
-
-    return coordinates
-
-def get_clear_pattern_file(clear_pattern_mode, path=None):
-    """Return a .thr file path based on pattern_name."""
-    if not clear_pattern_mode or clear_pattern_mode == 'none':
-        return
-    logger.info("Clear pattern mode: " + clear_pattern_mode)
-    if clear_pattern_mode == "random":
-        return random.choice(list(CLEAR_PATTERNS.values()))
-
-    if clear_pattern_mode == 'adaptive':
-        _, first_rho = parse_theta_rho_file(path)[0]
-        if first_rho < 0.5:
-            return CLEAR_PATTERNS['clear_from_out']
-        else:
-            return random.choice([CLEAR_PATTERNS['clear_from_in'], CLEAR_PATTERNS['clear_sideway']])
-    else:
-        return CLEAR_PATTERNS[clear_pattern_mode]
-
-def schedule_checker(schedule_hours):
-    """Pauses/resumes execution based on a given time range."""
-    if not schedule_hours:
-        return
-
-    start_time, end_time = schedule_hours
-    now = datetime.now().time()
-
-    if start_time <= now < end_time:
-        if state.pause_requested:
-            logger.info("Starting execution: Within schedule")
-            connection_manager.update_machine_position()
-        state.pause_requested = False
-        with state.pause_condition:
-            state.pause_condition.notify_all()
-    else:
-        if not state.pause_requested:
-            logger.info("Pausing execution: Outside schedule")
-        state.pause_requested = True
-        connection_manager.update_machine_position()
-        threading.Thread(target=wait_for_start_time, args=(schedule_hours,), daemon=True).start()
-
-def wait_for_start_time(schedule_hours):
-    """Keep checking every 30 seconds if the time is within the schedule to resume execution."""
-    start_time, end_time = schedule_hours
-
-    while state.pause_requested:
-        now = datetime.now().time()
-        if start_time <= now < end_time:
-            logger.info("Resuming execution: Within schedule")
-            state.pause_requested = False
-            with state.pause_condition:
-                state.pause_condition.notify_all()
-            break
-        else:
-            time.sleep(30)
-            
-            
-def move_polar(theta, rho):
-    """
-    This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
-    and translate the motion to gcode jog command and sent to grbl. 
-    
-    Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the 
-    x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
-    
-    X axis (angular): 50mm = 1 revolution
-    Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
-    
-    Args:
-        theta (_type_): _description_
-        rho (_type_): _description_
-    """
-    # Adding soft limit to reduce hardware sound
-    soft_limit_inner = 0.01
-    if rho < soft_limit_inner:
-        rho = soft_limit_inner
-    
-    soft_limit_outter = 0.015
-    if rho > (1-soft_limit_outter):
-        rho = (1-soft_limit_outter)
-    
-    if state.gear_ratio == 6.25:
-        x_scaling_factor = 2
-        y_scaling_factor = 3.7
-    else:
-        x_scaling_factor = 2
-        y_scaling_factor = 5
-    
-    delta_theta = theta - state.current_theta
-    delta_rho = rho - state.current_rho
-    x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor)  # Added -1 to reverse direction
-    y_increment = delta_rho * 100 / y_scaling_factor
-    
-    x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
-    y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
-        
-    offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
-
-    if state.gear_ratio == 6.25:
-        y_increment -= offset
-    else:
-        y_increment += offset
-    
-    new_x_abs = state.machine_x + x_increment
-    new_y_abs = state.machine_y + y_increment
-    
-    # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
-    
-    connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
-    state.current_theta = theta
-    state.current_rho = rho
-    state.machine_x = new_x_abs
-    state.machine_y = new_y_abs
-    
-def pause_execution():
-    logger.info("Pausing pattern execution")
-    with state.pause_condition:
-        state.pause_requested = True
-    return True
-
-def resume_execution():
-    logger.info("Resuming pattern execution")
-    with state.pause_condition:
-        state.pause_requested = False
-        state.pause_condition.notify_all()
-    return True
-    
-def reset_theta():
-    logger.info('Resetting Theta')
-    state.current_theta = 0
-    connection_manager.update_machine_position()
-
-def set_speed(new_speed):
-    state.speed = new_speed
-    logger.info(f'Set new state.speed {new_speed}')
-
-def run_theta_rho_file(file_path, schedule_hours=None):
-    """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
-    
-    # Check if connection is still valid, if not, restart
-    # if not connection_manager.get_status_response() and isinstance(state.conn, connection_manager.WebSocketConnection):
-    #     logger.info('Cannot get status response, restarting connection')
-    #     connection_manager.restart_connection(home=False)
-    # if (state.conn.is_connected() if state.conn else False):
-    #     logger.error('Connection not established')
-    #     return
-    # if not file_path:
-    #     return
-    
-    coordinates = parse_theta_rho_file(file_path)
-    total_coordinates = len(coordinates)
-
-    if total_coordinates < 2:
-        logger.warning("Not enough coordinates for interpolation")
-        state.current_playing_file = None
-        state.execution_progress = None
-        return
-
-    state.execution_progress = (0, total_coordinates, None)
-    
-    # stop actions without resetting the playlist
-    stop_actions(clear_playlist=False)
-
-    state.current_playing_file = file_path
-    state.execution_progress = (0, 0, None)
-    state.stop_requested = False
-    logger.info(f"Starting pattern execution: {file_path}")
-    logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
-    reset_theta()
-    with tqdm(
-        total=total_coordinates,
-        unit="coords",
-        desc=f"Executing Pattern {file_path}",
-        dynamic_ncols=True,
-        disable=False,  # Force enable the progress bar
-        mininterval=1.0  # Optional: reduce update frequency to prevent flooding
-    ) as pbar:
-        for i, coordinate in enumerate(coordinates):
-            theta, rho = coordinate
-            if state.stop_requested:
-                logger.info("Execution stopped by user after completing the current batch")
-                break
-
-            with state.pause_condition:
-                while state.pause_requested:
-                    logger.info("Execution paused...")
-                    state.pause_condition.wait()
-
-            schedule_checker(schedule_hours)
-            move_polar(theta, rho)
-            
-            if i != 0:
-                pbar.update(1)
-                estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
-                elapsed_time = pbar.format_dict['elapsed']
-                state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
-
-    connection_manager.check_idle()
-
-    state.current_playing_file = None
-    state.execution_progress = None
-    logger.info("Pattern execution completed")
-
-def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, schedule_hours=None):
-    """Run multiple .thr files in sequence with options."""
-    state.stop_requested = False
-    
-    # Set initial playlist state
-    state.playlist_mode = run_mode
-    state.current_playlist_index = 0
-    
-    if shuffle:
-        random.shuffle(file_paths)
-        logger.info("Playlist shuffled")
-
-    while True:
-        for idx, path in enumerate(file_paths):
-            logger.info(f"Upcoming pattern: {path}")
-            state.current_playlist_index = idx
-            schedule_checker(schedule_hours)
-            if state.stop_requested:
-                logger.info("Execution stopped before starting next pattern")
-                state.current_playlist = None
-                state.current_playlist_index = None
-                state.playlist_mode = None
-                return
-
-            if clear_pattern:
-                if state.stop_requested:
-                    logger.info("Execution stopped before running the next clear pattern")
-                    state.current_playlist = None
-                    state.current_playlist_index = None
-                    state.playlist_mode = None
-                    return
-
-                clear_file_path = get_clear_pattern_file(clear_pattern, path)
-                logger.info(f"Running clear pattern: {clear_file_path}")
-                run_theta_rho_file(clear_file_path, schedule_hours)
-
-            if not state.stop_requested:
-                
-                logger.info(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
-                run_theta_rho_file(path, schedule_hours)
-
-            if idx < len(file_paths) - 1:
-                if state.stop_requested:
-                    logger.info("Execution stopped before running the next clear pattern")
-                    return
-                if pause_time > 0:
-                    logger.info(f"Pausing for {pause_time} seconds")
-                    time.sleep(pause_time)
-
-        if run_mode == "indefinite":
-            logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
-            if pause_time > 0:
-                logger.debug(f"Pausing for {pause_time} seconds before restarting")
-                time.sleep(pause_time)
-            if shuffle:
-                random.shuffle(file_paths)
-                logger.info("Playlist reshuffled for the next loop")
-            continue
-        else:
-            logger.info("Playlist completed")
-            state.current_playlist = None
-            state.current_playlist_index = None
-            state.playlist_mode = None
-            break
-    logger.info("All requested patterns completed (or stopped)")
-
-def stop_actions(clear_playlist = True):
-    """Stop all current actions."""
-    with state.pause_condition:
-        state.pause_requested = False
-        state.stop_requested = True
-        state.current_playing_file = None
-        state.execution_progress = None
-        state.is_clearing = False
-        if clear_playlist:
-            # Clear playlist state
-            state.current_playlist = None
-            state.current_playlist_index = None
-            state.playlist_mode = None
-        state.pause_condition.notify_all()
-        connection_manager.update_machine_position()
-
-def get_status():
-    """Get the current execution status."""
-    # Update state.is_clearing based on current file
-    if state.current_playing_file in CLEAR_PATTERNS.values():
-        state.is_clearing = True
-    else:
-        state.is_clearing = False
-
-    return {
-        "ser_port": state.port,
-        "stop_requested": state.stop_requested,
-        "pause_requested": state.pause_requested,
-        "current_playing_file": state.current_playing_file,
-        "execution_progress": state.execution_progress,
-        "current_playing_index": state.current_playlist_index,
-        "current_playlist": state.current_playlist,
-        "is_clearing": state.is_clearing,
-        "current_speed": state.speed
-    }

+ 0 - 0
dune_weaver_flask/modules/__init__.py → modules/__init__.py


+ 0 - 0
dune_weaver_flask/modules/connection/__init__.py → modules/connection/__init__.py


+ 2 - 2
dune_weaver_flask/modules/connection/connection_manager.py → modules/connection/connection_manager.py

@@ -5,8 +5,8 @@ import serial
 import serial.tools.list_ports
 import websocket
 
-from dune_weaver_flask.modules.core.state import state
-from dune_weaver_flask.modules.core.pattern_manager import move_polar, reset_theta
+from modules.core.state import state
+from modules.core.pattern_manager import move_polar, reset_theta
 
 logger = logging.getLogger(__name__)
 

+ 0 - 0
dune_weaver_flask/modules/core/__init__.py → modules/core/__init__.py


+ 381 - 0
modules/core/pattern_manager.py

@@ -0,0 +1,381 @@
+import os
+import threading
+import time
+import random
+import logging
+from datetime import datetime
+from tqdm import tqdm
+from modules.connection import connection_manager
+from modules.core.state import state
+from math import pi
+import asyncio
+import json
+
+# Configure logging
+logger = logging.getLogger(__name__)
+
+# Global state
+THETA_RHO_DIR = './patterns'
+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)
+
+# Create an asyncio Event for pause/resume
+pause_event = asyncio.Event()
+pause_event.set()  # Initially not paused
+
+# Create an asyncio Lock for pattern execution
+pattern_lock = asyncio.Lock()
+
+# Progress update task
+progress_update_task = None
+
+def list_theta_rho_files():
+    files = []
+    for root, _, filenames in os.walk(THETA_RHO_DIR):
+        for file in filenames:
+            relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
+            files.append(relative_path)
+    logger.debug(f"Found {len(files)} theta-rho files")
+    return files
+
+def parse_theta_rho_file(file_path):
+    """Parse a theta-rho file and return a list of (theta, rho) pairs."""
+    coordinates = []
+    try:
+        logger.debug(f"Parsing theta-rho file: {file_path}")
+        with open(file_path, 'r') as file:
+            for line in file:
+                line = line.strip()
+                if not line or line.startswith("#"):
+                    continue
+                try:
+                    theta, rho = map(float, line.split())
+                    coordinates.append((theta, rho))
+                except ValueError:
+                    logger.warning(f"Skipping invalid line: {line}")
+                    continue
+    except Exception as e:
+        logger.error(f"Error reading file: {e}")
+        return coordinates
+
+    # Normalization Step
+    if coordinates:
+        first_theta = coordinates[0][0]
+        normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
+        coordinates = normalized
+        logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
+
+    return coordinates
+
+def get_clear_pattern_file(clear_pattern_mode, path=None):
+    """Return a .thr file path based on pattern_name."""
+    if not clear_pattern_mode or clear_pattern_mode == 'none':
+        return
+    logger.info("Clear pattern mode: " + clear_pattern_mode)
+    if clear_pattern_mode == "random":
+        return random.choice(list(CLEAR_PATTERNS.values()))
+
+    if clear_pattern_mode == 'adaptive':
+        if not path:
+            logger.warning("No path provided for adaptive clear pattern")
+            return random.choice(list(CLEAR_PATTERNS.values()))
+            
+        coordinates = parse_theta_rho_file(path)
+        if not coordinates:
+            logger.warning("No valid coordinates found in file for adaptive clear pattern")
+            return random.choice(list(CLEAR_PATTERNS.values()))
+            
+        first_rho = coordinates[0][1]
+        if first_rho < 0.5:
+            return CLEAR_PATTERNS['clear_from_out']
+        else:
+            return random.choice([CLEAR_PATTERNS['clear_from_in'], CLEAR_PATTERNS['clear_sideway']])
+    else:
+        if clear_pattern_mode not in CLEAR_PATTERNS:
+            logger.warning(f"Invalid clear pattern mode: {clear_pattern_mode}")
+            return random.choice(list(CLEAR_PATTERNS.values()))
+        return CLEAR_PATTERNS[clear_pattern_mode]
+
+async def run_theta_rho_file(file_path):
+    """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
+    if pattern_lock.locked():
+        logger.warning("Another pattern is already running. Cannot start a new one.")
+        return
+
+    async with pattern_lock:  # This ensures only one pattern can run at a time
+        # Start progress update task
+        global progress_update_task
+        progress_update_task = asyncio.create_task(broadcast_progress())
+        
+        coordinates = parse_theta_rho_file(file_path)
+        total_coordinates = len(coordinates)
+
+        if total_coordinates < 2:
+            logger.warning("Not enough coordinates for interpolation")
+            state.current_playing_file = None
+            state.execution_progress = None
+            return
+
+        state.execution_progress = (0, total_coordinates, None, 0)
+        
+        # stop actions without resetting the playlist
+        stop_actions(clear_playlist=False)
+
+        state.current_playing_file = file_path
+        state.stop_requested = False
+        logger.info(f"Starting pattern execution: {file_path}")
+        logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
+        reset_theta()
+        
+        start_time = time.time()
+        
+        with tqdm(
+            total=total_coordinates,
+            unit="coords",
+            desc=f"Executing Pattern {file_path}",
+            dynamic_ncols=True,
+            disable=False,
+            mininterval=1.0
+        ) as pbar:
+            for i, coordinate in enumerate(coordinates):
+                theta, rho = coordinate
+                if state.stop_requested:
+                    logger.info("Execution stopped by user")
+                    break
+
+                # Wait for resume if paused
+                if state.pause_requested:
+                    logger.info("Execution paused...")
+                    await pause_event.wait()
+                    logger.info("Execution resumed...")
+
+                move_polar(theta, rho)
+                
+                if i != 0:
+                    pbar.update(1)
+                    elapsed_time = time.time() - start_time
+                    estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
+                    state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
+                
+                # Add a small delay to allow other async operations
+                await asyncio.sleep(0.001)
+
+        connection_manager.check_idle()
+        state.current_playing_file = None
+        state.execution_progress = None
+        logger.info("Pattern execution completed")
+        
+        # Cancel progress update task
+        if progress_update_task:
+            progress_update_task.cancel()
+            try:
+                await progress_update_task
+            except asyncio.CancelledError:
+                pass
+
+async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
+    """Run multiple .thr files in sequence with options."""
+    state.stop_requested = False
+    
+    # Set initial playlist state
+    state.playlist_mode = run_mode
+    state.current_playlist_index = 0
+    
+    if shuffle:
+        random.shuffle(file_paths)
+        logger.info("Playlist shuffled")
+
+    try:
+        while True:
+            for idx, path in enumerate(file_paths):
+                logger.info(f"Upcoming pattern: {path}")
+                state.current_playlist_index = idx
+
+                if state.stop_requested:
+                    logger.info("Execution stopped before starting next pattern")
+                    return
+
+                if clear_pattern:
+                    if state.stop_requested:
+                        logger.info("Execution stopped before running the next clear pattern")
+                        return
+
+                    clear_file_path = get_clear_pattern_file(clear_pattern, path)
+                    logger.info(f"Running clear pattern: {clear_file_path}")
+                    await run_theta_rho_file(clear_file_path)
+
+                if not state.stop_requested:
+                    logger.info(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
+                    await run_theta_rho_file(path)
+
+                if idx < len(file_paths) - 1:
+                    if state.stop_requested:
+                        logger.info("Execution stopped before running the next clear pattern")
+                        return
+                    if pause_time > 0:
+                        logger.info(f"Pausing for {pause_time} seconds")
+                        await asyncio.sleep(pause_time)
+
+            if run_mode == "indefinite":
+                logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
+                if pause_time > 0:
+                    logger.debug(f"Pausing for {pause_time} seconds before restarting")
+                    await asyncio.sleep(pause_time)
+                if shuffle:
+                    random.shuffle(file_paths)
+                    logger.info("Playlist reshuffled for the next loop")
+                continue
+            else:
+                logger.info("Playlist completed")
+                break
+    finally:
+        state.current_playlist = None
+        state.current_playlist_index = None
+        state.playlist_mode = None
+        logger.info("All requested patterns completed (or stopped)")
+
+def stop_actions(clear_playlist = True):
+    """Stop all current actions."""
+    with state.pause_condition:
+        state.pause_requested = False
+        state.stop_requested = True
+        state.current_playing_file = None
+        state.execution_progress = None
+        state.is_clearing = False
+        
+        if clear_playlist:
+            # Clear playlist state
+            state.current_playlist = None
+            state.current_playlist_index = None
+            state.playlist_mode = None
+            
+        state.pause_condition.notify_all()
+        connection_manager.update_machine_position()
+
+def move_polar(theta, rho):
+    """
+    This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
+    and translate the motion to gcode jog command and sent to grbl. 
+    
+    Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the 
+    x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
+    
+    X axis (angular): 50mm = 1 revolution
+    Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
+    
+    Args:
+        theta (_type_): _description_
+        rho (_type_): _description_
+    """
+    # Adding soft limit to reduce hardware sound
+    soft_limit_inner = 0.01
+    if rho < soft_limit_inner:
+        rho = soft_limit_inner
+    
+    soft_limit_outter = 0.015
+    if rho > (1-soft_limit_outter):
+        rho = (1-soft_limit_outter)
+    
+    if state.gear_ratio == 6.25:
+        x_scaling_factor = 2
+        y_scaling_factor = 3.7
+    else:
+        x_scaling_factor = 2
+        y_scaling_factor = 5
+    
+    delta_theta = theta - state.current_theta
+    delta_rho = rho - state.current_rho
+    x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor)  # Added -1 to reverse direction
+    y_increment = delta_rho * 100 / y_scaling_factor
+    
+    x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
+    y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
+        
+    offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
+
+    if state.gear_ratio == 6.25:
+        y_increment -= offset
+    else:
+        y_increment += offset
+    
+    new_x_abs = state.machine_x + x_increment
+    new_y_abs = state.machine_y + y_increment
+    
+    # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
+    
+    connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
+    state.current_theta = theta
+    state.current_rho = rho
+    state.machine_x = new_x_abs
+    state.machine_y = new_y_abs
+    
+def pause_execution():
+    """Pause pattern execution using asyncio Event."""
+    logger.info("Pausing pattern execution")
+    state.pause_requested = True
+    pause_event.clear()  # Clear the event to pause execution
+    return True
+
+def resume_execution():
+    """Resume pattern execution using asyncio Event."""
+    logger.info("Resuming pattern execution")
+    state.pause_requested = False
+    pause_event.set()  # Set the event to resume execution
+    return True
+    
+def reset_theta():
+    logger.info('Resetting Theta')
+    state.current_theta = 0
+    connection_manager.update_machine_position()
+
+def set_speed(new_speed):
+    state.speed = new_speed
+    logger.info(f'Set new state.speed {new_speed}')
+
+def get_status():
+    """Get the current status of pattern execution."""
+    status = {
+        "current_file": state.current_playing_file,
+        "is_paused": state.pause_requested,
+        "is_running": bool(state.current_playing_file and not state.stop_requested),
+        "progress": None
+    }
+    
+    if state.execution_progress:
+        current, total, remaining_time, elapsed_time = state.execution_progress
+        status["progress"] = {
+            "current": current,
+            "total": total,
+            "remaining_time": remaining_time,
+            "elapsed_time": elapsed_time,
+            "percentage": (current / total * 100) if total > 0 else 0
+        }
+    
+    return status
+
+async def broadcast_progress():
+    """Background task to broadcast progress updates."""
+    from app import active_status_connections
+    while True:
+        if not pattern_lock.locked():
+            # No pattern running, stop the task
+            break
+            
+        status = get_status()
+        disconnected = set()
+        
+        for websocket in active_status_connections:
+            try:
+                await websocket.send_json(status)
+            except Exception:
+                disconnected.add(websocket)
+        
+        # Clean up disconnected clients
+        active_status_connections.difference_update(disconnected)
+        
+        # Wait before next update
+        await asyncio.sleep(0.5)

+ 18 - 15
dune_weaver_flask/modules/core/playlist_manager.py → modules/core/playlist_manager.py

@@ -2,8 +2,9 @@ import json
 import os
 import threading
 import logging
-from dune_weaver_flask.modules.core import pattern_manager
-from dune_weaver_flask.modules.core.state import state
+import asyncio
+from modules.core import pattern_manager
+from modules.core.state import state
 
 # Configure logging
 logger = logging.getLogger(__name__)
@@ -84,8 +85,12 @@ def add_to_playlist(playlist_name, pattern):
     logger.info(f"Added pattern '{pattern}' to playlist '{playlist_name}'")
     return True
 
-def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, schedule_hours=None):
+async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, schedule_hours=None):
     """Run a playlist with the given options."""
+    if pattern_manager.pattern_lock.locked():
+        logger.warning("Cannot start playlist: Another pattern is already running")
+        return False, "Cannot start playlist: Another pattern is already running"
+
     playlists = load_playlists()
     if playlist_name not in playlists:
         logger.error(f"Cannot run non-existent playlist: {playlist_name}")
@@ -101,18 +106,16 @@ def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="sing
     try:
         logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
         state.current_playlist = playlist_name
-        threading.Thread(
-            target=pattern_manager.run_theta_rho_files,
-            args=(file_paths,),
-            kwargs={
-                'pause_time': pause_time,
-                'clear_pattern': clear_pattern,
-                'run_mode': run_mode,
-                'shuffle': shuffle,
-                'schedule_hours': schedule_hours
-            },
-            daemon=True
-        ).start()
+        asyncio.create_task(
+            pattern_manager.run_theta_rho_files(
+                file_paths,
+                pause_time=pause_time,
+                clear_pattern=clear_pattern,
+                run_mode=run_mode,
+                shuffle=shuffle,
+                schedule_hours=schedule_hours
+            )
+        )
         return True, f"Playlist '{playlist_name}' is now running."
     except Exception as e:
         logger.error(f"Failed to run playlist '{playlist_name}': {str(e)}")

+ 0 - 0
dune_weaver_flask/modules/core/state.py → modules/core/state.py


+ 0 - 0
dune_weaver_flask/modules/mqtt/__init__.py → modules/mqtt/__init__.py


+ 0 - 0
dune_weaver_flask/modules/mqtt/base.py → modules/mqtt/base.py


+ 0 - 0
dune_weaver_flask/modules/mqtt/factory.py → modules/mqtt/factory.py


+ 4 - 4
dune_weaver_flask/modules/mqtt/handler.py → modules/mqtt/handler.py

@@ -8,9 +8,9 @@ import paho.mqtt.client as mqtt
 import logging
 
 from .base import BaseMQTTHandler
-from dune_weaver_flask.modules.core.state import state
-from dune_weaver_flask.modules.core.pattern_manager import list_theta_rho_files
-from dune_weaver_flask.modules.core.playlist_manager import list_all_playlists
+from modules.core.state import state
+from modules.core.pattern_manager import list_theta_rho_files
+from modules.core.playlist_manager import list_all_playlists
 
 logger = logging.getLogger(__name__)
 
@@ -295,7 +295,7 @@ class MQTTHandler(BaseMQTTHandler):
         """Callback when message is received."""
         try:
             if msg.topic == self.pattern_select_topic:
-                from dune_weaver_flask.modules.core.pattern_manager import THETA_RHO_DIR
+                from modules.core.pattern_manager import THETA_RHO_DIR
                 # Handle pattern selection
                 pattern_name = msg.payload.decode()
                 if pattern_name in self.patterns:

+ 1 - 1
dune_weaver_flask/modules/mqtt/mock.py → modules/mqtt/mock.py

@@ -1,7 +1,7 @@
 """Mock MQTT handler implementation."""
 from typing import Dict, Callable
 from .base import BaseMQTTHandler
-from dune_weaver_flask.modules.core.state import state
+from modules.core.state import state
 
 
 

+ 4 - 4
dune_weaver_flask/modules/mqtt/utils.py → modules/mqtt/utils.py

@@ -1,14 +1,14 @@
 """MQTT utilities and callback management."""
 import os
 from typing import Dict, Callable
-from dune_weaver_flask.modules.core.pattern_manager import (
+from modules.core.pattern_manager import (
     run_theta_rho_file, stop_actions, pause_execution,
     resume_execution, THETA_RHO_DIR,
     run_theta_rho_files, list_theta_rho_files
 )
-from dune_weaver_flask.modules.core.playlist_manager import get_playlist, run_playlist
-from dune_weaver_flask.modules.connection.connection_manager import home
-from dune_weaver_flask.modules.core.state import state
+from modules.core.playlist_manager import get_playlist, run_playlist
+from modules.connection.connection_manager import home
+from modules.core.state import state
 
 def create_mqtt_callbacks() -> Dict[str, Callable]:
     """Create and return the MQTT callback registry."""

+ 0 - 0
dune_weaver_flask/modules/update/__init__.py → modules/update/__init__.py


+ 0 - 0
dune_weaver_flask/modules/update/update_manager.py → modules/update/update_manager.py


+ 14 - 7
requirements.txt

@@ -1,7 +1,14 @@
-flask
-pyserial
-esptool
-tqdm
-paho-mqtt
-python-dotenv
-websocket-client
+flask>=2.0.1
+pyserial>=3.5
+esptool>=4.1
+tqdm>=4.65.0
+paho-mqtt>=1.6.1
+python-dotenv>=1.0.0
+websocket-client>=1.6.1
+fastapi>=0.100.0
+uvicorn[standard]>=0.23.0
+pydantic>=2.0.0
+jinja2>=3.1.2
+aiofiles>=23.1.0
+python-multipart>=0.0.6
+websockets>=11.0.3  # Required for FastAPI WebSocket support

+ 0 - 0
dune_weaver_flask/static/IMG_7404.gif → static/IMG_7404.gif


+ 0 - 0
dune_weaver_flask/static/IMG_9753.png → static/IMG_9753.png


+ 0 - 0
dune_weaver_flask/static/UI.png → static/UI.png


+ 0 - 0
dune_weaver_flask/static/UI_1.3.png → static/UI_1.3.png


+ 0 - 0
dune_weaver_flask/static/css/all.min.css → static/css/all.min.css


+ 0 - 0
dune_weaver_flask/static/css/style.css → static/css/style.css


+ 0 - 0
dune_weaver_flask/static/fontawesome.min.css → static/fontawesome.min.css


+ 0 - 0
dune_weaver_flask/static/icons/chevron-down.svg → static/icons/chevron-down.svg


+ 0 - 0
dune_weaver_flask/static/icons/chevron-left.svg → static/icons/chevron-left.svg


+ 0 - 0
dune_weaver_flask/static/icons/chevron-right.svg → static/icons/chevron-right.svg


+ 0 - 0
dune_weaver_flask/static/icons/chevron-up.svg → static/icons/chevron-up.svg


+ 0 - 0
dune_weaver_flask/static/icons/pause.svg → static/icons/pause.svg


+ 0 - 0
dune_weaver_flask/static/icons/play.svg → static/icons/play.svg


+ 148 - 140
dune_weaver_flask/static/js/main.js → static/js/main.js

@@ -248,18 +248,29 @@ async function runThetaRho() {
     const preExecutionAction = document.getElementById('pre_execution').value;
 
     logMessage(`Running file: ${selectedFile} with pre-execution action: ${preExecutionAction}...`);
-    const response = await fetch('/run_theta_rho', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ file_name: selectedFile, pre_execution: preExecutionAction })
-    });
+    try {
+        const response = await fetch('/run_theta_rho', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ 
+                file_name: selectedFile, 
+                pre_execution: preExecutionAction 
+            })
+        });
 
-    const result = await response.json();
-    if (result.success) {
-        logMessage(`Pattern running: ${selectedFile}`, LOG_TYPE.SUCCESS);
-        updateCurrentlyPlaying();
-    } else {
-        logMessage(`Failed to run file: ${selectedFile}`,LOG_TYPE.ERROR);
+        const result = await response.json();
+        if (response.ok) {
+            logMessage(`Pattern running: ${selectedFile}`, LOG_TYPE.SUCCESS);
+            updateCurrentlyPlaying();
+        } else {
+            if (response.status === 409) {
+                logMessage("Cannot start pattern: Another pattern is already running", LOG_TYPE.WARNING);
+            } else {
+                logMessage(`Failed to run file: ${result.detail || 'Unknown error'}`, LOG_TYPE.ERROR);
+            }
+        }
+    } catch (error) {
+        logMessage(`Error running pattern: ${error.message}`, LOG_TYPE.ERROR);
     }
     updateCurrentlyPlaying();
 }
@@ -835,83 +846,44 @@ function clearSchedule() {
 // Function to run the selected playlist with specified parameters
 async function runPlaylist() {
     const playlistName = document.getElementById('playlist_name_display').textContent;
-
     if (!playlistName) {
-        logMessage("No playlist selected to run.");
+        logMessage('No playlist selected', 'error');
         return;
     }
 
-    const pauseTimeInput = document.getElementById('pause_time').value;
-    const clearPatternSelect = document.getElementById('clear_pattern').value;
+    const pauseTime = parseFloat(document.getElementById('pause_time').value) || 0;
+    const clearPattern = document.getElementById('clear_pattern').value;
     const runMode = document.querySelector('input[name="run_mode"]:checked').value;
     const shuffle = document.getElementById('shuffle_playlist').checked;
-    const startTimeInput = document.getElementById('start_time').value.trim();
-    const endTimeInput = document.getElementById('end_time').value.trim();
-
-    const pauseTime = parseFloat(pauseTimeInput);
-    if (isNaN(pauseTime) || pauseTime < 0) {
-        logMessage("Invalid pause time. Please enter a non-negative number.", LOG_TYPE.WARNING);
-        return;
-    }
-
-    // Validate start and end time format and logic
-    let startTime = startTimeInput || null;
-    let endTime = endTimeInput || null;
-
-    // Ensure that if one time is filled, the other must be as well
-    if ((startTime && !endTime) || (!startTime && endTime)) {
-        logMessage("Both start and end times must be provided together or left blank.", LOG_TYPE.WARNING);
-        return;
-    }
-
-    // If both are provided, validate format and ensure start_time < end_time
-    if (startTime && endTime) {
-        try {
-            const startDateTime = new Date(`1970-01-01T${startTime}:00`);
-            const endDateTime = new Date(`1970-01-01T${endTime}:00`);
-
-            if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
-                logMessage("Invalid time format. Please use HH:MM format (e.g., 09:30).", LOG_TYPE.WARNING);
-                return;
-            }
-
-            if (startDateTime >= endDateTime) {
-                logMessage("Start time must be earlier than end time.", LOG_TYPE.WARNING);
-                return;
-            }
-        } catch (error) {
-            logMessage("Error parsing start or end time. Ensure correct HH:MM format.", LOG_TYPE.ERROR);
-            return;
-        }
-    }
-
-    logMessage(`Running playlist: ${playlistName} with pause_time=${pauseTime}, clear_pattern=${clearPatternSelect}, run_mode=${runMode}, shuffle=${shuffle}.`);
 
     try {
         const response = await fetch('/run_playlist', {
             method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
+            headers: {
+                'Content-Type': 'application/json',
+            },
             body: JSON.stringify({
                 playlist_name: playlistName,
                 pause_time: pauseTime,
-                clear_pattern: clearPatternSelect,
+                clear_pattern: clearPattern,
                 run_mode: runMode,
-                shuffle: shuffle,
-                start_time: startTimeInput,
-                end_time: endTimeInput
+                shuffle: shuffle
             })
         });
 
-        const result = await response.json();
-
-        if (result.success) {
-            logMessage(`Playlist "${playlistName}" is now running.`, LOG_TYPE.SUCCESS);
-            updateCurrentlyPlaying();
-        } else {
-            logMessage(`Failed to run playlist "${playlistName}": ${result.error}`, LOG_TYPE.ERROR);
+        if (!response.ok) {
+            if (response.status === 409) {
+                logMessage('Another pattern is already running', 'warning');
+            } else {
+                const errorData = await response.json();
+                logMessage(errorData.detail || 'Failed to run playlist', 'error');
+            }
+            return;
         }
+
+        logMessage(`Started playlist: ${playlistName}`, 'success');
     } catch (error) {
-        logMessage(`Error running playlist "${playlistName}": ${error.message}`, LOG_TYPE.ERROR);
+        logMessage('Error running playlist: ' + error, 'error');
     }
 }
 
@@ -1458,8 +1430,6 @@ function attachFullScreenListeners() {
 
 let lastPreviewedFile = null; // Track the last previewed file
 
-let updateInterval = null;
-
 async function updateCurrentlyPlaying() {
     try {
         if (!document.hasFocus()) return; // Stop execution if the page is not visible
@@ -1537,12 +1507,6 @@ function formatSecondsToHMS(seconds) {
 function handleVisibilityChange() {
     if (document.hasFocus()) {
         updateCurrentlyPlaying(); // Run immediately
-        if (!updateInterval) {
-            updateInterval = setInterval(updateCurrentlyPlaying, 3000);
-        }
-    } else {
-        clearInterval(updateInterval);
-        updateInterval = null;
     }
 }
 
@@ -1574,79 +1538,37 @@ function getCookie(name) {
 
 // Save settings to cookies
 function saveSettingsToCookies() {
-    // Save the pause time
     const pauseTime = document.getElementById('pause_time').value;
-    setCookie('pause_time', pauseTime, 365);
-
-    // Save the clear pattern
     const clearPattern = document.getElementById('clear_pattern').value;
-    setCookie('clear_pattern', clearPattern, 365);
-
-    // Save the run mode
     const runMode = document.querySelector('input[name="run_mode"]:checked').value;
-    setCookie('run_mode', runMode, 365);
-
-    // Save shuffle playlist checkbox state
-    const shufflePlaylist = document.getElementById('shuffle_playlist').checked;
-    setCookie('shuffle_playlist', shufflePlaylist, 365);
-
-    // Save pre-execution action
-    const preExecution = document.getElementById('pre_execution').value;
-    setCookie('pre_execution', preExecution, 365);
-
-    // Save start and end times
-    const startTime = document.getElementById('start_time').value;
-    const endTime = document.getElementById('end_time').value;
-    setCookie('start_time', startTime, 365);
-    setCookie('end_time', endTime, 365);
+    const shuffle = document.getElementById('shuffle_playlist').checked;
 
-    logMessage('Settings saved.');
+    setCookie('pause_time', pauseTime, 365);
+    setCookie('clear_pattern', clearPattern, 365);
+    setCookie('run_mode', runMode, 365);
+    setCookie('shuffle', shuffle, 365);
 }
 
 // Load settings from cookies
 function loadSettingsFromCookies() {
-    // Load the pause time
     const pauseTime = getCookie('pause_time');
-    if (pauseTime !== null) {
+    if (pauseTime !== '') {
         document.getElementById('pause_time').value = pauseTime;
     }
 
-    // Load the clear pattern
     const clearPattern = getCookie('clear_pattern');
-    if (clearPattern !== null) {
+    if (clearPattern !== '') {
         document.getElementById('clear_pattern').value = clearPattern;
     }
 
-    // Load the run mode
     const runMode = getCookie('run_mode');
-    if (runMode !== null) {
+    if (runMode !== '') {
         document.querySelector(`input[name="run_mode"][value="${runMode}"]`).checked = true;
     }
 
-    // Load the shuffle playlist checkbox state
-    const shufflePlaylist = getCookie('shuffle_playlist');
-    if (shufflePlaylist !== null) {
-        document.getElementById('shuffle_playlist').checked = shufflePlaylist === 'true';
-    }
-
-    // Load the pre-execution action
-    const preExecution = getCookie('pre_execution');
-    if (preExecution !== null) {
-        document.getElementById('pre_execution').value = preExecution;
-    }
-
-    // Load start and end times
-    const startTime = getCookie('start_time');
-    if (startTime !== null) {
-        document.getElementById('start_time').value = startTime;
-    }
-    const endTime = getCookie('end_time');
-    if (endTime !== null) {
-        document.getElementById('end_time').value = endTime;
-    }
-
-    if (startTime && endTime ) {
-        document.getElementById('clear_time').style.display = 'block';
+    const shuffle = getCookie('shuffle');
+    if (shuffle !== '') {
+        document.getElementById('shuffle_playlist').checked = shuffle === 'true';
     }
 
     logMessage('Settings loaded from cookies.');
@@ -1655,15 +1577,12 @@ function loadSettingsFromCookies() {
 // Call this function to save settings when a value is changed
 function attachSettingsSaveListeners() {
     // Add event listeners to inputs
-    document.getElementById('pause_time').addEventListener('input', saveSettingsToCookies);
+    document.getElementById('pause_time').addEventListener('change', saveSettingsToCookies);
     document.getElementById('clear_pattern').addEventListener('change', saveSettingsToCookies);
     document.querySelectorAll('input[name="run_mode"]').forEach(input => {
         input.addEventListener('change', saveSettingsToCookies);
     });
     document.getElementById('shuffle_playlist').addEventListener('change', saveSettingsToCookies);
-    document.getElementById('pre_execution').addEventListener('change', saveSettingsToCookies);
-    document.getElementById('start_time').addEventListener('change', saveSettingsToCookies);
-    document.getElementById('end_time').addEventListener('change', saveSettingsToCookies);
 }
 
 
@@ -1840,7 +1759,42 @@ themeIcon.className = savedTheme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
 
 document.addEventListener("visibilitychange", handleVisibilityChange);
 
-// Initialization
+// Add WebSocket connection for status updates
+let statusSocket = null;
+
+function connectStatusWebSocket() {
+    // Close existing connection if any
+    if (statusSocket) {
+        statusSocket.close();
+    }
+
+    // Create WebSocket connection
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    statusSocket = new WebSocket(`${protocol}//${window.location.host}/ws/status`);
+
+    statusSocket.onopen = () => {
+        console.log('Status WebSocket connected');
+        // Request initial status
+        statusSocket.send('get_status');
+    };
+
+    statusSocket.onmessage = (event) => {
+        const status = JSON.parse(event.data);
+        updateCurrentlyPlayingUI(status);
+    };
+
+    statusSocket.onclose = () => {
+        console.log('Status WebSocket disconnected. Reconnecting in 5 seconds...');
+        setTimeout(connectStatusWebSocket, 5000);
+    };
+
+    statusSocket.onerror = (error) => {
+        console.error('WebSocket error:', error);
+        statusSocket.close();
+    };
+}
+
+// Replace the polling mechanism with WebSocket
 document.addEventListener('DOMContentLoaded', () => {
     const activeTab = getCookie('activeTab') || 'patterns'; // Default to 'patterns' tab
     switchTab(activeTab); // Load the active tab
@@ -1852,9 +1806,63 @@ document.addEventListener('DOMContentLoaded', () => {
     loadWledIp();
     updateWledUI();
 
-    // Periodically check for currently playing status
-    if (document.hasFocus()) {
-        updateInterval = setInterval(updateCurrentlyPlaying, 3000);
-    }
+    // Initialize WebSocket connection for status updates
+    connectStatusWebSocket();
+
+    // Handle visibility change
+    document.addEventListener('visibilitychange', () => {
+        if (document.visibilityState === 'visible' && statusSocket && statusSocket.readyState !== WebSocket.OPEN) {
+            connectStatusWebSocket();
+        }
+    });
+
     checkForUpdates();
-});
+});
+
+// Update the updateCurrentlyPlayingUI function to handle WebSocket updates
+function updateCurrentlyPlayingUI(status) {
+    const currentlyPlayingContainer = document.getElementById('currently-playing-container');
+    const currentlyPlayingFile = document.getElementById('currently-playing-file');
+    const progressBar = document.getElementById('play_progress');
+    const progressText = document.getElementById('play_progress_text');
+    const pausePlayButton = document.getElementById('pausePlayCurrent');
+
+    if (!currentlyPlayingContainer || !currentlyPlayingFile || !progressBar || !progressText) return;
+
+    if (status.current_file) {
+        // Show the container and update file name
+        document.body.classList.add('playing');
+        currentlyPlayingFile.textContent = status.current_file.replace('./patterns/', '');
+        
+        // Update progress information if available
+        if (status.progress) {
+            const progress = status.progress;
+            const percentage = progress.percentage.toFixed(1);
+            const remainingTime = progress.remaining_time ? formatSecondsToHMS(progress.remaining_time) : 'calculating...';
+            const elapsedTime = formatSecondsToHMS(progress.elapsed_time);
+            
+            progressBar.value = percentage;
+            progressText.textContent = `${percentage}% (Elapsed: ${elapsedTime} | Remaining: ${remainingTime})`;
+        } else {
+            progressBar.value = 0;
+            progressText.textContent = '0%';
+        }
+
+        // Update pause/play button
+        if (pausePlayButton) {
+            pausePlayButton.innerHTML = status.is_paused ? 
+                '<i class="fa-solid fa-play"></i>' : 
+                '<i class="fa-solid fa-pause"></i>';
+        }
+    } else {
+        // Hide the container when no file is playing
+        document.body.classList.remove('playing');
+        progressBar.value = 0;
+        progressText.textContent = '0%';
+    }
+
+    // Update other UI elements based on status
+    if (typeof updatePlaylistUI === 'function') {
+        updatePlaylistUI(status);
+    }
+}

+ 0 - 0
dune_weaver_flask/static/webfonts/Roboto-Italic-VariableFont_wdth,wght.ttf → static/webfonts/Roboto-Italic-VariableFont_wdth,wght.ttf


+ 0 - 0
dune_weaver_flask/static/webfonts/Roboto-VariableFont_wdth,wght.ttf → static/webfonts/Roboto-VariableFont_wdth,wght.ttf


+ 0 - 0
dune_weaver_flask/static/webfonts/fa-regular-400.ttf → static/webfonts/fa-regular-400.ttf


+ 0 - 0
dune_weaver_flask/static/webfonts/fa-regular-400.woff2 → static/webfonts/fa-regular-400.woff2


+ 0 - 0
dune_weaver_flask/static/webfonts/fa-solid-900.ttf → static/webfonts/fa-solid-900.ttf


+ 0 - 0
dune_weaver_flask/static/webfonts/fa-solid-900.woff2 → static/webfonts/fa-solid-900.woff2


+ 0 - 14
dune_weaver_flask/templates/index.html → templates/index.html

@@ -170,20 +170,6 @@
                         </select>
                     </div>
                 </div>
-                <h3>Schedule:</h3>
-                <div class="control-group">
-                    <div class="item column">
-                        <label for="start_time">Start time</label>
-                        <input type="time" id="start_time" min="00:00" max="24:00">
-                    </div>
-                    <div class="item column">
-                        <label for="end_time">End time</label>
-                        <input type="time" id="end_time" min="00:00" max="24:00">
-                    </div>
-                    <button id="clear_time" onclick="clearSchedule()" style="display: none" class="small cancel">
-                        <i class="fa-solid fa-delete-left"></i>
-                    </button>
-                </div>
             </div>
         </section>