app.py 18 KB

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