Explorar el Código

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

Tuan Nguyen hace 11 meses
padre
commit
7cc8d58333
Se han modificado 41 ficheros con 1109 adiciones y 1054 borrados
  1. 0 0
      __init__.py
  2. 537 6
      app.py
  3. 0 498
      dune_weaver_flask/app.py
  4. 0 363
      dune_weaver_flask/modules/core/pattern_manager.py
  5. 0 0
      modules/__init__.py
  6. 0 0
      modules/connection/__init__.py
  7. 2 2
      modules/connection/connection_manager.py
  8. 0 0
      modules/core/__init__.py
  9. 381 0
      modules/core/pattern_manager.py
  10. 18 15
      modules/core/playlist_manager.py
  11. 0 0
      modules/core/state.py
  12. 0 0
      modules/mqtt/__init__.py
  13. 0 0
      modules/mqtt/base.py
  14. 0 0
      modules/mqtt/factory.py
  15. 4 4
      modules/mqtt/handler.py
  16. 1 1
      modules/mqtt/mock.py
  17. 4 4
      modules/mqtt/utils.py
  18. 0 0
      modules/update/__init__.py
  19. 0 0
      modules/update/update_manager.py
  20. 14 7
      requirements.txt
  21. 0 0
      static/IMG_7404.gif
  22. 0 0
      static/IMG_9753.png
  23. 0 0
      static/UI.png
  24. 0 0
      static/UI_1.3.png
  25. 0 0
      static/css/all.min.css
  26. 0 0
      static/css/style.css
  27. 0 0
      static/fontawesome.min.css
  28. 0 0
      static/icons/chevron-down.svg
  29. 0 0
      static/icons/chevron-left.svg
  30. 0 0
      static/icons/chevron-right.svg
  31. 0 0
      static/icons/chevron-up.svg
  32. 0 0
      static/icons/pause.svg
  33. 0 0
      static/icons/play.svg
  34. 148 140
      static/js/main.js
  35. 0 0
      static/webfonts/Roboto-Italic-VariableFont_wdth,wght.ttf
  36. 0 0
      static/webfonts/Roboto-VariableFont_wdth,wght.ttf
  37. 0 0
      static/webfonts/fa-regular-400.ttf
  38. 0 0
      static/webfonts/fa-regular-400.woff2
  39. 0 0
      static/webfonts/fa-solid-900.ttf
  40. 0 0
      static/webfonts/fa-solid-900.woff2
  41. 0 14
      templates/index.html

+ 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>