1
0

app.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  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. from dune_weaver_flask.modules.led.led_controller import create_led_controller
  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 = WebSocketConnection('ws://fluidnc.local:81')
  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. logger.info(f'Successfully connected to serial port {port}')
  43. return jsonify({'success': True})
  44. except Exception as e:
  45. logger.error(f'Failed to connect to serial port {port}: {str(e)}')
  46. return jsonify({'error': str(e)}), 500
  47. @app.route('/disconnect', methods=['POST'])
  48. def disconnect():
  49. try:
  50. state.conn.close()
  51. logger.info('Successfully disconnected from serial port')
  52. return jsonify({'success': True})
  53. except Exception as e:
  54. logger.error(f'Failed to disconnect serial: {str(e)}')
  55. return jsonify({'error': str(e)}), 500
  56. @app.route('/restart_connection', methods=['POST'])
  57. def restart():
  58. port = request.json.get('port')
  59. if not port:
  60. logger.warning("Restart serial request received without port")
  61. return jsonify({'error': 'No port provided'}), 400
  62. try:
  63. logger.info(f"Restarting connection on port {port}")
  64. connection_manager.restart_connection()
  65. return jsonify({'success': True})
  66. except Exception as e:
  67. logger.error(f"Failed to restart serial on port {port}: {str(e)}")
  68. return jsonify({'error': str(e)}), 500
  69. @app.route('/list_theta_rho_files', methods=['GET'])
  70. def list_theta_rho_files():
  71. logger.debug("Listing theta-rho files")
  72. files = pattern_manager.list_theta_rho_files()
  73. return jsonify(sorted(files))
  74. @app.route('/upload_theta_rho', methods=['POST'])
  75. def upload_theta_rho():
  76. custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, 'custom_patterns')
  77. os.makedirs(custom_patterns_dir, exist_ok=True)
  78. logger.debug(f'Ensuring custom patterns directory exists: {custom_patterns_dir}')
  79. file = request.files['file']
  80. if file:
  81. file_path = os.path.join(custom_patterns_dir, file.filename)
  82. file.save(file_path)
  83. logger.info(f'Successfully uploaded theta-rho file: {file.filename}')
  84. return jsonify({'success': True})
  85. logger.warning('Upload theta-rho request received without file')
  86. return jsonify({'success': False})
  87. @app.route('/run_theta_rho', methods=['POST'])
  88. def run_theta_rho():
  89. file_name = request.json.get('file_name')
  90. pre_execution = request.json.get('pre_execution')
  91. if not file_name:
  92. logger.warning('Run theta-rho request received without file name')
  93. return jsonify({'error': 'No file name provided'}), 400
  94. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  95. if not os.path.exists(file_path):
  96. logger.error(f'Theta-rho file not found: {file_path}')
  97. return jsonify({'error': 'File not found'}), 404
  98. try:
  99. files_to_run = [file_path]
  100. logger.info(f'Running theta-rho file: {file_name} with pre_execution={pre_execution}')
  101. pattern_manager.run_theta_rho_files(files_to_run, clear_pattern=pre_execution)
  102. return jsonify({'success': True})
  103. except Exception as e:
  104. logger.error(f'Failed to run theta-rho file {file_name}: {str(e)}')
  105. return jsonify({'error': str(e)}), 500
  106. @app.route('/stop_execution', methods=['POST'])
  107. def stop_execution():
  108. pattern_manager.stop_actions()
  109. return jsonify({'success': True})
  110. @app.route('/send_home', methods=['POST'])
  111. def send_home():
  112. try:
  113. connection_manager.home()
  114. return jsonify({'success': True})
  115. except Exception as e:
  116. logger.error(f"Failed to send home command: {str(e)}")
  117. return jsonify({'error': str(e)}), 500
  118. @app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
  119. def run_specific_theta_rho_file(file_name):
  120. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  121. if not os.path.exists(file_path):
  122. return jsonify({'error': 'File not found'}), 404
  123. pattern_manager.run_theta_rho_file(file_path)
  124. return jsonify({'success': True})
  125. @app.route('/delete_theta_rho_file', methods=['POST'])
  126. def delete_theta_rho_file():
  127. file_name = request.json.get('file_name')
  128. if not file_name:
  129. logger.warning("Delete theta-rho file request received without filename")
  130. return jsonify({"success": False, "error": "No file name provided"}), 400
  131. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  132. if not os.path.exists(file_path):
  133. logger.error(f"Attempted to delete non-existent file: {file_path}")
  134. return jsonify({"success": False, "error": "File not found"}), 404
  135. try:
  136. os.remove(file_path)
  137. logger.info(f"Successfully deleted theta-rho file: {file_name}")
  138. return jsonify({"success": True})
  139. except Exception as e:
  140. logger.error(f"Failed to delete theta-rho file {file_name}: {str(e)}")
  141. return jsonify({"success": False, "error": str(e)}), 500
  142. @app.route('/move_to_center', methods=['POST'])
  143. def move_to_center():
  144. global current_theta
  145. try:
  146. if not state.conn.is_connected():
  147. logger.warning("Attempted to move to center without connection")
  148. return jsonify({"success": False, "error": "Connection not established"}), 400
  149. logger.info("Moving device to center position")
  150. pattern_manager.reset_theta()
  151. pattern_manager.move_polar(0, 0)
  152. return jsonify({"success": True})
  153. except Exception as e:
  154. logger.error(f"Failed to move to center: {str(e)}")
  155. return jsonify({"success": False, "error": str(e)}), 500
  156. @app.route('/move_to_perimeter', methods=['POST'])
  157. def move_to_perimeter():
  158. global current_theta
  159. try:
  160. if not state.conn.is_connected():
  161. logger.warning("Attempted to move to perimeter without connection")
  162. return jsonify({"success": False, "error": "Connection not established"}), 400
  163. pattern_manager.reset_theta()
  164. pattern_manager.move_polar(0,1)
  165. return jsonify({"success": True})
  166. except Exception as e:
  167. logger.error(f"Failed to move to perimeter: {str(e)}")
  168. return jsonify({"success": False, "error": str(e)}), 500
  169. @app.route('/preview_thr', methods=['POST'])
  170. def preview_thr():
  171. file_name = request.json.get('file_name')
  172. if not file_name:
  173. logger.warning("Preview theta-rho request received without filename")
  174. return jsonify({'error': 'No file name provided'}), 400
  175. file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
  176. if not os.path.exists(file_path):
  177. logger.error(f"Attempted to preview non-existent file: {file_path}")
  178. return jsonify({'error': 'File not found'}), 404
  179. try:
  180. coordinates = pattern_manager.parse_theta_rho_file(file_path)
  181. return jsonify({'success': True, 'coordinates': coordinates})
  182. except Exception as e:
  183. logger.error(f"Failed to generate preview for {file_name}: {str(e)}")
  184. return jsonify({'error': str(e)}), 500
  185. @app.route('/send_coordinate', methods=['POST'])
  186. def send_coordinate():
  187. if not state.conn.is_connected():
  188. logger.warning("Attempted to send coordinate without connection")
  189. return jsonify({"success": False, "error": "connection not established"}), 400
  190. try:
  191. data = request.json
  192. theta = data.get('theta')
  193. rho = data.get('rho')
  194. if theta is None or rho is None:
  195. logger.warning("Send coordinate request missing theta or rho values")
  196. return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
  197. logger.debug(f"Sending coordinate: theta={theta}, rho={rho}")
  198. pattern_manager.move_polar(theta, rho)
  199. return jsonify({"success": True})
  200. except Exception as e:
  201. logger.error(f"Failed to send coordinate: {str(e)}")
  202. return jsonify({"success": False, "error": str(e)}), 500
  203. @app.route('/download/<filename>', methods=['GET'])
  204. def download_file(filename):
  205. return send_from_directory(pattern_manager.THETA_RHO_DIR, filename)
  206. @app.route('/serial_status', methods=['GET'])
  207. def serial_status():
  208. connected = state.conn.is_connected()
  209. port = state.port
  210. logger.debug(f"Serial status check - connected: {connected}, port: {port}")
  211. return jsonify({
  212. 'connected': connected,
  213. 'port': port
  214. })
  215. @app.route('/pause_execution', methods=['POST'])
  216. def pause_execution():
  217. if pattern_manager.pause_execution():
  218. return jsonify({'success': True, 'message': 'Execution paused'})
  219. @app.route('/status', methods=['GET'])
  220. def get_status():
  221. return jsonify(pattern_manager.get_status())
  222. @app.route('/resume_execution', methods=['POST'])
  223. def resume_execution():
  224. if pattern_manager.resume_execution():
  225. return jsonify({'success': True, 'message': 'Execution resumed'})
  226. # Playlist endpoints
  227. @app.route("/list_all_playlists", methods=["GET"])
  228. def list_all_playlists():
  229. playlist_names = playlist_manager.list_all_playlists()
  230. return jsonify(playlist_names)
  231. @app.route("/get_playlist", methods=["GET"])
  232. def get_playlist():
  233. playlist_name = request.args.get("name", "")
  234. if not playlist_name:
  235. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  236. playlist = playlist_manager.get_playlist(playlist_name)
  237. if not playlist:
  238. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  239. return jsonify(playlist)
  240. @app.route("/create_playlist", methods=["POST"])
  241. def create_playlist():
  242. data = request.get_json()
  243. if not data or "name" not in data or "files" not in data:
  244. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  245. success = playlist_manager.create_playlist(data["name"], data["files"])
  246. return jsonify({
  247. "success": success,
  248. "message": f"Playlist '{data['name']}' created/updated"
  249. })
  250. @app.route("/modify_playlist", methods=["POST"])
  251. def modify_playlist():
  252. data = request.get_json()
  253. if not data or "name" not in data or "files" not in data:
  254. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  255. success = playlist_manager.modify_playlist(data["name"], data["files"])
  256. return jsonify({"success": success, "message": f"Playlist '{data['name']}' updated"})
  257. @app.route("/delete_playlist", methods=["DELETE"])
  258. def delete_playlist():
  259. data = request.get_json()
  260. if not data or "name" not in data:
  261. return jsonify({"success": False, "error": "Missing 'name' field"}), 400
  262. success = playlist_manager.delete_playlist(data["name"])
  263. if not success:
  264. return jsonify({"success": False, "error": f"Playlist '{data['name']}' not found"}), 404
  265. return jsonify({
  266. "success": True,
  267. "message": f"Playlist '{data['name']}' deleted"
  268. })
  269. @app.route('/add_to_playlist', methods=['POST'])
  270. def add_to_playlist():
  271. data = request.json
  272. playlist_name = data.get('playlist_name')
  273. pattern = data.get('pattern')
  274. success = playlist_manager.add_to_playlist(playlist_name, pattern)
  275. if not success:
  276. return jsonify(success=False, error='Playlist not found'), 404
  277. return jsonify(success=True)
  278. @app.route("/run_playlist", methods=["POST"])
  279. def run_playlist():
  280. data = request.get_json()
  281. if not data or "playlist_name" not in data:
  282. logger.warning("Run playlist request received without playlist name")
  283. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  284. playlist_name = data["playlist_name"]
  285. pause_time = data.get("pause_time", 0)
  286. clear_pattern = data.get("clear_pattern", None)
  287. run_mode = data.get("run_mode", "single")
  288. shuffle = data.get("shuffle", False)
  289. schedule_hours = None
  290. start_time = data.get("start_time")
  291. end_time = data.get("end_time")
  292. if start_time and end_time:
  293. try:
  294. start_time_obj = datetime.strptime(start_time, "%H:%M").time()
  295. end_time_obj = datetime.strptime(end_time, "%H:%M").time()
  296. if start_time_obj >= end_time_obj:
  297. logger.error(f"Invalid schedule times: start_time {start_time} >= end_time {end_time}")
  298. return jsonify({"success": False, "error": "'start_time' must be earlier than 'end_time'"}), 400
  299. schedule_hours = (start_time_obj, end_time_obj)
  300. logger.info(f"Playlist {playlist_name} scheduled to run between {start_time} and {end_time}")
  301. except ValueError:
  302. logger.error(f"Invalid time format provided: start_time={start_time}, end_time={end_time}")
  303. return jsonify({"success": False, "error": "Invalid time format. Use HH:MM (e.g., '09:30')"}), 400
  304. logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
  305. success, message = playlist_manager.run_playlist(
  306. playlist_name,
  307. pause_time=pause_time,
  308. clear_pattern=clear_pattern,
  309. run_mode=run_mode,
  310. shuffle=shuffle,
  311. schedule_hours=schedule_hours
  312. )
  313. if not success:
  314. logger.error(f"Failed to run playlist '{playlist_name}': {message}")
  315. return jsonify({"success": False, "error": message}), 500
  316. return jsonify({"success": True, "message": message})
  317. # Firmware endpoints
  318. @app.route('/set_speed', methods=['POST'])
  319. def set_speed():
  320. try:
  321. data = request.json
  322. new_speed = data.get('speed')
  323. if new_speed is None:
  324. logger.warning("Set speed request received without speed value")
  325. return jsonify({"success": False, "error": "Speed is required"}), 400
  326. if not isinstance(new_speed, (int, float)) or new_speed <= 0:
  327. logger.warning(f"Invalid speed value received: {new_speed}")
  328. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  329. state.speed = new_speed
  330. return jsonify({"success": True, "speed": new_speed})
  331. except Exception as e:
  332. logger.error(f"Failed to set speed: {str(e)}")
  333. return jsonify({"success": False, "error": str(e)}), 500
  334. @app.route('/check_software_update', methods=['GET'])
  335. def check_updates():
  336. update_info = update_manager.check_git_updates()
  337. return jsonify(update_info)
  338. @app.route('/update_software', methods=['POST'])
  339. def update_software():
  340. logger.info("Starting software update process")
  341. success, error_message, error_log = update_manager.update_software()
  342. if success:
  343. logger.info("Software update completed successfully")
  344. return jsonify({"success": True})
  345. else:
  346. logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
  347. return jsonify({
  348. "success": False,
  349. "error": error_message,
  350. "details": error_log
  351. }), 500
  352. # LED strip control endpoints
  353. @app.route('/api/led/color', methods=['POST'])
  354. def set_led_color():
  355. """Set LED strip color."""
  356. data = request.get_json()
  357. if not data or 'color' not in data:
  358. return jsonify({'error': 'No color provided'}), 400
  359. try:
  360. color = tuple(data['color']) # Expect [R,G,B]
  361. state.led_controller.set_color(color)
  362. return jsonify({'status': 'success'})
  363. except Exception as e:
  364. return jsonify({'error': str(e)}), 400
  365. @app.route('/api/led/brightness', methods=['POST'])
  366. def set_led_brightness():
  367. """Set LED strip brightness."""
  368. data = request.get_json()
  369. if not data or 'brightness' not in data:
  370. return jsonify({'error': 'No brightness provided'}), 400
  371. try:
  372. brightness = int(data['brightness']) # 0-255
  373. state.led_controller.set_brightness(brightness)
  374. return jsonify({'status': 'success'})
  375. except Exception as e:
  376. return jsonify({'error': str(e)}), 400
  377. @app.route('/api/led/animation', methods=['POST'])
  378. def set_led_animation():
  379. """Start LED animation."""
  380. data = request.get_json()
  381. if not data or 'animation' not in data:
  382. return jsonify({'error': 'No animation type provided'}), 400
  383. try:
  384. animation = data['animation'] # 'rainbow', 'wave', 'fade'
  385. state.led_controller.start_animation(animation)
  386. return jsonify({'status': 'success'})
  387. except Exception as e:
  388. return jsonify({'error': str(e)}), 400
  389. @app.route('/api/led/animation/stop', methods=['POST'])
  390. def stop_led_animation():
  391. """Stop current LED animation."""
  392. try:
  393. state.led_controller.stop_current_animation()
  394. return jsonify({'status': 'success'})
  395. except Exception as e:
  396. return jsonify({'error': str(e)}), 400
  397. @app.route('/api/led/speed', methods=['POST'])
  398. def set_led_speed():
  399. """Set LED animation speed."""
  400. data = request.get_json()
  401. if not data or 'speed' not in data:
  402. return jsonify({'error': 'No speed provided'}), 400
  403. try:
  404. speed = int(data['speed']) # 1-100
  405. state.led_controller.set_animation_speed(speed)
  406. return jsonify({'status': 'success'})
  407. except Exception as e:
  408. return jsonify({'error': str(e)}), 400
  409. @app.route('/api/led/status', methods=['GET'])
  410. def get_led_status():
  411. """Get current LED strip status."""
  412. try:
  413. status = state.led_controller.get_status()
  414. return jsonify(status)
  415. except Exception as e:
  416. return jsonify({'error': str(e)}), 400
  417. @app.route('/api/led/power', methods=['POST'])
  418. def set_led_power():
  419. """Set LED strip power state."""
  420. data = request.get_json()
  421. if not data or 'state' not in data:
  422. return jsonify({'error': 'No power state provided'}), 400
  423. try:
  424. if data['state']:
  425. state.led_controller.turn_on()
  426. else:
  427. state.led_controller.turn_off()
  428. return jsonify({'status': 'success'})
  429. except Exception as e:
  430. return jsonify({'error': str(e)}), 400
  431. def on_exit():
  432. """Function to execute on application shutdown."""
  433. logger.info("Shutting down gracefully, please wait for execution to complete")
  434. pattern_manager.stop_actions()
  435. state.save()
  436. mqtt.cleanup_mqtt()
  437. def entrypoint():
  438. logger.info("Starting Dune Weaver application...")
  439. # Register the on_exit function
  440. atexit.register(on_exit)
  441. try:
  442. led_controller = create_led_controller(led_count=133, led_pin=18)
  443. led_controller.startup_indication()
  444. state.led_controller = led_controller
  445. except Exception as e:
  446. logger.warning(f"Cannot init LED: {e}")
  447. # Auto-connect to serial
  448. try:
  449. connection_manager.connect_device()
  450. logger.info("LED startup indication completed")
  451. except Exception as e:
  452. logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
  453. try:
  454. mqtt_handler = mqtt.init_mqtt()
  455. except Exception as e:
  456. logger.warning(f"Failed to initialize MQTT: {str(e)}")
  457. try:
  458. logger.info("Starting Flask server on port 8080...")
  459. app.run(debug=False, host='0.0.0.0', port=8080)
  460. except KeyboardInterrupt:
  461. logger.info("Keyboard interrupt received. Shutting down.")
  462. except Exception as e:
  463. logger.critical(f"Unexpected error during server startup: {str(e)}")
  464. finally:
  465. on_exit()