app.py 19 KB

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