app.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. from flask import Flask, request, jsonify, render_template, send_from_directory
  2. import atexit
  3. import os
  4. import logging
  5. from datetime import datetime
  6. import asyncio
  7. import json
  8. import threading
  9. import time
  10. from modules.connection import connection_manager
  11. from modules.core import pattern_manager
  12. from modules.core import playlist_manager
  13. from modules.update import update_manager
  14. from modules.core.state import state
  15. from modules import mqtt
  16. from modules.led.led_controller import LEDController, effect_idle
  17. # Configure logging
  18. logging.basicConfig(
  19. level=logging.INFO,
  20. format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
  21. handlers=[
  22. logging.StreamHandler(),
  23. # disable file logging for now, to not gobble up resources
  24. # logging.FileHandler('dune_weaver.log')
  25. ]
  26. )
  27. logger = logging.getLogger(__name__)
  28. app = Flask(__name__)
  29. # Create a global lock and thread tracking variable
  30. pattern_execution_lock = threading.Lock()
  31. current_execution_thread = None
  32. # Flask API Endpoints
  33. @app.route('/')
  34. def index():
  35. return render_template('index.html')
  36. @app.route('/list_serial_ports', methods=['GET'])
  37. def list_ports():
  38. logger.debug("Listing available serial ports")
  39. return jsonify(connection_manager.list_serial_ports())
  40. @app.route('/connect', methods=['POST'])
  41. def connect():
  42. port = request.json.get('port')
  43. if not port:
  44. state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
  45. connection_manager.device_init()
  46. logger.info(f'Successfully connected to websocket ws://fluidnc.local:81')
  47. return jsonify({'success': True})
  48. try:
  49. state.conn = connection_manager.SerialConnection(port)
  50. connection_manager.device_init()
  51. logger.info(f'Successfully connected to serial port {port}')
  52. return jsonify({'success': True})
  53. except Exception as e:
  54. logger.error(f'Failed to connect to serial port {port}: {str(e)}')
  55. return jsonify({'error': str(e)}), 500
  56. @app.route('/disconnect', methods=['POST'])
  57. def disconnect():
  58. try:
  59. state.conn.close()
  60. logger.info('Successfully disconnected from serial port')
  61. return jsonify({'success': True})
  62. except Exception as e:
  63. logger.error(f'Failed to disconnect serial: {str(e)}')
  64. return jsonify({'error': str(e)}), 500
  65. @app.route('/restart_connection', methods=['POST'])
  66. def restart():
  67. port = request.json.get('port')
  68. if not port:
  69. logger.warning("Restart serial request received without port")
  70. return jsonify({'error': 'No port provided'}), 400
  71. try:
  72. logger.info(f"Restarting connection on port {port}")
  73. connection_manager.restart_connection()
  74. return jsonify({'success': True})
  75. except Exception as e:
  76. logger.error(f"Failed to restart serial on port {port}: {str(e)}")
  77. return jsonify({'error': str(e)}), 500
  78. @app.route('/list_theta_rho_files', methods=['GET'])
  79. def list_theta_rho_files():
  80. logger.debug("Listing theta-rho files")
  81. files = pattern_manager.list_theta_rho_files()
  82. return jsonify(sorted(files))
  83. @app.route('/upload_theta_rho', methods=['POST'])
  84. def upload_theta_rho():
  85. custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, 'custom_patterns')
  86. os.makedirs(custom_patterns_dir, exist_ok=True)
  87. logger.debug(f'Ensuring custom patterns directory exists: {custom_patterns_dir}')
  88. file = request.files['file']
  89. if file:
  90. file_path = os.path.join(custom_patterns_dir, file.filename)
  91. file.save(file_path)
  92. logger.info(f'Successfully uploaded theta-rho file: {file.filename}')
  93. return jsonify({'success': True})
  94. logger.warning('Upload theta-rho request received without file')
  95. return jsonify({'success': False})
  96. @app.route('/run_theta_rho', methods=['POST'])
  97. def run_theta_rho():
  98. file_name = request.json.get('file_name')
  99. pre_execution = request.json.get('pre_execution')
  100. global current_execution_thread
  101. if not file_name:
  102. logger.warning('Run theta-rho request received without file name')
  103. return jsonify({'error': 'No file name provided'}), 400
  104. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  105. if not os.path.exists(file_path):
  106. logger.error(f'Theta-rho file not found: {file_path}')
  107. return jsonify({'error': 'File not found'}), 404
  108. # Check if a pattern is already running
  109. if current_execution_thread and current_execution_thread.is_alive():
  110. logger.warning(f'Attempted to run pattern while another is already running')
  111. return jsonify({'error': 'A pattern is already running. Stop it before starting a new one.'}), 409
  112. try:
  113. # Create a thread that will execute the pattern
  114. current_execution_thread = threading.Thread(
  115. target=execute_pattern,
  116. args=(file_name, pre_execution),
  117. daemon=True
  118. )
  119. # Start the thread
  120. current_execution_thread.start()
  121. return jsonify({"success": True, "message": f"Pattern {file_name} started in background"})
  122. except Exception as e:
  123. logger.error(f'Failed to run theta-rho file {file_name}: {str(e)}')
  124. return jsonify({'error': str(e)}), 500
  125. def execute_pattern(file_name, pre_execution):
  126. if not (state.conn.is_connected() if state.conn else False):
  127. logger.warning("Attempted to run a pattern without a connection")
  128. return jsonify({"success": False, "error": "Connection not established"}), 400
  129. files_to_run = [os.path.join(pattern_manager.THETA_RHO_DIR, file_name)]
  130. logger.info(f'Running theta-rho file: {file_name} with pre_execution={pre_execution}')
  131. pattern_manager.run_theta_rho_files(files_to_run, clear_pattern=pre_execution)
  132. @app.route('/stop_execution', methods=['POST'])
  133. def stop_execution():
  134. if not (state.conn.is_connected() if state.conn else False):
  135. logger.warning("Attempted to stop without a connection")
  136. return jsonify({"success": False, "error": "Connection not established"}), 400
  137. pattern_manager.stop_actions()
  138. return jsonify({'success': True})
  139. @app.route('/send_home', methods=['POST'])
  140. def send_home():
  141. try:
  142. if not (state.conn.is_connected() if state.conn else False):
  143. logger.warning("Attempted to move to home without a connection")
  144. return jsonify({"success": False, "error": "Connection not established"}), 400
  145. connection_manager.home()
  146. return jsonify({'success': True})
  147. except Exception as e:
  148. logger.error(f"Failed to send home command: {str(e)}")
  149. return jsonify({'error': str(e)}), 500
  150. @app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
  151. def run_specific_theta_rho_file(file_name):
  152. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  153. if not os.path.exists(file_path):
  154. return jsonify({'error': 'File not found'}), 404
  155. if not (state.conn.is_connected() if state.conn else False):
  156. logger.warning("Attempted to run a pattern without a connection")
  157. return jsonify({"success": False, "error": "Connection not established"}), 400
  158. pattern_manager.run_theta_rho_file(file_path)
  159. return jsonify({'success': True})
  160. @app.route('/delete_theta_rho_file', methods=['POST'])
  161. def delete_theta_rho_file():
  162. file_name = request.json.get('file_name')
  163. if not file_name:
  164. logger.warning("Delete theta-rho file request received without filename")
  165. return jsonify({"success": False, "error": "No file name provided"}), 400
  166. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  167. if not os.path.exists(file_path):
  168. logger.error(f"Attempted to delete non-existent file: {file_path}")
  169. return jsonify({"success": False, "error": "File not found"}), 404
  170. try:
  171. os.remove(file_path)
  172. logger.info(f"Successfully deleted theta-rho file: {file_name}")
  173. return jsonify({"success": True})
  174. except Exception as e:
  175. logger.error(f"Failed to delete theta-rho file {file_name}: {str(e)}")
  176. return jsonify({"success": False, "error": str(e)}), 500
  177. @app.route('/move_to_center', methods=['POST'])
  178. def move_to_center():
  179. global current_theta
  180. try:
  181. if not (state.conn.is_connected() if state.conn else False):
  182. logger.warning("Attempted to move to center without a connection")
  183. return jsonify({"success": False, "error": "Connection not established"}), 400
  184. logger.info("Moving device to center position")
  185. pattern_manager.reset_theta()
  186. pattern_manager.move_polar(0, 0)
  187. return jsonify({"success": True})
  188. except Exception as e:
  189. logger.error(f"Failed to move to center: {str(e)}")
  190. return jsonify({"success": False, "error": str(e)}), 500
  191. @app.route('/move_to_perimeter', methods=['POST'])
  192. def move_to_perimeter():
  193. global current_theta
  194. try:
  195. if not (state.conn.is_connected() if state.conn else False):
  196. logger.warning("Attempted to move to perimeter without a connection")
  197. return jsonify({"success": False, "error": "Connection not established"}), 400
  198. pattern_manager.reset_theta()
  199. pattern_manager.move_polar(0,1)
  200. return jsonify({"success": True})
  201. except Exception as e:
  202. logger.error(f"Failed to move to perimeter: {str(e)}")
  203. return jsonify({"success": False, "error": str(e)}), 500
  204. @app.route('/preview_thr', methods=['POST'])
  205. def preview_thr():
  206. file_name = request.json.get('file_name')
  207. if not file_name:
  208. logger.warning("Preview theta-rho request received without filename")
  209. return jsonify({'error': 'No file name provided'}), 400
  210. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  211. if not os.path.exists(file_path):
  212. logger.error(f"Attempted to preview non-existent file: {file_path}")
  213. return jsonify({'error': 'File not found'}), 404
  214. try:
  215. coordinates = pattern_manager.parse_theta_rho_file(file_path)
  216. return jsonify({'success': True, 'coordinates': coordinates})
  217. except Exception as e:
  218. logger.error(f"Failed to generate preview for {file_name}: {str(e)}")
  219. return jsonify({'error': str(e)}), 500
  220. @app.route('/send_coordinate', methods=['POST'])
  221. def send_coordinate():
  222. if not (state.conn.is_connected() if state.conn else False):
  223. logger.warning("Attempted to send coordinate without a connection")
  224. return jsonify({"success": False, "error": "connection not established"}), 400
  225. try:
  226. data = request.json
  227. theta = data.get('theta')
  228. rho = data.get('rho')
  229. if theta is None or rho is None:
  230. logger.warning("Send coordinate request missing theta or rho values")
  231. return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
  232. logger.debug(f"Sending coordinate: theta={theta}, rho={rho}")
  233. pattern_manager.move_polar(theta, rho)
  234. return jsonify({"success": True})
  235. except Exception as e:
  236. logger.error(f"Failed to send coordinate: {str(e)}")
  237. return jsonify({"success": False, "error": str(e)}), 500
  238. @app.route('/download/<filename>', methods=['GET'])
  239. def download_file(filename):
  240. return send_from_directory(pattern_manager.THETA_RHO_DIR, filename)
  241. @app.route('/serial_status', methods=['GET'])
  242. def serial_status():
  243. connected = state.conn.is_connected() if state.conn else False
  244. port = state.port
  245. logger.debug(f"Serial status check - connected: {connected}, port: {port}")
  246. return jsonify({
  247. 'connected': connected,
  248. 'port': port
  249. })
  250. @app.route('/pause_execution', methods=['POST'])
  251. def pause_execution():
  252. if pattern_manager.pause_execution():
  253. return jsonify({'success': True, 'message': 'Execution paused'})
  254. @app.route('/status', methods=['GET'])
  255. def get_status():
  256. """Endpoint to get current status information."""
  257. return jsonify(pattern_manager.get_status())
  258. @app.route('/resume_execution', methods=['POST'])
  259. def resume_execution():
  260. if pattern_manager.resume_execution():
  261. return jsonify({'success': True, 'message': 'Execution resumed'})
  262. # Playlist endpoints
  263. @app.route("/list_all_playlists", methods=["GET"])
  264. def list_all_playlists():
  265. playlist_names = playlist_manager.list_all_playlists()
  266. return jsonify(playlist_names)
  267. @app.route("/get_playlist", methods=["GET"])
  268. def get_playlist():
  269. playlist_name = request.args.get("name", "")
  270. if not playlist_name:
  271. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  272. playlist = playlist_manager.get_playlist(playlist_name)
  273. if not playlist:
  274. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  275. return jsonify(playlist)
  276. @app.route("/create_playlist", methods=["POST"])
  277. def create_playlist():
  278. data = request.get_json()
  279. if not data or "name" not in data or "files" not in data:
  280. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  281. success = playlist_manager.create_playlist(data["name"], data["files"])
  282. return jsonify({
  283. "success": success,
  284. "message": f"Playlist '{data['name']}' created/updated"
  285. })
  286. @app.route("/modify_playlist", methods=["POST"])
  287. def modify_playlist():
  288. data = request.get_json()
  289. if not data or "name" not in data or "files" not in data:
  290. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  291. success = playlist_manager.modify_playlist(data["name"], data["files"])
  292. return jsonify({"success": success, "message": f"Playlist '{data['name']}' updated"})
  293. @app.route("/delete_playlist", methods=["DELETE"])
  294. def delete_playlist():
  295. data = request.get_json()
  296. if not data or "name" not in data:
  297. return jsonify({"success": False, "error": "Missing 'name' field"}), 400
  298. success = playlist_manager.delete_playlist(data["name"])
  299. if not success:
  300. return jsonify({"success": False, "error": f"Playlist '{data['name']}' not found"}), 404
  301. return jsonify({
  302. "success": True,
  303. "message": f"Playlist '{data['name']}' deleted"
  304. })
  305. @app.route('/add_to_playlist', methods=['POST'])
  306. def add_to_playlist():
  307. data = request.json
  308. playlist_name = data.get('playlist_name')
  309. pattern = data.get('pattern')
  310. success = playlist_manager.add_to_playlist(playlist_name, pattern)
  311. if not success:
  312. return jsonify(success=False, error='Playlist not found'), 404
  313. return jsonify(success=True)
  314. @app.route("/run_playlist", methods=["POST"])
  315. def run_playlist():
  316. data = request.get_json()
  317. if not data or "playlist_name" not in data:
  318. logger.warning("Run playlist request received without playlist name")
  319. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  320. if not (state.conn.is_connected() if state.conn else False):
  321. logger.warning("Attempted to run a playlist without a connection")
  322. return jsonify({"success": False, "error": "Connection not established"}), 400
  323. playlist_name = data["playlist_name"]
  324. pause_time = data.get("pause_time", 0)
  325. clear_pattern = data.get("clear_pattern", None)
  326. run_mode = data.get("run_mode", "single")
  327. shuffle = data.get("shuffle", False)
  328. start_time = data.get("start_time")
  329. end_time = data.get("end_time")
  330. logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
  331. success, message = playlist_manager.run_playlist(
  332. playlist_name,
  333. pause_time=pause_time,
  334. clear_pattern=clear_pattern,
  335. run_mode=run_mode,
  336. shuffle=shuffle,
  337. )
  338. if not success:
  339. logger.error(f"Failed to run playlist '{playlist_name}': {message}")
  340. return jsonify({"success": False, "error": message}), 500
  341. return jsonify({"success": True, "message": message})
  342. # Firmware endpoints
  343. @app.route('/set_speed', methods=['POST'])
  344. def set_speed():
  345. try:
  346. if not (state.conn.is_connected() if state.conn else False):
  347. logger.warning("Attempted to change speed without a connection")
  348. return jsonify({"success": False, "error": "Connection not established"}), 400
  349. data = request.json
  350. new_speed = data.get('speed')
  351. if new_speed is None:
  352. logger.warning("Set speed request received without speed value")
  353. return jsonify({"success": False, "error": "Speed is required"}), 400
  354. if not isinstance(new_speed, (int, float)) or new_speed <= 0:
  355. logger.warning(f"Invalid speed value received: {new_speed}")
  356. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  357. state.speed = new_speed
  358. return jsonify({"success": True, "speed": new_speed})
  359. except Exception as e:
  360. logger.error(f"Failed to set speed: {str(e)}")
  361. return jsonify({"success": False, "error": str(e)}), 500
  362. @app.route('/check_software_update', methods=['GET'])
  363. def check_updates():
  364. update_info = update_manager.check_git_updates()
  365. return jsonify(update_info)
  366. @app.route('/update_software', methods=['POST'])
  367. def update_software():
  368. logger.info("Starting software update process")
  369. success, error_message, error_log = update_manager.update_software()
  370. if success:
  371. logger.info("Software update completed successfully")
  372. return jsonify({"success": True})
  373. else:
  374. logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
  375. return jsonify({
  376. "success": False,
  377. "error": error_message,
  378. "details": error_log
  379. }), 500
  380. @app.route('/set_wled_ip', methods=['POST'])
  381. def set_wled_ip():
  382. """Save the WLED IP address to state"""
  383. data = request.json
  384. wled_ip = data.get("wled_ip")
  385. # Save to state
  386. state.wled_ip = wled_ip
  387. state.save()
  388. if state.wled_ip:
  389. state.led_controlller = LEDController(state.wled_ip)
  390. logger.info(f"WLED IP updated: {wled_ip}")
  391. return jsonify({"success": True, "wled_ip": state.wled_ip})
  392. @app.route('/get_wled_ip', methods=['GET'])
  393. def get_wled_ip():
  394. # Logic to get WLED IP address
  395. try:
  396. # Replace with your actual logic to get the WLED IP
  397. wled_ip = state.wled_ip if hasattr(state, 'wled_ip') else None
  398. if wled_ip:
  399. state.led_controlller = LEDController(state.wled_ip)
  400. return jsonify({"ip": wled_ip})
  401. except Exception as e:
  402. logger.error(f"Error getting WLED IP: {str(e)}")
  403. return jsonify({"error": str(e)}), 500
  404. @app.route('/skip_pattern', methods=['POST'])
  405. def skip_pattern():
  406. if not state.current_playlist:
  407. return jsonify({"success": False, "error": "No playlist is currently running"})
  408. state.skip_requested = True
  409. return jsonify({"success": True})
  410. def on_exit():
  411. """Function to execute on application shutdown."""
  412. logger.info("Shutting down gracefully, please wait for execution to complete")
  413. pattern_manager.stop_actions()
  414. state.save()
  415. mqtt.cleanup_mqtt()
  416. logger.info("Shutdown complete")
  417. def entrypoint():
  418. logger.info("Starting Dune Weaver application...")
  419. # Register the on_exit function
  420. atexit.register(on_exit)
  421. # Auto-connect to serial
  422. try:
  423. connection_manager.connect_device()
  424. except Exception as e:
  425. logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
  426. try:
  427. mqtt_handler = mqtt.init_mqtt()
  428. except Exception as e:
  429. logger.warning(f"Failed to initialize MQTT: {str(e)}")
  430. try:
  431. logger.info("Starting Flask server on port 8080...")
  432. # Run the Flask app
  433. app.run(debug=False, host='0.0.0.0', port=8080)
  434. except KeyboardInterrupt:
  435. logger.info("Keyboard interrupt received. Shutting down.")
  436. except Exception as e:
  437. logger.critical(f"Unexpected error during server startup: {str(e)}")
  438. finally:
  439. on_exit()
  440. if __name__ == "__main__":
  441. entrypoint()