app.py 32 KB

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