app.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144
  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 = None
  31. # Table status
  32. current_playing_file = None
  33. execution_progress = None
  34. firmware_version = None
  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": "./arduino_code_TMC2209/arduino_code_TMC2209.ino",
  42. "DRV8825": "./arduino_code/arduino_code.ino",
  43. "esp32": "./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
  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. "arduino_table_name": arduino_table_name,
  581. "arduino_driver_type": arduino_driver_type,
  582. "firmware_version": firmware_version,
  583. "current_playing_index": current_playing_index,
  584. "current_playlist": current_playlist,
  585. "is_clearing": is_clearing
  586. })
  587. @app.route('/resume_execution', methods=['POST'])
  588. def resume_execution():
  589. """Resume execution after pausing."""
  590. global pause_requested
  591. with pause_condition:
  592. pause_requested = False
  593. pause_condition.notify_all() # Unblock the waiting thread
  594. return jsonify({'success': True, 'message': 'Execution resumed'})
  595. def load_playlists():
  596. """
  597. Load the entire playlists dictionary from the JSON file.
  598. Returns something like: {
  599. "My Playlist": ["file1.thr", "file2.thr"],
  600. "Another": ["x.thr"]
  601. }
  602. """
  603. with open(PLAYLISTS_FILE, "r") as f:
  604. return json.load(f)
  605. def save_playlists(playlists_dict):
  606. """
  607. Save the entire playlists dictionary back to the JSON file.
  608. """
  609. with open(PLAYLISTS_FILE, "w") as f:
  610. json.dump(playlists_dict, f, indent=2)
  611. @app.route("/list_all_playlists", methods=["GET"])
  612. def list_all_playlists():
  613. """
  614. Returns a list of all playlist names.
  615. Example return: ["My Playlist", "Another Playlist"]
  616. """
  617. playlists_dict = load_playlists()
  618. playlist_names = list(playlists_dict.keys())
  619. return jsonify(playlist_names)
  620. @app.route("/get_playlist", methods=["GET"])
  621. def get_playlist():
  622. """
  623. GET /get_playlist?name=My%20Playlist
  624. Returns: { "name": "My Playlist", "files": [... ] }
  625. """
  626. playlist_name = request.args.get("name", "")
  627. if not playlist_name:
  628. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  629. playlists_dict = load_playlists()
  630. if playlist_name not in playlists_dict:
  631. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  632. files = playlists_dict[playlist_name] # e.g. ["file1.thr", "file2.thr"]
  633. return jsonify({
  634. "name": playlist_name,
  635. "files": files
  636. })
  637. @app.route("/create_playlist", methods=["POST"])
  638. def create_playlist():
  639. """
  640. POST /create_playlist
  641. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  642. Creates or overwrites a playlist with the given name.
  643. """
  644. data = request.get_json()
  645. if not data or "name" not in data or "files" not in data:
  646. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  647. playlist_name = data["name"]
  648. files = data["files"]
  649. # Load all playlists
  650. playlists_dict = load_playlists()
  651. # Overwrite or create new
  652. playlists_dict[playlist_name] = files
  653. # Save changes
  654. save_playlists(playlists_dict)
  655. return jsonify({
  656. "success": True,
  657. "message": f"Playlist '{playlist_name}' created/updated"
  658. })
  659. @app.route("/modify_playlist", methods=["POST"])
  660. def modify_playlist():
  661. """
  662. POST /modify_playlist
  663. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  664. Updates (or creates) the existing playlist with a new file list.
  665. You can 404 if you only want to allow modifications to existing playlists.
  666. """
  667. data = request.get_json()
  668. if not data or "name" not in data or "files" not in data:
  669. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  670. playlist_name = data["name"]
  671. files = data["files"]
  672. # Load all playlists
  673. playlists_dict = load_playlists()
  674. # Optional: If you want to disallow creating a new playlist here:
  675. # if playlist_name not in playlists_dict:
  676. # return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  677. # Overwrite or create new
  678. playlists_dict[playlist_name] = files
  679. # Save
  680. save_playlists(playlists_dict)
  681. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' updated"})
  682. @app.route("/delete_playlist", methods=["DELETE"])
  683. def delete_playlist():
  684. """
  685. DELETE /delete_playlist
  686. Body: { "name": "My Playlist" }
  687. Removes the playlist from the single JSON file.
  688. """
  689. data = request.get_json()
  690. if not data or "name" not in data:
  691. return jsonify({"success": False, "error": "Missing 'name' field"}), 400
  692. playlist_name = data["name"]
  693. playlists_dict = load_playlists()
  694. if playlist_name not in playlists_dict:
  695. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  696. # Remove from dict
  697. del playlists_dict[playlist_name]
  698. save_playlists(playlists_dict)
  699. return jsonify({
  700. "success": True,
  701. "message": f"Playlist '{playlist_name}' deleted"
  702. })
  703. @app.route('/add_to_playlist', methods=['POST'])
  704. def add_to_playlist():
  705. data = request.json
  706. playlist_name = data.get('playlist_name')
  707. pattern = data.get('pattern')
  708. # Load existing playlists
  709. with open('playlists.json', 'r') as f:
  710. playlists = json.load(f)
  711. # Add pattern to the selected playlist
  712. if playlist_name in playlists:
  713. playlists[playlist_name].append(pattern)
  714. with open('playlists.json', 'w') as f:
  715. json.dump(playlists, f)
  716. return jsonify(success=True)
  717. else:
  718. return jsonify(success=False, error='Playlist not found'), 404
  719. @app.route("/run_playlist", methods=["POST"])
  720. def run_playlist():
  721. """
  722. POST /run_playlist
  723. Body (JSON):
  724. {
  725. "playlist_name": "My Playlist",
  726. "pause_time": 1.0, # Optional: seconds to pause between patterns
  727. "clear_pattern": "random", # Optional: "clear_in", "clear_out", "clear_sideway", or "random"
  728. "run_mode": "single", # 'single' or 'indefinite'
  729. "shuffle": True # true or false
  730. "start_time": ""
  731. "end_time": ""
  732. }
  733. """
  734. data = request.get_json()
  735. # Validate input
  736. if not data or "playlist_name" not in data:
  737. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  738. playlist_name = data["playlist_name"]
  739. pause_time = data.get("pause_time", 0)
  740. clear_pattern = data.get("clear_pattern", None)
  741. run_mode = data.get("run_mode", "single") # Default to 'single' run
  742. shuffle = data.get("shuffle", False) # Default to no shuffle
  743. start_time = data.get("start_time", None)
  744. end_time = data.get("end_time", None)
  745. # Validate pause_time
  746. if not isinstance(pause_time, (int, float)) or pause_time < 0:
  747. return jsonify({"success": False, "error": "'pause_time' must be a non-negative number"}), 400
  748. # Validate clear_pattern
  749. valid_patterns = ["clear_in", "clear_out", "clear_sideway", "random"]
  750. if clear_pattern not in valid_patterns:
  751. clear_pattern = None
  752. # Validate run_mode
  753. if run_mode not in ["single", "indefinite"]:
  754. return jsonify({"success": False, "error": "'run_mode' must be 'single' or 'indefinite'"}), 400
  755. # Validate shuffle
  756. if not isinstance(shuffle, bool):
  757. return jsonify({"success": False, "error": "'shuffle' must be a boolean value"}), 400
  758. schedule_hours = None
  759. if start_time and end_time:
  760. try:
  761. # Convert HH:MM to datetime.time objects
  762. start_time_obj = datetime.strptime(start_time, "%H:%M").time()
  763. end_time_obj = datetime.strptime(end_time, "%H:%M").time()
  764. # Ensure start_time is before end_time
  765. if start_time_obj >= end_time_obj:
  766. return jsonify({"success": False, "error": "'start_time' must be earlier than 'end_time'"}), 400
  767. # Create schedule tuple with full time
  768. schedule_hours = (start_time_obj, end_time_obj)
  769. except ValueError:
  770. return jsonify({"success": False, "error": "Invalid time format. Use HH:MM (e.g., '09:30')"}), 400
  771. # Load playlists
  772. playlists = load_playlists()
  773. if playlist_name not in playlists:
  774. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  775. file_paths = playlists[playlist_name]
  776. file_paths = [os.path.join(THETA_RHO_DIR, file) for file in file_paths]
  777. if not file_paths:
  778. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' is empty"}), 400
  779. # Start the playlist execution in a separate thread
  780. try:
  781. threading.Thread(
  782. target=run_theta_rho_files,
  783. args=(file_paths,),
  784. kwargs={
  785. 'pause_time': pause_time,
  786. 'clear_pattern': clear_pattern,
  787. 'run_mode': run_mode,
  788. 'shuffle': shuffle,
  789. 'schedule_hours': schedule_hours
  790. },
  791. daemon=True # Daemonize thread to exit with the main program
  792. ).start()
  793. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' is now running."})
  794. except Exception as e:
  795. return jsonify({"success": False, "error": str(e)}), 500
  796. @app.route('/set_speed', methods=['POST'])
  797. def set_speed():
  798. """Set the speed for the Arduino."""
  799. global ser
  800. if ser is None or not ser.is_open:
  801. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  802. try:
  803. # Parse the speed value from the request
  804. data = request.json
  805. speed = data.get('speed')
  806. if speed is None:
  807. return jsonify({"success": False, "error": "Speed is required"}), 400
  808. if not isinstance(speed, (int, float)) or speed <= 0:
  809. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  810. # Send the SET_SPEED command to the Arduino
  811. command = f"SET_SPEED {speed}"
  812. send_command(command)
  813. return jsonify({"success": True, "speed": speed})
  814. except Exception as e:
  815. return jsonify({"success": False, "error": str(e)}), 500
  816. @app.route('/get_firmware_info', methods=['GET', 'POST'])
  817. def get_firmware_info():
  818. """
  819. Compare the installed firmware version and motor type with the one in the .ino file.
  820. """
  821. global ser
  822. if ser is None or not ser.is_open:
  823. return jsonify({"success": False, "error": "Arduino not connected or serial port not open"}), 400
  824. try:
  825. if request.method == "GET":
  826. # Attempt to retrieve installed firmware details from the Arduino
  827. ser.reset_input_buffer()
  828. ser.reset_output_buffer()
  829. ser.write(b"GET_VERSION\n")
  830. time.sleep(0.5)
  831. installed_version = 'Unknown'
  832. installed_type = 'Unknown'
  833. if ser.in_waiting > 0:
  834. response = ser.readline().decode().strip()
  835. if " | " in response:
  836. installed_version, installed_type = response.split(" | ", 1)
  837. # If Arduino provides valid details, proceed with comparison
  838. if installed_version != 'Unknown' and installed_type != 'Unknown':
  839. ino_path = MOTOR_TYPE_MAPPING.get(installed_type)
  840. firmware_details = get_ino_firmware_details(ino_path)
  841. if not firmware_details or not firmware_details.get("version") or not firmware_details.get("motorType"):
  842. return jsonify({"success": False, "error": "Failed to retrieve .ino firmware details"}), 500
  843. update_available = (
  844. installed_version != firmware_details["version"] or
  845. installed_type != firmware_details["motorType"]
  846. )
  847. return jsonify({
  848. "success": True,
  849. "installedVersion": installed_version,
  850. "installedType": installed_type,
  851. "inoVersion": firmware_details["version"],
  852. "inoType": firmware_details["motorType"],
  853. "updateAvailable": update_available
  854. })
  855. # If Arduino details are unknown, indicate the need for POST
  856. return jsonify({
  857. "success": True,
  858. "installedVersion": installed_version,
  859. "installedType": installed_type,
  860. "updateAvailable": False
  861. })
  862. if request.method == "POST":
  863. motor_type = request.json.get("motorType", None)
  864. if not motor_type or motor_type not in MOTOR_TYPE_MAPPING:
  865. return jsonify({"success": False, "error": "Invalid or missing motor type"}), 400
  866. ino_path = MOTOR_TYPE_MAPPING.get(motor_type)
  867. firmware_details = get_ino_firmware_details(ino_path)
  868. if not firmware_details or not firmware_details.get("version") or not firmware_details.get("motorType"):
  869. return jsonify({"success": False, "error": "Failed to retrieve .ino firmware details"}), 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)