1
0

app.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  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. try:
  127. if not (state.conn.is_connected() if state.conn else False):
  128. logger.warning("Attempted to run a pattern without a connection")
  129. return
  130. files_to_run = [os.path.join(pattern_manager.THETA_RHO_DIR, file_name)]
  131. logger.info(f'Running theta-rho file: {file_name} with pre_execution={pre_execution}')
  132. pattern_manager.run_theta_rho_files(files_to_run, clear_pattern=pre_execution)
  133. except Exception as e:
  134. logger.error(f"Error in pattern execution thread: {str(e)}")
  135. finally:
  136. # Ensure state is properly reset when thread exits
  137. if state.current_playing_file:
  138. logger.info("Thread exit: resetting current_playing_file to None")
  139. state.current_playing_file = None
  140. @app.route('/stop_execution', methods=['POST'])
  141. def stop_execution():
  142. if not (state.conn.is_connected() if state.conn else False):
  143. logger.warning("Attempted to stop without a connection")
  144. return jsonify({"success": False, "error": "Connection not established"}), 400
  145. pattern_manager.stop_actions()
  146. return jsonify({'success': True})
  147. @app.route('/send_home', methods=['POST'])
  148. def send_home():
  149. try:
  150. if not (state.conn.is_connected() if state.conn else False):
  151. logger.warning("Attempted to move to home without a connection")
  152. return jsonify({"success": False, "error": "Connection not established"}), 400
  153. connection_manager.home()
  154. return jsonify({'success': True})
  155. except Exception as e:
  156. logger.error(f"Failed to send home command: {str(e)}")
  157. return jsonify({'error': str(e)}), 500
  158. @app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
  159. def run_specific_theta_rho_file(file_name):
  160. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  161. if not os.path.exists(file_path):
  162. return jsonify({'error': 'File not found'}), 404
  163. if not (state.conn.is_connected() if state.conn else False):
  164. logger.warning("Attempted to run a pattern without a connection")
  165. return jsonify({"success": False, "error": "Connection not established"}), 400
  166. pattern_manager.run_theta_rho_file(file_path)
  167. return jsonify({'success': True})
  168. @app.route('/delete_theta_rho_file', methods=['POST'])
  169. def delete_theta_rho_file():
  170. file_name = request.json.get('file_name')
  171. if not file_name:
  172. logger.warning("Delete theta-rho file request received without filename")
  173. return jsonify({"success": False, "error": "No file name provided"}), 400
  174. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  175. if not os.path.exists(file_path):
  176. logger.error(f"Attempted to delete non-existent file: {file_path}")
  177. return jsonify({"success": False, "error": "File not found"}), 404
  178. try:
  179. os.remove(file_path)
  180. logger.info(f"Successfully deleted theta-rho file: {file_name}")
  181. return jsonify({"success": True})
  182. except Exception as e:
  183. logger.error(f"Failed to delete theta-rho file {file_name}: {str(e)}")
  184. return jsonify({"success": False, "error": str(e)}), 500
  185. @app.route('/move_to_center', methods=['POST'])
  186. def move_to_center():
  187. global current_theta
  188. try:
  189. if not (state.conn.is_connected() if state.conn else False):
  190. logger.warning("Attempted to move to center without a connection")
  191. return jsonify({"success": False, "error": "Connection not established"}), 400
  192. logger.info("Moving device to center position")
  193. pattern_manager.reset_theta()
  194. pattern_manager.move_polar(0, 0)
  195. return jsonify({"success": True})
  196. except Exception as e:
  197. logger.error(f"Failed to move to center: {str(e)}")
  198. return jsonify({"success": False, "error": str(e)}), 500
  199. @app.route('/move_to_perimeter', methods=['POST'])
  200. def move_to_perimeter():
  201. global current_theta
  202. try:
  203. if not (state.conn.is_connected() if state.conn else False):
  204. logger.warning("Attempted to move to perimeter without a connection")
  205. return jsonify({"success": False, "error": "Connection not established"}), 400
  206. pattern_manager.reset_theta()
  207. pattern_manager.move_polar(0,1)
  208. return jsonify({"success": True})
  209. except Exception as e:
  210. logger.error(f"Failed to move to perimeter: {str(e)}")
  211. return jsonify({"success": False, "error": str(e)}), 500
  212. @app.route('/preview_thr', methods=['POST'])
  213. def preview_thr():
  214. file_name = request.json.get('file_name')
  215. if not file_name:
  216. logger.warning("Preview theta-rho request received without filename")
  217. return jsonify({'error': 'No file name provided'}), 400
  218. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  219. if not os.path.exists(file_path):
  220. logger.error(f"Attempted to preview non-existent file: {file_path}")
  221. return jsonify({'error': 'File not found'}), 404
  222. try:
  223. coordinates = pattern_manager.parse_theta_rho_file(file_path)
  224. return jsonify({'success': True, 'coordinates': coordinates})
  225. except Exception as e:
  226. logger.error(f"Failed to generate preview for {file_name}: {str(e)}")
  227. return jsonify({'error': str(e)}), 500
  228. @app.route('/send_coordinate', methods=['POST'])
  229. def send_coordinate():
  230. if not (state.conn.is_connected() if state.conn else False):
  231. logger.warning("Attempted to send coordinate without a connection")
  232. return jsonify({"success": False, "error": "connection not established"}), 400
  233. try:
  234. data = request.json
  235. theta = data.get('theta')
  236. rho = data.get('rho')
  237. if theta is None or rho is None:
  238. logger.warning("Send coordinate request missing theta or rho values")
  239. return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
  240. logger.debug(f"Sending coordinate: theta={theta}, rho={rho}")
  241. pattern_manager.move_polar(theta, rho)
  242. return jsonify({"success": True})
  243. except Exception as e:
  244. logger.error(f"Failed to send coordinate: {str(e)}")
  245. return jsonify({"success": False, "error": str(e)}), 500
  246. @app.route('/download/<filename>', methods=['GET'])
  247. def download_file(filename):
  248. return send_from_directory(pattern_manager.THETA_RHO_DIR, filename)
  249. @app.route('/serial_status', methods=['GET'])
  250. def serial_status():
  251. connected = state.conn.is_connected() if state.conn else False
  252. port = state.port
  253. logger.debug(f"Serial status check - connected: {connected}, port: {port}")
  254. return jsonify({
  255. 'connected': connected,
  256. 'port': port
  257. })
  258. @app.route('/pause_execution', methods=['POST'])
  259. def pause_execution():
  260. if pattern_manager.pause_execution():
  261. return jsonify({'success': True, 'message': 'Execution paused'})
  262. @app.route('/status', methods=['GET'])
  263. def get_status():
  264. """Endpoint to get current status information."""
  265. return jsonify(pattern_manager.get_status())
  266. @app.route('/resume_execution', methods=['POST'])
  267. def resume_execution():
  268. if pattern_manager.resume_execution():
  269. return jsonify({'success': True, 'message': 'Execution resumed'})
  270. # Playlist endpoints
  271. @app.route("/list_all_playlists", methods=["GET"])
  272. def list_all_playlists():
  273. playlist_names = playlist_manager.list_all_playlists()
  274. return jsonify(playlist_names)
  275. @app.route("/get_playlist", methods=["GET"])
  276. def get_playlist():
  277. playlist_name = request.args.get("name", "")
  278. if not playlist_name:
  279. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  280. playlist = playlist_manager.get_playlist(playlist_name)
  281. if not playlist:
  282. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  283. return jsonify(playlist)
  284. @app.route("/create_playlist", methods=["POST"])
  285. def create_playlist():
  286. data = request.get_json()
  287. if not data or "playlist_name" not in data or "files" not in data:
  288. return jsonify({"success": False, "error": "Playlist 'playlist_name' and 'files' are required"}), 400
  289. success = playlist_manager.create_playlist(data["playlist_name"], data["files"])
  290. return jsonify({
  291. "success": success,
  292. "message": f"Playlist '{data['playlist_name']}' created/updated"
  293. })
  294. @app.route("/modify_playlist", methods=["POST"])
  295. def modify_playlist():
  296. data = request.get_json()
  297. if not data or "playlist_name" not in data or "files" not in data:
  298. return jsonify({"success": False, "error": "Playlist 'playlist_name' and 'files' are required"}), 400
  299. success = playlist_manager.modify_playlist(data["playlist_name"], data["files"])
  300. return jsonify({"success": success, "message": f"Playlist '{data['playlist_name']}' updated"})
  301. @app.route("/delete_playlist", methods=["DELETE"])
  302. def delete_playlist():
  303. data = request.get_json()
  304. if not data or "playlist_name" not in data:
  305. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  306. success = playlist_manager.delete_playlist(data["playlist_name"])
  307. if not success:
  308. return jsonify({"success": False, "error": f"Playlist '{data['playlist_name']}' not found"}), 404
  309. return jsonify({
  310. "success": True,
  311. "message": f"Playlist '{data['playlist_name']}' deleted"
  312. })
  313. @app.route('/add_to_playlist', methods=['POST'])
  314. def add_to_playlist():
  315. data = request.json
  316. playlist_name = data.get('playlist_name')
  317. pattern = data.get('pattern')
  318. success = playlist_manager.add_to_playlist(playlist_name, pattern)
  319. if not success:
  320. return jsonify(success=False, error='Playlist not found'), 404
  321. return jsonify(success=True)
  322. @app.route("/run_playlist", methods=["POST"])
  323. def run_playlist():
  324. data = request.get_json()
  325. if not data or "playlist_name" not in data:
  326. logger.warning("Run playlist request received without playlist name")
  327. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  328. if not (state.conn.is_connected() if state.conn else False):
  329. logger.warning("Attempted to run a playlist without a connection")
  330. return jsonify({"success": False, "error": "Connection not established"}), 400
  331. playlist_name = data["playlist_name"]
  332. pause_time = data.get("pause_time", 0)
  333. clear_pattern = data.get("clear_pattern", None)
  334. run_mode = data.get("run_mode", "single")
  335. shuffle = data.get("shuffle", False)
  336. start_time = data.get("start_time")
  337. end_time = data.get("end_time")
  338. logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
  339. success, message = playlist_manager.run_playlist(
  340. playlist_name,
  341. pause_time=pause_time,
  342. clear_pattern=clear_pattern,
  343. run_mode=run_mode,
  344. shuffle=shuffle,
  345. )
  346. if not success:
  347. logger.error(f"Failed to run playlist '{playlist_name}': {message}")
  348. return jsonify({"success": False, "error": message}), 500
  349. return jsonify({"success": True, "message": message})
  350. # Firmware endpoints
  351. @app.route('/set_speed', methods=['POST'])
  352. def set_speed():
  353. try:
  354. if not (state.conn.is_connected() if state.conn else False):
  355. logger.warning("Attempted to change speed without a connection")
  356. return jsonify({"success": False, "error": "Connection not established"}), 400
  357. data = request.json
  358. new_speed = data.get('speed')
  359. if new_speed is None:
  360. logger.warning("Set speed request received without speed value")
  361. return jsonify({"success": False, "error": "Speed is required"}), 400
  362. if not isinstance(new_speed, (int, float)) or new_speed <= 0:
  363. logger.warning(f"Invalid speed value received: {new_speed}")
  364. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  365. state.speed = new_speed
  366. return jsonify({"success": True, "speed": new_speed})
  367. except Exception as e:
  368. logger.error(f"Failed to set speed: {str(e)}")
  369. return jsonify({"success": False, "error": str(e)}), 500
  370. @app.route('/check_software_update', methods=['GET'])
  371. def check_updates():
  372. update_info = update_manager.check_git_updates()
  373. return jsonify(update_info)
  374. @app.route('/update_software', methods=['POST'])
  375. def update_software():
  376. logger.info("Starting software update process")
  377. success, error_message, error_log = update_manager.update_software()
  378. if success:
  379. logger.info("Software update completed successfully")
  380. return jsonify({"success": True})
  381. else:
  382. logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
  383. return jsonify({
  384. "success": False,
  385. "error": error_message,
  386. "details": error_log
  387. }), 500
  388. @app.route('/set_wled_ip', methods=['POST'])
  389. def set_wled_ip():
  390. """Save the WLED IP address to state"""
  391. data = request.json
  392. wled_ip = data.get("wled_ip")
  393. # Save to state
  394. state.wled_ip = wled_ip
  395. state.save()
  396. if state.wled_ip:
  397. state.led_controlller = LEDController(state.wled_ip)
  398. logger.info(f"WLED IP updated: {wled_ip}")
  399. return jsonify({"success": True, "wled_ip": state.wled_ip})
  400. @app.route('/get_wled_ip', methods=['GET'])
  401. def get_wled_ip():
  402. # Logic to get WLED IP address
  403. try:
  404. # Replace with your actual logic to get the WLED IP
  405. wled_ip = state.wled_ip if hasattr(state, 'wled_ip') else None
  406. if wled_ip:
  407. state.led_controlller = LEDController(state.wled_ip)
  408. return jsonify({"ip": wled_ip})
  409. except Exception as e:
  410. logger.error(f"Error getting WLED IP: {str(e)}")
  411. return jsonify({"error": str(e)}), 500
  412. @app.route('/skip_pattern', methods=['POST'])
  413. def skip_pattern():
  414. if not state.current_playlist:
  415. return jsonify({"success": False, "error": "No playlist is currently running"})
  416. state.skip_requested = True
  417. return jsonify({"success": True})
  418. def on_exit():
  419. """Function to execute on application shutdown."""
  420. logger.info("Shutting down gracefully, please wait for execution to complete")
  421. pattern_manager.stop_actions()
  422. state.save()
  423. mqtt.cleanup_mqtt()
  424. logger.info("Shutdown complete")
  425. def entrypoint():
  426. logger.info("Starting Dune Weaver application...")
  427. # Register the on_exit function
  428. atexit.register(on_exit)
  429. # Auto-connect to serial
  430. try:
  431. connection_manager.connect_device()
  432. except Exception as e:
  433. logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
  434. try:
  435. mqtt_handler = mqtt.init_mqtt()
  436. except Exception as e:
  437. logger.warning(f"Failed to initialize MQTT: {str(e)}")
  438. try:
  439. logger.info("Starting Flask server on port 8080...")
  440. # Run the Flask app
  441. app.run(debug=False, host='0.0.0.0', port=8080)
  442. except KeyboardInterrupt:
  443. logger.info("Keyboard interrupt received. Shutting down.")
  444. except Exception as e:
  445. logger.critical(f"Unexpected error during server startup: {str(e)}")
  446. finally:
  447. on_exit()
  448. if __name__ == "__main__":
  449. entrypoint()