app.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. from flask import Flask, request, jsonify, render_template
  2. import os
  3. import serial
  4. import time
  5. import random
  6. import threading
  7. import serial.tools.list_ports
  8. import math
  9. import json
  10. from datetime import datetime
  11. import subprocess
  12. app = Flask(__name__)
  13. # Configuration
  14. THETA_RHO_DIR = './patterns'
  15. IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
  16. CLEAR_PATTERNS = {
  17. "clear_from_in": "./patterns/clear_from_in.thr",
  18. "clear_from_out": "./patterns/clear_from_out.thr",
  19. "clear_sideway": "./patterns/clear_sideway.thr"
  20. }
  21. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  22. # Serial connection (First available will be selected by default)
  23. ser = None
  24. ser_port = None # Global variable to store the serial port name
  25. stop_requested = False
  26. pause_requested = False
  27. pause_condition = threading.Condition()
  28. # Global variables to store device information
  29. arduino_table_name = None
  30. arduino_driver_type = 'Unknown'
  31. # Table status
  32. current_playing_file = None
  33. execution_progress = None
  34. firmware_version = 'Unknown'
  35. current_playing_index = None
  36. current_playlist = None
  37. is_clearing = False
  38. serial_lock = threading.Lock()
  39. PLAYLISTS_FILE = os.path.join(os.getcwd(), "playlists.json")
  40. MOTOR_TYPE_MAPPING = {
  41. "TMC2209": "./firmware/arduino_code_TMC2209/arduino_code_TMC2209.ino",
  42. "DRV8825": "./firmware/arduino_code/arduino_code.ino",
  43. "esp32": "./firmware/esp32/esp32.ino"
  44. }
  45. # Ensure the file exists and contains at least an empty JSON object
  46. if not os.path.exists(PLAYLISTS_FILE):
  47. with open(PLAYLISTS_FILE, "w") as f:
  48. json.dump({}, f, indent=2)
  49. def get_ino_firmware_details(ino_file_path):
  50. """
  51. Extract firmware details, including version and motor type, from the given .ino file.
  52. Args:
  53. ino_file_path (str): Path to the .ino file.
  54. Returns:
  55. dict: Dictionary containing firmware details such as version and motor type, or None if not found.
  56. """
  57. try:
  58. if not ino_file_path:
  59. raise ValueError("Invalid path: ino_file_path is None or empty.")
  60. firmware_details = {"version": None, "motorType": None}
  61. with open(ino_file_path, "r") as file:
  62. for line in file:
  63. # Extract firmware version
  64. if "firmwareVersion" in line:
  65. start = line.find('"') + 1
  66. end = line.rfind('"')
  67. if start != -1 and end != -1 and start < end:
  68. firmware_details["version"] = line[start:end]
  69. # Extract motor type
  70. if "motorType" in line:
  71. start = line.find('"') + 1
  72. end = line.rfind('"')
  73. if start != -1 and end != -1 and start < end:
  74. firmware_details["motorType"] = line[start:end]
  75. if not firmware_details["version"]:
  76. print(f"Firmware version not found in file: {ino_file_path}")
  77. if not firmware_details["motorType"]:
  78. print(f"Motor type not found in file: {ino_file_path}")
  79. return firmware_details if any(firmware_details.values()) else None
  80. except FileNotFoundError:
  81. print(f"File not found: {ino_file_path}")
  82. return None
  83. except Exception as e:
  84. print(f"Error reading .ino file: {str(e)}")
  85. return None
  86. def list_serial_ports():
  87. """Return a list of available serial ports."""
  88. ports = serial.tools.list_ports.comports()
  89. return [port.device for port in ports if port.device not in IGNORE_PORTS]
  90. def connect_to_serial(port=None, baudrate=115200):
  91. """Automatically connect to the first available serial port or a specified port."""
  92. global ser, ser_port, arduino_table_name, arduino_driver_type, firmware_version
  93. try:
  94. if port is None:
  95. ports = list_serial_ports()
  96. if not ports:
  97. print("No serial port connected")
  98. return False
  99. port = ports[0] # Auto-select the first available port
  100. with serial_lock:
  101. if ser and ser.is_open:
  102. ser.close()
  103. ser = serial.Serial(port, baudrate, timeout=2) # Set timeout to avoid infinite waits
  104. ser_port = port # Store the connected port globally
  105. print(f"Connected to serial port: {port}")
  106. time.sleep(2) # Allow time for the connection to establish
  107. # Read initial startup messages from Arduino
  108. arduino_table_name = None
  109. arduino_driver_type = None
  110. while ser.in_waiting > 0:
  111. line = ser.readline().decode().strip()
  112. print(f"Arduino: {line}") # Print the received message
  113. # Store the device details based on the expected messages
  114. if "Table:" in line:
  115. arduino_table_name = line.replace("Table: ", "").strip()
  116. elif "Drivers:" in line:
  117. arduino_driver_type = line.replace("Drivers: ", "").strip()
  118. elif "Version:" in line:
  119. firmware_version = line.replace("Version: ", "").strip()
  120. # Display stored values
  121. print(f"Detected Table: {arduino_table_name or 'Unknown'}")
  122. print(f"Detected Drivers: {arduino_driver_type or 'Unknown'}")
  123. return True # Successfully connected
  124. except serial.SerialException as e:
  125. print(f"Failed to connect to serial port {port}: {e}")
  126. port = None # Reset the port to try the next available one
  127. print("Max retries reached. Could not connect to a serial port.")
  128. return False
  129. def disconnect_serial():
  130. """Disconnect the current serial connection."""
  131. global ser, ser_port
  132. if ser and ser.is_open:
  133. ser.close()
  134. ser = None
  135. ser_port = None # Reset the port name
  136. def restart_serial(port, baudrate=115200):
  137. """Restart the serial connection."""
  138. disconnect_serial()
  139. connect_to_serial(port, baudrate)
  140. def parse_theta_rho_file(file_path):
  141. """
  142. Parse a theta-rho file and return a list of (theta, rho) pairs.
  143. Normalizes the list so the first theta is always 0.
  144. """
  145. coordinates = []
  146. try:
  147. with open(file_path, 'r') as file:
  148. for line in file:
  149. line = line.strip()
  150. # Skip header or comment lines (starting with '#' or empty lines)
  151. if not line or line.startswith("#"):
  152. continue
  153. # Parse lines with theta and rho separated by spaces
  154. try:
  155. theta, rho = map(float, line.split())
  156. coordinates.append((theta, rho))
  157. except ValueError:
  158. print(f"Skipping invalid line: {line}")
  159. continue
  160. except Exception as e:
  161. print(f"Error reading file: {e}")
  162. return coordinates
  163. # ---- Normalization Step ----
  164. if coordinates:
  165. # Take the first coordinate's theta
  166. first_theta = coordinates[0][0]
  167. # Shift all thetas so the first coordinate has theta=0
  168. normalized = []
  169. for (theta, rho) in coordinates:
  170. normalized.append((theta - first_theta, rho))
  171. # Replace original list with normalized data
  172. coordinates = normalized
  173. return coordinates
  174. def send_coordinate_batch(ser, coordinates):
  175. """Send a batch of theta-rho pairs to the Arduino."""
  176. # print("Sending batch:", coordinates)
  177. batch_str = ";".join(f"{theta:.5f},{rho:.5f}" for theta, rho in coordinates) + ";\n"
  178. ser.write(batch_str.encode())
  179. def send_command(command):
  180. """Send a single command to the Arduino."""
  181. ser.write(f"{command}\n".encode())
  182. print(f"Sent: {command}")
  183. # Wait for "R" acknowledgment from Arduino
  184. while True:
  185. with serial_lock:
  186. if ser.in_waiting > 0:
  187. response = ser.readline().decode().strip()
  188. print(f"Arduino response: {response}")
  189. if response == "R":
  190. print("Command execution completed.")
  191. break
  192. def wait_for_start_time(schedule_hours):
  193. """
  194. Keep checking every 30 seconds if the time is within the schedule to resume execution.
  195. """
  196. global pause_requested
  197. start_time, end_time = schedule_hours
  198. while pause_requested:
  199. now = datetime.now().time()
  200. if start_time <= now < end_time:
  201. print("Resuming execution: Within schedule.")
  202. pause_requested = False
  203. with pause_condition:
  204. pause_condition.notify_all()
  205. break # Exit the loop once resumed
  206. else:
  207. time.sleep(30) # Wait for 30 seconds before checking again
  208. # Function to check schedule based on start and end time
  209. def schedule_checker(schedule_hours):
  210. """
  211. Pauses/resumes execution based on a given time range.
  212. Parameters:
  213. - schedule_hours (tuple): (start_time, end_time) as `datetime.time` objects.
  214. """
  215. global pause_requested
  216. if not schedule_hours:
  217. return # No scheduling restriction
  218. start_time, end_time = schedule_hours
  219. now = datetime.now().time() # Get the current time as `datetime.time`
  220. # Check if we are currently within the scheduled time
  221. if start_time <= now < end_time:
  222. if pause_requested:
  223. print("Starting execution: Within schedule.")
  224. pause_requested = False # Resume execution
  225. with pause_condition:
  226. pause_condition.notify_all()
  227. else:
  228. if not pause_requested:
  229. print("Pausing execution: Outside schedule.")
  230. pause_requested = True # Pause execution
  231. # Start a background thread to periodically check for start time
  232. threading.Thread(target=wait_for_start_time, args=(schedule_hours,), daemon=True).start()
  233. def run_theta_rho_file(file_path, schedule_hours=None):
  234. """Run a theta-rho file by sending data in optimized batches."""
  235. global stop_requested, current_playing_file, execution_progress
  236. stop_requested = False
  237. current_playing_file = file_path # Track current playing file
  238. execution_progress = (0, 0) # Reset progress
  239. coordinates = parse_theta_rho_file(file_path)
  240. total_coordinates = len(coordinates)
  241. if total_coordinates < 2:
  242. print("Not enough coordinates for interpolation.")
  243. current_playing_file = None # Clear tracking if failed
  244. execution_progress = None
  245. return
  246. execution_progress = (0, total_coordinates) # Update total coordinates
  247. batch_size = 10 # Smaller batches may smooth movement further
  248. for i in range(0, total_coordinates, batch_size):
  249. if stop_requested:
  250. print("Execution stopped by user after completing the current batch.")
  251. break
  252. with pause_condition:
  253. while pause_requested:
  254. print("Execution paused...")
  255. pause_condition.wait() # This will block execution until notified
  256. batch = coordinates[i:i + batch_size]
  257. if i == 0:
  258. send_coordinate_batch(ser, batch)
  259. execution_progress = (i + batch_size, total_coordinates) # Update progress
  260. continue
  261. while True:
  262. schedule_checker(schedule_hours) # Check if within schedule
  263. with serial_lock:
  264. if ser.in_waiting > 0:
  265. response = ser.readline().decode().strip()
  266. if response == "R":
  267. send_coordinate_batch(ser, batch)
  268. execution_progress = (i + batch_size, total_coordinates) # Update progress
  269. break
  270. else:
  271. print(f"Arduino response: {response}")
  272. reset_theta()
  273. ser.write("FINISHED\n".encode())
  274. # Clear tracking variables when done
  275. current_playing_file = None
  276. execution_progress = None
  277. print("Pattern execution completed.")
  278. def get_clear_pattern_file(pattern_name):
  279. """Return a .thr file path based on pattern_name."""
  280. if pattern_name == "random":
  281. # Randomly pick one of the three known patterns
  282. return random.choice(list(CLEAR_PATTERNS.values()))
  283. # If pattern_name is invalid or absent, default to 'clear_from_in'
  284. return CLEAR_PATTERNS.get(pattern_name, CLEAR_PATTERNS["clear_from_in"])
  285. def run_theta_rho_files(
  286. file_paths,
  287. pause_time=0,
  288. clear_pattern=None,
  289. run_mode="single",
  290. shuffle=False,
  291. schedule_hours=None
  292. ):
  293. """
  294. Runs multiple .thr files in sequence with options for pausing, clearing, shuffling, and looping.
  295. Parameters:
  296. - file_paths (list): List of file paths to run.
  297. - pause_time (float): Seconds to pause between patterns.
  298. - clear_pattern (str): Specific clear pattern to run ("clear_in", "clear_out", "clear_sideway", or "random").
  299. - run_mode (str): "single" for one-time run or "indefinite" for looping.
  300. - shuffle (bool): Whether to shuffle the playlist before running.
  301. """
  302. global stop_requested
  303. global current_playlist
  304. global current_playing_index
  305. stop_requested = False # Reset stop flag at the start
  306. if shuffle:
  307. random.shuffle(file_paths)
  308. print("Playlist shuffled.")
  309. current_playlist = file_paths
  310. while True:
  311. for idx, path in enumerate(file_paths):
  312. current_playing_index = idx
  313. schedule_checker(schedule_hours)
  314. if stop_requested:
  315. print("Execution stopped before starting next pattern.")
  316. return
  317. if clear_pattern:
  318. if stop_requested:
  319. print("Execution stopped before running the next clear pattern.")
  320. return
  321. # Determine the clear pattern to run
  322. clear_file_path = get_clear_pattern_file(clear_pattern)
  323. print(f"Running clear pattern: {clear_file_path}")
  324. run_theta_rho_file(clear_file_path, schedule_hours)
  325. if not stop_requested:
  326. # Run the main pattern
  327. print(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
  328. run_theta_rho_file(path, schedule_hours)
  329. if idx < len(file_paths) -1:
  330. if stop_requested:
  331. print("Execution stopped before running the next clear pattern.")
  332. return
  333. # Pause after each pattern if requested
  334. if pause_time > 0:
  335. print(f"Pausing for {pause_time} seconds...")
  336. time.sleep(pause_time)
  337. # After completing the playlist
  338. if run_mode == "indefinite":
  339. print("Playlist completed. Restarting as per 'indefinite' run mode.")
  340. if pause_time > 0:
  341. print(f"Pausing for {pause_time} seconds before restarting...")
  342. time.sleep(pause_time)
  343. if shuffle:
  344. random.shuffle(file_paths)
  345. print("Playlist reshuffled for the next loop.")
  346. continue
  347. else:
  348. print("Playlist completed.")
  349. break
  350. # Reset theta after execution or stopping
  351. reset_theta()
  352. ser.write("FINISHED\n".encode())
  353. print("All requested patterns completed (or stopped).")
  354. def reset_theta():
  355. """Reset theta on the Arduino."""
  356. ser.write("RESET_THETA\n".encode())
  357. while True:
  358. with serial_lock:
  359. if ser.in_waiting > 0:
  360. response = ser.readline().decode().strip()
  361. print(f"Arduino response: {response}")
  362. if response == "THETA_RESET":
  363. print("Theta successfully reset.")
  364. break
  365. time.sleep(0.5) # Small delay to avoid busy waiting
  366. # Flask API Endpoints
  367. @app.route('/')
  368. def index():
  369. return render_template('index.html')
  370. @app.route('/list_serial_ports', methods=['GET'])
  371. def list_ports():
  372. return jsonify(list_serial_ports())
  373. @app.route('/connect_serial', methods=['POST'])
  374. def connect_serial():
  375. port = request.json.get('port')
  376. if not port:
  377. return jsonify({'error': 'No port provided'}), 400
  378. try:
  379. connect_to_serial(port)
  380. return jsonify({'success': True})
  381. except Exception as e:
  382. return jsonify({'error': str(e)}), 500
  383. @app.route('/disconnect_serial', methods=['POST'])
  384. def disconnect():
  385. try:
  386. disconnect_serial()
  387. return jsonify({'success': True})
  388. except Exception as e:
  389. return jsonify({'error': str(e)}), 500
  390. @app.route('/restart_serial', methods=['POST'])
  391. def restart():
  392. port = request.json.get('port')
  393. if not port:
  394. return jsonify({'error': 'No port provided'}), 400
  395. try:
  396. restart_serial(port)
  397. return jsonify({'success': True})
  398. except Exception as e:
  399. return jsonify({'error': str(e)}), 500
  400. @app.route('/list_theta_rho_files', methods=['GET'])
  401. def list_theta_rho_files():
  402. files = []
  403. for root, _, filenames in os.walk(THETA_RHO_DIR):
  404. for file in filenames:
  405. # Construct the relative file path
  406. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  407. files.append(relative_path)
  408. return jsonify(sorted(files))
  409. @app.route('/upload_theta_rho', methods=['POST'])
  410. def upload_theta_rho():
  411. custom_patterns_dir = os.path.join(THETA_RHO_DIR, 'custom_patterns')
  412. os.makedirs(custom_patterns_dir, exist_ok=True) # Ensure the directory exists
  413. file = request.files['file']
  414. if file:
  415. file.save(os.path.join(custom_patterns_dir, file.filename))
  416. return jsonify({'success': True})
  417. return jsonify({'success': False})
  418. @app.route('/run_theta_rho', methods=['POST'])
  419. def run_theta_rho():
  420. file_name = request.json.get('file_name')
  421. pre_execution = request.json.get('pre_execution') # 'clear_in', 'clear_out', 'clear_sideway', or 'none'
  422. if not file_name:
  423. return jsonify({'error': 'No file name provided'}), 400
  424. file_path = os.path.join(THETA_RHO_DIR, file_name)
  425. if not os.path.exists(file_path):
  426. return jsonify({'error': 'File not found'}), 404
  427. try:
  428. # Build a list of files to run in sequence
  429. files_to_run = []
  430. if pre_execution == 'clear_in':
  431. files_to_run.append('./patterns/clear_from_in.thr')
  432. elif pre_execution == 'clear_out':
  433. files_to_run.append('./patterns/clear_from_out.thr')
  434. elif pre_execution == 'clear_sideway':
  435. files_to_run.append('./patterns/clear_sideway.thr')
  436. elif pre_execution == 'none':
  437. pass # No pre-execution action required
  438. # Finally, add the main file
  439. files_to_run.append(file_path)
  440. # Run them in one shot using run_theta_rho_files (blocking call)
  441. threading.Thread(
  442. target=run_theta_rho_files,
  443. args=(files_to_run,),
  444. kwargs={
  445. 'pause_time': 0,
  446. 'clear_pattern': None
  447. }
  448. ).start()
  449. return jsonify({'success': True})
  450. except Exception as e:
  451. return jsonify({'error': str(e)}), 500
  452. @app.route('/stop_execution', methods=['POST'])
  453. def stop_execution():
  454. global stop_requested
  455. stop_requested = True
  456. current_playing_index = None
  457. current_playlist = None
  458. is_clearing = False
  459. current_playing_file = None
  460. execution_progress = None
  461. return jsonify({'success': True})
  462. @app.route('/send_home', methods=['POST'])
  463. def send_home():
  464. """Send the HOME command to the Arduino."""
  465. try:
  466. send_command("HOME")
  467. return jsonify({'success': True})
  468. except Exception as e:
  469. return jsonify({'error': str(e)}), 500
  470. @app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
  471. def run_specific_theta_rho_file(file_name):
  472. """Run a specific theta-rho file."""
  473. file_path = os.path.join(THETA_RHO_DIR, file_name)
  474. if not os.path.exists(file_path):
  475. return jsonify({'error': 'File not found'}), 404
  476. threading.Thread(target=run_theta_rho_file, args=(file_path,)).start()
  477. return jsonify({'success': True})
  478. @app.route('/delete_theta_rho_file', methods=['POST'])
  479. def delete_theta_rho_file():
  480. data = request.json
  481. file_name = data.get('file_name')
  482. if not file_name:
  483. return jsonify({"success": False, "error": "No file name provided"}), 400
  484. file_path = os.path.join(THETA_RHO_DIR, file_name)
  485. if not os.path.exists(file_path):
  486. return jsonify({"success": False, "error": "File not found"}), 404
  487. try:
  488. os.remove(file_path)
  489. return jsonify({"success": True})
  490. except Exception as e:
  491. return jsonify({"success": False, "error": str(e)}), 500
  492. @app.route('/move_to_center', methods=['POST'])
  493. def move_to_center():
  494. """Move the sand table to the center position."""
  495. try:
  496. if ser is None or not ser.is_open:
  497. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  498. coordinates = [(0, 0)] # Center position
  499. send_coordinate_batch(ser, coordinates)
  500. return jsonify({"success": True})
  501. except Exception as e:
  502. return jsonify({"success": False, "error": str(e)}), 500
  503. @app.route('/move_to_perimeter', methods=['POST'])
  504. def move_to_perimeter():
  505. """Move the sand table to the perimeter position."""
  506. try:
  507. if ser is None or not ser.is_open:
  508. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  509. MAX_RHO = 1
  510. coordinates = [(0, MAX_RHO)] # Perimeter position
  511. send_coordinate_batch(ser, coordinates)
  512. return jsonify({"success": True})
  513. except Exception as e:
  514. return jsonify({"success": False, "error": str(e)}), 500
  515. @app.route('/preview_thr', methods=['POST'])
  516. def preview_thr():
  517. file_name = request.json.get('file_name')
  518. if not file_name:
  519. return jsonify({'error': 'No file name provided'}), 400
  520. file_path = os.path.join(THETA_RHO_DIR, file_name)
  521. if not os.path.exists(file_path):
  522. return jsonify({'error': 'File not found'}), 404
  523. try:
  524. # Parse the .thr file with transformations
  525. coordinates = parse_theta_rho_file(file_path)
  526. return jsonify({'success': True, 'coordinates': coordinates})
  527. except Exception as e:
  528. return jsonify({'error': str(e)}), 500
  529. @app.route('/send_coordinate', methods=['POST'])
  530. def send_coordinate():
  531. """Send a single (theta, rho) coordinate to the Arduino."""
  532. global ser
  533. if ser is None or not ser.is_open:
  534. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  535. try:
  536. data = request.json
  537. theta = data.get('theta')
  538. rho = data.get('rho')
  539. if theta is None or rho is None:
  540. return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
  541. # Send the coordinate to the Arduino
  542. send_coordinate_batch(ser, [(theta, rho)])
  543. reset_theta()
  544. return jsonify({"success": True})
  545. except Exception as e:
  546. return jsonify({"success": False, "error": str(e)}), 500
  547. # Expose files for download if needed
  548. @app.route('/download/<filename>', methods=['GET'])
  549. def download_file(filename):
  550. """Download a file from the theta-rho directory."""
  551. return send_from_directory(THETA_RHO_DIR, filename)
  552. @app.route('/serial_status', methods=['GET'])
  553. def serial_status():
  554. global ser, ser_port
  555. return jsonify({
  556. 'connected': ser.is_open if ser else False,
  557. 'port': ser_port # Include the port name
  558. })
  559. @app.route('/pause_execution', methods=['POST'])
  560. def pause_execution():
  561. """Pause the current execution."""
  562. global pause_requested
  563. with pause_condition:
  564. pause_requested = True
  565. return jsonify({'success': True, 'message': 'Execution paused'})
  566. @app.route('/status', methods=['GET'])
  567. def get_status():
  568. """Returns the current status of the sand table."""
  569. global is_clearing
  570. if current_playing_file in CLEAR_PATTERNS.values():
  571. is_clearing = True
  572. else:
  573. is_clearing = False
  574. return jsonify({
  575. "ser_port": ser_port,
  576. "stop_requested": stop_requested,
  577. "pause_requested": pause_requested,
  578. "current_playing_file": current_playing_file,
  579. "execution_progress": execution_progress,
  580. "current_playing_index": current_playing_index,
  581. "current_playlist": current_playlist,
  582. "is_clearing": is_clearing
  583. })
  584. @app.route('/resume_execution', methods=['POST'])
  585. def resume_execution():
  586. """Resume execution after pausing."""
  587. global pause_requested
  588. with pause_condition:
  589. pause_requested = False
  590. pause_condition.notify_all() # Unblock the waiting thread
  591. return jsonify({'success': True, 'message': 'Execution resumed'})
  592. def load_playlists():
  593. """
  594. Load the entire playlists dictionary from the JSON file.
  595. Returns something like: {
  596. "My Playlist": ["file1.thr", "file2.thr"],
  597. "Another": ["x.thr"]
  598. }
  599. """
  600. with open(PLAYLISTS_FILE, "r") as f:
  601. return json.load(f)
  602. def save_playlists(playlists_dict):
  603. """
  604. Save the entire playlists dictionary back to the JSON file.
  605. """
  606. with open(PLAYLISTS_FILE, "w") as f:
  607. json.dump(playlists_dict, f, indent=2)
  608. @app.route("/list_all_playlists", methods=["GET"])
  609. def list_all_playlists():
  610. """
  611. Returns a list of all playlist names.
  612. Example return: ["My Playlist", "Another Playlist"]
  613. """
  614. playlists_dict = load_playlists()
  615. playlist_names = list(playlists_dict.keys())
  616. return jsonify(playlist_names)
  617. @app.route("/get_playlist", methods=["GET"])
  618. def get_playlist():
  619. """
  620. GET /get_playlist?name=My%20Playlist
  621. Returns: { "name": "My Playlist", "files": [... ] }
  622. """
  623. playlist_name = request.args.get("name", "")
  624. if not playlist_name:
  625. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  626. playlists_dict = load_playlists()
  627. if playlist_name not in playlists_dict:
  628. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  629. files = playlists_dict[playlist_name] # e.g. ["file1.thr", "file2.thr"]
  630. return jsonify({
  631. "name": playlist_name,
  632. "files": files
  633. })
  634. @app.route("/create_playlist", methods=["POST"])
  635. def create_playlist():
  636. """
  637. POST /create_playlist
  638. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  639. Creates or overwrites a playlist with the given name.
  640. """
  641. data = request.get_json()
  642. if not data or "name" not in data or "files" not in data:
  643. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  644. playlist_name = data["name"]
  645. files = data["files"]
  646. # Load all playlists
  647. playlists_dict = load_playlists()
  648. # Overwrite or create new
  649. playlists_dict[playlist_name] = files
  650. # Save changes
  651. save_playlists(playlists_dict)
  652. return jsonify({
  653. "success": True,
  654. "message": f"Playlist '{playlist_name}' created/updated"
  655. })
  656. @app.route("/modify_playlist", methods=["POST"])
  657. def modify_playlist():
  658. """
  659. POST /modify_playlist
  660. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  661. Updates (or creates) the existing playlist with a new file list.
  662. You can 404 if you only want to allow modifications to existing playlists.
  663. """
  664. data = request.get_json()
  665. if not data or "name" not in data or "files" not in data:
  666. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  667. playlist_name = data["name"]
  668. files = data["files"]
  669. # Load all playlists
  670. playlists_dict = load_playlists()
  671. # Optional: If you want to disallow creating a new playlist here:
  672. # if playlist_name not in playlists_dict:
  673. # return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  674. # Overwrite or create new
  675. playlists_dict[playlist_name] = files
  676. # Save
  677. save_playlists(playlists_dict)
  678. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' updated"})
  679. @app.route("/delete_playlist", methods=["DELETE"])
  680. def delete_playlist():
  681. """
  682. DELETE /delete_playlist
  683. Body: { "name": "My Playlist" }
  684. Removes the playlist from the single JSON file.
  685. """
  686. data = request.get_json()
  687. if not data or "name" not in data:
  688. return jsonify({"success": False, "error": "Missing 'name' field"}), 400
  689. playlist_name = data["name"]
  690. playlists_dict = load_playlists()
  691. if playlist_name not in playlists_dict:
  692. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  693. # Remove from dict
  694. del playlists_dict[playlist_name]
  695. save_playlists(playlists_dict)
  696. return jsonify({
  697. "success": True,
  698. "message": f"Playlist '{playlist_name}' deleted"
  699. })
  700. @app.route('/add_to_playlist', methods=['POST'])
  701. def add_to_playlist():
  702. data = request.json
  703. playlist_name = data.get('playlist_name')
  704. pattern = data.get('pattern')
  705. # Load existing playlists
  706. with open('playlists.json', 'r') as f:
  707. playlists = json.load(f)
  708. # Add pattern to the selected playlist
  709. if playlist_name in playlists:
  710. playlists[playlist_name].append(pattern)
  711. with open('playlists.json', 'w') as f:
  712. json.dump(playlists, f)
  713. return jsonify(success=True)
  714. else:
  715. return jsonify(success=False, error='Playlist not found'), 404
  716. @app.route("/run_playlist", methods=["POST"])
  717. def run_playlist():
  718. """
  719. POST /run_playlist
  720. Body (JSON):
  721. {
  722. "playlist_name": "My Playlist",
  723. "pause_time": 1.0, # Optional: seconds to pause between patterns
  724. "clear_pattern": "random", # Optional: "clear_in", "clear_out", "clear_sideway", or "random"
  725. "run_mode": "single", # 'single' or 'indefinite'
  726. "shuffle": True # true or false
  727. "start_time": ""
  728. "end_time": ""
  729. }
  730. """
  731. data = request.get_json()
  732. # Validate input
  733. if not data or "playlist_name" not in data:
  734. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  735. playlist_name = data["playlist_name"]
  736. pause_time = data.get("pause_time", 0)
  737. clear_pattern = data.get("clear_pattern", None)
  738. run_mode = data.get("run_mode", "single") # Default to 'single' run
  739. shuffle = data.get("shuffle", False) # Default to no shuffle
  740. start_time = data.get("start_time", None)
  741. end_time = data.get("end_time", None)
  742. # Validate pause_time
  743. if not isinstance(pause_time, (int, float)) or pause_time < 0:
  744. return jsonify({"success": False, "error": "'pause_time' must be a non-negative number"}), 400
  745. # Validate clear_pattern
  746. valid_patterns = ["clear_in", "clear_out", "clear_sideway", "random"]
  747. if clear_pattern not in valid_patterns:
  748. clear_pattern = None
  749. # Validate run_mode
  750. if run_mode not in ["single", "indefinite"]:
  751. return jsonify({"success": False, "error": "'run_mode' must be 'single' or 'indefinite'"}), 400
  752. # Validate shuffle
  753. if not isinstance(shuffle, bool):
  754. return jsonify({"success": False, "error": "'shuffle' must be a boolean value"}), 400
  755. schedule_hours = None
  756. if start_time and end_time:
  757. try:
  758. # Convert HH:MM to datetime.time objects
  759. start_time_obj = datetime.strptime(start_time, "%H:%M").time()
  760. end_time_obj = datetime.strptime(end_time, "%H:%M").time()
  761. # Ensure start_time is before end_time
  762. if start_time_obj >= end_time_obj:
  763. return jsonify({"success": False, "error": "'start_time' must be earlier than 'end_time'"}), 400
  764. # Create schedule tuple with full time
  765. schedule_hours = (start_time_obj, end_time_obj)
  766. except ValueError:
  767. return jsonify({"success": False, "error": "Invalid time format. Use HH:MM (e.g., '09:30')"}), 400
  768. # Load playlists
  769. playlists = load_playlists()
  770. if playlist_name not in playlists:
  771. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  772. file_paths = playlists[playlist_name]
  773. file_paths = [os.path.join(THETA_RHO_DIR, file) for file in file_paths]
  774. if not file_paths:
  775. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' is empty"}), 400
  776. # Start the playlist execution in a separate thread
  777. try:
  778. threading.Thread(
  779. target=run_theta_rho_files,
  780. args=(file_paths,),
  781. kwargs={
  782. 'pause_time': pause_time,
  783. 'clear_pattern': clear_pattern,
  784. 'run_mode': run_mode,
  785. 'shuffle': shuffle,
  786. 'schedule_hours': schedule_hours
  787. },
  788. daemon=True # Daemonize thread to exit with the main program
  789. ).start()
  790. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' is now running."})
  791. except Exception as e:
  792. return jsonify({"success": False, "error": str(e)}), 500
  793. @app.route('/set_speed', methods=['POST'])
  794. def set_speed():
  795. """Set the speed for the Arduino."""
  796. global ser
  797. if ser is None or not ser.is_open:
  798. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  799. try:
  800. # Parse the speed value from the request
  801. data = request.json
  802. speed = data.get('speed')
  803. if speed is None:
  804. return jsonify({"success": False, "error": "Speed is required"}), 400
  805. if not isinstance(speed, (int, float)) or speed <= 0:
  806. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  807. # Send the SET_SPEED command to the Arduino
  808. command = f"SET_SPEEzD {speed}"
  809. send_command(command)
  810. return jsonify({"success": True, "speed": speed})
  811. except Exception as e:
  812. return jsonify({"success": False, "error": str(e)}), 500
  813. @app.route('/get_firmware_info', methods=['GET', 'POST'])
  814. def get_firmware_info():
  815. """
  816. Compare the installed firmware version and motor type with the one in the .ino file.
  817. """
  818. global firmware_version, arduino_driver_type, ser
  819. if ser is None or not ser.is_open:
  820. return jsonify({"success": False, "error": "Arduino not connected or serial port not open"}), 400
  821. try:
  822. if request.method == "GET":
  823. # Attempt to retrieve installed firmware details from the Arduino
  824. ser.reset_input_buffer()
  825. ser.reset_output_buffer()
  826. ser.write(b"GET_VERSION\n")
  827. time.sleep(0.5)
  828. installed_version = firmware_version
  829. installed_type = arduino_driver_type
  830. # If Arduino provides valid details, proceed with comparison
  831. if installed_version != 'Unknown' and installed_type != 'Unknown':
  832. ino_path = MOTOR_TYPE_MAPPING.get(installed_type)
  833. firmware_details = get_ino_firmware_details(ino_path)
  834. if not firmware_details or not firmware_details.get("version") or not firmware_details.get("motorType"):
  835. return jsonify({"success": False, "error": "Failed to retrieve .ino firmware details"}), 500
  836. update_available = (
  837. installed_version != firmware_details["version"] or
  838. installed_type != firmware_details["motorType"]
  839. )
  840. return jsonify({
  841. "success": True,
  842. "installedVersion": installed_version,
  843. "installedType": installed_type,
  844. "inoVersion": firmware_details["version"],
  845. "inoType": firmware_details["motorType"],
  846. "updateAvailable": update_available
  847. })
  848. # If Arduino details are unknown, indicate the need for POST
  849. return jsonify({
  850. "success": True,
  851. "installedVersion": installed_version,
  852. "installedType": installed_type,
  853. "updateAvailable": False
  854. })
  855. elif request.method == "POST":
  856. motor_type = request.json.get("motorType", None)
  857. if not motor_type or motor_type not in MOTOR_TYPE_MAPPING:
  858. return jsonify({
  859. "success": False,
  860. "error": "Invalid or missing motor type"
  861. }), 400
  862. # Fetch firmware details for the given motor type
  863. ino_path = MOTOR_TYPE_MAPPING[motor_type]
  864. firmware_details = get_ino_firmware_details(ino_path)
  865. if not firmware_details:
  866. return jsonify({
  867. "success": False,
  868. "error": "Failed to retrieve .ino firmware details"
  869. }), 500
  870. return jsonify({
  871. "success": True,
  872. "installedVersion": 'Unknown',
  873. "installedType": motor_type,
  874. "inoVersion": firmware_details["version"],
  875. "inoType": firmware_details["motorType"],
  876. "updateAvailable": True
  877. })
  878. except Exception as e:
  879. return jsonify({"success": False, "error": str(e)}), 500
  880. @app.route('/flash_firmware', methods=['POST'])
  881. def flash_firmware():
  882. """
  883. Compile and flash the firmware to the connected Arduino.
  884. """
  885. global ser_port
  886. # Ensure the Arduino is connected
  887. if ser_port is None or ser is None or not ser.is_open:
  888. return jsonify({"success": False, "error": "No Arduino connected or connection lost"}), 400
  889. build_dir = "/tmp/arduino_build" # Temporary build directory
  890. try:
  891. data = request.json
  892. motor_type = data.get("motorType", None)
  893. # Validate motor type
  894. if not motor_type or motor_type not in MOTOR_TYPE_MAPPING:
  895. return jsonify({"success": False, "error": "Invalid or missing motor type"}), 400
  896. # Get the .ino file path based on the motor type
  897. ino_file_path = MOTOR_TYPE_MAPPING[motor_type]
  898. ino_file_name = os.path.basename(ino_file_path)
  899. # Install required libraries
  900. required_libraries = ["AccelStepper"] # AccelStepper includes MultiStepper
  901. for library in required_libraries:
  902. library_install_command = ["arduino-cli", "lib", "install", library]
  903. install_process = subprocess.run(library_install_command, capture_output=True, text=True)
  904. if install_process.returncode != 0:
  905. return jsonify({
  906. "success": False,
  907. "error": f"Library installation failed for {library}: {install_process.stderr}"
  908. }), 500
  909. # Step 1: Compile the .ino file to a .hex file
  910. compile_command = [
  911. "arduino-cli",
  912. "compile",
  913. "--fqbn", "arduino:avr:uno", # Use the detected FQBN
  914. "--output-dir", build_dir,
  915. ino_file_path
  916. ]
  917. compile_process = subprocess.run(compile_command, capture_output=True, text=True)
  918. if compile_process.returncode != 0:
  919. return jsonify({
  920. "success": False,
  921. "error": compile_process.stderr
  922. }), 500
  923. # Step 2: Flash the .hex file to the Arduino
  924. hex_file_path = os.path.join(build_dir, ino_file_name+".hex")
  925. flash_command = [
  926. "avrdude",
  927. "-v",
  928. "-c", "arduino", # Programmer type
  929. "-p", "atmega328p", # Microcontroller type
  930. "-P", ser_port, # Use the dynamic serial port
  931. "-b", "115200", # Baud rate
  932. "-D",
  933. "-U", f"flash:w:{hex_file_path}:i" # Flash memory write command
  934. ]
  935. flash_process = subprocess.run(flash_command, capture_output=True, text=True)
  936. if flash_process.returncode != 0:
  937. return jsonify({
  938. "success": False,
  939. "error": flash_process.stderr
  940. }), 500
  941. return jsonify({"success": True, "message": "Firmware flashed successfully"})
  942. except Exception as e:
  943. return jsonify({"success": False, "error": str(e)}), 500
  944. finally:
  945. # Clean up temporary files
  946. if os.path.exists(build_dir):
  947. for file in os.listdir(build_dir):
  948. os.remove(os.path.join(build_dir, file))
  949. os.rmdir(build_dir)
  950. if __name__ == '__main__':
  951. # Auto-connect to serial
  952. connect_to_serial()
  953. app.run(debug=True, host='0.0.0.0', port=8080)