app.py 43 KB

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