app.py 43 KB

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