| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- 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 contextlib import asynccontextmanager
- # Configure logging
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
- handlers=[
- logging.StreamHandler(),
- ]
- )
- logger = logging.getLogger(__name__)
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- # Startup
- 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)}")
- yield # This separates startup from shutdown code
- # Shutdown
- on_exit()
- app = FastAPI(lifespan=lifespan)
- 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:
- status = pattern_manager.get_status()
- try:
- await websocket.send_json({
- "type": "status_update",
- "data": status
- })
- except RuntimeError as e:
- if "close message has been sent" in str(e):
- break
- raise
- await asyncio.sleep(1)
- except WebSocketDisconnect:
- pass
- finally:
- active_status_connections.discard(websocket)
- try:
- await websocket.close()
- except RuntimeError:
- pass
- 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({
- "type": "status_update",
- "data": status
- })
- except WebSocketDisconnect:
- disconnected.add(websocket)
- except RuntimeError:
- disconnected.add(websocket)
-
- 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}')
-
- # Pass arguments in the correct order
- background_tasks.add_task(
- pattern_manager.run_theta_rho_files,
- files_to_run, # First positional argument
- clear_pattern=request.pre_execution if request.pre_execution != "none" else None # Named argument
- )
- 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.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(playlist_manager.PLAYLISTS_FILE):
- 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
- 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
- if __name__ == "__main__":
- entrypoint()
|